Editor/Services/ArchWallSnap.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public enum ArchSnapKind {
None,
Corner,
Face
}
public readonly struct ArchWallSnapped {
public ArchSnapKind Kind { get; init; }
public Vector2 Point { get; init; }
public ArchWall Wall { get; init; }
public Vector2 Heading { get; init; }
public Vector2 Start { get; init; }
public float Length { get; init; }
// Line belongs to geometry above this storey — snapped head aligns to it, not base
public bool Above { get; init; }
public float Distance { get; init; }
public bool Took => Kind != ArchSnapKind.None;
}
// Separate reach for corners (a point) vs faces (a line) — different targets need different bands
public readonly record struct ArchSnapReach( float Corner, float Face ) {
public bool Any => Corner > 0.01f || Face > 0.01f;
public static readonly ArchSnapReach None = default;
}
// Corners win over faces at equal reach — a corner is an authored point, a face is just a line
public static class ArchWallSnap {
const float Span = 20f;
// Generous band for flush placement (clicks); drags use Reach() instead or they lose their across component
public static ArchSnapReach Flush( float authored ) {
var band = authored > 0.01f ? authored : MathF.Max( Span, new ArchGridService().SubgridSize() );
return new ArchSnapReach( band * 2.5f, band * 5f );
}
public static float Unstated => new ArchGridService().SubgridSize();
public static ArchSnapReach Reach( float authored ) {
var band = authored > 0.01f ? authored : Unstated;
return new ArchSnapReach( band, band );
}
public static IEnumerable<ArchWall> On( ArchPlan plan, int level, ArchRoofPart deck ) {
if ( plan is null ) {
return Enumerable.Empty<ArchWall>();
}
return deck is not null ? deck.Walls : plan.WallsOn( level );
}
public static ArchWallSnapped Nearest( IEnumerable<ArchWall> walls, Vector2 point, ArchSnapReach reach ) {
if ( walls is null || !reach.Any ) {
return default;
}
var corner = default( ArchWallSnapped );
var face = default( ArchWallSnapped );
foreach ( var wall in walls ) {
var length = wall.Length;
if ( length < ArchGridService.LeastWallLength ) {
continue;
}
var line = new ArchSnapLine( wall.Start, wall.Direction, length, wall, false );
Closer( ref corner, ArchSnapKind.Corner, wall.Start, line, reach.Corner, point );
Closer( ref corner, ArchSnapKind.Corner, wall.End, line, reach.Corner, point );
var along = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, length );
Closer( ref face, ArchSnapKind.Face, wall.PointAt( along ), line, reach.Face, point );
}
return corner.Took ? corner : face;
}
public static ArchWallSnapped Nearest( IEnumerable<ArchSnapLoop> loops, Vector2 point, ArchSnapReach reach ) {
if ( loops is null || !reach.Any ) {
return default;
}
var corner = default( ArchWallSnapped );
var face = default( ArchWallSnapped );
foreach ( var edge in loops ) {
var loop = edge.Loop;
if ( loop is null || loop.Count < 2 ) {
continue;
}
for ( var index = 0; index < loop.Count; index++ ) {
var from = loop[index];
var span = loop[(index + 1) % loop.Count] - from;
var length = span.Length;
if ( length < ArchGridService.LeastWallLength ) {
continue;
}
var heading = span / length;
var line = new ArchSnapLine( from, heading, length, null, edge.Above );
var travelled = Math.Clamp( Vector2.Dot( point - from, heading ), 0f, length );
Closer( ref corner, ArchSnapKind.Corner, from, line, reach.Corner, point );
Closer( ref face, ArchSnapKind.Face, from + heading * travelled, line, reach.Face, point );
}
}
return corner.Took ? corner : face;
}
// Measured to the near FACE, not the centreline: on mixed thicknesses that reorders the candidates against
// ArchTool.NearestWall, and the face is what a part seated flush against a wall lands on.
public static ArchWall NearestWallFace( IEnumerable<ArchRoom> rooms, Vector2 point, float unstatedThickness, out float distance, out Vector2 onFace ) {
distance = float.MaxValue;
onFace = default;
ArchWall best = null;
foreach ( var room in rooms ) {
foreach ( var wall in room.Walls ) {
var span = wall.End - wall.Start;
var length = span.Length;
if ( length < 1f ) {
continue;
}
var travelled = Math.Clamp( Vector2.Dot( point - wall.Start, span ) / (length * length), 0f, 1f );
var at = wall.Start + span * travelled;
var side = point - at;
var thickness = wall.Thickness > 0.1f ? wall.Thickness : unstatedThickness;
var face = at + (side.IsNearZeroLength ? wall.Normal : side.Normal) * thickness * 0.5f;
var gap = (face - point).Length;
if ( gap < distance ) {
distance = gap;
onFace = face;
best = wall;
}
}
}
return best;
}
// Corners win over faces across both walls and loops
public static ArchWallSnapped Nearest( IEnumerable<ArchWall> walls, IEnumerable<ArchSnapLoop> loops, Vector2 point, ArchSnapReach reach ) {
var onWalls = Nearest( walls, point, reach );
var onEdges = Nearest( loops, point, reach );
if ( !onWalls.Took || !onEdges.Took ) {
return onWalls.Took ? onWalls : onEdges;
}
if ( onWalls.Kind != onEdges.Kind ) {
return onWalls.Kind == ArchSnapKind.Corner ? onWalls : onEdges;
}
return onWalls.Distance <= onEdges.Distance ? onWalls : onEdges;
}
readonly record struct ArchSnapLine( Vector2 Start, Vector2 Heading, float Length, ArchWall Wall, bool Above );
static void Closer( ref ArchWallSnapped best, ArchSnapKind kind, Vector2 at, ArchSnapLine line, float reach, Vector2 point ) {
var distance = (point - at).Length;
if ( distance > reach || (best.Took && distance >= best.Distance) ) {
return;
}
best = new ArchWallSnapped {
Kind = kind,
Point = at,
Wall = line.Wall,
Heading = line.Heading,
Start = line.Start,
Length = line.Length,
Above = line.Above,
Distance = distance
};
}
public static string Describe( ArchSnapKind kind ) => kind switch {
ArchSnapKind.Corner => "corner",
ArchSnapKind.Face => "wall",
_ => null
};
}