Editor utility for architectural footprints. Provides geometry helpers for polygon loops: creating rectangles and ellipses, repairing and winding loops, hit tests (Contains, Encloses, Crossings), boolean operations over axis-aligned cell grids (Union, Subtract, Intersect), offset/grow/shrink of shapes, tracing cells back to loops, and various support math (mitring, bounding, winding, area).
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public readonly struct ArchBox
{
public Vector2 Min { get; init; }
public Vector2 Max { get; init; }
public ArchBox Grown( float amount )
{
var margin = new Vector2( amount, amount );
return new ArchBox { Min = Min - margin, Max = Max + margin };
}
}
// Cell grid instead of a clipper - every authored footprint is a union of axis-aligned rectangles.
public static class ArchFootprint
{
const float Grain = 0.02f;
// Eight sides is a round enough shaft to read as one and few enough faces to weld cleanly; the
// author raises it when a lift core wants to look turned rather than cut.
public const int LeastSegments = 8;
// Turned about a point, in one place: a shape and everything filed under it have to swing through the
// same angle about the same centre, and two callers doing their own trigonometry is two centres.
public static List<Vector2> Turned( IReadOnlyList<Vector2> loop, Vector2 about, float degrees )
{
var turned = new List<Vector2>( loop.Count );
var radians = degrees.DegreeToRadian();
var cos = MathF.Cos( radians );
var sin = MathF.Sin( radians );
foreach ( var point in loop )
{
var local = point - about;
turned.Add( about + new Vector2( local.x * cos - local.y * sin, local.x * sin + local.y * cos ) );
}
return turned;
}
public static List<Vector2> Rect( Vector2 min, Vector2 max )
{
return new List<Vector2> { min, new( max.x, min.y ), max, new( min.x, max.y ) };
}
// One answer to "what does this part read as": its authored loop when it has one, otherwise the rect
// its bounds describe. Platforms, roofs and cutouts all ask this, so a shape dragged as a loop is
// never re-derived per consumer - the way ArchCutSegment.Outline answers for a cut's own legs.
public static List<Vector2> OrRect( IReadOnlyList<Vector2> loop, Vector2 min, Vector2 max )
{
return loop is { Count: >= 4 } ? new List<Vector2>( loop ) : Rect( min, max );
}
// A closed n-gon inscribed in the box, so a circle is authored as the bounds that were dragged plus
// a segment count - the vertices are DERIVED, which is what keeps a round shape round while the drag
// itself still snaps. Never closed with a repeated first point: a duplicate corner is a degenerate
// edge, and a degenerate edge is where a mesh stops being manifold.
public static List<Vector2> Ellipse( Vector2 min, Vector2 max, int segments )
{
var sides = Math.Max( 3, segments );
var centre = (min + max) * 0.5f;
var radius = (max - min) * 0.5f;
var loop = new List<Vector2>( sides );
// Started half a step round so an eight-sided shaft presents flats to the axes rather than points,
// which is how an octagonal core sits against a wall instead of poking a corner through it.
var lead = MathF.PI / sides;
for ( var index = 0; index < sides; index++ )
{
var angle = lead + index * MathF.Tau / sides;
loop.Add( centre + new Vector2( MathF.Cos( angle ) * radius.x, MathF.Sin( angle ) * radius.y ) );
}
return Wind( loop );
}
public static bool IsRectilinear( IReadOnlyList<Vector2> loop )
{
if ( loop is null || loop.Count < 4 )
{
return false;
}
for ( var index = 0; index < loop.Count; index++ )
{
var span = loop[(index + 1) % loop.Count] - loop[index];
if ( MathF.Abs( span.x ) > Grain && MathF.Abs( span.y ) > Grain )
{
return false;
}
}
return true;
}
public static bool IsSimple( IReadOnlyList<Vector2> loop )
{
if ( loop is null || loop.Count < 4 || loop.Distinct().Count() != loop.Count )
{
return false;
}
for ( var first = 0; first < loop.Count; first++ )
{
var firstEnd = (first + 1) % loop.Count;
for ( var second = first + 1; second < loop.Count; second++ )
{
var secondEnd = (second + 1) % loop.Count;
if ( first == second || firstEnd == second || secondEnd == first )
{
continue;
}
if ( Intersects( loop[first], loop[firstEnd], loop[second], loop[secondEnd] ) )
{
return false;
}
}
}
return true;
}
// One answer to "what does a drag become": repaired to a simple loop, wound to the canonical
// direction, and its bounds read off. Platforms, roofs and rooms all author their footprint this
// way, so a part's stored shape can never disagree with the outline it draws.
public static (List<Vector2> Loop, Vector2 Min, Vector2 Max) Authored( IReadOnlyList<Vector2> outline )
{
var loop = Wind( Repair( outline ) );
Bounds( loop, out var min, out var max );
return (loop, min, max);
}
public static List<Vector2> Repair( IReadOnlyList<Vector2> loop )
{
if ( loop is not { Count: >= 3 } )
{
return new List<Vector2>();
}
if ( IsSimple( loop ) )
{
return loop.ToList();
}
Bounds( loop, out var min, out var max );
return Rect( min, max );
}
public static void Bounds( IReadOnlyList<Vector2> loop, out Vector2 min, out Vector2 max )
{
min = new Vector2( float.MaxValue, float.MaxValue );
max = new Vector2( float.MinValue, float.MinValue );
foreach ( var point in loop )
{
min = new Vector2( MathF.Min( min.x, point.x ), MathF.Min( min.y, point.y ) );
max = new Vector2( MathF.Max( max.x, point.x ), MathF.Max( max.y, point.y ) );
}
}
// Which side of a CCW loop the floor stands on, as a sign on the edge's outward normal: void left,
// floor right is -1, the mirror is +1. Well trims and rail guards all ask this before probing an
// edge, so a hole and the guard round it can never disagree about which way the floor is.
public static float FloorSide( IReadOnlyList<Vector2> loop )
{
return SignedArea( loop ) > 0f ? -1f : 1f;
}
public static float SignedArea( IReadOnlyList<Vector2> loop )
{
var total = 0f;
for ( var index = 0; index < loop.Count; index++ )
{
var current = loop[index];
var next = loop[(index + 1) % loop.Count];
total += current.x * next.y - next.x * current.y;
}
return total * 0.5f;
}
static bool Intersects( Vector2 a, Vector2 b, Vector2 c, Vector2 d )
{
return MathF.Max( MathF.Min( a.x, b.x ), MathF.Min( c.x, d.x ) ) <= MathF.Min( MathF.Max( a.x, b.x ), MathF.Max( c.x, d.x ) ) + Grain
&& MathF.Max( MathF.Min( a.y, b.y ), MathF.Min( c.y, d.y ) ) <= MathF.Min( MathF.Max( a.y, b.y ), MathF.Max( c.y, d.y ) ) + Grain
&& Side( a, b, c ) * Side( a, b, d ) <= Grain
&& Side( c, d, a ) * Side( c, d, b ) <= Grain;
}
static float Side( Vector2 a, Vector2 b, Vector2 point ) => (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
static bool ProperlyCrosses( Vector2 a, Vector2 b, Vector2 c, Vector2 d )
{
var first = Side( a, b, c );
var second = Side( a, b, d );
var third = Side( c, d, a );
var fourth = Side( c, d, b );
return (first > Grain && second < -Grain || first < -Grain && second > Grain)
&& (third > Grain && fourth < -Grain || third < -Grain && fourth > Grain);
}
public static List<Vector2> Wind( List<Vector2> loop )
{
if ( loop.Count < 3 || SignedArea( loop ) >= 0f )
{
return loop;
}
loop.Reverse();
return loop;
}
// Even-odd coverage cancels overlaps - clip each loop against what is covered to keep the set disjoint.
public static List<List<Vector2>> Union( IEnumerable<IReadOnlyList<Vector2>> loops )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
if ( Slanted( filled ) )
{
return Carved( filled, null );
}
var region = new List<IReadOnlyList<Vector2>>();
foreach ( var loop in filled )
{
region.AddRange( Subtract( new[] { loop }, region ) );
}
return Trace( Cells( region, null ) );
}
// What the loops and the outline BOTH cover. Both sides donate coordinates, so the outline's boundary lands
// on a cell line rather than slicing one - a clamp to the outline's bounds fills a notch straight back in.
public static List<List<Vector2>> Intersect( IEnumerable<IReadOnlyList<Vector2>> loops, IReadOnlyList<Vector2> outline )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
if ( outline is not { Count: >= 3 } || filled.Count == 0 )
{
return filled.Select( loop => loop.ToList() ).ToList();
}
if ( Slanted( filled ) || !IsRectilinear( outline ) )
{
var spill = Subtract( filled, new[] { outline } )
.Select( loop => (IReadOnlyList<Vector2>)loop )
.ToList();
return Subtract( filled, spill );
}
var donors = filled.SelectMany( loop => loop ).Concat( outline ).ToList();
var xs = Axis( donors.Select( point => point.x ) );
var ys = Axis( donors.Select( point => point.y ) );
var cells = new List<ArchBox>();
for ( var ix = 0; ix + 1 < xs.Count; ix++ )
{
for ( var iy = 0; iy + 1 < ys.Count; iy++ )
{
var min = new Vector2( xs[ix], ys[iy] );
var max = new Vector2( xs[ix + 1], ys[iy + 1] );
var centre = (min + max) * 0.5f;
if ( Encloses( filled, centre ) && Contains( outline, centre ) )
{
cells.Add( new ArchBox { Min = min, Max = max } );
}
}
}
return Trace( cells );
}
public static List<List<Vector2>> Subtract( IEnumerable<IReadOnlyList<Vector2>> loops, IReadOnlyList<IReadOnlyList<Vector2>> holes )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
if ( holes is not { Count: > 0 } )
{
return filled.Select( loop => loop.ToList() ).ToList();
}
if ( Slanted( filled ) || Slanted( holes ) )
{
return Carved( filled, holes );
}
return Trace( Cells( filled, holes ) );
}
// The cell grid is exact while every edge lies on an axis, and squares off any edge that does not - which
// is how a circular bool came back as a stepped cross under its own round trim. ArchCarve's arrangement
// already splits along a slanted edge and walks its own boundary, so anything diagonal is answered there.
static bool Slanted( IEnumerable<IReadOnlyList<Vector2>> loops )
{
return loops.Any( loop => loop is { Count: >= 3 } && !IsRectilinear( loop ) );
}
static List<List<Vector2>> Carved( IReadOnlyList<IReadOnlyList<Vector2>> filled, IReadOnlyList<IReadOnlyList<Vector2>> holes )
{
var carve = new ArchCarve();
foreach ( var loop in filled )
{
carve.Plus( ArchCarveVolume.Over( loop, 0f, 1f ) );
}
foreach ( var loop in holes ?? Array.Empty<IReadOnlyList<Vector2>>() )
{
carve.Less( ArchCarveVolume.Over( loop, -1f, 2f ) );
}
return carve.Resolve().Outline( 0.5f );
}
// Dilation distributes over union, so reflex corners square off with no join rule.
//
// That only holds while every edge lies on an axis. A cell grid squares a slanted edge off, so a turned loop
// came back as an axis-aligned stair of boxes - which is a shaft ring standing square while the hole it lines
// is at thirty degrees. A lone slanted loop is therefore OFFSET instead: each edge moved off the material and
// the corners mitred where the moved edges meet, which is the mitre the ring was always documented to have.
public static List<List<Vector2>> Grow( IEnumerable<IReadOnlyList<Vector2>> loops, float amount )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
if ( amount > Grain && filled.Count == 1 && !IsRectilinear( filled[0] ) )
{
return Offset( filled[0], amount );
}
var cells = Cells( filled, null );
if ( amount <= Grain )
{
return Trace( cells );
}
return Trace( cells.Select( cell => cell.Grown( amount ) ).ToList() );
}
// Off the MATERIAL, not outward in world terms: an outer loop is wound with what it covers on its left, so
// the right-hand normal already points away from it, and a hole wound the other way points into itself -
// which is the same dilation, closing the hole rather than opening it.
static List<List<Vector2>> Offset( IReadOnlyList<Vector2> loop, float amount )
{
var moved = new List<Vector2>( loop.Count );
for ( var index = 0; index < loop.Count; index++ )
{
var behind = loop[(index - 1 + loop.Count) % loop.Count];
var here = loop[index];
var ahead = loop[(index + 1) % loop.Count];
moved.Add( Mitred( behind, here, ahead, amount ) );
}
var offset = Simplify( moved );
// A dilation that turned the loop inside out is a hole narrower than twice the amount, and it is gone.
return offset.Count >= 3 && SignedArea( offset ) * SignedArea( loop ) > 0f
? new List<List<Vector2>> { offset }
: new List<List<Vector2>>();
}
// Where the two moved edge lines cross. A corner sharp enough to throw the mitre out to nothing useful is
// bevelled instead - taken back to the nearer of the two moved ends.
static Vector2 Mitred( Vector2 behind, Vector2 here, Vector2 ahead, float amount )
{
var into = ArchRegion.Outward( behind, here ) * amount;
var away = ArchRegion.Outward( here, ahead ) * amount;
var turn = (here - behind).Normal;
var next = (ahead - here).Normal;
var cross = turn.x * next.y - turn.y * next.x;
if ( MathF.Abs( cross ) < 0.0001f )
{
return here + into;
}
var reach = ((here + away) - (here + into)).x * next.y - ((here + away) - (here + into)).y * next.x;
var mitre = here + into + turn * (reach / cross);
return (mitre - here).Length > amount * MitreReach ? here + (into + away) * 0.5f : mitre;
}
// Four times the offset is a corner sharper than any authored shape needs a point on.
const float MitreReach = 4f;
// Erode by growing what surrounds it and subtracting - keeps reflex corners square, no self-intersection.
public static List<List<Vector2>> Shrink( IEnumerable<IReadOnlyList<Vector2>> loops, float amount )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
if ( filled.Count == 0 || amount <= Grain )
{
return filled.Select( loop => loop.ToList() ).ToList();
}
Bounds( filled.SelectMany( loop => loop ).ToList(), out var min, out var max );
var margin = new Vector2( amount * 3f, amount * 3f );
var around = Subtract( new[] { Rect( min - margin, max + margin ) }, filled );
return Subtract( filled, Grow( around, amount ) );
}
// Traced regions are outers plus holes wound the other way - solids want the outers only.
public static IEnumerable<List<Vector2>> Outer( IEnumerable<List<Vector2>> region )
{
return region.Where( loop => loop.Count >= 3 && SignedArea( loop ) > 0f );
}
public static List<Vector2> Merge( IReadOnlyList<Vector2> outline, IReadOnlyList<Vector2> addition )
{
var merged = Union( new[] { outline, addition } );
var outer = Outer( merged ).Where( IsSimple ).ToList();
if ( outer.Count == 1 )
{
return outer[0];
}
return null;
}
// The cheap bounds question: do these two boxes overlap, grain tolerance included. Culls, porch
// drops and roof reaches all ask it - a room that merely touches a roof's box from outside must not
// seal it, so the test is strict past the grain rather than inclusive at the edge.
public static bool Overlaps( ArchBox first, ArchBox second )
{
return first.Min.x < second.Max.x - Grain && first.Max.x > second.Min.x + Grain
&& first.Min.y < second.Max.y - Grain && first.Max.y > second.Min.y + Grain;
}
public static bool Overlaps( IReadOnlyList<Vector2> first, IReadOnlyList<Vector2> second )
{
if ( first is not { Count: >= 3 } || second is not { Count: >= 3 } )
{
return false;
}
Bounds( first, out var firstMin, out var firstMax );
Bounds( second, out var secondMin, out var secondMax );
if ( firstMax.x <= secondMin.x + Grain || secondMax.x <= firstMin.x + Grain
|| firstMax.y <= secondMin.y + Grain || secondMax.y <= firstMin.y + Grain )
{
return false;
}
if ( first.Any( point => Contains( second, point ) ) || second.Any( point => Contains( first, point ) ) )
{
return true;
}
for ( var firstIndex = 0; firstIndex < first.Count; firstIndex++ )
{
var firstFrom = first[firstIndex];
var firstTo = first[(firstIndex + 1) % first.Count];
for ( var secondIndex = 0; secondIndex < second.Count; secondIndex++ )
{
var secondFrom = second[secondIndex];
var secondTo = second[(secondIndex + 1) % second.Count];
if ( ProperlyCrosses( firstFrom, firstTo, secondFrom, secondTo ) )
{
return true;
}
}
}
return false;
}
// Both sides donate coordinates, so a hole's boundary lands on a cell line, not slicing one.
public static List<ArchBox> Cells( IEnumerable<IReadOnlyList<Vector2>> loops, IReadOnlyList<IReadOnlyList<Vector2>> holes )
{
var filled = loops.Where( loop => loop is { Count: >= 3 } ).ToList();
var removed = (holes ?? Array.Empty<IReadOnlyList<Vector2>>()).Where( loop => loop is { Count: >= 3 } ).ToList();
var cells = new List<ArchBox>();
if ( filled.Count == 0 )
{
return cells;
}
var donors = filled.Concat( removed ).SelectMany( loop => loop ).ToList();
var xs = Axis( donors.Select( point => point.x ) );
var ys = Axis( donors.Select( point => point.y ) );
for ( var ix = 0; ix + 1 < xs.Count; ix++ )
{
for ( var iy = 0; iy + 1 < ys.Count; iy++ )
{
var min = new Vector2( xs[ix], ys[iy] );
var max = new Vector2( xs[ix + 1], ys[iy + 1] );
var centre = (min + max) * 0.5f;
if ( Encloses( filled, centre ) && !Encloses( removed, centre ) )
{
cells.Add( new ArchBox { Min = min, Max = max } );
}
}
}
return cells;
}
// The one copy of the crossing count - it was written out five times over.
public static bool Contains( IReadOnlyList<Vector2> loop, Vector2 point )
{
if ( loop is not { Count: >= 3 } )
{
return false;
}
var inside = false;
for ( int index = 0, previous = loop.Count - 1; index < loop.Count; previous = index++ )
{
var a = loop[index];
var b = loop[previous];
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;
}
}
return inside;
}
// One answer to "is this point solid": inside the region and inside none of its holes. Slab cells
// and plank lanes both ask it, so a hole that opens the slab opens the boards over it the same way.
public static bool Covered(
IReadOnlyList<IReadOnlyList<Vector2>> region,
IReadOnlyList<ArchFloorCutout> cutouts,
Vector2 point )
{
return Encloses( region, point ) && !cutouts.Any( cutout => cutout.Contains( point ) );
}
// Even-odd across the whole set, so an outer plus a hole reads as a ring, not a solid.
public static bool Encloses( IReadOnlyList<IReadOnlyList<Vector2>> loops, Vector2 point )
{
var inside = false;
foreach ( var loop in loops )
{
if ( Contains( loop, point ) )
{
inside = !inside;
}
}
return inside;
}
// Solved, not sampled - stepping quantises boundaries; an ALONG-edge segment reports no crossing.
public static IEnumerable<float> Crossings( IReadOnlyList<Vector2> loop, Vector2 from, Vector2 to )
{
var span = to - from;
for ( var index = 0; index < loop.Count; index++ )
{
var corner = loop[index];
var edge = loop[(index + 1) % loop.Count] - corner;
var turn = span.x * edge.y - span.y * edge.x;
if ( MathF.Abs( turn ) < 1e-6f )
{
continue;
}
var offset = corner - from;
var alongSpan = (offset.x * edge.y - offset.y * edge.x) / turn;
var alongEdge = (offset.x * span.y - offset.y * span.x) / turn;
if ( alongSpan > 0f && alongSpan < 1f && alongEdge >= 0f && alongEdge <= 1f )
{
yield return alongSpan;
}
}
}
// The stretches of a segment that lie INSIDE a loop, as parameters along it. Split at the crossings and
// keep the spans whose midpoint is enclosed - the same walk the board lanes do, asked of one loop, which
// is what "how much of this wall is standing in that hole" means.
public static IEnumerable<(float From, float To)> Inside( IReadOnlyList<Vector2> loop, Vector2 from, Vector2 to )
{
if ( loop is not { Count: >= 3 } )
{
yield break;
}
var cuts = new List<float> { 0f, 1f };
cuts.AddRange( Crossings( loop, from, to ) );
cuts.Sort();
for ( var index = 0; index + 1 < cuts.Count; index++ )
{
var start = cuts[index];
var end = cuts[index + 1];
if ( end - start > Grain && Contains( loop, Vector2.Lerp( from, to, (start + end) * 0.5f ) ) )
{
yield return (start, end);
}
}
}
public static List<List<Vector2>> Trace( IReadOnlyList<ArchBox> boxes )
{
if ( boxes.Count == 0 )
{
return new List<List<Vector2>>();
}
var xs = Axis( boxes.SelectMany( box => new[] { box.Min.x, box.Max.x } ) );
var ys = Axis( boxes.SelectMany( box => new[] { box.Min.y, box.Max.y } ) );
var columns = xs.Count - 1;
var covered = new bool[columns * (ys.Count - 1)];
foreach ( var box in boxes )
{
var fromX = Index( xs, box.Min.x );
var toX = Index( xs, box.Max.x );
var fromY = Index( ys, box.Min.y );
var toY = Index( ys, box.Max.y );
for ( var ix = fromX; ix < toX; ix++ )
{
for ( var iy = fromY; iy < toY; iy++ )
{
covered[iy * columns + ix] = true;
}
}
}
return Trace( xs, ys, covered );
}
// Wound with covered on the left - outers CCW, holes clockwise, the winding the tool assumes.
public static List<List<Vector2>> Trace( List<float> xs, List<float> ys, bool[] covered )
{
var columns = xs.Count - 1;
var rows = ys.Count - 1;
var segments = new Dictionary<int, List<int>>();
bool Covered( int ix, int iy )
{
return ix >= 0 && iy >= 0 && ix < columns && iy < rows && covered[iy * columns + ix];
}
int Node( int ix, int iy ) => iy * (columns + 1) + ix;
void Segment( int fromX, int fromY, int toX, int toY )
{
var key = Node( fromX, fromY );
if ( !segments.TryGetValue( key, out var ends ) )
{
ends = new List<int>();
segments[key] = ends;
}
ends.Add( Node( toX, toY ) );
}
for ( var ix = 0; ix < columns; ix++ )
{
for ( var iy = 0; iy < rows; iy++ )
{
if ( !Covered( ix, iy ) )
{
continue;
}
if ( !Covered( ix, iy - 1 ) ) Segment( ix, iy, ix + 1, iy );
if ( !Covered( ix + 1, iy ) ) Segment( ix + 1, iy, ix + 1, iy + 1 );
if ( !Covered( ix, iy + 1 ) ) Segment( ix + 1, iy + 1, ix, iy + 1 );
if ( !Covered( ix - 1, iy ) ) Segment( ix, iy + 1, ix, iy );
}
}
var loops = new List<List<Vector2>>();
while ( segments.Count > 0 )
{
var start = segments.Keys.First();
var nodes = new List<int>();
var at = start;
while ( segments.TryGetValue( at, out var ends ) && ends.Count > 0 )
{
var next = ends[0];
ends.RemoveAt( 0 );
if ( ends.Count == 0 )
{
segments.Remove( at );
}
nodes.Add( at );
at = next;
if ( at == start )
{
break;
}
}
var loop = Simplify( nodes.Select( node => new Vector2( xs[node % (columns + 1)], ys[node / (columns + 1)] ) ).ToList() );
if ( loop.Count >= 4 )
{
loops.Add( loop );
}
}
return loops;
}
static List<Vector2> Simplify( List<Vector2> loop )
{
var kept = new List<Vector2>();
for ( var index = 0; index < loop.Count; index++ )
{
var previous = loop[(index - 1 + loop.Count) % loop.Count];
var current = loop[index];
var next = loop[(index + 1) % loop.Count];
var into = current - previous;
var outOf = next - current;
if ( MathF.Abs( into.x * outOf.y - into.y * outOf.x ) > Grain )
{
kept.Add( current );
}
}
return kept;
}
static List<float> Axis( IEnumerable<float> values )
{
return values.Select( Snap ).Distinct().OrderBy( value => value ).ToList();
}
static int Index( List<float> axis, float value )
{
var found = axis.BinarySearch( Snap( value ) );
return found < 0 ? ~found : found;
}
static float Snap( float value ) => ArchGridService.Fine( value );
}