Files
OpenNest/OpenNest.Core/Splitting/DrawingSplitter.cs
AJ Isaacs 6880dee489 fix(splitter): preserve disconnected strips and trim cuts around cutouts
Splits that cross an interior cutout previously merged physically
disconnected strips into one drawing and drew cut lines through the hole.
The region boundary now spans full feature-edge extents (trimmed against
cutout polygons) and line entities are Liang-Barsky clipped, so multi-split
edges work. Arcs are properly clipped at region boundaries via iterative
split-at-intersection so circles that straddle a split contribute to both
sides. AssemblePieces groups a region's entities into connected closed
loops and nests holes by bbox-pre-check + vertex-in-polygon containment,
so one region can emit multiple drawings when a cutout fully spans it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:46:47 -04:00

720 lines
27 KiB
C#

using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
/// </summary>
public static class DrawingSplitter
{
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
{
if (splitLines.Count == 0)
return new List<Drawing> { drawing };
var profile = BuildProfile(drawing);
DecomposeCircles(profile);
var perimeter = profile.Perimeter;
var bounds = perimeter.BoundingBox;
var sortedLines = splitLines
.Where(l => IsLineInsideBounds(l, bounds))
.OrderBy(l => l.Position)
.ToList();
if (sortedLines.Count == 0)
return new List<Drawing> { drawing };
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<Drawing>();
var pieceIndex = 1;
foreach (var region in regions)
{
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
if (pieceEntities.Count == 0)
continue;
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
var allEntities = new List<Entity>();
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
// 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;
}
private static ShapeProfile BuildProfile(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return new ShapeProfile(entities);
}
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
{
var entities = new List<Entity>();
foreach (var cutout in cutouts)
{
if (IsCutoutInRegion(cutout, region))
entities.AddRange(cutout.Entities);
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
{
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
if (clipped.Count > 0)
entities.AddRange(clipped);
}
}
return entities;
}
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
{
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
var offsetX = -pieceBounds.X;
var offsetY = -pieceBounds.Y;
foreach (var e in entities)
e.Offset(offsetX, offsetY);
var pgm = ConvertGeometry.ToProgram(entities);
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
piece.Color = source.Color;
piece.Priority = source.Priority;
piece.Material = source.Material;
piece.Constraints = source.Constraints;
piece.Customer = source.Customer;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
if (source.Bends != null && source.Bends.Count > 0)
{
piece.Bends = new List<Bending.Bend>();
foreach (var bend in source.Bends)
{
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
if (clipped == null)
continue;
piece.Bends.Add(new Bending.Bend
{
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
Direction = bend.Direction,
Angle = bend.Angle,
Radius = bend.Radius,
NoteText = bend.NoteText,
});
}
}
return piece;
}
/// <summary>
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
/// Returns the clipped start/end or null if the line is entirely outside.
/// </summary>
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
double t0 = 0, t1 = 1;
double[] p = { -dx, dx, -dy, dy };
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
for (var i = 0; i < 4; i++)
{
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
{
if (q[i] < -Math.Tolerance.Epsilon)
return null;
}
else
{
var t = q[i] / p[i];
if (p[i] < 0)
t0 = System.Math.Max(t0, t);
else
t1 = System.Math.Min(t1, t);
if (t0 > t1)
return null;
}
}
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
return (clippedStart, clippedEnd);
}
private static void DecomposeCircles(ShapeProfile profile)
{
DecomposeCirclesInShape(profile.Perimeter);
foreach (var cutout in profile.Cutouts)
DecomposeCirclesInShape(cutout);
}
private static void DecomposeCirclesInShape(Shape shape)
{
for (var i = shape.Entities.Count - 1; i >= 0; i--)
{
if (shape.Entities[i] is Circle circle)
{
var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
shape.Entities.RemoveAt(i);
shape.Entities.Insert(i, arc2);
shape.Entities.Insert(i, arc1);
}
}
}
private static bool IsLineInsideBounds(SplitLine line, Box bounds)
{
return line.Axis == CutOffAxis.Vertical
? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
: line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
}
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
{
var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
var xEdges = new List<double> { bounds.Left };
xEdges.AddRange(verticals.Select(v => v.Position));
xEdges.Add(bounds.Right);
var yEdges = new List<double> { bounds.Bottom };
yEdges.AddRange(horizontals.Select(h => h.Position));
yEdges.Add(bounds.Top);
var regions = new List<Box>();
for (var yi = 0; yi < yEdges.Count - 1; yi++)
for (var xi = 0; xi < xEdges.Count - 1; xi++)
regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
return regions;
}
/// <summary>
/// Clip perimeter to a region by walking entities, splitting at split line crossings,
/// and stitching in feature edges. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
foreach (var entity in perimeter.Entities)
ProcessEntity(entity, region, entities);
if (entities.Count == 0)
return new List<Entity>();
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<Entity> entities)
{
if (entity is Line line)
{
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;
}
if (entity is Arc arc)
{
foreach (var sub in ClipArcToRegion(arc, region))
entities.Add(sub);
return;
}
}
/// <summary>
/// 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.
/// </summary>
private static List<Arc> 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> { arc };
foreach (var edge in edges)
{
var next = new List<Arc>();
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<Arc> { a };
foreach (var pt in pts)
{
var replaced = new List<Arc>();
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<Arc>();
foreach (var a in arcs)
{
if (region.Contains(a.MidPoint()))
result.Add(a);
}
return result;
}
/// <summary>
/// Returns split lines whose position matches a boundary edge of the region.
/// </summary>
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
{
var result = new List<SplitLine>();
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical)
{
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
else
{
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
}
return result;
}
/// <summary>
/// Returns -1 or +1 indicating which side of the split line the region center is on.
/// </summary>
private static int RegionSideOf(Box region, SplitLine sl)
{
return SplitLineIntersect.SideOf(region.Center, sl);
}
/// <summary>
/// Returns the midpoint of an entity. For lines: average of endpoints.
/// For arcs: point at the mid-angle.
/// </summary>
private static Vector MidPoint(Entity entity)
{
if (entity is Line line)
return line.MidPoint;
if (entity is Arc arc)
{
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
}
return new Vector(0, 0);
}
/// <summary>
/// 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.
/// </summary>
private static void InsertFeatureEdges(List<Entity> entities,
Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters,
List<Polygon> cutoutPolygons)
{
foreach (var sl in boundarySplitLines)
{
var isVertical = sl.Axis == CutOffAxis.Vertical;
var extentStart = isVertical ? region.Bottom : region.Left;
var extentEnd = isVertical ? region.Top : region.Right;
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
continue;
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// Trim any line segments that cross a cutout — cut lines must never
// travel through a hole.
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
entities.AddRange(featureEdge);
}
}
/// <summary>
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> 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.
/// </summary>
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
{
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
return featureEdge;
var result = new List<Entity>();
foreach (var entity in featureEdge)
{
if (entity is Line line)
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
else
result.Add(entity);
}
return result;
}
/// <summary>
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
/// </summary>
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
{
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
var ts = new List<double> { 0.0, 1.0 };
foreach (var poly in cutoutPolygons)
{
var polyLines = poly.ToLines();
foreach (var edge in polyLines)
{
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);
}
}
}
ts.Sort();
var segments = new List<Line>();
for (var i = 0; i < ts.Count - 1; i++)
{
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 segments;
}
/// <summary>
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
/// </summary>
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
{
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;
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 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<SplitLine> splitLines)
{
var bb = cutout.BoundingBox;
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
return true;
if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
return true;
}
return false;
}
/// <summary>
/// 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 <see cref="InsertFeatureEdges"/>) and these
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
/// using endpoint connectivity, which produces the correct closed loops — one
/// loop per physically-connected strip of material.
/// </summary>
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
{
var entities = new List<Entity>();
foreach (var entity in cutout.Entities)
ProcessEntity(entity, region, entities);
return entities;
}
/// <summary>
/// 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 <see cref="Drawing"/> — outer loop first, then its contained holes.
/// Each outer loop is normalized to CW winding and each hole to CCW.
/// </summary>
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
{
var pieces = new List<List<Entity>>();
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<Polygon>(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++)
{
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;
}
}
// For each outer, attach the holes that fall inside it.
for (var i = 0; i < shapes.Count; i++)
{
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<Entity>();
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);
}
return pieces;
}
/// <summary>
/// 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.
/// </summary>
private static Vector FirstVertexOf(Shape shape)
{
if (shape.Entities.Count == 0)
return new Vector(0, 0);
return GetStartPoint(shape.Entities[0]);
}
/// <summary>
/// True iff box <paramref name="inner"/> is entirely inside box
/// <paramref name="outer"/> (tolerant comparison).
/// </summary>
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)
{
return entity switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => new Vector(0, 0)
};
}
private static Vector GetEndPoint(Entity entity)
{
return entity switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => new Vector(0, 0)
};
}
private static ISplitFeature GetFeature(SplitType type)
{
return type switch
{
SplitType.Straight => new StraightSplit(),
SplitType.WeldGapTabs => new WeldGapTabSplit(),
SplitType.SpikeGroove => new SpikeGrooveSplit(),
_ => new StraightSplit()
};
}
}