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.
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 );
}
}