11 Commits
Author SHA1 Message Date
ajandClaude Opus 5.5 c33337cea2 fix(cnc): stop double-counting first incremental rapid in rapid display
RapidEnumerator primed the walk position at the first pierce point, then
the skipped first rapid advanced it again. Raw programs start with a zero
rapid so this was invisible, but lead-in programs start with a real
incremental offset to the pierce, which shifted every later rapid by that
delta and drew rapids off the sheet. Start the walk at the program origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:21:37 -04:00
ajandClaude Opus 5.5 b0997f614b fix(post): post sheet cut-offs in CI Fiber without comp or lead-in
Posting a nest with a sheet cut-off threw because the CI Fiber writer
treated every contour as a compensated part contour and requires a
linear lead-in after G41/G42 (TF5200 13.2.4.1). Cut-offs are open
centreline cuts with no lead-in and no inside/outside, so they now post
without G41/G42 and run after every part on the sheet so the sheet is
not severed before the parts are cut (matching the CL post).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:14:07 -04:00
aj 6d7c98ef4a docs: document defaults.json nest-defaults mechanism replacing .nstdot 2026-09-28 08:22:49 -04:00
aj f44c1d465f feat(ui): defaults.json-backed new nests; retire .nstdot templates
New nests now load plate defaults from %APPDATA%/OpenNest/defaults.json
instead of a .nstdot nest template:

- Tools > Nest Defaults... edits the file directly (new dialog).
- Tools > Save Current Plate as Defaults captures the active plate with
  no dialog (visible when a nest is open).
- New_Click no longer unzips a template; a corrupt defaults file warns
  once per session and falls back to built-in values.
- First run converts an existing NestTemplatePath .nstdot to
  defaults.json and clears the legacy setting (kept readable on a
  failed conversion); the setting itself stays, marked legacy.
- Save As no longer offers the .nstdot filter; SaveTemplate removed.
- BomImportForm and OptionsForm template plumbing removed in favor of
  the shared NestDefaults store.
