KitShape.cs

A component that procedurally builds various kit piece meshes (tiles, walls, ramps, flag, tee pad, cups) and their collision for a mini-golf style hole. It generates vertex/index buffers per material, creates a Model, assigns a ModelRenderer/ModelCollider, and provides a welded collision mesh for use by a parent GolfHoleCollision.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// One piece of the hole kit, built as a single clean mesh with a matching collision mesh when
/// the piece is enabled, in the editor and in the game alike. The kit's prefabs each carry one of
/// these; the material is the shared kit material. Grid: 50 units, tiles 200 x 200 with their top
/// at the piece's own height, walls 100 long sitting on a tile edge, ramps 200 long rising 50.
/// </summary>
[Title( "Kit Shape" ), Icon( "category" )]
public sealed class KitShape : Component, Component.ExecuteInEditor
{
	public enum ShapeKind
	{
		/// <summary>200 x 200 tile, 50 thick, top at the piece's height.</summary>
		Tile,
		/// <summary>100 long rail, 10 thick, 30 high, centred on a tile edge.</summary>
		Wall,
		/// <summary>An L of two 100 rails meeting at the piece, running +x and +y.</summary>
		Corner,
		/// <summary>200 long wedge rising 50 towards +x, flush with the tiles at both ends.</summary>
		Ramp,
		/// <summary>A flat round pad on the floor where the balls start.</summary>
		TeePad,
		/// <summary>A thin pole with a small flag.</summary>
		Flag,
		/// <summary>A tile with a round cup opening dropping straight down.</summary>
		CupStraight,
		/// <summary>A tile with a smooth bowl curving down into the cup opening.</summary>
		CupCurved,
	}

	[Property]
	public ShapeKind Kind { get; set; } = ShapeKind.Tile;

	/// <summary>The material for the whole kit. Change it to restyle every piece.</summary>
	[Property]
	public string MaterialPath { get; set; } = "prefabs/holekit/kit.vmat";

	/// <summary>
	/// Length of a Wall piece, in units. 100 is the kit's standard rail; the wall builder sets any
	/// length so a wall fits exactly between two posts.
	/// </summary>
	[Property, Range( 10f, 5000f )]
	public float Length { get; set; } = WallLength;

	/// <summary>
	/// Height difference between the two ends of a Wall piece, in units: the +x end sits this much
	/// higher than the -x end. The ends stay vertical and the top and bottom edges follow the slope,
	/// so a wall on a hill is a parallelogram with no wedge gap at the posts. 0 is a flat wall.
	/// </summary>
	[Property, Range( -500f, 500f )]
	public float Rise { get; set; } = 0f;

	/// <summary>The wall length actually built: a missing or broken value (0 after a code reload or an old save) means the standard 100.</summary>
	float SafeLength => Length >= 1f && !float.IsNaN( Length ) ? Length : WallLength;

	/// <summary>Whether the piece has collision (the flag and the tee pad don't block anything).</summary>
	[Property]
	public bool Collision { get; set; } = true;

	public const float Tile = 200f;
	public const float TileThickness = 50f;
	public const float WallLength = 100f;
	public const float WallThickness = 10f;
	public const float WallHeight = 30f;
	public const float RampRise = 50f;
	public const float CupRadius = 6f;
	public const float CupDepth = 48f;
	public const float BowlDepth = 12f;
	public const float BowlRadius = 60f;

	/// <summary>Flag pole: an engine dev metal, so nothing is downloaded.</summary>
	public const string PoleMaterial = "materials/dev/dev_metal_rough30.vmat";
	/// <summary>The flag itself: plain engine red.</summary>
	public const string FlagMaterial = "materials/dev/primary_red.vmat";
	/// <summary>Tee pad top and side: rubber, about four repeats across the pad.</summary>
	public const string TeeMaterial = "textures/tcom_rubber_damaged_0.5x0.5_1k.vmat";
	/// <summary>The thin ring around the tee pad: plain engine white.</summary>
	public const string RingMaterial = "materials/dev/primary_white.vmat";
	public const float TeePadRadius = 24f;
	public const float TeePadHeight = 1.5f;
	public const float TeeRingWidth = 1.5f;
	public const float PoleHeight = 72f;
	public const float PoleRadius = 1f;

