editor/AdvancedMappingTools/Procedural/WallDetailMaterials.cs

Editor-side helper for the procedural wall tool. Adds interior/exterior material fields and an opening toggle to ConnectedWallSettings, extends WallEditor with the matching material properties and UnusedOptions rules, and then assigns a material per face on every WallPanel part by picking the nearest path segment and testing the face normal against that segment's left vector, rebuilding the part's MaterialHash afterwards.

Rough Code
namespace AdvancedMappingTools;
public sealed partial class ConnectedWallSettings
{
	[Property, Group( "Openings" ), Title( "Use Openings" ), WideMode]
	public bool UseOpenings { get; set; } = true;

	[Property, Group( "Materials" ), WideMode]
	public bool SeparateWallMaterials { get; set; }

	[Property, Group( "Materials" ), ResourceType( "vmat" ), WideMode]
	public string InteriorMaterial { get; set; }

	[Property, Group( "Materials" ), ResourceType( "vmat" ), WideMode]
	public string ExteriorMaterial { get; set; }
}

public sealed partial class WallEditor
{
	public override bool ChangesOtherSettings( string property ) => property is nameof( AdvancedCorners ) or nameof( OpeningGrid ) or nameof( OpeningKind ) or nameof( UseWallOpenings ) or nameof( SeparateWallMaterials ) or nameof( Mode ) or nameof( Placement ) or nameof( TopStyle ) or nameof( ConnectedParts );
	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var h = new HashSet<string>( base.UnusedOptions );
			if ( !SeparateWallMaterials )
				h.UnionWith( [nameof( InteriorSurface ), nameof( ExteriorSurface )] );
			if ( !UsesConnections )
				h.UnionWith( [nameof( UseWallOpenings ), nameof( OpeningGrid ), nameof( AdvancedCorners )] );
			if ( !UsesConnections || !AdvancedCorners )
				h.Add( nameof( CornerProfile ) );
			if ( !UsesConnections || !OpeningGrid || !UseWallOpenings )
				h.UnionWith( [nameof( OpeningColumns ), nameof( OpeningRows ), nameof( OpeningRowSpacing ), nameof( OpeningKind ), nameof( GridOpeningWidth ), nameof( GridOpeningHeight ), nameof( GridSill )] );
			if ( OpeningKind != WallOpeningKind.Window )
				h.Add( nameof( GridSill ) );
			if ( !HasCap )
				h.UnionWith( [nameof( CapHeight ), nameof( CapOverhang )] );
			return h;
		}
	}

	[Property, Group( "Materials" ), WideMode]
	public bool SeparateWallMaterials { get; set; }

	[Property, Group( "Materials" ), Title( "Interior (Left Side)" ), WideMode]
	public Material InteriorSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Exterior (Right Side)" ), WideMode]
	public Material ExteriorSurface { get; set; }

	[Property, Group( "Openings" ), Title( "Use Wall Openings" ), WideMode]
	public bool UseWallOpenings { get; set; } = true;
}

internal static class WallDetailMaterials
{
	public static void Apply( PartPlan root, ConnectedWallSettings s, GenContext context, PathTrack track )
	{
		if ( !s.SeparateWallMaterials )
			return;
		var inside = context.Material( s.InteriorMaterial ?? s.Material );
		var outside = context.Material( s.ExteriorMaterial ?? s.Material );
		foreach ( var part in root.All().Where( p => p.Kind == "WallPanel" && p.Mesh != null ) )
			foreach ( var face in part.Mesh.FaceHandles )
			{
				var vertices = part.Mesh.GetFaceVertices( face ).Select( part.Mesh.GetVertexPosition ).ToArray();
				if ( vertices.Length < 3 )
					continue;
				var normal = Vector3.Cross( vertices[1] - vertices[0], vertices[2] - vertices[0] ).Normal;
				if ( MathF.Abs( normal.z ) > .5f )
					continue;
				var center = vertices.Aggregate( Vector3.Zero, ( sum, p ) => sum + p ) / vertices.Length;
				var segment = Enumerable.Range( 0, Math.Max( 1, track.Count - 1 ) ).OrderBy( i =>
				{
					var a = track.Frames[i].Position;
					var b = track.Frames[Math.Min( i + 1, track.Count - 1 )].Position;
					var d = (b - a).WithZ( 0 );
					var t = d.LengthSquared > .0001f ? Math.Clamp( (center - a).WithZ( 0 ).Dot( d ) / d.LengthSquared, 0, 1 ) : 0;
					return (center - (a + d * t)).WithZ( 0 ).LengthSquared;
				} ).First();
				var direction = (track.Frames[Math.Min( segment + 1, track.Count - 1 )].Position - track.Frames[segment].Position).WithZ( 0 );
				if ( direction.Length < .01f )
					direction = Vector3.Forward;
				var left = new Vector3( -direction.y, direction.x, 0 ).Normal;
				if ( MathF.Abs( normal.Dot( left ) ) > .5f )
					part.Mesh.SetFaceMaterial( face, normal.Dot( left ) > 0 ? inside : outside );
			}

		PartPlan Rehash( PartPlan p )
		{
			for ( var i = 0; i < p.Children.Count; i++ )
				p.Children[i] = Rehash( p.Children[i] );
			if ( p.Kind != "WallPanel" || p.Mesh == null )
				return p;
			var copy = new PartPlan
			{
				Id = p.Id,
				Name = p.Name,
				Kind = p.Kind,
				Category = p.Category,
				Seed = p.Seed,
				UsesSeed = p.UsesSeed,
				Local = p.Local,
				Mesh = p.Mesh,
				Dimensions = p.Dimensions,
				GeometryHash = p.GeometryHash,
				MaterialHash = PartHash.Materials( p.Mesh ),
				Smoothing = p.Smoothing,
				Tint = p.Tint
			};
			foreach ( var c in p.Children )
				copy.Add( c );
			return copy;
		}

		Rehash( root );
	}
}