2026-09-28 08:20:49 -04:00
aj bdb78a689d feat(defaults): JSON nest defaults store with per-field fallback
Replaces the .nstdot nest-template mechanism for new-nest plate
defaults. NestDefaults persists units, plate size, quadrant, part
spacing, and edge spacing to a single JSON file (DefaultPath:
%APPDATA%/OpenNest/defaults.json). Load never throws: a missing,
corrupt, or partially valid file degrades field-by-field to the
built-in fallback values, and unknown fields or a future version
number are ignored.
2026-09-28 07:09:52 -04:00
aj 3f89f8f5dd feat(ui): Tools > Expand Spacing for selected parts
Mirrors PushSelected: PlateView/SelectionManager.ExpandSelected runs
Expander.Expand on the selection, marks parts dirty, regenerates cut
offs, and reports achieved spacing plus blocked-pair count in the
status bar. Menu item joins Align Selected's enable surface.
2026-09-28 03:17:55 -04:00
aj 3cab90747a feat(expand): plateview spacing expander
Grows part-to-part spacing of a selected group with the work area and
non-selected parts as hard boundaries. Doubling + bisection search over
the target spacing; Gauss-Seidel straight-line relaxation with anchor
mover policy (later-index selected part moves, first selection never).
Overlapping input is separated along penetration MTVs instead of being
rejected. Cancel/failure never mutates part positions. Clearance gains
BoundaryDistance for ring-pair gaps (part-in-cutout legality).
2026-09-28 03:05:45 -04:00
aj fff3bef4e4 feat(geom): signed clearance kernel between polygons
Omnidirectional minimum distance with separating direction (positive)
and penetration depth with minimum-translation direction (negative),
for the PlateView spacing expander. Overlap verdict defers to
Collision.HasOverlap so kernels never disagree. Basis for the fixed-s
separation solver.
2026-09-27 23:36:26 -04:00
aj 2b78fb3a75 fix(geometry): resolve blocking contacts across directional slide paths 2026-09-27 23:24:31 -04:00
aj a04460c57b refactor(ui): drop duplicate plate preview from nesting progress dialog
The dialog rendered the same best-parts layout as transparent ghost
parts already shown live on the main plate view, and its embedded view
reset zoom on every improvement while lacking placed parts, work-area
and cut-off context. The progress dialog is now a compact stats strip
(266px wide) with the plate counter preserved for multi-plate runs.
Removes the never-called SetStationaryParts stationary-preview path
along with PreviewPlate/UpdatePreview/CreatePreviewPlate.
2026-09-27 20:43:34 -04:00
aj 27948ba8cf docs(posts): replace bundled manual with implementation references 2026-09-27 19:52:25 -04:00
44 changed files with 5806 additions and 1306 deletions
+4
View File
@@ -217,3 +217,7 @@ docs/superpowers/
# Local test config (contains user-specific paths to proprietary test assets) # Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf
+4 -1
View File
@@ -111,7 +111,8 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
The UI application with MDI interface. 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. - **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`. - **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`. - **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). - **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. **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 ## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`. - OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
+5 -4
View File
@@ -12,13 +12,14 @@ namespace OpenNest.CNC
var results = new List<Segment>(); var results = new List<Segment>();
// Draw the rapid from the previous tool position to the program's first // Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets // pierce point. The walk then starts at the program origin (basePos), not
// Incremental deltas from the correct absolute location (basePos), which // the pierce: the skipped first rapid still advances pos, so starting at
// matters for raw pre-lead-in programs that are emitted Incremental. // the pierce would apply a nonzero Incremental first delta twice (as in
// lead-in programs) and shift every later rapid by it.
var firstPierce = FirstPiercePoint(pgm, basePos); var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce)); results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce; var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results); Walk(pgm, basePos, ref pos, skipFirst: true, results);
return results; return results;
} }
+336
View File
@@ -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>
/// &gt; 0: minimum boundary distance. 0: touching. &lt; 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
);
}
}
}
+831
View File
@@ -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;
}
}
}
+806
View File
@@ -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;
}
}
}
}
+168 -353
View File
@@ -320,8 +320,9 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along a push direction before /// Computes the translation distance along a push direction before any edge of
/// any edge of movingLines contacts any edge of stationaryLines. /// 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. /// Returns double.MaxValue if no collision path exists.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
@@ -334,7 +335,7 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <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. /// by (movingDx, movingDy) without creating new Line objects.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
@@ -345,37 +346,57 @@ namespace OpenNest.Geometry
PushDirection direction PushDirection direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
var movingOffset = new Vector(movingDx, movingDy); movingLines,
movingDx,
movingDy,
stationaryLines,
direction,
SlideContactClassifier.FromLines(
movingLines,
new Vector(movingDx, movingDy),
stationaryLines,
Vector.Zero
)
);
}
// Case 1: Each moving vertex -> each stationary edge /// <summary>
var movingVertices = CollectVertices(movingLines, movingOffset); /// <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); var stationaryEdges = ToEdgeArray(stationaryLines);
SortEdgesForPruning(stationaryEdges, direction); SortEdgesForPruning(stationaryEdges, direction);
foreach (var mv in movingVertices) var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
{
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 movingEdges = ToEdgeArray(movingLines); var movingEdges = ToEdgeArray(movingLines);
SortEdgesForPruning(movingEdges, opposite); SortEdgesForPruning(movingEdges, OppositeDirection(direction));
foreach (var sv in stationaryVertices) var source = new AxisSlideEvents(
{ movingEdges,
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); movingOffset,
if (d < minDist) movingVertices,
minDist = d; stationaryEdges,
} Vector.Zero,
stationaryVertices,
return minDist; direction
);
var unit = DirectionToOffset(direction, 1.0);
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
} }
/// <summary> /// <summary>
@@ -396,8 +417,8 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum directional distance using raw edge arrays and location offsets /// Computes the blocking directional distance using raw edge arrays and location
/// to avoid all intermediate object allocations. /// offsets. Sorts both edge arrays in place for pruning.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges, (Vector start, Vector end)[] movingEdges,
@@ -407,36 +428,58 @@ namespace OpenNest.Geometry
PushDirection direction PushDirection direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
movingEdges,
SortEdgesForPruning(stationaryEdges, direction); movingOffset,
stationaryEdges,
// Case 1: Each moving vertex -> each stationary edge stationaryOffset,
var movingVertices = CollectVertices(movingEdges, movingOffset); direction,
SlideContactClassifier.FromEdges(
foreach (var mv in movingVertices) movingEdges,
{ movingOffset,
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); stationaryEdges,
if (d < minDist) stationaryOffset
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;
} }
/// <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( public static double OneWayDistance(
Vector vertex, Vector vertex,
(Vector start, Vector end)[] edges, (Vector start, Vector end)[] edges,
@@ -628,8 +671,8 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction /// Computes the translation distance along an arbitrary unit direction before any
/// before any edge of movingLines contacts any edge of stationaryLines. /// edge of movingLines first blocks against an edge of stationaryLines.
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Line> movingLines, List<Line> movingLines,
@@ -637,58 +680,41 @@ namespace OpenNest.Geometry
Vector direction Vector direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
var dirX = direction.X; movingLines,
var dirY = direction.Y; stationaryLines,
direction,
SlideContactClassifier.FromLines(
movingLines,
Vector.Zero,
stationaryLines,
Vector.Zero
)
);
}
var movingVertices = CollectVertices(movingLines, Vector.Zero); /// <summary>
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
foreach (var mv in movingVertices) /// caller-supplied contact topology.
{ /// </summary>
for (var i = 0; i < stationaryLines.Count; i++) public static double DirectionalDistance(
{ List<Line> movingLines,
var e = stationaryLines[i]; List<Line> stationaryLines,
var d = RayEdgeDistance( Vector direction,
mv.X, SlideContactClassifier contacts
mv.Y, )
e.pt1.X, {
e.pt1.Y, var source = new LineSlideEvents(
e.pt2.X, movingLines,
e.pt2.Y, CollectVertices(movingLines, Vector.Zero).ToArray(),
dirX, 0,
dirY 0,
); stationaryLines,
if (d < minDist) CollectVertices(stationaryLines, Vector.Zero).ToArray(),
minDist = d; direction.X,
} direction.Y
} );
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
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> /// <summary>
@@ -710,10 +736,10 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction /// Computes the translation distance along an arbitrary unit direction before any
/// before any vertex/edge of movingEntities contacts any vertex/edge of /// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
/// stationaryEntities. Works with native Line, Arc, and Circle entities /// native Line, Arc, and Circle entities without tessellation. A contact that the
/// without tessellation. /// push slides along or leaves does not block (see <see cref="SlideContact"/>).
/// </summary> /// </summary>
public static double DirectionalDistance( public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> movingEntities,
@@ -721,228 +747,42 @@ namespace OpenNest.Geometry
Vector direction Vector direction
) )
{ {
var minDist = double.MaxValue; return DirectionalDistance(
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(
movingEntities, movingEntities,
stationaryEntities, stationaryEntities,
dirX, direction,
dirY, new SlideContactClassifier(movingEntities, stationaryEntities)
minDist
); );
if (minDist <= 0) }
return 0;
minDist = ArcToLineClosestDistance( /// <summary>
stationaryEntities, /// <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, movingEntities,
oppX, ExtractEntityVertices(movingEntities),
oppY, 0,
minDist 0,
stationaryEntities,
ExtractEntityVertices(stationaryEntities),
direction.X,
direction.Y,
arcToLine: true
); );
if (minDist <= 0) return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
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;
} }
private static double ArcToLineClosestDistance( public static Vector[] ExtractEntityVertices(List<Entity> entities)
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)
{ {
var vertices = new HashSet<Vector>(); 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) private static double BoxProjectionMin(Box box, double dx, double dy)
{ {
var x = dx >= 0 ? box.Left : box.Right; var x = dx >= 0 ? box.Left : box.Right;
+270
View File
@@ -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; }
}
}
+347
View File
@@ -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);
}
}
+29 -447
View File
@@ -1,7 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
@@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets SlideOffset[] offsets
) )
{ {
var count = offsets.Length; var results = new double[offsets.Length];
var results = new double[count]; var movingVertices = Vertices(movingTemplateLines);
var stationaryVertices = Vertices(stationaryLines);
var contacts = SlideContactClassifier.FromLines(
movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare();
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines); System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{ {
var key = (offset.DirX, offset.DirY); var offset = offsets[i];
if (vertexCache.ContainsKey(key)) var source = new LineSlideEvents(
continue; movingTemplateLines, movingVertices, offset.Dx, offset.Dy,
stationaryLines, stationaryVertices, offset.DirX, offset.DirY);
var leading = FilterVerticesByProjection( results[i] = SlideResolver.FirstBlocking(ref source,
allMovingVerts, contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
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;
}
);
return results; return results;
} }
@@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets SlideOffset[] offsets
) )
{ {
var count = offsets.Length; var results = new double[offsets.Length];
var results = new double[count]; var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities);
var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities);
var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare();
var allMovingVerts = ExtractVerticesFromEntities(movingEntities); // All vertices participate: a leading-half filter can miss the next contact
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities); // after sliding past an initial touch on a concave boundary.
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
var movingCurves = ExtractCurveParams(movingEntities);
var stationaryCurves = ExtractCurveParams(stationaryEntities);
var vertexCache =
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{ {
var key = (offset.DirX, offset.DirY); var offset = offsets[i];
if (vertexCache.ContainsKey(key)) var source = new EntitySlideEvents(
continue; movingEntities, movingVertices, offset.Dx, offset.Dy,
stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true);
var leading = FilterVerticesByProjection( results[i] = SlideResolver.FirstBlocking(ref source,
allMovingVerts, contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
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;
}
);
return results; return results;
} }
private readonly struct CurveParams private static Vector[] Vertices(List<Line> lines) =>
{ lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
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;
}
} }
} }
+10 -1
View File
@@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit
SlideOffset[] offsets 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 stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines); var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
var count = offsets.Length; var count = offsets.Length;
@@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit
/// <summary> /// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface. /// 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> /// </summary>
private static int DirectionVectorToInt(double dirX, double dirY) private static int DirectionVectorToInt(double dirX, double dirY)
{ {
+3 -2
View File
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
public interface ISlideComputer : IDisposable public interface ISlideComputer : IDisposable
{ {
/// <summary> /// <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> /// </summary>
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param> /// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param> /// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
); );
/// <summary> /// <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. /// Uploads segment data once for all offsets, reducing GPU round-trips.
/// </summary> /// </summary>
double[] ComputeBatchMultiDir( double[] ComputeBatchMultiDir(
+20 -26
View File
@@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(moving) : 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] ??= obstacleEntities[i] ??=
halfSpacing > 0 halfSpacing > 0
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing) ? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
: PartGeometry.GetPerimeterEntities(obstacleParts[i]); : 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( var d = SpatialQuery.DirectionalDistance(
movingEntities, movingEntities,
obstacleEntities[i], obstacleEntities[i],
direction direction
); );
if (
d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)
)
{
continue;
}
if (d < distance) if (d < distance)
distance = d; distance = d;
@@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill
{ {
for (var i = 0; i < parts.Count; i++) 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 true;
} }
return false; 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( public static double Push(
List<Part> movingParts, List<Part> movingParts,
List<Part> obstacleParts, List<Part> obstacleParts,
+771
View File
@@ -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
);
}
}
}
}
+5 -10
View File
@@ -83,8 +83,9 @@ namespace OpenNest.Engine.Fill
// Slide uses locations, not cached bounds; Offset already translates the box. // Slide uses locations, not cached bounds; Offset already translates the box.
// Slide part2 left toward part1. // Slide part2 left toward part1.
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left); // Keep complete loops so the shared kernel can classify tangential contacts.
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right); var movingLines = boundary2.GetLines(part2.Location);
var stationaryLines = boundary1.GetLines(part1.Location);
var dist = SpatialQuery.DirectionalDistance( var dist = SpatialQuery.DirectionalDistance(
movingLines, movingLines,
stationaryLines, stationaryLines,
@@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill
PushDirection direction PushDirection direction
) )
{ {
var opposite = SpatialQuery.OppositeDirection(direction);
var movingEdges = movingBoundary.GetEdges(direction);
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
return SpatialQuery.DirectionalDistance( return SpatialQuery.DirectionalDistance(
movingEdges, movingBoundary.GetLines(movingLocation),
movingLocation, stationaryBoundary.GetLines(stationaryLocation),
stationaryEdges,
stationaryLocation,
direction direction
); );
} }
+175 -92
View File
@@ -1,8 +1,13 @@
#nullable enable
using System; using System;
using System.Collections.Generic;
using ILGPU; using ILGPU;
using ILGPU.Algorithms; using ILGPU.Algorithms;
using ILGPU.Runtime; using ILGPU.Runtime;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Gpu namespace OpenNest.Gpu
{ {
@@ -19,7 +24,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results ArrayView1D<ContactWitness, Stride1D.Dense>, // results
int, int,
int, int,
int int
@@ -30,7 +35,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results ArrayView1D<ContactWitness, Stride1D.Dense>, // results
ArrayView1D<int, Stride1D.Dense>, // directions ArrayView1D<int, Stride1D.Dense>, // directions
int, int,
int int
@@ -47,22 +52,24 @@ namespace OpenNest.Gpu
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw; private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep; 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>? _gpuMovingRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep; 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>? _gpuOffsets;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults; private MemoryBuffer1D<ContactWitness, Stride1D.Dense>? _gpuResults;
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs; private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
private int _offsetCapacity; private int _offsetCapacity;
public GpuSlideComputer() public GpuSlideComputer() : this(preferCPU: false) { }
public GpuSlideComputer(bool preferCPU)
{ {
_context = Context.CreateDefault(); _context = Context.CreateDefault();
_accelerator = _context _accelerator = _context
.GetPreferredDevice(preferCPU: false) .GetPreferredDevice(preferCPU)
.CreateAccelerator(_context); .CreateAccelerator(_context);
_kernel = _accelerator.LoadAutoGroupedStreamKernel< _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<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<ContactWitness, Stride1D.Dense>,
int, int,
int, 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<double, Stride1D.Dense>, ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>, ArrayView1D<ContactWitness, Stride1D.Dense>,
ArrayView1D<int, Stride1D.Dense>, ArrayView1D<int, Stride1D.Dense>,
int, int,
int int
@@ -118,21 +125,24 @@ namespace OpenNest.Gpu
EnsureMoving(movingTemplateSegments, movingCount); EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount); 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( _kernel(
offsetCount, offsetCount,
_gpuStationaryPrep!.View, _gpuStationaryPrep!.View,
_gpuMovingPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuOffsets.View.SubView(0, offsetCount * 2),
_gpuResults!.View, _gpuResults!.View.SubView(0, offsetCount),
stationaryCount, stationaryCount,
movingCount, movingCount,
(int)direction (int)direction
); );
_accelerator.Synchronize(); _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; return results;
@@ -161,93 +171,138 @@ namespace OpenNest.Gpu
EnsureMoving(movingTemplateSegments, movingCount); EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount); EnsureOffsetBuffers(offsetCount);
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions); _accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(
_accelerator.DefaultStream, (ReadOnlySpan<int>)directions.AsSpan(0, offsetCount));
_kernelMultiDir( _kernelMultiDir(
offsetCount, offsetCount,
_gpuStationaryPrep!.View, _gpuStationaryPrep!.View,
_gpuMovingPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuOffsets.View.SubView(0, offsetCount * 2),
_gpuResults!.View, _gpuResults!.View.SubView(0, offsetCount),
_gpuDirs.View, _gpuDirs.View.SubView(0, offsetCount),
stationaryCount, stationaryCount,
movingCount movingCount
); );
_accelerator.Synchronize(); _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; 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) private void EnsureStationary(double[] data, int count)
{ {
// Fast check: if same object or content is identical, skip upload // Cache the active prefix by value: callers may reuse an array with a
if ( // different segment count or mutate its coordinates between batches.
_gpuStationaryPrep != null var active = data.AsSpan(0, count * 4);
&& _lastStationaryData != null if (_gpuStationaryPrep != null && _lastStationaryData != null
&& _lastStationaryData.Length == data.Length && active.SequenceEqual(_lastStationaryData))
) return;
{
// Reference equality or content equality
if (
_lastStationaryData == data
|| new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(
new ReadOnlySpan<double>(data)
)
)
{
return;
}
}
_gpuStationaryRaw?.Dispose(); _gpuStationaryRaw?.Dispose();
_gpuStationaryPrep?.Dispose(); _gpuStationaryPrep?.Dispose();
_gpuStationaryRaw = _accelerator.Allocate1D(data); var snapshot = active.ToArray();
_gpuStationaryRaw = _accelerator.Allocate1D(snapshot);
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10); _gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count); _prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
_accelerator.Synchronize(); _accelerator.Synchronize();
_lastStationaryData = data; // store reference for next comparison _lastStationaryData = snapshot;
} }
private void EnsureMoving(double[] data, int count) private void EnsureMoving(double[] data, int count)
{ {
if ( // Cache the active prefix by value: callers may reuse an array with a
_gpuMovingPrep != null // different segment count or mutate its coordinates between batches.
&& _lastMovingData != null var active = data.AsSpan(0, count * 4);
&& _lastMovingData.Length == data.Length if (_gpuMovingPrep != null && _lastMovingData != null
) && active.SequenceEqual(_lastMovingData))
{ return;
if (
_lastMovingData == data
|| new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(
new ReadOnlySpan<double>(data)
)
)
{
return;
}
}
_gpuMovingRaw?.Dispose(); _gpuMovingRaw?.Dispose();
_gpuMovingPrep?.Dispose(); _gpuMovingPrep?.Dispose();
_gpuMovingRaw = _accelerator.Allocate1D(data); var snapshot = active.ToArray();
_gpuMovingRaw = _accelerator.Allocate1D(snapshot);
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10); _gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count); _prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
_accelerator.Synchronize(); _accelerator.Synchronize();
_lastMovingData = data; _lastMovingData = snapshot;
} }
private void EnsureOffsetBuffers(int offsetCount) private void EnsureOffsetBuffers(int offsetCount)
@@ -262,7 +317,7 @@ namespace OpenNest.Gpu
_gpuDirs?.Dispose(); _gpuDirs?.Dispose();
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2); _gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
_gpuResults = _accelerator.Allocate1D<double>(newCapacity); _gpuResults = _accelerator.Allocate1D<ContactWitness>(newCapacity);
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity); _gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
_offsetCapacity = newCapacity; _offsetCapacity = newCapacity;
@@ -293,8 +348,8 @@ namespace OpenNest.Gpu
var dy = y2 - y1; var dy = y2 - y1;
// invD is used for parameter 't'. We use a small epsilon for stability. // 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 + 4] = (XMath.Abs(dx) < Tolerance.Epsilon) ? 0 : 1.0 / dx;
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy; prepared[index * 10 + 5] = (XMath.Abs(dy) < Tolerance.Epsilon) ? 0 : 1.0 / dy;
prepared[index * 10 + 6] = XMath.Min(x1, x2); prepared[index * 10 + 6] = XMath.Min(x1, x2);
prepared[index * 10 + 7] = XMath.Max(x1, x2); prepared[index * 10 + 7] = XMath.Max(x1, x2);
@@ -304,12 +359,48 @@ namespace OpenNest.Gpu
// ── Main Slide Kernels ─────────────────────────────────────── // ── 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( private static void SlideKernel(
Index1D index, Index1D index,
ArrayView1D<double, Stride1D.Dense> stationaryPrep, ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep, ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets, ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results, ArrayView1D<ContactWitness, Stride1D.Dense> results,
int sCount, int sCount,
int mCount, int mCount,
int direction int direction
@@ -337,7 +428,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense> stationaryPrep, ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep, ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets, ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results, ArrayView1D<ContactWitness, Stride1D.Dense> results,
ArrayView1D<int, Stride1D.Dense> directions, ArrayView1D<int, Stride1D.Dense> directions,
int sCount, int sCount,
int mCount 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> sPrep,
ArrayView1D<double, Stride1D.Dense> mPrep, ArrayView1D<double, Stride1D.Dense> mPrep,
double dx, double dx,
@@ -371,20 +462,20 @@ namespace OpenNest.Gpu
int direction int direction
) )
{ {
const double eps = 0.00001; const double eps = Tolerance.Epsilon;
var minDist = double.MaxValue; var nearest = new ContactWitness { Distance = double.MaxValue };
var horizontal = direction >= 2; var horizontal = direction >= 2;
var oppDir = direction ^ 1; var oppDir = direction ^ 1;
// ── Forward Pass: moving vertices vs stationary edges ───── // ── 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 m1x = mPrep[i * 10 + 0] + dx;
var m1y = mPrep[i * 10 + 1] + dy; var m1y = mPrep[i * 10 + 1] + dy;
var m2x = mPrep[i * 10 + 2] + dx; var m2x = mPrep[i * 10 + 2] + dx;
var m2y = mPrep[i * 10 + 3] + dy; 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 sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7]; 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) if (mv1 >= sMin - eps && mv1 <= sMax + eps)
{ {
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps); var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
if (d < minDist) Consider(ref nearest, d, m1x, m1y,
minDist = d; direction, vertexMoves: true);
} }
// Test moving vertex 2 against stationary edge j // Test moving vertex 2 against stationary edge j
@@ -403,21 +494,21 @@ namespace OpenNest.Gpu
if (mv2 >= sMin - eps && mv2 <= sMax + eps) if (mv2 >= sMin - eps && mv2 <= sMax + eps)
{ {
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps); var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
if (d < minDist) Consider(ref nearest, d, m2x, m2y,
minDist = d; direction, vertexMoves: true);
} }
} }
} }
// ── Reverse Pass: stationary vertices vs moving edges ───── // ── 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 s1x = sPrep[i * 10 + 0];
var s1y = sPrep[i * 10 + 1]; var s1y = sPrep[i * 10 + 1];
var s2x = sPrep[i * 10 + 2]; var s2x = sPrep[i * 10 + 2];
var s2y = sPrep[i * 10 + 3]; 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 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); 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) if (sv1 >= mMin - eps && sv1 <= mMax + eps)
{ {
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps); var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) Consider(ref nearest, d, s1x, s1y,
minDist = d; oppDir, vertexMoves: false);
} }
// Test stationary vertex 2 against moving edge j // Test stationary vertex 2 against moving edge j
@@ -436,13 +527,13 @@ namespace OpenNest.Gpu
if (sv2 >= mMin - eps && sv2 <= mMax + eps) if (sv2 >= mMin - eps && sv2 <= mMax + eps)
{ {
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps); var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) Consider(ref nearest, d, s2x, s2y,
minDist = d; oppDir, vertexMoves: false);
} }
} }
} }
return minDist; return nearest;
} }
private static double RayEdgeLean( private static double RayEdgeLean(
@@ -472,9 +563,7 @@ namespace OpenNest.Gpu
var ix = p1x + t * (p2x - p1x); var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx); var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
else // Vertical (Up=0, Down=1) else // Vertical (Up=0, Down=1)
{ {
@@ -489,9 +578,7 @@ namespace OpenNest.Gpu
var iy = p1y + t * (p2y - p1y); var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy); var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
} }
@@ -524,9 +611,7 @@ namespace OpenNest.Gpu
var ix = p1x + t * (p2x - p1x); var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx); var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
else // Vertical else // Vertical
{ {
@@ -541,9 +626,7 @@ namespace OpenNest.Gpu
var iy = p1y + t * (p2y - p1y); var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy); var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist >= -eps ? dist : double.MaxValue;
return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
} }
} }
@@ -54,6 +54,28 @@ namespace OpenNest.Tests.CNC
Assert.Equal(new Vector(106, 206), segments[1].To); Assert.Equal(new Vector(106, 206), segments[1].To);
} }
[Fact]
public void Enumerate_IncrementalProgramWithNonZeroFirstRapid_DoesNotDoubleCountIt()
{
// Lead-in programs (ContourCuttingStrategy output) are Incremental and
// start with a nonzero rapid from the part origin to the first pierce.
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(2, 3));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new RapidMove(1, 1));
var segments = RapidEnumerator.Enumerate(
pgm,
basePos: new Vector(100, 200),
startPos: new Vector(0, 0)
);
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(102, 203), segments[0].To);
Assert.Equal(new Vector(107, 203), segments[1].From);
Assert.Equal(new Vector(108, 204), segments[1].To);
}
[Fact] [Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce() public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{ {
@@ -222,6 +222,35 @@ public class CIFiberPostProcessorTests
Assert.Contains("13.2.4.1", ex.Message); Assert.Contains("13.2.4.1", ex.Message);
} }
[Fact]
public void Post_CutOff_PostsLastWithoutCompensationOrLeadIn()
{
var nest = MakeSquareWithHoleNest();
var plate = nest.Plates[0];
// Vertical cut-off at X=20: a bare rapid + line, no lead-in.
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutOff.Regenerate(plate, new CutOffSettings());
plate.Parts.Insert(0, new Part(cutOff.Drawing, Vector.Zero));
var lines = Lines(Post(nest));
// Cut-off follows the real part despite being first in plate order.
var partEnd = lines.IndexOf("( PART END )");
var cutOffPart = lines.IndexOf("( Part #2 )");
Assert.True(partEnd >= 0 && cutOffPart > partEnd);
var n3 = lines.IndexOf("N3:");
Assert.True(n3 > cutOffPart);
Assert.Equal("/L \"L0\"", lines[n3 + 1]);
Assert.Equal("V.E.R3=3", lines[n3 + 2]);
Assert.StartsWith("G0X20Y", lines[n3 + 3]);
Assert.Equal("/L \"L4\"", lines[n3 + 4]);
Assert.Equal("/L \"L6\"", lines[n3 + 5]);
Assert.StartsWith("G1X20Y", lines[n3 + 6]);
Assert.Equal("/L \"ZHSOFF\"", lines[n3 + 7]);
}
[Fact] [Fact]
public void Post_ValidatesTableSize() public void Post_ValidatesTableSize()
{ {
+209
View File
@@ -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);
}
}
+101 -12
View File
@@ -298,22 +298,111 @@ namespace OpenNest.Tests.Fill
Assert.Equal(32, moving.BoundingBox.Left, 7); 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) private static void AssertClearance(Part moving, Part obstacle, double spacing)
{ {
var clearance = double.MaxValue; var clearance = double.MaxValue;
foreach (var a in PartGeometry.GetPartLines(moving)) foreach (var a in PartGeometry.GetPartLines(moving))
foreach (var b in PartGeometry.GetPartLines(obstacle)) foreach (var b in PartGeometry.GetPartLines(obstacle))
{ {
Assert.False(Intersect.Intersects(a, b, out _)); Assert.False(Intersect.Intersects(a, b, out _));
clearance = System.Math.Min( clearance = System.Math.Min(
clearance, clearance,
a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint)) a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint))
); );
clearance = System.Math.Min( clearance = System.Math.Min(
clearance, clearance,
b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint)) b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint))
); );
} }
Assert.True( Assert.True(
clearance >= spacing - 1e-7, clearance >= spacing - 1e-7,
$"Clearance {clearance:R} is less than spacing {spacing:R}" $"Clearance {clearance:R} is less than spacing {spacing:R}"
+286
View File
@@ -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();
}
+4 -16
View File
@@ -10,13 +10,13 @@ using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using OpenNest.Actions; using OpenNest.Actions;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Engine;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Forms; using OpenNest.Forms;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
using OpenNest.Engine;
using OpenNest.Engine.Jobs.Placement;
namespace OpenNest.Controls namespace OpenNest.Controls
{ {
@@ -562,9 +562,6 @@ namespace OpenNest.Controls
Plate.Parts.Add(part); Plate.Parts.Add(part);
} }
public void SetStationaryParts(List<Part> parts) =>
previewManager.SetStationaryParts(parts);
public void SetActiveParts(List<Part> parts) => previewManager.SetActiveParts(parts); public void SetActiveParts(List<Part> parts) => previewManager.SetActiveParts(parts);
public void ClearPreviewParts() => previewManager.ClearPreviewParts(); public void ClearPreviewParts() => previewManager.ClearPreviewParts();
@@ -578,23 +575,12 @@ namespace OpenNest.Controls
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
var progressForm = new NestProgressForm(cts, showPlateRow: false); 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 => var progress = new Progress<NestProgress>(p =>
{ {
progressForm.UpdateProgress(p); progressForm.UpdateProgress(p);
if (p.IsOverallBest) if (p.IsOverallBest)
{
progressForm.UpdatePreview(p.BestParts);
SetActiveParts(p.BestParts); SetActiveParts(p.BestParts);
}
ActiveWorkArea = p.ActiveWorkArea; ActiveWorkArea = p.ActiveWorkArea;
}); });
@@ -749,6 +735,8 @@ namespace OpenNest.Controls
public void PushSelected(PushDirection direction) => selection.PushSelected(direction); public void PushSelected(PushDirection direction) => selection.PushSelected(direction);
public bool ExpandSelected() => selection.ExpandSelected();
public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(angle); public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(angle);
protected override void UpdateMatrix() protected override void UpdateMatrix()
+3 -29
View File
@@ -6,7 +6,6 @@ namespace OpenNest.Controls
internal class PreviewManager internal class PreviewManager
{ {
private readonly PlateView view; private readonly PlateView view;
private readonly List<LayoutPart> stationaryParts = new List<LayoutPart>();
private readonly List<LayoutPart> activeParts = new List<LayoutPart>(); private readonly List<LayoutPart> activeParts = new List<LayoutPart>();
public PreviewManager(PlateView view) public PreviewManager(PlateView view)
@@ -14,32 +13,11 @@ namespace OpenNest.Controls
this.view = view; this.view = view;
} }
public IReadOnlyList<LayoutPart> PreviewParts => public IReadOnlyList<LayoutPart> PreviewParts => activeParts;
activeParts.Count > 0 ? activeParts : stationaryParts;
public Brush PreviewBrush => public Brush PreviewBrush => view.ColorScheme.ActivePreviewPartBrush;
activeParts.Count > 0
? view.ColorScheme.ActivePreviewPartBrush
: view.ColorScheme.PreviewPartBrush;
public Pen PreviewPen => public Pen PreviewPen => view.ColorScheme.ActivePreviewPartPen;
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 void SetActiveParts(List<Part> parts) public void SetActiveParts(List<Part> parts)
{ {
@@ -56,7 +34,6 @@ namespace OpenNest.Controls
public void ClearPreviewParts() public void ClearPreviewParts()
{ {
stationaryParts.Clear();
activeParts.Clear(); activeParts.Clear();
view.Invalidate(); view.Invalidate();
} }
@@ -69,19 +46,16 @@ namespace OpenNest.Controls
view.Plate.Parts.Add(part); view.Plate.Parts.Add(part);
} }
stationaryParts.Clear();
activeParts.Clear(); activeParts.Clear();
} }
public void Update() public void Update()
{ {
stationaryParts.ForEach(p => p.Update(view));
activeParts.ForEach(p => p.Update(view)); activeParts.ForEach(p => p.Update(view));
} }
public void Clear() public void Clear()
{ {
stationaryParts.Clear();
activeParts.Clear(); activeParts.Clear();
} }
} }
+30
View File
@@ -167,6 +167,36 @@ namespace OpenNest.Controls
view.Invalidate(); 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) public LayoutPart GetPartAtControlPoint(Point pt)
{ {
var pt2 = view.PointControlToGraph(pt); var pt2 = view.PointControlToGraph(pt);
+32 -54
View File
@@ -5,6 +5,7 @@ using System.Drawing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Windows.Forms; using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.IO.Bom; using OpenNest.IO.Bom;
@@ -16,7 +17,7 @@ namespace OpenNest.Forms
private List<BomPartRow> _parts; private List<BomPartRow> _parts;
private Dictionary<string, GroupSettings> _groupSettings; private Dictionary<string, GroupSettings> _groupSettings;
private bool _suppressRegroup; private bool _suppressRegroup;
private Nest.PlateSettings _templateDefaults; private NestDefaults _defaults;
public Form MdiParentForm { get; set; } public Form MdiParentForm { get; set; }
@@ -25,37 +26,14 @@ namespace OpenNest.Forms
InitializeComponent(); InitializeComponent();
_parts = new List<BomPartRow>(); _parts = new List<BomPartRow>();
_groupSettings = new Dictionary<string, GroupSettings>(); _groupSettings = new Dictionary<string, GroupSettings>();
_templateDefaults = LoadTemplateDefaults(); _defaults = NestDefaults.Load(NestDefaults.DefaultPath);
ApplyTemplateDefaults(); ApplyDefaults();
} }
private Nest.PlateSettings LoadTemplateDefaults() private void ApplyDefaults()
{ {
var templatePath = Properties.Settings.Default.NestTemplatePath; txtPlateWidth.Text = _defaults.Size.Width.ToString("0.####");
if (File.Exists(templatePath)) txtPlateLength.Text = _defaults.Size.Length.ToString("0.####");
{
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.####");
} }
#region File Browsing #region File Browsing
@@ -147,9 +125,9 @@ namespace OpenNest.Forms
var matchedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var matchedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in analysis.Groups) foreach (var group in analysis.Groups)
foreach (var part in group.Parts) foreach (var part in group.Parts)
if (part.DxfPath != null) if (part.DxfPath != null)
matchedPaths[part.Item.FileName ?? ""] = part.DxfPath; matchedPaths[part.Item.FileName ?? ""] = part.DxfPath;
_parts = new List<BomPartRow>(); _parts = new List<BomPartRow>();
@@ -290,10 +268,10 @@ namespace OpenNest.Forms
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w) var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w)
? w ? w
: _templateDefaults.Size.Width; : _defaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var l) var defaultLength = double.TryParse(txtPlateLength.Text, out var l)
? l ? l
: _templateDefaults.Size.Length; : _defaults.Size.Length;
var groups = _parts var groups = _parts
.Where(p => .Where(p =>
@@ -336,11 +314,11 @@ namespace OpenNest.Forms
group.Sum(p => p.Qty ?? 0), group.Sum(p => p.Qty ?? 0),
existing ? gs.PlateWidth : defaultWidth, existing ? gs.PlateWidth : defaultWidth,
existing ? gs.PlateLength : defaultLength, existing ? gs.PlateLength : defaultLength,
existing ? gs.PartSpacing : _templateDefaults.PartSpacing, existing ? gs.PartSpacing : _defaults.PartSpacing,
existing ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left, existing ? gs.EdgeLeft : _defaults.EdgeSpacing.Left,
existing ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom, existing ? gs.EdgeBottom : _defaults.EdgeSpacing.Bottom,
existing ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right, existing ? gs.EdgeRight : _defaults.EdgeSpacing.Right,
existing ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top existing ? gs.EdgeTop : _defaults.EdgeSpacing.Top
); );
} }
@@ -374,23 +352,23 @@ namespace OpenNest.Forms
{ {
PlateWidth = row["Plate Width"] is double pw PlateWidth = row["Plate Width"] is double pw
? pw ? pw
: _templateDefaults.Size.Width, : _defaults.Size.Width,
PlateLength = row["Plate Length"] is double pl PlateLength = row["Plate Length"] is double pl
? pl ? pl
: _templateDefaults.Size.Length, : _defaults.Size.Length,
PartSpacing = row["Part Spacing"] is double ps PartSpacing = row["Part Spacing"] is double ps
? ps ? ps
: _templateDefaults.PartSpacing, : _defaults.PartSpacing,
EdgeLeft = row["Edge Left"] is double el EdgeLeft = row["Edge Left"] is double el
? el ? el
: _templateDefaults.EdgeSpacing.Left, : _defaults.EdgeSpacing.Left,
EdgeBottom = row["Edge Bottom"] is double eb EdgeBottom = row["Edge Bottom"] is double eb
? eb ? eb
: _templateDefaults.EdgeSpacing.Bottom, : _defaults.EdgeSpacing.Bottom,
EdgeRight = row["Edge Right"] is double er EdgeRight = row["Edge Right"] is double er
? er ? er
: _templateDefaults.EdgeSpacing.Right, : _defaults.EdgeSpacing.Right,
EdgeTop = row["Edge Top"] is double et ? et : _templateDefaults.EdgeSpacing.Top, 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) var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw)
? dw ? dw
: _templateDefaults.Size.Width; : _defaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl) var defaultLength = double.TryParse(txtPlateLength.Text, out var dl)
? dl ? dl
: _templateDefaults.Size.Length; : _defaults.Size.Length;
var groups = _parts var groups = _parts
.Where(p => .Where(p =>
@@ -477,11 +455,11 @@ namespace OpenNest.Forms
var hasSettings = _groupSettings.TryGetValue(key, out var gs); var hasSettings = _groupSettings.TryGetValue(key, out var gs);
var plateWidth = hasSettings ? gs.PlateWidth : defaultWidth; var plateWidth = hasSettings ? gs.PlateWidth : defaultWidth;
var plateLength = hasSettings ? gs.PlateLength : defaultLength; var plateLength = hasSettings ? gs.PlateLength : defaultLength;
var partSpacing = hasSettings ? gs.PartSpacing : _templateDefaults.PartSpacing; var partSpacing = hasSettings ? gs.PartSpacing : _defaults.PartSpacing;
var edgeLeft = hasSettings ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left; var edgeLeft = hasSettings ? gs.EdgeLeft : _defaults.EdgeSpacing.Left;
var edgeBottom = hasSettings ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom; var edgeBottom = hasSettings ? gs.EdgeBottom : _defaults.EdgeSpacing.Bottom;
var edgeRight = hasSettings ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right; var edgeRight = hasSettings ? gs.EdgeRight : _defaults.EdgeSpacing.Right;
var edgeTop = hasSettings ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top; var edgeTop = hasSettings ? gs.EdgeTop : _defaults.EdgeSpacing.Top;
var nestName = $"{jobName} - {thickness:0.###} {material}"; var nestName = $"{jobName} - {thickness:0.###} {material}";
var nest = new Nest(nestName); var nest = new Nest(nestName);
@@ -490,7 +468,7 @@ namespace OpenNest.Forms
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength); nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
nest.Thickness = thickness; nest.Thickness = thickness;
nest.Material = new Material(material); nest.Material = new Material(material);
nest.PlateDefaults.Quadrant = _templateDefaults.Quadrant; nest.PlateDefaults.Quadrant = _defaults.Quadrant;
nest.PlateDefaults.PartSpacing = partSpacing; nest.PlateDefaults.PartSpacing = partSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing( nest.PlateDefaults.EdgeSpacing = new Spacing(
edgeLeft, edgeLeft,
+6 -22
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Drawing; using System.Drawing;
@@ -301,16 +301,11 @@ namespace OpenNest.Forms
public void SaveAs() public void SaveAs()
{ {
var dlg = new SaveFileDialog(); var dlg = new SaveFileDialog();
dlg.Filter = $"{NestFormat.FileFilter}|Template File|*.nstdot"; dlg.Filter = NestFormat.FileFilter;
dlg.FileName = Nest.Name; dlg.FileName = Nest.Name;
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK) if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{ SaveAs(dlg.FileName);
if (dlg.FilterIndex == 2)
SaveTemplate(dlg.FileName);
else
SaveAs(dlg.FileName);
}
} }
public void SaveAs(string path) public void SaveAs(string path)
@@ -319,17 +314,6 @@ namespace OpenNest.Forms
Text = Document.Name; 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() public void Import()
{ {
var dlg = new OpenFileDialog(); var dlg = new OpenFileDialog();
@@ -943,9 +927,9 @@ namespace OpenNest.Forms
// Refresh all parts to use the updated programs // Refresh all parts to use the updated programs
foreach (var plate in Nest.Plates) foreach (var plate in Nest.Plates)
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
if (!part.BaseDrawing.IsCutOff) if (!part.BaseDrawing.IsCutOff)
part.Update(); part.Update();
UpdateDrawingList(); UpdateDrawingList();
PlateView.Invalidate(); PlateView.Invalidate();
+28 -1
View File
@@ -65,6 +65,7 @@
mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsExpandSpacing = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem();
@@ -81,6 +82,8 @@
mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem(); mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator(); toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator();
mnuToolsMachineConfig = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsMachineConfig = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsNestDefaults = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsSaveCurrentAsDefaults = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem(); mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem();
mnuNest = new System.Windows.Forms.ToolStripMenuItem(); mnuNest = new System.Windows.Forms.ToolStripMenuItem();
mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem();
@@ -419,7 +422,7 @@
// //
// mnuTools // 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.Name = "mnuTools";
mnuTools.Size = new System.Drawing.Size(47, 20); mnuTools.Size = new System.Drawing.Size(47, 20);
mnuTools.Text = "&Tools"; mnuTools.Text = "&Tools";
@@ -445,6 +448,13 @@
this.mnuToolsPatternTile.Text = "Pattern Tile"; this.mnuToolsPatternTile.Text = "Pattern Tile";
this.mnuToolsPatternTile.Click += PatternTile_Click; 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
// //
mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical }); 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.Text = "Machine Configuration...";
mnuToolsMachineConfig.Click += MachineConfig_Click; 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
// //
mnuToolsOptions.Name = "mnuToolsOptions"; mnuToolsOptions.Name = "mnuToolsOptions";
@@ -1217,6 +1241,8 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5; private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5;
private System.Windows.Forms.ToolStripMenuItem mnuTools; private System.Windows.Forms.ToolStripMenuItem mnuTools;
private System.Windows.Forms.ToolStripMenuItem mnuToolsMachineConfig; 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 mnuToolsOptions;
private System.Windows.Forms.ToolStripMenuItem mnuNest; private System.Windows.Forms.ToolStripMenuItem mnuNest;
private System.Windows.Forms.ToolStripMenuItem mnuNestEdit; private System.Windows.Forms.ToolStripMenuItem mnuNestEdit;
@@ -1306,6 +1332,7 @@
private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea; private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea;
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer; private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile; private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
private System.Windows.Forms.ToolStripMenuItem mnuToolsExpandSpacing;
private System.Windows.Forms.ToolStripButton btnSaveAs; private System.Windows.Forms.ToolStripButton btnSaveAs;
private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem;
private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel; private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel;
+116 -53
View File
@@ -10,16 +10,16 @@ using System.Windows.Forms;
using OpenNest.Actions; using OpenNest.Actions;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Data; using OpenNest.Data;
using OpenNest.Engine;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Gpu; using OpenNest.Gpu;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Properties; using OpenNest.Properties;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
namespace OpenNest.Forms namespace OpenNest.Forms
{ {
@@ -90,14 +90,38 @@ namespace OpenNest.Forms
private Nest CreateDefaultNest() private Nest CreateDefaultNest()
{ {
var nest = new Nest(); var nest = new Nest();
nest.Units = Properties.Settings.Default.DefaultUnit; LoadNestDefaults().ApplyTo(nest);
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;
return 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) private string GetNestName(DateTime date, int id)
{ {
var year = (date.Year % 100).ToString("D2"); var year = (date.Year % 100).ToString("D2");
@@ -184,6 +208,8 @@ namespace OpenNest.Forms
mnuWindow.Visible = hasValue; mnuWindow.Visible = hasValue;
mnuToolsAlign.Visible = hasValue; mnuToolsAlign.Visible = hasValue;
mnuToolsMeasureArea.Visible = hasValue; mnuToolsMeasureArea.Visible = hasValue;
mnuToolsExpandSpacing.Visible = hasValue;
mnuToolsSaveCurrentAsDefaults.Visible = hasValue;
toolStripMenuItem14.Visible = hasValue; toolStripMenuItem14.Visible = hasValue;
mnuSetOffsetIncrement.Visible = hasValue; mnuSetOffsetIncrement.Visible = hasValue;
@@ -453,10 +479,52 @@ namespace OpenNest.Forms
{ {
base.OnLoad(e); base.OnLoad(e);
MigrateNestTemplate();
if (Settings.Default.CreateNewNestOnOpen) if (Settings.Default.CreateNewNestOnOpen)
New_Click(this, new EventArgs()); 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) protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{ {
base.OnClosing(e); base.OnClosing(e);
@@ -472,30 +540,7 @@ namespace OpenNest.Forms
var windowState = var windowState =
ActiveMdiChild != null ? ActiveMdiChild.WindowState : FormWindowState.Maximized; ActiveMdiChild != null ? ActiveMdiChild.WindowState : FormWindowState.Maximized;
Nest nest; var nest = CreateDefaultNest();
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();
}
nest.DateCreated = DateTime.Now; nest.DateCreated = DateTime.Now;
nest.DateLastModified = 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) private void PatternTile_Click(object sender, EventArgs e)
{ {
if (activeForm == null) if (activeForm == null)
@@ -819,6 +878,25 @@ namespace OpenNest.Forms
form.ShowDialog(); 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) private void MachineConfig_Click(object sender, EventArgs e)
{ {
var appDataPath = Path.Combine( var appDataPath = Path.Combine(
@@ -1040,7 +1118,6 @@ namespace OpenNest.Forms
nestingCts = new CancellationTokenSource(); nestingCts = new CancellationTokenSource();
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true); var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
progressForm.PreviewPlate = CreatePreviewPlate(activeForm.PlateView.Plate);
var jobEngineName = EngineSelection.IsFillStrategy(EngineSelection.EngineName) var jobEngineName = EngineSelection.IsFillStrategy(EngineSelection.EngineName)
? null ? null
@@ -1052,9 +1129,7 @@ namespace OpenNest.Forms
progressForm.UpdateProgress(p); progressForm.UpdateProgress(p);
if (p.IsOverallBest) if (p.IsOverallBest)
progressForm.UpdatePreview(p.BestParts); activeForm.PlateView.SetActiveParts(p.BestParts);
activeForm.PlateView.SetActiveParts(p.BestParts);
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea; activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
}); });
@@ -1152,7 +1227,7 @@ namespace OpenNest.Forms
{ {
if (pr.IsNew) if (pr.IsNew)
{ {
var plate = GetOrCreatePlate(progressForm); var plate = GetOrCreatePlate();
plate.Size = pr.Plate.Size; plate.Size = pr.Plate.Size;
plate.Parts.AddRange(pr.Parts); plate.Parts.AddRange(pr.Parts);
} }
@@ -1172,7 +1247,7 @@ namespace OpenNest.Forms
if (remaining.Count == 0 || token.IsCancellationRequested) if (remaining.Count == 0 || token.IsCancellationRequested)
break; break;
var plate = GetOrCreatePlate(progressForm); var plate = GetOrCreatePlate();
var placed = await NestSinglePlateAsync( var placed = await NestSinglePlateAsync(
plate, plate,
@@ -1242,7 +1317,7 @@ namespace OpenNest.Forms
if (parts.Count == 0) if (parts.Count == 0)
continue; continue;
var plate = GetOrCreatePlate(progressForm); var plate = GetOrCreatePlate();
plate.Size = sheet.Stock.Size; plate.Size = sheet.Stock.Size;
plate.Parts.AddRange(parts); plate.Parts.AddRange(parts);
} }
@@ -1260,11 +1335,10 @@ namespace OpenNest.Forms
); );
} }
private Plate GetOrCreatePlate(NestProgressForm progressForm) private Plate GetOrCreatePlate()
{ {
var plate = activeForm.PlateManager.GetOrCreateEmpty(); var plate = activeForm.PlateManager.GetOrCreateEmpty();
activeForm.PlateManager.LoadLast(); activeForm.PlateManager.LoadLast();
progressForm.PreviewPlate = CreatePreviewPlate(plate);
return plate; return plate;
} }
@@ -1340,17 +1414,6 @@ namespace OpenNest.Forms
return true; 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) private void SequenceAllPlates_Click(object sender, EventArgs e)
{ {
if (activeForm == null) if (activeForm == null)
+364
View File
@@ -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;
}
}
+62
View File
@@ -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 &gt; 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
View File
@@ -17,11 +17,6 @@ namespace OpenNest.Forms
private void InitializeComponent() 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(); phaseStepper = new OpenNest.Controls.PhaseStepperControl();
resultsPanel = new System.Windows.Forms.Panel(); resultsPanel = new System.Windows.Forms.Panel();
resultsTable = new System.Windows.Forms.TableLayoutPanel(); resultsTable = new System.Windows.Forms.TableLayoutPanel();
@@ -46,8 +41,6 @@ namespace OpenNest.Forms
buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
stopButton = new System.Windows.Forms.Button(); stopButton = new System.Windows.Forms.Button();
acceptButton = 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(); statsPanel = new System.Windows.Forms.Panel();
resultsPanel.SuspendLayout(); resultsPanel.SuspendLayout();
resultsTable.SuspendLayout(); resultsTable.SuspendLayout();
@@ -55,10 +48,6 @@ namespace OpenNest.Forms
statusPanel.SuspendLayout(); statusPanel.SuspendLayout();
statusTable.SuspendLayout(); statusTable.SuspendLayout();
buttonPanel.SuspendLayout(); buttonPanel.SuspendLayout();
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
splitContainer.Panel1.SuspendLayout();
splitContainer.Panel2.SuspendLayout();
splitContainer.SuspendLayout();
statsPanel.SuspendLayout(); statsPanel.SuspendLayout();
SuspendLayout(); SuspendLayout();
// //
@@ -365,74 +354,6 @@ namespace OpenNest.Forms
acceptButton.UseVisualStyleBackColor = true; acceptButton.UseVisualStyleBackColor = true;
acceptButton.Click += AcceptButton_Click; 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
// //
statsPanel.AutoScroll = true; statsPanel.AutoScroll = true;
@@ -450,12 +371,12 @@ namespace OpenNest.Forms
// //
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(626, 341); ClientSize = new System.Drawing.Size(266, 341);
Controls.Add(splitContainer); Controls.Add(statsPanel);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
MaximizeBox = false; MaximizeBox = false;
MinimizeBox = false; MinimizeBox = false;
MinimumSize = new System.Drawing.Size(550, 380); MinimumSize = new System.Drawing.Size(320, 380);
Name = "NestProgressForm"; Name = "NestProgressForm";
ShowInTaskbar = false; ShowInTaskbar = false;
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
@@ -471,10 +392,6 @@ namespace OpenNest.Forms
statusTable.ResumeLayout(false); statusTable.ResumeLayout(false);
statusTable.PerformLayout(); statusTable.PerformLayout();
buttonPanel.ResumeLayout(false); buttonPanel.ResumeLayout(false);
splitContainer.Panel1.ResumeLayout(false);
splitContainer.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)splitContainer).EndInit();
splitContainer.ResumeLayout(false);
statsPanel.ResumeLayout(false); statsPanel.ResumeLayout(false);
statsPanel.PerformLayout(); statsPanel.PerformLayout();
ResumeLayout(false); ResumeLayout(false);
@@ -506,8 +423,6 @@ namespace OpenNest.Forms
private System.Windows.Forms.FlowLayoutPanel buttonPanel; private System.Windows.Forms.FlowLayoutPanel buttonPanel;
private System.Windows.Forms.Button acceptButton; private System.Windows.Forms.Button acceptButton;
private System.Windows.Forms.Button stopButton; private System.Windows.Forms.Button stopButton;
private System.Windows.Forms.SplitContainer splitContainer;
private System.Windows.Forms.Panel statsPanel; private System.Windows.Forms.Panel statsPanel;
private Controls.PlateView previewPlateView;
} }
} }
-28
View File
@@ -38,23 +38,11 @@ namespace OpenNest.Forms
set => acceptButton.Visible = value; set => acceptButton.Visible = value;
} }
public Plate PreviewPlate
{
get => previewPlateView.Plate;
set
{
previewPlateView.Plate = value;
previewPlateView.ZoomToFit();
}
}
public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true) public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)
{ {
this.cts = cts; this.cts = cts;
InitializeComponent(); InitializeComponent();
previewPlateView.AllowSelect = false;
if (!showPlateRow) if (!showPlateRow)
{ {
plateLabel.Visible = false; plateLabel.Visible = false;
@@ -104,20 +92,6 @@ namespace OpenNest.Forms
: progress.Phase.DisplayName(); : 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() public void ShowCompleted()
{ {
if (IsDisposed || !IsHandleCreated) if (IsDisposed || !IsHandleCreated)
@@ -178,8 +152,6 @@ namespace OpenNest.Forms
if (!cts.IsCancellationRequested) if (!cts.IsCancellationRequested)
cts.Cancel(); cts.Cancel();
previewPlateView.Dispose();
base.OnFormClosing(e); base.OnFormClosing(e);
} }
+9 -50
View File
@@ -34,9 +34,6 @@
this.toolTip1 = new System.Windows.Forms.ToolTip(this.components); this.toolTip1 = new System.Windows.Forms.ToolTip(this.components);
this.numericUpDown1 = new OpenNest.Controls.NumericUpDown(); this.numericUpDown1 = new OpenNest.Controls.NumericUpDown();
this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); 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.saveButton = new System.Windows.Forms.Button();
this.cancelButton = new System.Windows.Forms.Button(); this.cancelButton = new System.Windows.Forms.Button();
this.bottomPanel1 = new OpenNest.Controls.BottomPanel(); 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.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, 297F));
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 100F)); 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.label1, 0, 0);
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0); this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 1);
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0); this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 1);
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 2); this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 2);
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 2); this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 0);
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.Location = new System.Drawing.Point(12, 12); this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
this.tableLayoutPanel1.Name = "tableLayoutPanel1"; this.tableLayoutPanel1.Name = "tableLayoutPanel1";
this.tableLayoutPanel1.RowCount = 4; this.tableLayoutPanel1.RowCount = 3;
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, 33.33333F));
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, 33.33333F));
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, 33.33333F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160); this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
this.tableLayoutPanel1.TabIndex = 0; 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 // saveButton
// //
this.saveButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); 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 System.Windows.Forms.ToolTip toolTip1;
private Controls.BottomPanel bottomPanel1; private Controls.BottomPanel bottomPanel1;
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; 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.DataGridView strategyGrid;
private System.Windows.Forms.GroupBox strategyGroupBox; private System.Windows.Forms.GroupBox strategyGroupBox;
private System.Windows.Forms.Label colorSchemeLabel; private System.Windows.Forms.Label colorSchemeLabel;
-11
View File
@@ -72,7 +72,6 @@ namespace OpenNest.Forms
private void LoadSettings() private void LoadSettings()
{ {
textBox1.Text = Settings.Default.NestTemplatePath;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen; checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor; numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
@@ -92,7 +91,6 @@ namespace OpenNest.Forms
private void SaveSettings() private void SaveSettings()
{ {
Settings.Default.NestTemplatePath = textBox1.Text;
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked; Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value; Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
Settings.Default.ActiveColorScheme = Settings.Default.ActiveColorScheme =
@@ -144,14 +142,5 @@ namespace OpenNest.Forms
{ {
SaveSettings(); 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;
}
} }
} }
+2
View File
@@ -158,6 +158,8 @@ namespace OpenNest.Properties {
[global::System.Configuration.UserScopedSettingAttribute()] [global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")] [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 { public string NestTemplatePath {
get { get {
return ((string)(this["NestTemplatePath"])); return ((string)(this["NestTemplatePath"]));
@@ -135,8 +135,14 @@ namespace OpenNest.Posts.CincinnatiCIFiber
private int WriteSheet(Plate plate, TextWriter w, int contourNumber) private int WriteSheet(Plate plate, TextWriter w, int contourNumber)
{ {
// Cut-offs run last: severing the sheet first would free the
// skeleton before the parts are cut (matches the CL post).
var ordered = plate
.Parts.Where(p => !p.BaseDrawing.IsCutOff)
.Concat(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var partNumber = 0; var partNumber = 0;
foreach (var part in plate.Parts) foreach (var part in ordered)
{ {
partNumber++; partNumber++;
contourNumber = WritePart(part, partNumber, w, contourNumber); contourNumber = WritePart(part, partNumber, w, contourNumber);
@@ -158,10 +164,14 @@ namespace OpenNest.Posts.CincinnatiCIFiber
if (_config.SkipScribe) if (_config.SkipScribe)
contours = contours.Where(c => !IsScribeContour(c)).ToList(); contours = contours.Where(c => !IsScribeContour(c)).ToList();
var isCutOff = part.BaseDrawing.IsCutOff;
foreach (var contour in contours) foreach (var contour in contours)
{ {
contourNumber++; contourNumber++;
WriteContour(contour, contourNumber, w); if (isCutOff)
WriteCutOffContour(contour, contourNumber, w);
else
WriteContour(contour, contourNumber, w);
} }
CIFiberFormatter.Line(w, "( PART END )"); CIFiberFormatter.Line(w, "( PART END )");
@@ -227,6 +237,38 @@ namespace OpenNest.Posts.CincinnatiCIFiber
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd)); CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
} }
/// <summary>
/// Cut-offs are open straight lines with no lead-in: the line is the
/// beam centreline (CutOffSettings clearance already allows for kerf)
/// and an open line has no inside/outside, so no G41/G42 is selected
/// and the §13.2.4.1 linear-lead-in rule does not apply. The exterior
/// lead layer still runs so the pierce sequence matches a perimeter.
/// </summary>
private void WriteCutOffContour(CIFiberContour contour, int contourNumber, TextWriter w)
{
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}");
CIFiberFormatter.Line(w, $"G0X{Fmt(contour.Pierce.X)}Y{Fmt(contour.Pierce.Y)}");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerExteriorLeadin));
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCut));
var prev = contour.Pierce;
if (contour.LeadIn != null)
{
CIFiberFormatter.Line(w, FormatMotion(contour.LeadIn, prev));
prev = contour.LeadIn.EndPoint;
}
foreach (var motion in contour.LeadInExtra.Concat(contour.Cuts))
{
CIFiberFormatter.Line(w, FormatMotion(motion, prev));
prev = motion.EndPoint;
}
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
}
private string ResolvePartComment(Part part) private string ResolvePartComment(Part part)
{ {
if (!string.IsNullOrWhiteSpace(_config.PartComment)) if (!string.IsNullOrWhiteSpace(_config.PartComment))
+1 -1
View File
@@ -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. - **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. - **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). - **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 ## Requirements
Binary file not shown.
+76
View File
@@ -0,0 +1,76 @@
# 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.
- Sheet cut-offs post after every part on their sheet. Each segment is an open
line with no lead-in: rapid to its start, `/L "L4"`, `/L "L6"`, the `G1`,
and `/L "ZHSOFF"`. No `G41`/`G42` is selected, because the line is the beam
centreline and has no inside or outside. Whether the `L4` macro runs
correctly without a following lead-in move has not been confirmed on the
machine.
- 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,
cut-off ordering and output,
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.
+8 -2
View File
@@ -2,8 +2,14 @@
Reference for the G-code structure emitted by `OpenNest.Posts.Cincinnati`. Reference for the G-code structure emitted by `OpenNest.Posts.Cincinnati`.
Every code listed here maps to a section in the Cincinnati Laser Programming 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). Manual (EM-423 R-02/11). Section numbers in parentheses (e.g. `§1.52`)
Section numbers in parentheses (e.g. `§1.52`) refer to the manual. 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 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 to, or flag it here as a known custom extension. "Custom code" in this project
+37
View File
@@ -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.
+1 -1
View File
@@ -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. 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 ### Reproduced U-shaped part