	Model _model;
	ShapeKind _builtKind;
	string _builtMaterial;
	bool _builtCollision;
	float _builtLength;
	float _builtRise;

	/// <summary>One mesh of the piece: its material and its own vertex buffer. Most pieces are one
	/// part in the kit material; the flag and the tee pad have a part per material.</summary>
	sealed class Part
	{
		public string MaterialPath;
		public readonly List<SimpleVertex> Vertices = new();
		public readonly List<int> Indices = new();
		public readonly List<Vector3> Positions = new();
	}

	readonly List<Part> _parts = new();
	Part _part;
	float _uvScale = 100f;
	readonly List<Vector3> _positions = new();   // every part, for bounds and collision
	readonly List<int> _indices = new();         // into _positions
	readonly List<Vector3> _collisionPositions = new();
	readonly List<int> _collisionIndices = new();

	// Flag sway: a small yaw wobble in play. The pole and its cap are round, so only the flag shows it.
	bool _swayInit;
	float _swayPhase;
	Rotation _swayBase;

	/// <summary>The piece's collision mesh in piece space, with shared vertices welded. Used by the
	/// hole's <see cref="GolfHoleCollision"/> to build one collider for the whole hole.</summary>
	public IReadOnlyList<Vector3> CollisionPositions => _collisionPositions;
	public IReadOnlyList<int> CollisionIndices => _collisionIndices;

	/// <summary>Builds the piece if it hasn't been built yet in this session (after a code reload the
	/// lists start empty), so the hole collider never reads stale data.</summary>
	public void EnsureBuilt()
	{
		if ( _model is null || (Collision && _collisionIndices.Count == 0) ) Rebuild();
	}

	protected override void OnEnabled()
	{
		Rebuild();
	}

	TimeSince _sinceSpareCheck;

	protected override void OnUpdate()
	{
		// A saved scene can hand the piece a second renderer or collider after it was first built
		// (an older save's copies, applied with the prefab patch): rebuild to drop them.
		if ( !Game.IsPlaying && _sinceSpareCheck > 1f )
		{
			_sinceSpareCheck = 0;
			if ( GameObject.GetComponents<ModelRenderer>( true ).Count() > 1 || GameObject.GetComponents<ModelCollider>( true ).Count() > 1 )
				Rebuild();
		}

		// Rebuild when a property changed in the Inspector.
		if ( _model is null || Kind != _builtKind || MaterialPath != _builtMaterial || Collision != _builtCollision || SafeLength != _builtLength || Rise != _builtRise )
			Rebuild();

		// Gentle flag wave: a 1.5 degree sine sway with a random phase per flag, only while playing
		// so the saved transform never drifts.
		if ( Kind == ShapeKind.Flag && Game.IsPlaying )
		{
			if ( !_swayInit )
			{
				_swayInit = true;
				_swayBase = GameObject.LocalRotation;
				_swayPhase = Game.Random.Float( 0f, MathF.Tau );
			}
			GameObject.LocalRotation = _swayBase * Rotation.FromYaw( MathF.Sin( Time.Now * 1.5f + _swayPhase ) * 1.5f );
		}
	}

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

