Editor/GolfHoleEdgeWalls.cs

Editor inspector and utility for adding/removing kit wall and post models around the outer tile boundary of a GolfHoleCollision GameObject. The editor adds UI buttons that call into static routines which compute boundary loops from tile piece renderers, place corner posts and wall segments using kit models, and remove only the objects it created.

File AccessNative Interop
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Inspector for Golf Hole Collision: the component's own rows plus two buttons. "Add edge walls"
/// walls in the hole's outer boundary (every floor tile, ramp and cup tile edge with no neighbouring
/// piece) with the kit post and wall models, placed exactly like the Kit Wall Builder would place
/// them. "Remove edge walls" deletes only the pieces that button made. Editor only; both undo.
/// </summary>
[CustomEditor( typeof( GolfHoleCollision ) )]
public sealed class GolfHoleCollisionEditor : ComponentEditorWidget
{
	public GolfHoleCollisionEditor( SerializedObject obj ) : base( obj )
	{
		Layout = Layout.Column();

		var sheet = new Editor.ControlSheet();
		sheet.IncludePropertyNames = true;
		sheet.AddObject( obj, p => p.HasAttribute<PropertyAttribute>() );
		Layout.Add( sheet );

		var buttons = Layout.AddRow();
		buttons.Margin = 4f;
		buttons.Spacing = 4f;

		var add = new Editor.Button( "Add edge walls", "fence", this );
		add.ToolTip = "Posts and walls along every outer tile edge of this hole (kit post_10 and wall_100 models).";
		add.Clicked = () => GolfHoleEdgeWalls.Add( Hole );
		buttons.Add( add );

		var remove = new Editor.Button( "Remove edge walls", "delete", this );
		remove.ToolTip = "Deletes the posts and walls that Add edge walls made under this hole.";
		remove.Clicked = () => GolfHoleEdgeWalls.Remove( Hole );
		buttons.Add( remove );
	}

	GolfHoleCollision Hole => SerializedObject.Targets?.OfType<GolfHoleCollision>().FirstOrDefault();
}

/// <summary>
/// Finds the outer boundary of a hole's tile pieces and builds posts and walls along it. An edge is
/// on the boundary when no other piece has the same edge (same two end points in 3D, so two ramps
/// side by side join and a tile at another height does not). Where two boundary edges share the
/// same footprint at different heights (a ramp's sloped side beside a flat tile, or a height step)
/// only the higher one is walled: the lower one lies against the higher piece's side. The boundary
/// edges are joined into loops, straight runs are merged, a post stands at every corner and one
/// wall runs between posts; a sloped wall is sheared (Kit Model Wall) so it follows the surface.
/// Walls stand on the tile with their outer face on the edge line, foot (if the model has one)
/// towards the play side.
/// </summary>
public static class GolfHoleEdgeWalls
{
	/// <summary>Tag on every object these buttons create, so Remove touches nothing else.</summary>
	public const string Tag = "golf_edgewall";

	const string WallModelPath = "models/kit/wall_100.vmdl";
	const string PostModelPath = "models/kit/post_10.vmdl";

	/// <summary>Points closer than 1/4 unit are the same point.</summary>
	const float Grid = 4f;

	public static void Add( GolfHoleCollision hole )
	{
		if ( !hole.IsValid() ) return;
		var session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;
		if ( session is null ) return;

		var wallModel = KitWallBuilderTool.LoadKitModel( WallModelPath );
		var postModel = KitWallBuilderTool.LoadKitModel( PostModelPath );
		if ( wallModel is null || postModel is null )
		{
			Log.Warning( $"Add edge walls: {WallModelPath} or {PostModelPath} is missing" );
			return;
		}

		var loops = BoundaryLoops( hole.GameObject );
		if ( loops.Count == 0 )
		{
			Log.Info( $"Add edge walls: no tile edges found under {hole.GameObject.Name}" );
			return;
		}

		var footSide = FootSide( wallModel );
		var bounds = wallModel.Bounds;
		Log.Info( $"Add edge walls: wall model bounds x {bounds.Mins.x}..{bounds.Maxs.x}, y {bounds.Mins.y}..{bounds.Maxs.y}, z {bounds.Mins.z}..{bounds.Maxs.z}; foot side {(footSide > 0 ? "+y" : footSide < 0 ? "-y" : "none")}" );

		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Add edge walls" ).WithGameObjectCreations().Push();

		var parent = hole.GameObject;
		var posts = 0;
		var walls = 0;
		foreach ( var loop in loops )
		{
			foreach ( var point in loop.Points )
			{
				var post = KitWallBuilderTool.CreateModelObject( parent, "Post", postModel, point - KitWallBuilderTool.BottomCentre( postModel.Bounds ), Rotation.Identity, Vector3.One );
				post.Tags.Add( Tag );
				posts++;
			}

			var segments = loop.Closed ? loop.Points.Count : loop.Points.Count - 1;
			for ( var i = 0; i < segments; i++ )
			{
				var from = loop.Points[i];
				var to = loop.Points[(i + 1) % loop.Points.Count];
				var inward = loop.Inward[i];
				if ( (to - from).WithZ( 0f ).Length < 1f ) continue;

				// The wall stands on the tile: its outer face on the edge line, the play side (and the
				// foot, when the model has one) towards the tiles. The model's +Y is the run's left; the
				// run is reversed when the foot would end up outside.
				var inwardOnLeft = Vector3.Dot( Rotation.LookAt( (to - from).WithZ( 0f ).Normal, Vector3.Up ).Left, inward ) > 0f;
				var wantLeft = footSide >= 0;
				if ( inwardOnLeft != wantLeft ) (from, to) = (to, from);
				var lineY = wantLeft ? bounds.Mins.y : bounds.Maxs.y;

				var wall = KitWallBuilderTool.PlaceWallPiece( parent, wallModel, from, to, lineY );
				if ( wall is null ) continue;
				wall.Tags.Add( Tag );
				walls++;
			}
		}

		hole.MarkDirty();
		Log.Info( $"Add edge walls: {hole.GameObject.Name}: {posts} posts and {walls} walls along {loops.Count} loop(s)" );
	}

