Editor/KitWallBuilderTool.cs

Editor tool for the scene view that lets a creator place chains of wall posts and wall pieces between them. It handles preview drawing, snapping to existing posts, creating kit prefab or model-based posts and walls, fitting/repeating model pieces, undo grouping, and selecting which GameObject group (Hole) to parent pieces under.

File AccessExternal Download
using Sandbox;
using Editor;
using System;
using System.Linq;

/// <summary>
/// Kit Wall Builder: a Scene view tool that lays walls between posts, like Golf With Your Friends'
/// Easy Wall Builder. Click a surface to drop a post; every further click drops the next post and
/// fills a wall between the two, any angle, any length. On a slope a kit wall is a parallelogram
/// (vertical ends, edges along the ground line); a model wall is sheared the same way (Kit Model
/// Wall, a copy of the model's mesh). Right-click or Esc ends the chain; Shift-click afterwards
/// continues from the last post. Pieces go under the selected Hole group (or the scene root), each
/// click with its own undo step.
///
/// The sidebar takes a Wall Model and a Post Model (any model asset, built along its X axis). With
/// no model set, the wall is the hole kit's wall prefab (a Kit Shape with its Length set) and the
/// post a 10 x 10 x 30 kit wall piece. A model gives a plain object with a Model Renderer and a
/// Model Collider, keeping the model's own materials. Fit: Stretch scales one copy to the run,
/// Repeat lays whole copies and trims the last one by scale.
///
/// Collision: Golf Hole Collision merges kit pieces only; a model wall keeps its own Model Collider,
/// which is fine for a wall (the merge exists for floor seams).
/// </summary>
[EditorTool( "kitwall" )]
[Title( "Kit Wall Builder" )]
[Icon( "fence" )]
public sealed class KitWallBuilderTool : EditorTool
{
	public enum WallFit
	{
		/// <summary>One copy of the wall model, scaled along its length to fit between the posts.</summary>
		Stretch,

		/// <summary>Whole copies of the wall model along the run; the last one is trimmed by scale.</summary>
		Repeat,
	}

	const string WallPrefab = "prefabs/holekit/wall.prefab";
	const float PostSize = KitShape.WallThickness;

	/// <summary>Model for the walls; empty means the hole kit wall.</summary>
	[Property, Title( "Wall Model" )]
	public Model WallModel { get; set; }

	/// <summary>Model for the posts; empty means a 10 x 10 x 30 kit wall piece.</summary>
	[Property, Title( "Post Model" )]
	public Model PostModel { get; set; }

	/// <summary>How a wall model fills the run between two posts.</summary>
	[Property, Title( "Fit" )]
	public WallFit Fit { get; set; } = WallFit.Stretch;

	/// <summary>A click this close to a post (in units) uses that post instead of placing a new one.</summary>
	[Property, Title( "Snap Distance" ), Range( 1f, 200f )]
	public float SnapDistance { get; set; } = KitShape.WallThickness;

	Vector3? _lastPost;
	bool _chainOpen;
	readonly System.Collections.Generic.List<Vector3> _chainPosts = new();
	Editor.Label _status;

	public override void OnEnabled()
	{
		// A course whose Golf Course Info says Minigolf gets the hedge set: its wall between posts and
		// its corner piece as the post. Elsewhere the Blender-made kit models, when they exist, then the
		// kit prefab. Model.Load reads them from disk; ResourceLibrary only knows models something already loaded.
		if ( GolfCourseInfo.Find( SceneEditorSession.Active?.Scene )?.WallStyle == GolfCourseInfo.KitWallStyle.Minigolf )
		{
			WallModel ??= LoadKitModel( "models/kit/minigolf/minigolf_wall.vmdl" );
			PostModel ??= LoadKitModel( "models/kit/minigolf/minigolf_corner.vmdl" );
		}
		WallModel ??= LoadKitModel( "models/kit/wall_100.vmdl" );
		PostModel ??= LoadKitModel( "models/kit/post_10.vmdl" );
	}

	internal static Model LoadKitModel( string path )
	{
		var model = Model.Load( path );
		return model is null || model.IsError ? null : model;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "Kit Wall Builder", "fence" );
		sidebar.MinimumWidth = 320;

		var models = sidebar.AddGroup( "Models" );
		var so = this.GetSerialized();
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( WallModel ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( PostModel ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( Fit ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( SnapDistance ) ) ) );
		models.Add( new Editor.Label( "Empty slots use the hole kit wall. A wall model is built along its X axis." ) );