	void Rebuild()
	{
		RebuildCount++;
		_parts.Clear();
		_positions.Clear();
		_indices.Clear();
		BeginPart( MaterialPath );

		switch ( Kind )
		{
			case ShapeKind.Tile: Box( new Vector3( -100, -100, -TileThickness ), new Vector3( 100, 100, 0 ) ); break;
			case ShapeKind.Wall: WallShape( SafeLength, Rise ); break;
			case ShapeKind.Corner: CornerShape(); break;
			case ShapeKind.Ramp: RampShape(); break;
			case ShapeKind.TeePad: TeePadShape(); break;
			case ShapeKind.Flag: FlagShape(); break;
			case ShapeKind.CupStraight: CupShape( false ); break;
			case ShapeKind.CupCurved: CupShape( true ); break;
		}

		var builder = Model.Builder;
		foreach ( var part in _parts )
		{
			if ( part.Indices.Count == 0 ) continue;
			var mesh = new Mesh( Material.Load( part.MaterialPath ) );
			mesh.CreateVertexBuffer( part.Vertices.Count, part.Vertices );
			mesh.CreateIndexBuffer( part.Indices.Count, part.Indices );
			mesh.Bounds = BBox.FromPoints( part.Positions );
			builder = builder.AddMesh( mesh );
		}
		_collisionPositions.Clear();
		_collisionIndices.Clear();
		if ( Collision )
		{
			// The render mesh keeps a vertex per face corner (flat shading). Physics gets a welded copy with
			// shared vertices, so the triangles of one face join up and the ball can't catch on their edges.
			var welded = _collisionPositions;
			var weldedIndices = _collisionIndices;
			welded.Clear();
			weldedIndices.Clear();
			var lookup = new Dictionary<Vector3, int>();
			foreach ( var i in _indices )
			{
				var p = _positions[i];
				if ( !lookup.TryGetValue( p, out var w ) )
				{
					w = welded.Count;
					welded.Add( p );
					lookup[p] = w;
				}
				weldedIndices.Add( w );
			}
			builder.AddCollisionMesh( welded, weldedIndices );
		}

		_model = builder.Create();
		_builtKind = Kind;
		_builtLength = SafeLength;
		_builtRise = Rise;
		_builtMaterial = MaterialPath;
		_builtCollision = Collision;

		// One renderer and one collider, driven from here and never saved: they are rebuilt from the
		// piece on load. Spare copies (older saves stored them, and re-applied prefab patches can
		// stack them up) would keep showing the engine's error model, so they go.
		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;

		var colliders = GameObject.GetComponents<ModelCollider>( true ).ToList();
		var collider = colliders.FirstOrDefault();
		foreach ( var spare in colliders.Skip( 1 ) ) spare.Destroy();
		if ( collider.IsValid() ) collider.Flags |= ComponentFlags.NotSaved;
		var hole = GameObject.GetComponentInParent<GolfHoleCollision>( true );
		if ( hole is not null )
		{
			// Part of a hole: the hole builds one welded collider for all its pieces (GolfHoleCollision).
			if ( collider.IsValid() ) collider.Enabled = false;
			hole.MarkDirty();
		}
		else if ( Collision )
		{
			collider ??= GameObject.AddComponent<ModelCollider>();
			collider.Flags |= ComponentFlags.NotSaved;
			collider.Model = _model;
			collider.Static = true;
			collider.Enabled = true;
			// Barely bouncy surface (Assets/surface/golf_green.surface) so the seams between pieces don't launch the ball.
			collider.Surface = Surface.FindByName( "golf_green" ) ?? collider.Surface;
		}
		else if ( collider.IsValid() )
		{
			collider.Enabled = false;
		}
	}

	// ---------------------------------------------------------------- mesh helpers (flat shaded unless noted)

	/// <summary>Starts a new part of the piece in the given material; uvScale is the world size of one texture repeat.</summary>
	void BeginPart( string materialPath, float uvScale = 100f )
	{
		_part = new Part { MaterialPath = materialPath };
		_parts.Add( _part );
		_uvScale = uvScale;
	}

	/// <summary>Box-projected texture coordinate for a point with this normal, one repeat per _uvScale units.</summary>
	Vector2 Uv( Vector3 p, Vector3 normal )
		=> (MathF.Abs( normal.z ) > 0.5f ? new Vector2( p.x, p.y ) : new Vector2( MathF.Abs( normal.x ) > 0.5f ? p.y : p.x, p.z )) / _uvScale;

	void AddVertex( Vector3 p, Vector3 normal, Vector3 tangent, Vector2 uv )
	{
		_part.Indices.Add( _part.Vertices.Count );
		_part.Vertices.Add( new SimpleVertex( p, normal, tangent, uv ) );
		_part.Positions.Add( p );
		_indices.Add( _positions.Count );
		_positions.Add( p );
	}