	public static void Remove( GolfHoleCollision hole )
	{
		if ( !hole.IsValid() ) return;
		var session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;
		if ( session is null ) return;

		var made = hole.GameObject.Children.Where( go => go.IsValid() && go.Tags.Has( Tag ) ).ToList();
		if ( made.Count == 0 )
		{
			Log.Info( $"Remove edge walls: nothing to remove under {hole.GameObject.Name}" );
			return;
		}

		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Remove edge walls" ).WithGameObjectDestructions( made ).Push();
		foreach ( var go in made ) go.Destroy();

		hole.MarkDirty();
		Log.Info( $"Remove edge walls: {hole.GameObject.Name}: removed {made.Count} pieces" );
	}

	/// <summary>
	/// Which side of the wall model is wider at the base than higher up: +1 for +Y, -1 for -Y,
	/// 0 for a wall with the same width all the way up.
	/// </summary>
	static int FootSide( Model model )
	{
		var vertices = model.GetVertices();
		if ( vertices is null || vertices.Length == 0 ) return 0;
		var bounds = model.Bounds;
		var baseTop = bounds.Mins.z + 1f;
		var upperFrom = bounds.Mins.z + bounds.Size.z * 0.5f;
		var baseMin = float.MaxValue; var baseMax = float.MinValue;
		var upperMin = float.MaxValue; var upperMax = float.MinValue;
		foreach ( var v in vertices )
		{
			if ( v.Position.z <= baseTop ) { baseMin = MathF.Min( baseMin, v.Position.y ); baseMax = MathF.Max( baseMax, v.Position.y ); }
			if ( v.Position.z >= upperFrom ) { upperMin = MathF.Min( upperMin, v.Position.y ); upperMax = MathF.Max( upperMax, v.Position.y ); }
		}
		if ( baseMin == float.MaxValue || upperMin == float.MaxValue ) return 0;
		var plus = baseMax - upperMax;
		var minus = upperMin - baseMin;
		if ( plus > 0.5f && plus >= minus ) return 1;
		if ( minus > 0.5f ) return -1;
		return 0;
	}

	// ---------------------------------------------------------------- boundary

	sealed class Loop
	{
		public List<Vector3> Points = new();
		/// <summary>Horizontal direction into the tiles for the segment starting at each point.</summary>
		public List<Vector3> Inward = new();
		public bool Closed;
	}

	record struct Edge( Key A, Key B, Vector3 Inward, float Height );