		var how = sidebar.AddGroup( "How to use" );
		how.Add( new Editor.Label( "Click a surface: a post.\nNext click: a post and a wall between.\nClick an existing post: the wall joins it (the first post closes the loop).\nRight-click or Esc: end the chain.\nShift-click: continue from the last post.\nCtrl+Z: remove the last click." ) );

		var where = sidebar.AddGroup( "Pieces go under" );
		_status = new Editor.Label( ParentName() );
		where.Add( _status );

		return sidebar;
	}

	public override void OnDisabled()
	{
		_lastPost = null;
		_chainOpen = false;
		_chainPosts.Clear();
	}

	public override void OnUpdate()
	{
		if ( _status.IsValid() ) _status.Text = ParentName();

		// Esc ends the chain; so does a right-click.
		if ( Editor.Application.IsKeyDown( KeyCode.Escape ) || Gizmo.WasRightMousePressed )
			_chainOpen = false;

		// The whole view is ours while the tool is active, so clicks don't pick objects.
		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );

		var trace = Scene.Trace.Ray( Gizmo.CurrentRay, 100000 ).UseRenderMeshes( true ).Run();
		if ( !trace.Hit ) return;

		var parent = FindParent();
		var point = trace.HitPosition;
		var continuing = _lastPost.HasValue && (_chainOpen || Gizmo.IsShiftPressed);

		// Near an existing post: use it exactly, so a loop closes on the post it started from.
		var snapped = NearestPost( parent, point );
		if ( snapped.HasValue ) point = snapped.Value;

		DrawPreview( point, continuing, snapped.HasValue );

		if ( !Gizmo.WasLeftMousePressed ) return;

		if ( snapped.HasValue )
		{
			if ( !continuing || _lastPost.Value.Distance( point ) <= PostSize ) return;   // the post we are standing on
			PlaceWall( parent, _lastPost.Value, point );
			var closedLoop = _chainPosts.Count > 0 && _chainPosts[0].Distance( point ) <= SnapDistance;
			_lastPost = point;
			_chainOpen = !closedLoop;
			if ( closedLoop ) _chainPosts.Clear();
			return;
		}

		if ( continuing && _lastPost.Value.Distance( point ) > PostSize )
			PlaceWall( parent, _lastPost.Value, point );

		PlacePost( parent, point );
		if ( !continuing ) _chainPosts.Clear();
		_chainPosts.Add( point );
		_lastPost = point;
		_chainOpen = true;
	}

	/// <summary>
	/// The base of the nearest post within the snap distance: posts of this chain first, then every
	/// object named Post under the group the pieces go to. Null when none is close.
	/// </summary>
	Vector3? NearestPost( GameObject parent, Vector3 point )
	{
		Vector3? best = null;
		var bestDistance = SnapDistance;

		void Consider( Vector3 basePoint )
		{
			var distance = basePoint.Distance( point );
			if ( distance <= bestDistance ) { bestDistance = distance; best = basePoint; }
		}

		foreach ( var post in _chainPosts ) Consider( post );
		if ( _lastPost.HasValue ) Consider( _lastPost.Value );

		var children = parent.IsValid() ? parent.Children : Scene.Children;
		foreach ( var go in children )
		{
			if ( !go.IsValid() || go.Name != "Post" ) continue;
			var basePoint = go.WorldPosition;
			if ( go.GetComponent<KitShape>() is null && go.GetComponent<ModelRenderer>() is { Model: { } model } )
				basePoint += new Vector3( model.Bounds.Center.x, model.Bounds.Center.y, model.Bounds.Mins.z );
			Consider( basePoint );
		}
		return best;
	}

	/// <summary>The selected Hole group (the selection or one of its parents), else the scene root.</summary>
	GameObject FindParent()
	{
		foreach ( var selected in Selection.OfType<GameObject>() )
		{
			for ( var go = selected; go.IsValid(); go = go.Parent )
			{
				if ( go.GetComponent<GolfHoleCollision>() is not null || go.Name.StartsWith( "Hole ", StringComparison.OrdinalIgnoreCase ) )
					return go;
			}
		}
		return null;
	}

	string ParentName()
	{
		var parent = FindParent();
		return parent.IsValid() ? parent.Name : "the scene root (select a Hole group to build into it)";
	}

	// ---------------------------------------------------------------- placing

	void PlacePost( GameObject parent, Vector3 point )
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return;
		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Wall builder post" ).WithGameObjectCreations().Push();

		if ( PostModel is null )
		{
			var go = GameObject.Clone( WallPrefab, new Transform( point, Rotation.Identity ), parent, true, "Post" );
			var shape = go?.GetComponent<KitShape>();
			if ( shape is not null ) { shape.Length = PostSize; return; }

			// The kit wall is a model piece: stand it with its bottom centre on the click point.
			var prefabModel = go?.GetComponent<ModelRenderer>()?.Model;
			if ( prefabModel is not null ) go.WorldPosition = point - BottomCentre( prefabModel.Bounds );
			return;
		}

		// The model's bottom centre on the click point.
		var bounds = PostModel.Bounds;
		var bottomCentre = new Vector3( bounds.Center.x, bounds.Center.y, bounds.Mins.z );
		CreateModelObject( parent, "Post", PostModel, point - bottomCentre, Rotation.Identity, Vector3.One );
	}

	void PlaceWall( GameObject parent, Vector3 from, Vector3 to )
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return;
		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Wall builder wall" ).WithGameObjectCreations().Push();

		// Along the slope from post base to post base, roll kept level.
		var run = to - from;
		var length = run.Length;
		if ( length < 1f ) return;
		var rotation = Rotation.LookAt( run.Normal, Vector3.Up );

		if ( WallModel is null )
		{
			// A kit wall is a parallelogram: level yaw, horizontal length, and the height difference
			// built into the mesh as Rise, so both ends stand vertical on their post bases.
			var flat = run.WithZ( 0f );
			if ( flat.Length < 1f ) return;
			var go = GameObject.Clone( WallPrefab, new Transform( (from + to) * 0.5f, Rotation.LookAt( flat.Normal, Vector3.Up ) ), parent, true, "Wall" );
			var shape = go?.GetComponent<KitShape>();
			if ( shape is not null )
			{
				shape.Length = flat.Length;
				shape.Rise = run.z;
				return;
			}

			// The kit wall is a model piece: stretch it between the posts like a model wall.
			var prefabModel = go?.GetComponent<ModelRenderer>()?.Model;
			if ( prefabModel is not null ) FitModelObject( go, prefabModel, from, rotation, 0f, length );
			return;
		}

		var modelLength = ModelLength( WallModel.Bounds );

		if ( Fit == WallFit.Stretch )
		{
			PlaceWallPiece( parent, WallModel, from, to );
			return;
		}

		// Repeat: whole copies along the run, then the remainder trimmed by scale.
		var direction = run.Normal;
		var copies = (int)MathF.Floor( length / modelLength );
		for ( var i = 0; i < copies; i++ )
			PlaceWallPiece( parent, WallModel, from + direction * (i * modelLength), from + direction * ((i + 1) * modelLength) );

		var rest = length - copies * modelLength;
		if ( rest > 1f )
			PlaceWallPiece( parent, WallModel, from + direction * (copies * modelLength), to );
	}

	/// <summary>
	/// One wall model piece from one point to another, always a Kit Model Wall: the model's mesh
	/// rebuilt at the run's length (so the wall material repeats along the run instead of
	/// stretching with a scaled model) and, on a slope, sheared so the ends stay vertical and the
	/// top follows the ground line. Line Y is the model's local Y that sits on the line between
	/// the points (its bounds centre when not given). Returns the new object, or null for no run.
	/// </summary>
	internal static GameObject PlaceWallPiece( GameObject parent, Model model, Vector3 from, Vector3 to, float? lineY = null )
	{
		var run = to - from;
		var flat = run.WithZ( 0f );
		if ( flat.Length < 1f ) return null;
		var rotation = Rotation.LookAt( flat.Normal, Vector3.Up );

		var wall = new GameObject( true, "Wall" );
		if ( parent.IsValid() ) wall.SetParent( parent, false );
		wall.WorldPosition = from;
		wall.WorldRotation = rotation;
		var shear = wall.AddComponent<KitModelWall>();
		shear.Source = model;
		shear.Length = flat.Length;
		shear.Rise = run.z;
		shear.LineY = lineY ?? model.Bounds.Center.y;
		shear.Material = LoadWallMaterial();
		shear.Rebuild();
		return wall;
	}

	/// <summary>The material on every wall and post the tools make (concrete; the floor is grass).</summary>
	internal const string WallMaterialPath = "textures/tcom_concrete_base8_2x2_1k.vmat";

	internal static Material LoadWallMaterial()
	{
		var material = Material.Load( WallMaterialPath );
		if ( material is null ) Log.Warning( $"Kit wall material {WallMaterialPath} not found; walls and posts keep the model's own material." );
		return material;
	}

	/// <summary>Length of the model along X, the kit's wall axis, at least 1.</summary>
	static float ModelLength( BBox bounds ) => MathF.Max( 1f, bounds.Size.x );

	/// <summary>Bottom centre of the model's bounds, the point that stands on the floor.</summary>
	internal static Vector3 BottomCentre( BBox bounds ) => new( bounds.Center.x, bounds.Center.y, bounds.Mins.z );

	/// <summary>
	/// Places a model object so it covers [start, start + length] along the run from the first post:
	/// its X axis (the kit's wall axis) along the run, scaled on X so its bounds span the length,
	/// Z up, with its bottom on the line between the post bases at the model's local Line Y (its
	/// bounds centre when not given).
	/// </summary>
	internal static void FitModelObject( GameObject go, Model model, Vector3 from, Rotation rotation, float start, float length, float? lineY = null )
	{
		var bounds = model.Bounds;
		var scale = new Vector3( length / ModelLength( bounds ), 1f, 1f );
		var segmentCentre = from + rotation.Forward * (start + length * 0.5f);
		var onLine = new Vector3( bounds.Center.x, lineY ?? bounds.Center.y, bounds.Mins.z );

		go.WorldRotation = rotation;
		go.LocalScale = scale;
		go.WorldPosition = segmentCentre - rotation * (onLine * scale);
	}

	internal static GameObject CreateModelObject( GameObject parent, string name, Model model, Vector3 position, Rotation rotation, Vector3 scale )
	{
		var go = new GameObject( true, name );
		if ( parent.IsValid() ) go.SetParent( parent, false );
		go.WorldPosition = position;
		go.WorldRotation = rotation;
		go.LocalScale = scale;

		var renderer = go.AddComponent<ModelRenderer>();
		renderer.Model = model;
		renderer.MaterialOverride = LoadWallMaterial();

		var collider = go.AddComponent<ModelCollider>();
		collider.Model = model;
		collider.Static = true;
		collider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;
		return go;
	}

	// ---------------------------------------------------------------- preview

	/// <summary>
	/// A post outline at the cursor (cyan and thicker when it snaps to an existing post), and the
	/// wall footprint from the last post while a chain is on.
	/// </summary>
	void DrawPreview( Vector3 point, bool continuing, bool snapping )
	{
		Gizmo.Draw.Color = snapping ? Color.Cyan : Color.Green;
		Gizmo.Draw.LineThickness = snapping ? 4f : 2f;

		var postBox = PostModel is null
			? new BBox( new Vector3( -PostSize / 2f, -PostSize / 2f, 0f ), new Vector3( PostSize / 2f, PostSize / 2f, KitShape.WallHeight ) )
			: new BBox( PostModel.Bounds.Mins - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ), PostModel.Bounds.Maxs - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ) );
		using ( Gizmo.Scope( "kitwall-post", new Transform( point, Rotation.Identity ) ) )
			Gizmo.Draw.LineBBox( postBox );

		if ( !continuing ) return;

		var from = _lastPost.Value;
		var run = point - from;
		var length = run.Length;
		if ( length < 1f ) return;

		Gizmo.Draw.Color = Color.Yellow;
		Gizmo.Draw.LineThickness = 2f;

		// The parallelogram both wall kinds make: vertical ends, top and bottom along the ground line.
		var flat = run.WithZ( 0f );
		if ( flat.Length < 1f ) return;
		var yaw = Rotation.LookAt( flat.Normal, Vector3.Up );
		var thickness = WallModel is null ? KitShape.WallThickness : MathF.Max( 1f, WallModel.Bounds.Size.y );
		var height = WallModel is null ? KitShape.WallHeight : MathF.Max( 1f, WallModel.Bounds.Size.z );
		var up = Vector3.Up * height;
		var side = yaw.Left * (thickness / 2f);
		foreach ( var offset in new[] { side, -side } )
		{
			Gizmo.Draw.Line( from + offset, point + offset );
			Gizmo.Draw.Line( from + offset + up, point + offset + up );
			Gizmo.Draw.Line( from + offset, from + offset + up );
			Gizmo.Draw.Line( point + offset, point + offset + up );
		}
		Gizmo.Draw.Line( from + side + up, from - side + up );
		Gizmo.Draw.Line( point + side + up, point - side + up );
	}
}