	void Triangle( Vector3 a, Vector3 b, Vector3 c )
	{
		var normal = Vector3.Cross( b - a, c - a );
		if ( normal.IsNearlyZero() ) return;
		normal = normal.Normal;

		var tangent = MathF.Abs( normal.z ) < 0.9f ? Vector3.Cross( Vector3.Up, normal ).Normal : Vector3.Forward;
		foreach ( var p in new[] { a, b, c } )
			AddVertex( p, normal, tangent, Uv( p, normal ) );
	}

	/// <summary>A triangle with its own normal at each corner (smooth shading across neighbours).</summary>
	void SmoothTriangle( Vector3 a, Vector3 na, Vector3 b, Vector3 nb, Vector3 c, Vector3 nc )
	{
		if ( Vector3.Cross( b - a, c - a ).IsNearlyZero() ) return;
		foreach ( var (p, n) in new[] { (a, na), (b, nb), (c, nc) } )
		{
			var tangent = MathF.Abs( n.z ) < 0.9f ? Vector3.Cross( Vector3.Up, n ).Normal : Vector3.Forward;
			AddVertex( p, n, tangent, Uv( p, n ) );
		}
	}

	/// <summary>A quad a-b-c-d, counter-clockwise seen from its front.</summary>
	void Quad( Vector3 a, Vector3 b, Vector3 c, Vector3 d )
	{
		Triangle( a, b, c );
		Triangle( a, c, d );
	}

	/// <summary>
	/// A wall of the given length, sheared by rise: every corner's z is shifted by rise * x / length,
	/// so the +x end stands rise higher than the -x end while both ends stay vertical.
	/// </summary>
	void WallShape( float length, float rise )
	{
		var min = new Vector3( -length / 2, -WallThickness / 2, 0 );
		var max = new Vector3( length / 2, WallThickness / 2, WallHeight );
		var p = new Vector3[8];
		for ( var i = 0; i < 8; i++ )
		{
			var x = (i & 1) == 0 ? min.x : max.x;
			p[i] = new Vector3( x, (i & 2) == 0 ? min.y : max.y, ((i & 4) == 0 ? min.z : max.z) + rise * x / length );
		}

		Quad( p[4], p[5], p[7], p[6] ); // top
		Quad( p[0], p[2], p[3], p[1] ); // bottom
		Quad( p[0], p[1], p[5], p[4] ); // -y
		Quad( p[2], p[6], p[7], p[3] ); // +y
		Quad( p[0], p[4], p[6], p[2] ); // -x end
		Quad( p[1], p[3], p[7], p[5] ); // +x end
	}

	void Box( Vector3 min, Vector3 max )
	{
		var p = new Vector3[8];
		for ( var i = 0; i < 8; i++ )
			p[i] = new Vector3( (i & 1) == 0 ? min.x : max.x, (i & 2) == 0 ? min.y : max.y, (i & 4) == 0 ? min.z : max.z );

		Quad( p[4], p[5], p[7], p[6] ); // top
		Quad( p[0], p[2], p[3], p[1] ); // bottom
		Quad( p[0], p[1], p[5], p[4] ); // -y
		Quad( p[3], p[2], p[6], p[7] ); // +y
		Quad( p[2], p[0], p[4], p[6] ); // -x
		Quad( p[1], p[3], p[7], p[5] ); // +x
	}

