diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs index 99e54eb..0bbd577 100644 --- a/OpenNest.Core/Splitting/DrawingSplitter.cs +++ b/OpenNest.Core/Splitting/DrawingSplitter.cs @@ -32,12 +32,20 @@ public static class DrawingSplitter var regions = BuildClipRegions(sortedLines, bounds); var feature = GetFeature(parameters.Type); + // Polygonize cutouts once. Used for trimming feature edges (so cut lines + // don't travel through a cutout interior) and for hole/containment tests + // in the final component-assembly pass. + var cutoutPolygons = profile.Cutouts + .Select(c => c.ToPolygon()) + .Where(p => p != null) + .ToList(); + var results = new List(); var pieceIndex = 1; foreach (var region in regions) { - var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters); + var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons); if (pieceEntities.Count == 0) continue; @@ -47,9 +55,16 @@ public static class DrawingSplitter allEntities.AddRange(pieceEntities); allEntities.AddRange(cutoutEntities); - var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region); - results.Add(piece); - pieceIndex++; + // A single region may yield multiple physically-disjoint pieces when an + // interior cutout spans across it. Group the region's entities into + // connected closed loops, nest holes by containment, and emit one + // Drawing per outer loop (with its contained holes). + foreach (var pieceOfRegion in AssemblePieces(allEntities)) + { + var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region); + results.Add(piece); + pieceIndex++; + } } return results; @@ -218,100 +233,108 @@ public static class DrawingSplitter /// and stitching in feature edges. No polygon clipping library needed. /// private static List ClipPerimeterToRegion(Shape perimeter, Box region, - List splitLines, ISplitFeature feature, SplitParameters parameters) + List splitLines, ISplitFeature feature, SplitParameters parameters, + List cutoutPolygons) { var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var entities = new List(); - var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); foreach (var entity in perimeter.Entities) - { - ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); - } + ProcessEntity(entity, region, entities); if (entities.Count == 0) return new List(); - InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters); - EnsurePerimeterWinding(entities); + InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons); + // Winding is handled later in AssemblePieces, once connected components + // are known. At this stage the piece may still be multiple disjoint loops. return entities; } - private static void ProcessEntity(Entity entity, Box region, - List boundarySplitLines, List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) - { - // Find the first boundary split line this entity crosses - SplitLine crossedLine = null; - Vector? intersectionPt = null; - - foreach (var sl in boundarySplitLines) - { - if (SplitLineIntersect.CrossesSplitLine(entity, sl)) - { - var pt = SplitLineIntersect.FindIntersection(entity, sl); - if (pt != null) - { - crossedLine = sl; - intersectionPt = pt; - break; - } - } - } - - if (crossedLine != null) - { - // Entity crosses a split line — split it and keep the half inside the region - var regionSide = RegionSideOf(region, crossedLine); - var startPt = GetStartPoint(entity); - var startSide = SplitLineIntersect.SideOf(startPt, crossedLine); - var startInRegion = startSide == regionSide || startSide == 0; - - SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints); - } - else - { - // Entity doesn't cross any boundary split line — check if it's inside the region - var mid = MidPoint(entity); - if (region.Contains(mid)) - entities.Add(entity); - } - } - - private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion, - SplitLine crossedLine, List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) + private static void ProcessEntity(Entity entity, Box region, List entities) { if (entity is Line line) { - var (first, second) = line.SplitAt(point); - if (startInRegion) - { - if (first != null) entities.Add(first); - splitPoints.Add((point, crossedLine, true)); - } - else - { - splitPoints.Add((point, crossedLine, false)); - if (second != null) entities.Add(second); - } + var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region); + if (clipped == null) return; + if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return; + entities.Add(new Line(clipped.Value.Start, clipped.Value.End)); + return; } - else if (entity is Arc arc) + + if (entity is Arc arc) { - var (first, second) = arc.SplitAt(point); - if (startInRegion) - { - if (first != null) entities.Add(first); - splitPoints.Add((point, crossedLine, true)); - } - else - { - splitPoints.Add((point, crossedLine, false)); - if (second != null) entities.Add(second); - } + foreach (var sub in ClipArcToRegion(arc, region)) + entities.Add(sub); + return; } } + /// + /// Clips an arc against the four edges of a region box. Returns the sub-arcs + /// whose midpoints lie inside the region. Uses line-arc intersection to find + /// split points, then iteratively bisects the arc at each crossing. + /// + private static List ClipArcToRegion(Arc arc, Box region) + { + var edges = new[] + { + new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)), + new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)), + new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)), + new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom)) + }; + + var arcs = new List { arc }; + + foreach (var edge in edges) + { + var next = new List(); + foreach (var a in arcs) + { + if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0) + { + next.Add(a); + continue; + } + + // Split the arc at each intersection that actually lies on one of + // the working sub-arcs. Prior splits may make some original hits + // moot for the sub-arc that now holds them. + var working = new List { a }; + foreach (var pt in pts) + { + var replaced = new List(); + foreach (var w in working) + { + var onArc = OpenNest.Math.Angle.IsBetweenRad( + w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed); + if (!onArc) + { + replaced.Add(w); + continue; + } + + var (first, second) = w.SplitAt(pt); + if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first); + if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second); + } + working = replaced; + } + next.AddRange(working); + } + arcs = next; + } + + var result = new List(); + foreach (var a in arcs) + { + if (region.Contains(a.MidPoint())) + result.Add(a); + } + return result; + } + /// /// Returns split lines whose position matches a boundary edge of the region. /// @@ -365,104 +388,157 @@ public static class DrawingSplitter } /// - /// Groups split points by split line, pairs exits with entries, and generates feature edges. + /// For each boundary split line of the region, generates a feature edge that + /// spans the full region boundary along that split line and trims it against + /// interior cutouts. This produces one (or zero) feature edge per contiguous + /// material interval on the boundary, handling corner regions (one perimeter + /// crossing), spanning cutouts (two holes puncturing the line), and + /// normal mid-part splits uniformly. /// private static void InsertFeatureEdges(List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints, Box region, List boundarySplitLines, - ISplitFeature feature, SplitParameters parameters) + ISplitFeature feature, SplitParameters parameters, + List cutoutPolygons) { - // Group split points by their split line - var groups = new Dictionary>(); - foreach (var sp in splitPoints) + foreach (var sl in boundarySplitLines) { - if (!groups.ContainsKey(sp.Line)) - groups[sp.Line] = new List<(Vector, bool)>(); - groups[sp.Line].Add((sp.Point, sp.IsExit)); - } + var isVertical = sl.Axis == CutOffAxis.Vertical; + var extentStart = isVertical ? region.Bottom : region.Left; + var extentEnd = isVertical ? region.Top : region.Right; - foreach (var kvp in groups) - { - var sl = kvp.Key; - var points = kvp.Value; - - // Pair each exit with the next entry - var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList(); - var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList(); - - if (exits.Count == 0 || entries.Count == 0) + if (extentEnd - extentStart < Math.Tolerance.Epsilon) continue; - // For each exit, find the matching entry to form the feature edge span - // Sort exits and entries by their position along the split line - var isVertical = sl.Axis == CutOffAxis.Vertical; - exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList(); - entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList(); + var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); + var isNegativeSide = RegionSideOf(region, sl) < 0; + var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; - // Pair them up: each exit with the next entry (or vice versa) - var pairCount = System.Math.Min(exits.Count, entries.Count); - for (var i = 0; i < pairCount; i++) + // Trim any line segments that cross a cutout — cut lines must never + // travel through a hole. + featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons); + + entities.AddRange(featureEdge); + } + } + + /// + /// Subtracts any portions of line entities in that + /// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are + /// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap + /// tabs, spike-groove) can be added later if a test demands it. + /// + private static List TrimFeatureEdgeAgainstCutouts(List featureEdge, List cutoutPolygons) + { + if (cutoutPolygons.Count == 0 || featureEdge.Count == 0) + return featureEdge; + + var result = new List(); + foreach (var entity in featureEdge) + { + if (entity is Line line) + result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons)); + else + result.Add(entity); + } + return result; + } + + /// + /// Returns the sub-segments of that lie outside every + /// cutout polygon. Handles the common axis-aligned feature-edge case exactly. + /// + private static List SubtractCutoutsFromLine(Line line, List cutoutPolygons) + { + // Collect parameter values t in [0,1] where the line crosses any cutout edge. + var ts = new List { 0.0, 1.0 }; + foreach (var poly in cutoutPolygons) + { + var polyLines = poly.ToLines(); + foreach (var edge in polyLines) { - var exitPt = exits[i]; - var entryPt = entries[i]; - - var extentStart = isVertical - ? System.Math.Min(exitPt.Y, entryPt.Y) - : System.Math.Min(exitPt.X, entryPt.X); - var extentEnd = isVertical - ? System.Math.Max(exitPt.Y, entryPt.Y) - : System.Math.Max(exitPt.X, entryPt.X); - - var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); - - var isNegativeSide = RegionSideOf(region, sl) < 0; - var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; - - if (featureEdge.Count > 0) - featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis); - - entities.AddRange(featureEdge); + if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t)) + { + if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon) + ts.Add(t); + } } } - } - private static List AlignFeatureDirection(List featureEdge, Vector start, Vector end, CutOffAxis axis) - { - var featureStart = GetStartPoint(featureEdge[0]); - var featureEnd = GetEndPoint(featureEdge[^1]); - var isVertical = axis == CutOffAxis.Vertical; + ts.Sort(); - var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X; - var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X; - - if (edgeGoesForward != featureGoesForward) + var segments = new List(); + for (var i = 0; i < ts.Count - 1; i++) { - featureEdge = new List(featureEdge); - featureEdge.Reverse(); - foreach (var e in featureEdge) - e.Reverse(); + var t0 = ts[i]; + var t1 = ts[i + 1]; + if (t1 - t0 < Math.Tolerance.Epsilon) continue; + + var tMid = (t0 + t1) * 0.5; + var mid = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid); + + var insideCutout = false; + foreach (var poly in cutoutPolygons) + { + if (poly.ContainsPoint(mid)) + { + insideCutout = true; + break; + } + } + if (insideCutout) continue; + + var p0 = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0); + var p1 = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1); + + segments.Add(new Line(p0, p1)); } - return featureEdge; + return segments; } - private static void EnsurePerimeterWinding(List entities) + /// + /// Segment-segment intersection. On hit, returns the parameter t along segment AB + /// (0 = a0, 1 = a1) via . + /// + private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA) { - var shape = new Shape(); - shape.Entities.AddRange(entities); - var poly = shape.ToPolygon(); - if (poly != null && poly.RotationDirection() != RotationType.CW) - shape.Reverse(); + tOnA = 0; + var rx = a1.X - a0.X; + var ry = a1.Y - a0.Y; + var sx = b1.X - b0.X; + var sy = b1.Y - b0.Y; - entities.Clear(); - entities.AddRange(shape.Entities); + var denom = rx * sy - ry * sx; + if (System.Math.Abs(denom) < Math.Tolerance.Epsilon) + return false; + + var dx = b0.X - a0.X; + var dy = b0.Y - a0.Y; + var t = (dx * sy - dy * sx) / denom; + var u = (dx * ry - dy * rx) / denom; + + if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false; + if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false; + + tOnA = t; + return true; } private static bool IsCutoutInRegion(Shape cutout, Box region) { if (cutout.Entities.Count == 0) return false; - var pt = GetStartPoint(cutout.Entities[0]); - return region.Contains(pt); + var bb = cutout.BoundingBox; + // Fully contained iff the cutout's bounding box fits inside the region. + return bb.Left >= region.Left - Math.Tolerance.Epsilon + && bb.Right <= region.Right + Math.Tolerance.Epsilon + && bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon + && bb.Top <= region.Top + Math.Tolerance.Epsilon; } private static bool DoesCutoutCrossSplitLine(Shape cutout, List splitLines) @@ -479,57 +555,135 @@ public static class DrawingSplitter } /// - /// Clip a cutout shape to a region by walking entities, splitting at split line - /// intersections, keeping portions inside the region, and closing gaps with - /// straight lines. No polygon clipping library needed. + /// Clip a cutout shape to a region by walking entities and splitting at split-line + /// crossings. Only returns the cutout-edge fragments that lie inside the region — + /// it deliberately does NOT emit synthetic closing lines at the region boundary. + /// + /// Rationale: a closing line on the region boundary would overlap the split-line + /// feature edge and reintroduce a cut through the cutout interior. The feature + /// edge (trimmed against cutouts in ) and these + /// cutout fragments are stitched together later by + /// using endpoint connectivity, which produces the correct closed loops — one + /// loop per physically-connected strip of material. /// private static List ClipCutoutToRegion(Shape cutout, Box region, List splitLines) { - var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var entities = new List(); - var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); - foreach (var entity in cutout.Entities) + ProcessEntity(entity, region, entities); + return entities; + } + + /// + /// Groups a region's entities into closed components and nests holes inside + /// outer loops by point-in-polygon containment. Returns one entity list per + /// output — outer loop first, then its contained holes. + /// Each outer loop is normalized to CW winding and each hole to CCW. + /// + private static List> AssemblePieces(List entities) + { + var pieces = new List>(); + if (entities.Count == 0) return pieces; + + var shapes = ShapeBuilder.GetShapes(entities); + if (shapes.Count == 0) return pieces; + + // Polygonize every shape once so we can run containment tests. + var polygons = new List(shapes.Count); + foreach (var s in shapes) + polygons.Add(s.ToPolygon()); + + // Classify each shape as outer or hole using nesting by containment. + // Shape A is contained in shape B iff A's bounding box is strictly inside + // B's bounding box AND a representative vertex of A lies inside B's polygon. + // The bbox pre-check avoids the ambiguity of bbox-center tests when two + // shapes share a center (e.g., an outer half and a centered cutout). + var isHole = new bool[shapes.Count]; + for (var i = 0; i < shapes.Count; i++) { - ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); + var bbA = shapes[i].BoundingBox; + var repA = FirstVertexOf(shapes[i]); + + for (var j = 0; j < shapes.Count; j++) + { + if (i == j) continue; + if (polygons[j] == null) continue; + if (polygons[j].Vertices.Count < 3) continue; + + var bbB = shapes[j].BoundingBox; + if (!BoxContainsBox(bbB, bbA)) continue; + if (!polygons[j].ContainsPoint(repA)) continue; + + isHole[i] = true; + break; + } } - if (entities.Count == 0) - return new List(); - - // Close gaps with straight lines (connect exit→entry pairs) - var groups = new Dictionary>(); - foreach (var sp in splitPoints) + // For each outer, attach the holes that fall inside it. + for (var i = 0; i < shapes.Count; i++) { - if (!groups.ContainsKey(sp.Line)) - groups[sp.Line] = new List<(Vector, bool)>(); - groups[sp.Line].Add((sp.Point, sp.IsExit)); + if (isHole[i]) continue; + + var outer = shapes[i]; + var outerPoly = polygons[i]; + + // Enforce perimeter winding = CW. + if (outerPoly != null && outerPoly.Vertices.Count >= 3 + && outerPoly.RotationDirection() != RotationType.CW) + outer.Reverse(); + + var piece = new List(); + piece.AddRange(outer.Entities); + + for (var j = 0; j < shapes.Count; j++) + { + if (!isHole[j]) continue; + if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue; + + var bbJ = shapes[j].BoundingBox; + if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue; + + var rep = FirstVertexOf(shapes[j]); + if (!polygons[i].ContainsPoint(rep)) continue; + + var hole = shapes[j]; + var holePoly = polygons[j]; + if (holePoly != null && holePoly.Vertices.Count >= 3 + && holePoly.RotationDirection() != RotationType.CCW) + hole.Reverse(); + + piece.AddRange(hole.Entities); + } + + pieces.Add(piece); } - foreach (var kvp in groups) - { - var sl = kvp.Key; - var points = kvp.Value; - var isVertical = sl.Axis == CutOffAxis.Vertical; + return pieces; + } - var exits = points.Where(p => p.IsExit).Select(p => p.Point) - .OrderBy(p => isVertical ? p.Y : p.X).ToList(); - var entries = points.Where(p => !p.IsExit).Select(p => p.Point) - .OrderBy(p => isVertical ? p.Y : p.X).ToList(); + /// + /// Returns the first vertex of a shape (start point of its first entity). Used as + /// a representative for containment testing: if bbox pre-check says the whole + /// shape is inside another, testing one vertex is sufficient to confirm. + /// + private static Vector FirstVertexOf(Shape shape) + { + if (shape.Entities.Count == 0) + return new Vector(0, 0); + return GetStartPoint(shape.Entities[0]); + } - var pairCount = System.Math.Min(exits.Count, entries.Count); - for (var i = 0; i < pairCount; i++) - entities.Add(new Line(exits[i], entries[i])); - } - - // Ensure CCW winding for cutouts - var shape = new Shape(); - shape.Entities.AddRange(entities); - var poly = shape.ToPolygon(); - if (poly != null && poly.RotationDirection() != RotationType.CCW) - shape.Reverse(); - - return shape.Entities; + /// + /// True iff box is entirely inside box + /// (tolerant comparison). + /// + private static bool BoxContainsBox(Box outer, Box inner) + { + var eps = Math.Tolerance.Epsilon; + return inner.Left >= outer.Left - eps + && inner.Right <= outer.Right + eps + && inner.Bottom >= outer.Bottom - eps + && inner.Top <= outer.Top + eps; } private static Vector GetStartPoint(Entity entity) diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj index bc484e8..be3afca 100644 --- a/OpenNest.Tests/OpenNest.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -34,6 +34,9 @@ PreserveNewest + + PreserveNewest + diff --git a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs index 45ea0d8..534e7b2 100644 --- a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs +++ b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs @@ -384,6 +384,161 @@ public class DrawingSplitterTests } } + [Fact] + public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips() + { + // 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45). + // 4 vertical splits at x = 55, 110, 165, 220. + // + // Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the + // surviving material in each is two physically disjoint strips (upper + lower). + // R1 and R5 each have a solid edge that connects the top and bottom strips, so + // they remain single (notched) pieces. + // + // Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8. + var outerEntities = new List + { + new Line(new Vector(0, 0), new Vector(255, 0)), + new Line(new Vector(255, 0), new Vector(255, 55)), + new Line(new Vector(255, 55), new Vector(0, 55)), + new Line(new Vector(0, 55), new Vector(0, 0)) + }; + var slotEntities = new List + { + new Line(new Vector(10, 10), new Vector(245, 10)), + new Line(new Vector(245, 10), new Vector(245, 45)), + new Line(new Vector(245, 45), new Vector(10, 45)), + new Line(new Vector(10, 45), new Vector(10, 10)) + }; + var allEntities = new List(); + allEntities.AddRange(outerEntities); + allEntities.AddRange(slotEntities); + + var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities)); + var originalArea = drawing.Area; + + var splitLines = new List + { + new SplitLine(55.0, CutOffAxis.Vertical), + new SplitLine(110.0, CutOffAxis.Vertical), + new SplitLine(165.0, CutOffAxis.Vertical), + new SplitLine(220.0, CutOffAxis.Vertical) + }; + + var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight }); + + // R1 (0..55) → 1 notched piece, height 55 + // R2 (55..110) → upper strip + lower strip, each height 10 + // R3 (110..165)→ upper strip + lower strip, each height 10 + // R4 (165..220)→ upper strip + lower strip, each height 10 + // R5 (220..255)→ 1 notched piece, height 55 + Assert.Equal(8, results.Count); + + // Area preservation: sum of all output areas equals (outer − slot). + var totalArea = results.Sum(d => d.Area); + Assert.Equal(originalArea, totalArea, 1); + + // Box.Length = X-extent, Box.Width = Y-extent. + // Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and + // exactly 2 notched pieces (Y-extent 55mm) from R1 and R5. + var strips = results + .Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5) + .ToList(); + var notched = results + .Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5) + .ToList(); + + Assert.Equal(6, strips.Count); + Assert.Equal(2, notched.Count); + + // Each piece should form a closed perimeter (no dangling edges, no gaps). + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges"); + + for (var i = 0; i < entities.Count; i++) + { + var end = GetEndPoint(entities[i]); + var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]); + var gap = end.DistanceTo(nextStart); + Assert.True(gap < 0.01, + $"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start"); + } + } + } + + [Fact] + public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout() + { + // Real DXF regression: 255x55 plate with a centered slot cutout, split into + // five columns. Exercises the same path as the synthetic + // Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through + // the full DXF import pipeline. + var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf"); + Assert.True(File.Exists(path), $"Test DXF not found: {path}"); + + var imported = OpenNest.IO.Dxf.Import(path); + var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities); + + // Normalize to origin so the split line positions are predictable. + var bb = profile.Perimeter.BoundingBox; + var offsetX = -bb.X; + var offsetY = -bb.Y; + foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY); + foreach (var cutout in profile.Cutouts) + foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY); + + var allEntities = new List(); + allEntities.AddRange(profile.Perimeter.Entities); + foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities); + + var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities)); + var originalArea = drawing.Area; + + // Part is ~255x55 with an interior slot. Split into 5 columns (55mm each). + var splitLines = new List + { + new SplitLine(55.0, CutOffAxis.Vertical), + new SplitLine(110.0, CutOffAxis.Vertical), + new SplitLine(165.0, CutOffAxis.Vertical), + new SplitLine(220.0, CutOffAxis.Vertical) + }; + + var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight }); + + // Area must be preserved within tolerance (floating-point coords in the DXF). + var totalArea = results.Sum(d => d.Area); + Assert.Equal(originalArea, totalArea, 0); + + // At least one region must yield more than one physical strip — that's the + // whole point of the fix: a cutout that spans a region disconnects it. + Assert.True(results.Count > splitLines.Count + 1, + $"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}"); + + // Every output drawing must resolve into fully-closed shapes (outer loop + // and any hole loops), with no dangling geometry. A piece that contains + // a cutout will have its entities span more than one connected loop. + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities"); + + var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities); + Assert.NotEmpty(shapes); + + foreach (var shape in shapes) + { + Assert.True(shape.IsClosed(), + $"{piece.Name} contains an open chain of {shape.Entities.Count} entities"); + } + } + } + private static Vector GetStartPoint(Entity entity) { return entity switch diff --git a/OpenNest.Tests/Splitting/TestData/split_test.dxf b/OpenNest.Tests/Splitting/TestData/split_test.dxf new file mode 100644 index 0000000..2618e00 --- /dev/null +++ b/OpenNest.Tests/Splitting/TestData/split_test.dxf @@ -0,0 +1,2554 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1018 + 9 +$ACADMAINTVER + 70 + 2 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +19.91850545569161 + 20 +5.515998377183137 + 30 +-0.0000000349181948 + 9 +$EXTMAX + 10 +274.9408108934591 + 20 +60.52360502754011 + 30 +0.0000000814935747 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +12.0 + 20 +9.0 + 9 +$ORTHOMODE + 70 + 0 + 9 +$REGENMODE + 70 + 1 + 9 +$FILLMODE + 70 + 1 + 9 +$QTEXTMODE + 70 + 0 + 9 +$MIRRTEXT + 70 + 0 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 + 1 + 9 +$TEXTSIZE + 40 +0.2 + 9 +$TRACEWID + 40 +0.05 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 + 256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 + 0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.18 + 9 +$DIMEXO + 40 +0.0625 + 9 +$DIMDLI + 40 +0.38 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.18 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.18 + 9 +$DIMCEN + 40 +0.09 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 + 0 + 9 +$DIMLIM + 70 + 0 + 9 +$DIMTIH + 70 + 1 + 9 +$DIMTOH + 70 + 1 + 9 +$DIMSE1 + 70 + 0 + 9 +$DIMSE2 + 70 + 0 + 9 +$DIMTAD + 70 + 0 + 9 +$DIMZIN + 70 + 0 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 + 1 + 9 +$DIMSHO + 70 + 1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 + 0 + 9 +$DIMALTD + 70 + 2 + 9 +$DIMALTF + 40 +25.4 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 + 0 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 + 0 + 9 +$DIMSOXD + 70 + 0 + 9 +$DIMSAH + 70 + 0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +Standard + 9 +$DIMCLRD + 70 + 0 + 9 +$DIMCLRE + 70 + 0 + 9 +$DIMCLRT + 70 + 0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.09 + 9 +$DIMJUST + 70 + 0 + 9 +$DIMSD1 + 70 + 0 + 9 +$DIMSD2 + 70 + 0 + 9 +$DIMTOLJ + 70 + 1 + 9 +$DIMTZIN + 70 + 0 + 9 +$DIMALTZ + 70 + 0 + 9 +$DIMALTTZ + 70 + 0 + 9 +$DIMUPT + 70 + 0 + 9 +$DIMDEC + 70 + 4 + 9 +$DIMTDEC + 70 + 4 + 9 +$DIMALTU + 70 + 2 + 9 +$DIMALTTD + 70 + 2 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 + 0 + 9 +$DIMADEC + 70 + 0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 + 0 + 9 +$DIMDSEP + 70 + 46 + 9 +$DIMATFIT + 70 + 3 + 9 +$DIMFRAC + 70 + 0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 + 2 + 9 +$DIMLWD + 70 + -2 + 9 +$DIMLWE + 70 + -2 + 9 +$DIMTMOVE + 70 + 0 + 9 +$LUNITS + 70 + 2 + 9 +$LUPREC + 70 + 4 + 9 +$SKETCHINC + 40 +0.1 + 9 +$FILLETRAD + 40 +0.0 + 9 +$AUNITS + 70 + 0 + 9 +$AUPREC + 70 + 0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 + 0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 + 0 + 9 +$TDCREATE + 40 +2461141.845430382 + 9 +$TDUCREATE + 40 +2461142.012097049 + 9 +$TDUPDATE + 40 +2461141.879244201 + 9 +$TDUUPDATE + 40 +2461142.045910868 + 9 +$TDINDWG + 40 +0.0024387384 + 9 +$TDUSRTIMER + 40 +0.0024382986 + 9 +$USRTIMER + 70 + 1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 + 0 + 9 +$PDMODE + 70 + 0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 + 0 + 9 +$SPLINETYPE + 70 + 6 + 9 +$SPLINESEGS + 70 + 8 + 9 +$HANDSEED + 5 +A2 + 9 +$SURFTAB1 + 70 + 6 + 9 +$SURFTAB2 + 70 + 6 + 9 +$SURFTYPE + 70 + 6 + 9 +$SURFU + 70 + 6 + 9 +$SURFV + 70 + 6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 + 0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 + 0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 + 0 + 9 +$USERI2 + 70 + 0 + 9 +$USERI3 + 70 + 0 + 9 +$USERI4 + 70 + 0 + 9 +$USERI5 + 70 + 0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 + 1 + 9 +$SHADEDGE + 70 + 3 + 9 +$SHADEDIF + 70 + 70 + 9 +$TILEMODE + 70 + 1 + 9 +$MAXACTVP + 70 + 64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 + 0 + 9 +$PEXTMIN + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PEXTMAX + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +12.0 + 20 +9.0 + 9 +$UNITMODE + 70 + 0 + 9 +$VISRETAIN + 70 + 1 + 9 +$PLINEGEN + 70 + 0 + 9 +$PSLTSCALE + 70 + 1 + 9 +$TREEDEPTH + 70 + 3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 + 0 + 9 +$CMLSCALE + 40 +1.0 + 9 +$PROXYGRAPHICS + 70 + 1 + 9 +$MEASUREMENT + 70 + 0 + 9 +$CELWEIGHT +370 + -1 + 9 +$ENDCAPS +280 + 0 + 9 +$JOINSTYLE +280 + 0 + 9 +$LWDISPLAY +290 + 0 + 9 +$INSUNITS + 70 + 1 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 + 1 + 9 +$CEPSNTYPE +380 + 0 + 9 +$PSTYLEMODE +290 + 1 + 9 +$FINGERPRINTGUID + 2 +{FDEAD576-A652-11D2-9A35-0060089B3A3F} + 9 +$VERSIONGUID + 2 +{43BEA035-DE0A-47E5-AE2D-CFCAFBC579EF} + 9 +$EXTNAMES +290 + 1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 + 0 + 9 +$SORTENTS +280 + 127 + 9 +$INDEXCTL +280 + 0 + 9 +$HIDETEXT +280 + 1 + 9 +$XCLIPFRAME +290 + 0 + 9 +$HALOGAP +280 + 0 + 9 +$OBSCOLOR + 70 + 257 + 9 +$OBSLTYPE +280 + 0 + 9 +$INTERSECTIONDISPLAY +280 + 0 + 9 +$INTERSECTIONCOLOR + 70 + 257 + 9 +$DIMASSOC +280 + 2 + 9 +$PROJECTNAME + 1 + + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 + 0 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 + 0 + 91 + 2 +280 + 0 +281 + 0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 + 2 + 0 +VPORT + 5 +A1 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 + 0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +147.4296581745753 + 22 +33.01980170236163 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +121.7516279380305 + 41 +2.099662162162162 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 + 0 + 72 + 1000 + 73 + 1 + 74 + 3 + 75 + 0 + 76 + 0 + 77 + 0 + 78 + 0 +281 + 0 + 65 + 1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +LTYPE + 5 +14 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +15 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +16 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 + 0 + 3 +Solid line + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +LAYER + 5 +10 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 + 0 + 62 + 7 + 6 +Continuous +370 + -3 +390 +F + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +STYLE + 5 +11 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 + 0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 + 0 + 42 +0.2 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +APPID + 5 +12 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +A +330 +0 +100 +AcDbSymbolTable + 70 + 1 +100 +AcDbDimStyleTable + 71 + 0 + 0 +DIMSTYLE +105 +27 +330 +A +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 + 0 +340 +11 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +BLOCK_RECORD + 5 +1F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +22 + 0 +BLOCK_RECORD + 5 +58 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +59 + 0 +BLOCK_RECORD + 5 +5D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +5E + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +21 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5A +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +5B +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5F +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +ENDBLK + 5 +60 +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +89 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +5.519910847131079 + 30 +0.0 + 11 +19.92402932605608 + 21 +60.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8A +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +60.51991084713108 + 30 +0.0 + 11 +274.9240293260561 + 21 +60.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8B +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +5.519910847131079 + 30 +0.0 + 11 +274.924029326056 + 21 +5.519910847131079 + 31 +0.0 + 0 +LINE + 5 +8C +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +274.9240293260561 + 20 +60.51991084713108 + 30 +0.0 + 11 +274.924029326056 + 21 +5.519910847131079 + 31 +0.0 + 0 +LINE + 5 +8D +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +100.1372327616525 + 20 +50.51991084713108 + 30 +0.0 + 11 +264.9240293260561 + 21 +50.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8F +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +100.1372327616525 + 20 +15.51991084713107 + 30 +0.0 + 11 +264.924029326056 + 21 +15.51991084713108 + 31 +0.0 + 0 +LINE + 5 +90 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +264.9240293260561 + 20 +50.51991084713108 + 30 +0.0 + 11 +264.924029326056 + 21 +15.51991084713108 + 31 +0.0 + 0 +CIRCLE + 5 +98 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +17.5 + 0 +ARC + 5 +9A +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +27.5 +100 +AcDbArc + 50 +320.4788036413579 + 51 +39.52119635864218 + 0 +ARC + 5 +9B +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +27.5 +100 +AcDbArc + 50 +140.4788036413578 + 51 +219.5211963586422 + 0 +LINE + 5 +9C +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +57.71082589045966 + 20 +15.51991084713107 + 30 +0.0 + 11 +29.92402932605608 + 21 +15.51991084713108 + 31 +0.0 + 0 +LINE + 5 +9D +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +57.71082589045966 + 20 +50.51991084713108 + 30 +0.0 + 11 +29.92402932605609 + 21 +50.51991084713108 + 31 +0.0 + 0 +LINE + 5 +9E +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +29.92402932605608 + 20 +15.51991084713108 + 30 +0.0 + 11 +29.92402932605608 + 21 +50.51991084713109 + 31 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 + 1 + 3 +ACAD_COLOR +350 +73 + 3 +ACAD_GROUP +350 +D + 3 +ACAD_LAYOUT +350 +1A + 3 +ACAD_MATERIAL +350 +72 + 3 +ACAD_MLINESTYLE +350 +17 + 3 +ACAD_PLOTSETTINGS +350 +19 + 3 +ACAD_PLOTSTYLENAME +350 +E + 3 +AcDbVariableDictionary +350 +66 + 0 +DICTIONARY + 5 +73 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +D +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +1A +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Layout1 +350 +59 + 3 +Layout2 +350 +5E + 3 +Model +350 +22 + 0 +DICTIONARY + 5 +72 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +17 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Standard +350 +18 + 0 +DICTIONARY + 5 +19 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +ACDBDICTIONARYWDFLT + 5 +E +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Normal +350 +F +100 +AcDbDictionaryWithDefault +340 +F + 0 +DICTIONARY + 5 +66 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +DIMASSOC +350 +67 + 3 +HIDETEXT +350 +6B + 0 +LAYOUT + 5 +59 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +None + 4 + + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 688 + 72 + 0 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +1.0 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 + 1 + 71 + 1 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +58 + 0 +LAYOUT + 5 +5E +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +None + 4 + + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 688 + 72 + 0 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +1.0 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout2 + 70 + 1 + 71 + 2 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +5D + 0 +LAYOUT + 5 +22 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_A_(8.50_x_11.00_Inches) + 6 + + 40 +6.349999904632567 + 41 +19.04999923706055 + 42 +6.350006103515625 + 43 +19.04998779296875 + 44 +215.8999938964844 + 45 +279.3999938964844 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +2.584895464708373 + 70 + 11952 + 72 + 0 + 73 + 1 + 74 + 0 + 7 + + 75 + 0 +147 +0.3868628397755418 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 + 1 + 71 + 0 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +1F + 0 +MLINESTYLE + 5 +18 +102 +{ACAD_REACTORS +330 +17 +102 +} +330 +17 +100 +AcDbMlineStyle + 2 +STANDARD + 70 + 0 + 3 + + 62 + 256 + 51 +90.0 + 52 +90.0 + 71 + 2 + 49 +0.5 + 62 + 256 + 6 +BYLAYER + 49 +-0.5 + 62 + 256 + 6 +BYLAYER + 0 +ACDBPLACEHOLDER + 5 +F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E + 0 +DICTIONARYVAR + 5 +67 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +2 + 0 +DICTIONARYVAR + 5 +6B +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +1 + 0 +ENDSEC + 0 +EOF