Code/KitModelWall.cs

Editor component that generates a sheared/stretched mesh from a kit wall model to create sloped walls. It copies the source model vertices, applies stretch and slope transforms, computes normals/tangents/UVs, builds a runtime Model with optional welded collision mesh, and assigns a renderer and collider on the GameObject; it rebuilds when properties change.

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

/// <summary>
/// A wall made from a kit wall model (Blender-made, built along its X axis), stretched to a run and
/// sheared up a slope: a parallelogram with vertical ends and its top and bottom parallel to the
/// ground line, like the Kit Shape wall's Rise. The mesh is a copy of the model's vertices with
/// the model's own materials, so a sloped wall looks like the level ones. Built on load and never
/// saved (like Kit Shape), so the scene file only stores the four numbers.
///
/// Local frame: the run starts at the object's origin and goes along +X for Length (horizontal),
/// rising by Rise; Line Y is the model's local Y that sits on that line (its bounds centre for a
/// wall centred on the line, a side face for a wall standing beside it). The Kit Wall Builder and
/// the hole's Add edge walls button make these for sloped runs.
/// </summary>
[Title( "Kit Model Wall" ), Icon( "fence" )]
public sealed class KitModelWall : Component, Component.ExecuteInEditor
{
	/// <summary>The kit wall model to copy (models/kit/wall_100.vmdl).</summary>
	[Property] public Model Source { get; set; }

	/// <summary>Horizontal length of the run, in units.</summary>
	[Property] public float Length { get; set; } = 100f;

	/// <summary>Height difference over the run: the +x end sits this much higher.</summary>
	[Property] public float Rise { get; set; }

	/// <summary>The model's local Y that lies on the run line.</summary>
	[Property, Title( "Line Y" )] public float LineY { get; set; }

	[Property] public bool Collision { get; set; } = true;

	/// <summary>Material drawn instead of the model's own (the kit wall material); empty keeps the model's.</summary>
	[Property] public Material Material { get; set; }

	/// <summary>World units per texture repeat. The mesh gets box-projected texture coordinates at its stretched size, so the texture repeats along the run instead of stretching with it.</summary>
	[Property, Title( "Texture Repeat" )] public float TextureRepeat { get; set; } = 100f;

	Model _model;
	Model _builtSource;
	Material _builtMaterial;
	float _builtLength, _builtRise, _builtLineY, _builtRepeat;
	bool _builtCollision;

	protected override void OnEnabled() => Rebuild();

	protected override void OnUpdate()
	{
		if ( _model is null || _builtSource != Source || _builtLength != Length || _builtRise != Rise || _builtLineY != LineY || _builtCollision != Collision || _builtMaterial != Material || _builtRepeat != TextureRepeat )
			Rebuild();
	}

	/// <summary>How many kit model walls have been rebuilt since load (see golf_perf).</summary>
	public static int RebuildCount;

	/// <summary>Builds the sheared copy and puts it on this object's renderer and collider.</summary>
	public void Rebuild()
	{
		RebuildCount++;
		_builtSource = Source;
		_builtLength = Length;
		_builtRise = Rise;
		_builtLineY = LineY;
		_builtCollision = Collision;
		_builtMaterial = Material;
		_builtRepeat = TextureRepeat;

		if ( Source is null || Source.IsError ) return;
		var sourceVertices = Source.GetVertices();
		var sourceIndices = Source.GetIndices();
		if ( sourceVertices is null || sourceIndices is null || sourceVertices.Length == 0 || sourceIndices.Length < 3 ) return;

		var bounds = Source.Bounds;
		var length = MathF.Max( 1f, Length );
		var stretch = length / MathF.Max( 1f, bounds.Size.x );
		var slope = Rise / length;

		// Shear: x' = stretch * (x - minX), z' = (z - minZ) + slope * x'. Normals take the inverse
		// transpose, tangents the matrix itself, both renormalised.
		var vertices = new List<SimpleVertex>( sourceVertices.Length );
		var positions = new List<Vector3>( sourceVertices.Length );
		var repeat = MathF.Max( 1f, TextureRepeat );
		foreach ( var v in sourceVertices )
		{
			var x = (v.Position.x - bounds.Mins.x) * stretch;
			var p = new Vector3( x, v.Position.y - LineY, v.Position.z - bounds.Mins.z + slope * x );
			var n = new Vector3( v.Normal.x / stretch - slope * v.Normal.z, v.Normal.y, v.Normal.z ).Normal;
			var t = new Vector3( stretch * v.Tangent.x, v.Tangent.y, slope * stretch * v.Tangent.x + v.Tangent.z ).Normal;
			// Box projection on the stretched mesh: the texture repeats every TextureRepeat units
			// along the run (and up the wall) however long the wall is.
			var ax = MathF.Abs( n.x ); var ay = MathF.Abs( n.y ); var az = MathF.Abs( n.z );
			var uv = az >= ax && az >= ay ? new Vector2( p.x, p.y ) : ax >= ay ? new Vector2( p.y, p.z ) : new Vector2( p.x, p.z );
			vertices.Add( new SimpleVertex( p, n, t, uv / repeat ) );
			positions.Add( p );
		}
		var indices = sourceIndices.Select( i => (int)i ).ToList();

		var material = Source.Materials.FirstOrDefault() ?? Material.Load( "prefabs/holekit/kit.vmat" );
		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( vertices.Count, vertices );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = BBox.FromPoints( positions );

		var builder = Model.Builder.AddMesh( mesh );
		if ( Collision )
		{
			// Physics gets a welded copy so the ball never meets an edge between a face's triangles.
			var welded = new List<Vector3>();
			var weldedIndices = new List<int>();
			var lookup = new Dictionary<(int, int, int), int>();
			foreach ( var i in indices )
			{
				var p = positions[i];
				var key = ((int)MathF.Round( p.x * 16f ), (int)MathF.Round( p.y * 16f ), (int)MathF.Round( p.z * 16f ));
				if ( !lookup.TryGetValue( key, out var w ) )
				{
					w = welded.Count;
					welded.Add( p );
					lookup[key] = w;
				}
				weldedIndices.Add( w );
			}
			builder.AddCollisionMesh( welded, weldedIndices );
		}
		_model = builder.Create();

		// One renderer and one collider, driven from here and never saved (rebuilt on load).
		var renderers = GameObject.GetComponents<ModelRenderer>( true ).ToList();
		var renderer = renderers.FirstOrDefault() ?? GameObject.AddComponent<ModelRenderer>();
		foreach ( var spare in renderers.Skip( 1 ) ) spare.Destroy();
		renderer.Flags |= ComponentFlags.NotSaved;
		renderer.Model = _model;
		renderer.MaterialOverride = Material;

		var colliders = GameObject.GetComponents<ModelCollider>( true ).ToList();
		var collider = colliders.FirstOrDefault();
		foreach ( var spare in colliders.Skip( 1 ) ) spare.Destroy();
		if ( Collision )
		{
			collider ??= GameObject.AddComponent<ModelCollider>();
			collider.Flags |= ComponentFlags.NotSaved;
			collider.Model = _model;
			collider.Static = true;
			collider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;
			collider.Enabled = true;
		}
		else if ( collider.IsValid() )
		{
			collider.Flags |= ComponentFlags.NotSaved;
			collider.Enabled = false;
		}

		// Under a hole the hole's welded collider takes this piece in (and switches the collider off).
		GameObject.GetComponentInParent<GolfHoleCollision>( true )?.MarkDirty();
	}
}