	/// <summary>A flat polygon (counter-clockwise, in the xy plane) extruded from z0 up to z1.</summary>
	void Extrude( IReadOnlyList<Vector2> polygon, float z0, float z1 )
	{
		var n = polygon.Count;
		// caps: fan triangulation works for the shapes here (convex or the L, which is fanned from a reflex-safe corner)
		var cap = Triangulate( polygon );
		for ( var i = 0; i < cap.Count; i += 3 )
		{
			var a = polygon[cap[i]]; var b = polygon[cap[i + 1]]; var c = polygon[cap[i + 2]];
			Triangle( new Vector3( a.x, a.y, z1 ), new Vector3( b.x, b.y, z1 ), new Vector3( c.x, c.y, z1 ) );
			Triangle( new Vector3( a.x, a.y, z0 ), new Vector3( c.x, c.y, z0 ), new Vector3( b.x, b.y, z0 ) );
		}

		for ( var i = 0; i < n; i++ )
		{
			var a = polygon[i]; var b = polygon[(i + 1) % n];
			Quad( new Vector3( a.x, a.y, z0 ), new Vector3( b.x, b.y, z0 ), new Vector3( b.x, b.y, z1 ), new Vector3( a.x, a.y, z1 ) );
		}
	}

	/// <summary>Ear clipping for a simple counter-clockwise polygon.</summary>
	static List<int> Triangulate( IReadOnlyList<Vector2> polygon )
	{
		var result = new List<int>();
		var left = new List<int>();
		for ( var i = 0; i < polygon.Count; i++ ) left.Add( i );

		var guard = 0;
		while ( left.Count > 3 && guard++ < 1000 )
		{
			var clipped = false;
			for ( var i = 0; i < left.Count; i++ )
			{
				var i0 = left[(i + left.Count - 1) % left.Count];
				var i1 = left[i];
				var i2 = left[(i + 1) % left.Count];
				var a = polygon[i0]; var b = polygon[i1]; var c = polygon[i2];
				var cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
				if ( cross <= 0f ) continue;

				var inside = false;
				foreach ( var j in left )
				{
					if ( j == i0 || j == i1 || j == i2 ) continue;
					if ( PointInTriangle( polygon[j], a, b, c ) ) { inside = true; break; }
				}
				if ( inside ) continue;

				result.Add( i0 ); result.Add( i1 ); result.Add( i2 );
				left.RemoveAt( i );
				clipped = true;
				break;
			}
			if ( !clipped ) break;
		}

		if ( left.Count == 3 ) { result.Add( left[0] ); result.Add( left[1] ); result.Add( left[2] ); }
		return result;
	}