	/// <summary>
	/// The boundary of the tile pieces as point loops in world space, corners only (straight runs
	/// merged), each with the inward direction of every segment.
	/// </summary>
	static List<Loop> BoundaryLoops( GameObject holeObject )
	{
		// Every top-surface edge of every piece, counted by its two end points in 3D.
		var edges = new Dictionary<(Key, Key), (Edge edge, int count)>();
		foreach ( var renderer in holeObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
		{
			var go = renderer.GameObject;
			if ( !go.IsValid() || !go.Enabled || go == holeObject || go.Tags.Has( Tag ) ) continue;
			var corners = TopCorners( renderer.Model );
			if ( corners is null ) continue;

			var world = corners.Select( c => go.WorldTransform.PointToWorld( c ) ).ToArray();
			var centre = go.WorldTransform.PointToWorld( Vector3.Zero );
			for ( var i = 0; i < world.Length; i++ )
			{
				var a = world[i];
				var b = world[(i + 1) % world.Length];
				var ka = Key.Of( a );
				var kb = Key.Of( b );
				var key = ka.CompareTo( kb ) <= 0 ? (ka, kb) : (kb, ka);
				var inward = (centre - (a + b) * 0.5f).WithZ( 0f ).Normal;
				edges[key] = edges.TryGetValue( key, out var e )
					? (e.edge, e.count + 1)
					: (new Edge( key.Item1, key.Item2, inward, key.Item1.Point.z + key.Item2.Point.z ), 1);
			}
		}

		// Boundary edges: used by one piece only. Of the edges sharing one footprint at different
		// heights only the highest is kept: the lower lies against the higher piece's side.
		var open = edges.Values.Where( e => e.count == 1 ).Select( e => e.edge )
			.GroupBy( e => (e.A.Flat, e.B.Flat) )
			.Select( g => g.OrderByDescending( e => e.Height ).First() )
			.ToList();

		var byPoint = new Dictionary<Key, List<int>>();
		for ( var i = 0; i < open.Count; i++ )
		{
			foreach ( var end in new[] { open[i].A, open[i].B } )
			{
				if ( !byPoint.TryGetValue( end, out var list ) ) byPoint[end] = list = new List<int>();
				list.Add( i );
			}
		}

		// Walk the edges into chains; a chain that comes back to its first point is a closed loop.
		var used = new bool[open.Count];
		var loops = new List<Loop>();
		for ( var start = 0; start < open.Count; start++ )
		{
			if ( used[start] ) continue;
			used[start] = true;
			var chain = new List<Key> { open[start].A, open[start].B };
			var inward = new List<Vector3> { open[start].Inward };
			var closed = false;
			while ( true )
			{
				var tail = chain[^1];
				var next = byPoint[tail].FirstOrDefault( i => !used[i], -1 );
				if ( next < 0 ) break;
				used[next] = true;
				var other = open[next].A.Equals( tail ) ? open[next].B : open[next].A;
				inward.Add( open[next].Inward );
				if ( other.Equals( chain[0] ) ) { closed = true; break; }
				chain.Add( other );
			}
			loops.Add( MergeStraight( chain.Select( k => k.Point ).ToList(), inward, closed ) );
		}
		return loops;
	}

	/// <summary>Corners of a piece's top surface in piece space, in order around it; null for pieces that are not tiles.</summary>
	static Vector3[] TopCorners( Model model )
	{
		var name = model?.Name ?? "";
		if ( name.Contains( "ramp", StringComparison.OrdinalIgnoreCase ) )
			return new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, KitShape.RampRise ), new Vector3( 100, 100, KitShape.RampRise ), new Vector3( -100, 100, 0 ) };
		// The kit floor tile, the cup tiles, the trench cup tile and every trench floor piece (floor,
		// floor_corner, floor_end, floor_mud_pit, floor_t_tile, floor_x_tile): 200 x 200 tiles, top at z 0.
		if ( name.Contains( "floor", StringComparison.OrdinalIgnoreCase ) || name.Contains( "cup_", StringComparison.OrdinalIgnoreCase ) || name.Contains( "trench_hole", StringComparison.OrdinalIgnoreCase ) )
			return new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, 0 ), new Vector3( 100, 100, 0 ), new Vector3( -100, 100, 0 ) };
		return null;
	}

	/// <summary>
	/// Drops every point where the direction does not change, so one wall covers a straight run.
	/// Segment i runs from point i to point i + 1 (back to 0 on a closed loop) with inward[i].
	/// </summary>
	static Loop MergeStraight( List<Vector3> points, List<Vector3> inward, bool closed )
	{
		var loop = new Loop { Closed = closed };
		if ( points.Count < 3 )
		{
			loop.Points = points;
			loop.Inward = inward;
			return loop;
		}
		for ( var i = 0; i < points.Count; i++ )
		{
			var isEnd = !closed && (i == 0 || i == points.Count - 1);
			if ( !isEnd )
			{
				var prev = points[(i - 1 + points.Count) % points.Count];
				var next = points[(i + 1) % points.Count];
				if ( Vector3.Dot( (points[i] - prev).Normal, (next - points[i]).Normal ) >= 0.9999f ) continue;
			}
			loop.Points.Add( points[i] );
			loop.Inward.Add( i < inward.Count ? inward[i] : inward[^1] );
		}
		return loop;
	}

	/// <summary>A world point snapped to the grid, usable as a dictionary key.</summary>
	readonly record struct Key( int X, int Y, int Z ) : IComparable<Key>
	{
		public static Key Of( Vector3 p ) => new( (int)MathF.Round( p.x * Grid ), (int)MathF.Round( p.y * Grid ), (int)MathF.Round( p.z * Grid ) );
		public Vector3 Point => new( X / Grid, Y / Grid, Z / Grid );
		public (int, int) Flat => (X, Y);
		public int CompareTo( Key other ) => X != other.X ? X.CompareTo( other.X ) : Y != other.Y ? Y.CompareTo( other.Y ) : Z.CompareTo( other.Z );
	}
}