	static bool PointInTriangle( Vector2 p, Vector2 a, Vector2 b, Vector2 c )
	{
		float Sign( Vector2 p1, Vector2 p2, Vector2 p3 ) => (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
		var d1 = Sign( p, a, b ); var d2 = Sign( p, b, c ); var d3 = Sign( p, c, a );
		var neg = d1 < 0 || d2 < 0 || d3 < 0;
		var pos = d1 > 0 || d2 > 0 || d3 > 0;
		return !(neg && pos);
	}

	/// <summary>A vertical cylinder around a centre, from z0 to z1.</summary>
	void Cylinder( Vector3 centre, float radius, float z0, float z1, int segments, bool capTop, bool capBottom )
	{
		for ( var i = 0; i < segments; i++ )
		{
			var a0 = MathF.Tau * i / segments;
			var a1 = MathF.Tau * (i + 1) / segments;
			var p0 = centre + new Vector3( MathF.Cos( a0 ) * radius, MathF.Sin( a0 ) * radius, 0 );
			var p1 = centre + new Vector3( MathF.Cos( a1 ) * radius, MathF.Sin( a1 ) * radius, 0 );
			Quad( p0.WithZ( z0 ), p1.WithZ( z0 ), p1.WithZ( z1 ), p0.WithZ( z1 ) );
			if ( capTop ) Triangle( centre.WithZ( z1 ), p0.WithZ( z1 ), p1.WithZ( z1 ) );
			if ( capBottom ) Triangle( centre.WithZ( z0 ), p1.WithZ( z0 ), p0.WithZ( z0 ) );
		}
	}

	/// <summary>A vertical cylinder with a smooth side (radial normals); the caps stay flat.</summary>
	void SmoothCylinder( Vector3 centre, float radius, float z0, float z1, int segments, bool capTop, bool capBottom )
	{
		for ( var i = 0; i < segments; i++ )
		{
			var a0 = MathF.Tau * i / segments;
			var a1 = MathF.Tau * (i + 1) / segments;
			var n0 = new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0 );
			var n1 = new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0 );
			var p0 = centre + n0 * radius;
			var p1 = centre + n1 * radius;
			SmoothTriangle( p0.WithZ( z0 ), n0, p1.WithZ( z0 ), n1, p1.WithZ( z1 ), n1 );
			SmoothTriangle( p0.WithZ( z0 ), n0, p1.WithZ( z1 ), n1, p0.WithZ( z1 ), n0 );
			if ( capTop ) Triangle( centre.WithZ( z1 ), p0.WithZ( z1 ), p1.WithZ( z1 ) );
			if ( capBottom ) Triangle( centre.WithZ( z0 ), p1.WithZ( z0 ), p0.WithZ( z0 ) );
		}
	}

	/// <summary>A smooth sphere (latitude rings by longitude segments).</summary>
	void SmoothSphere( Vector3 centre, float radius, int rings, int segments )
	{
		Vector3 N( int ring, int seg )
		{
			var lat = -MathF.PI / 2 + MathF.PI * ring / rings;
			var lon = MathF.Tau * seg / segments;
			return new Vector3( MathF.Cos( lat ) * MathF.Cos( lon ), MathF.Cos( lat ) * MathF.Sin( lon ), MathF.Sin( lat ) );
		}
		for ( var r = 0; r < rings; r++ )
			for ( var s = 0; s < segments; s++ )
			{
				var n00 = N( r, s ); var n10 = N( r, s + 1 ); var n11 = N( r + 1, s + 1 ); var n01 = N( r + 1, s );
				SmoothTriangle( centre + n00 * radius, n00, centre + n10 * radius, n10, centre + n11 * radius, n11 );
				SmoothTriangle( centre + n00 * radius, n00, centre + n11 * radius, n11, centre + n01 * radius, n01 );
			}
	}

	/// <summary>A flat ring (or a disc when r0 is 0) at height z, facing up.</summary>
	void Ring( float z, float r0, float r1, int segments )
	{
		var centre = new Vector3( 0, 0, z );
		for ( var i = 0; i < segments; i++ )
		{
			var a0 = MathF.Tau * i / segments;
			var a1 = MathF.Tau * (i + 1) / segments;
			var d0 = new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0 );
			var d1 = new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0 );
			if ( r0 <= 0f ) Triangle( centre, centre + d0 * r1, centre + d1 * r1 );
			else Quad( centre + d0 * r1, centre + d1 * r1, centre + d1 * r0, centre + d0 * r0 );
		}
	}

	// ---------------------------------------------------------------- the pieces

	void CornerShape()
	{
		var t = WallThickness / 2;
		var l = WallLength;
		var shape = new List<Vector2> { new( -t, -t ), new( l, -t ), new( l, t ), new( t, t ), new( t, l ), new( -t, l ) };
		Extrude( shape, 0f, WallHeight );
	}

	void RampShape()
	{
		// Cross-section in x/z, extruded along y: flat bottom at -50, top rising from 0 to 50.
		var a = new Vector3( -100, -100, -TileThickness ); var b = new Vector3( 100, -100, -TileThickness );
		var c = new Vector3( 100, -100, RampRise ); var d = new Vector3( -100, -100, 0 );
		var a2 = a.WithY( 100 ); var b2 = b.WithY( 100 ); var c2 = c.WithY( 100 ); var d2 = d.WithY( 100 );
		Quad( d, c, c2, d2 );   // sloped top
		Quad( a, a2, b2, b );   // bottom
		Quad( b, b2, c2, c );   // +x end (tall)
		Quad( a, d, d2, a2 );   // -x end (short)
		Quad( a, b, c, d );     // -y side
		Quad( a2, d2, c2, b2 ); // +y side
	}

	/// <summary>A smooth 2-wide metal pole with a round cap and a red triangular flag (20 long, 12 high) at the top, pointing +y.</summary>
	void FlagShape()
	{
		BeginPart( PoleMaterial );
		SmoothCylinder( Vector3.Zero, PoleRadius, 0f, PoleHeight, 24, false, false );
		SmoothSphere( new Vector3( 0, 0, PoleHeight ), PoleRadius * 1.6f, 6, 24 );

		BeginPart( FlagMaterial );
		var top = new Vector3( 0, PoleRadius, PoleHeight );
		var bottom = new Vector3( 0, PoleRadius, PoleHeight - 12f );
		var tip = new Vector3( 0, PoleRadius + 20f, PoleHeight - 6f );
		Triangle( top, bottom, tip ); // one side
		Triangle( top, tip, bottom ); // the other
	}

	/// <summary>The round rubber tee pad, 1.5 high, with a thin white ring around the top edge.</summary>
	void TeePadShape()
	{
		BeginPart( TeeMaterial, TeePadRadius * 2f / 4f ); // four texture repeats across the pad
		SmoothCylinder( Vector3.Zero, TeePadRadius, 0f, TeePadHeight, 32, false, true );
		Ring( TeePadHeight, 0f, TeePadRadius - TeeRingWidth, 32 );

		BeginPart( RingMaterial );
		Ring( TeePadHeight, TeePadRadius - TeeRingWidth, TeePadRadius, 32 );
	}

	/// <summary>
	/// A 200 x 200 tile with a round opening of CupRadius at the centre: the top is a band from the
	/// square edge to the circle (bowl-shaped for the curved cup), the opening drops to the cup floor.
	/// </summary>
	void CupShape( bool bowl )
	{
		const int segments = 48;
		var rimZ = bowl ? -BowlDepth : 0f;
		var floorZ = -CupDepth - (bowl ? BowlDepth : 0f);

		// Radii from the opening out to the square edge, with the height of the top surface at each.
		var radii = bowl
			? new[] { CupRadius, 10f, 16f, 24f, 34f, 46f, BowlRadius, 80f, float.NaN }
			: new[] { CupRadius, 50f, float.NaN };

		Vector3 Top( float radius, float angle )
		{
			var cos = MathF.Cos( angle ); var sin = MathF.Sin( angle );
			if ( float.IsNaN( radius ) )
			{
				// the square edge
				var m = MathF.Max( MathF.Abs( cos ), MathF.Abs( sin ) );
				return new Vector3( 100f * cos / m, 100f * sin / m, 0f );
			}
			var z = 0f;
			if ( bowl && radius < BowlRadius )
			{
				var t = (radius - CupRadius) / (BowlRadius - CupRadius);
				z = -BowlDepth * (0.5f + 0.5f * MathF.Cos( MathF.PI * t ));
			}
			return new Vector3( cos * radius, sin * radius, z );
		}

		for ( var i = 0; i < segments; i++ )
		{
			var a0 = MathF.Tau * i / segments;
			var a1 = MathF.Tau * (i + 1) / segments;

			// top surface, ring by ring
			for ( var r = 0; r < radii.Length - 1; r++ )
				Quad( Top( radii[r], a0 ), Top( radii[r + 1], a0 ), Top( radii[r + 1], a1 ), Top( radii[r], a1 ) );

			// the opening's wall and the cup floor
			var i0 = Top( CupRadius, a0 ); var i1 = Top( CupRadius, a1 );
			Quad( i0.WithZ( rimZ ), i1.WithZ( rimZ ), i1.WithZ( floorZ ), i0.WithZ( floorZ ) );
			Triangle( new Vector3( 0, 0, floorZ ), i0.WithZ( floorZ ), i1.WithZ( floorZ ) );

			// outer side and the flat bottom
			var e0 = Top( float.NaN, a0 ); var e1 = Top( float.NaN, a1 );
			Quad( e0.WithZ( -TileThickness ), e1.WithZ( -TileThickness ), e1, e0 );
			Triangle( new Vector3( 0, 0, -TileThickness ), e1.WithZ( -TileThickness ), e0.WithZ( -TileThickness ) );
		}
	}
}