editor/AdvancedMappingTools/Geometry/RoofHollow.cs

Editor-side partial of RoofEditor that adds the hollow roof options and generates the geometry for them. Exposes properties for shell thickness, bottom closure, wall fitting, gutters, overhangs and closure materials, hides the irrelevant ones in UnusedOptions, samples nearby wall tops for WallMargin, and BuildHollowRoof rebuilds a solid roof mesh as offset thin panels with shared inner corners so the overhang stays open.

namespace AdvancedMappingTools;
public sealed partial class RoofEditor
{
	[Property, Group( "Roof Interior" ), Title( "Hollow Roof" ), WideMode, Description( "Thin roof skin without filled end faces at the overhang. Enable Fit To Walls for optional wall-aligned closures. Flat roofs remain a single slab." )]
	public bool Hollow { get; set; }

	[Property, Group( "Roof Interior" ), Title( "Shell Thickness" ), Range( .25f, 32, slider: false ), WideMode]
	public float ShellThickness { get; set; } = 4;

	[Property, Group( "Roof Interior" ), Title( "Bottom / Ceiling Closure" ), WideMode]
	public bool BottomClosure { get; set; }

	[Property, Group( "Roof Interior" ), Title( "Bottom Thickness" ), Range( .25f, 128, slider: false ), WideMode]
	public float BottomThickness { get; set; } = 4;

	[Property, Group( "Roof Interior" ), Title( "Fit To Walls" ), WideMode, Description( "Close the space between wall tops and the pitched roof with perimeter infill. Keeps the center open. Round roofs need a matching round footprint." )]
	public bool FitToWalls { get; set; } = true;

	[Property, Group( "Wall Closures" ), Title( "Closure Thickness (0 = Wall)" ), Range( 0, 512, slider: false ), WideMode, Description( "Zero uses the detected wall thickness. Overrides grow inward from the wall exterior, not into the roof overhang." )]
	public float WallClosureThickness { get; set; }

	[Property, Group( "Gutters" ), Title( "Gutter Width" ), Range( 1, 24, slider: false ), WideMode, Description( "Minimum width. With downpipes the channel widens automatically to fit the outlet." )]
	public float GutterWidth { get; set; } = 3;

	[Property, Group( "Gutters" ), Title( "Gutter Height" ), Range( 1, 24, slider: false ), WideMode]
	public float GutterHeight { get; set; } = 3;

	[Property, Group( "Gutters" ), Title( "Gutter Projection" ), Range( 0, 32, slider: false ), WideMode]
	public float GutterProjection { get; set; } = 0;

	[Property, Group( "Gutters" ), Title( "Gutter Vertical Offset" ), Range( -32, 32, slider: false ), WideMode]
	public float GutterVerticalOffset { get; set; } = 0;

	[Property, Group( "Roof Overhangs" ), Title( "End Overhang (-1 = All)" ), Range( -1, 256, slider: false ), WideMode, Description( "Overhang along the first drawn edge. Minus one uses Overhang." )]
	public float EndOverhang { get; set; } = -1;

	[Property, Group( "Roof Overhangs" ), Title( "Eave Overhang (-1 = All)" ), Range( -1, 256, slider: false ), WideMode, Description( "Overhang across the drawn depth. Minus one uses Overhang." )]
	public float EaveOverhang { get; set; } = -1;

	[Property, Group( "Gutters" ), Title( "Gutter Wall Thickness" ), Range( .25f, 4, slider: false ), WideMode]
	public float GutterWallThickness { get; set; } = .5f;

	[Property, Group( "Materials" ), Title( "Wall Closure Material" ), WideMode]
	public Material WallClosureSurface { get; set; }

	public override bool ChangesOtherSettings( string property ) => property is nameof( SeparateOverhangs ) or nameof( EdgeTrim ) or nameof( ManualDownpipePosition ) or nameof( FrontGutter ) or nameof( BackGutter ) or nameof( Hollow ) or nameof( BottomClosure ) or nameof( Shape ) or nameof( FitToWalls ) or nameof( Gutters ) or nameof( RoofWindows ) or nameof( CutDormerOpenings ) or nameof( Downpipes ) or nameof( GutterEndReturns ) or nameof( EnableDormers ) or nameof( DistributeDownpipes ) or nameof( RoofWindowFrames ) or nameof( RoofWindowGlass ) or nameof( RoofWindowFlashing ) or nameof( DormerGlass ) or nameof( DormerRoof ) or nameof( DormerWindow );
	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var hidden = new HashSet<string>( base.UnusedOptions );
			if ( Shape == RoofShape.Flat )
				hidden.Add( nameof( Hollow ) );
			if ( !Hollow || Shape == RoofShape.Flat )
				hidden.UnionWith( [nameof( ShellThickness ), nameof( BottomClosure ), nameof( BottomThickness ), nameof( FitToWalls )] );
			else if ( !BottomClosure )
				hidden.Add( nameof( BottomThickness ) );
			if ( !Hollow || Shape == RoofShape.Flat || !FitToWalls )
				hidden.UnionWith( [nameof( WallClosureThickness ), nameof( WallClosureSurface )] );
			if ( !Gutters )
				hidden.UnionWith( [nameof( GutterWidth ), nameof( GutterHeight ), nameof( GutterProjection ), nameof( GutterVerticalOffset ), nameof( GutterWallThickness )] );
			if ( !SeparateOverhangs )
				hidden.UnionWith( [nameof( StartOverhang ), nameof( FinishOverhang ), nameof( FrontOverhang ), nameof( BackOverhang )] );
			if ( !EdgeTrim )
				hidden.UnionWith( [nameof( EdgeProfile ), nameof( EdgeTrimDepth ), nameof( EdgeTrimSurface )] );
			if ( !Gutters )
				hidden.UnionWith( [nameof( FrontGutter ), nameof( BackGutter ), nameof( FrontDownpipes ), nameof( BackDownpipes ), nameof( ManualDownpipePosition ), nameof( DownpipeAlong )] );
			if ( !Downpipes || !Gutters )
				hidden.UnionWith( [nameof( FrontDownpipes ), nameof( BackDownpipes ), nameof( ManualDownpipePosition ), nameof( DownpipeAlong )] );
			if ( !ManualDownpipePosition )
				hidden.Add( nameof( DownpipeAlong ) );
			HideHouseDetailOptions( hidden );
			return hidden;
		}
	}

	void RefreshWallThickness()
	{
		if ( !ConnectFinished || SceneEditorSession.Active is null || Points.Count == 0 )
			return;
		var halves = new List<float>();
		foreach ( var point in Points )
		{
			var corner = WallTops.Nearest( SceneEditorSession.Active.Scene, point.Position, MathF.Max( DockRadius, 1 ) );
			if ( corner is { } found && MathF.Abs( found.Top.z - point.Position.z ) < .5f )
				halves.Add( found.Half );
		}

		if ( halves.Count > 0 )
			WallMargin = halves.Max();
	}

	/// <summary>Retain the existing outer roof shape and materials, replace its skin with closed thin panels. No bottom plate.</summary>
	public static void BuildHollowRoof( MeshKit kit, Transform frame, float x0, float x1, float y0, float y1, RoofShape shape, float rise, float edge, float thickness, Material surface, Material trim, IReadOnlyList<Vector2> custom = null )
	{
		if ( shape == RoofShape.Flat || rise <= 0 )
		{
			BuildRoof( kit, frame, x0, x1, y0, y1, shape, rise, edge, surface, trim, custom );
			return;
		}

		var source = new MeshKit
		{
			LiftFlushFaces = false
		};
		BuildRoof( source, Transform.Zero, x0, x1, y0, y1, shape, rise, edge, surface, trim, custom );
		var mesh = source.Finish();
		kit.Frame = frame;
		kit.LiftFlushFaces = false;
		var roofTriangles = new List<(Vector3 A, Vector3 B, Vector3 C)>();
		foreach ( var f in mesh.FaceHandles )
		{
			var points = mesh.GetFaceVertices( f ).Select( mesh.GetVertexPosition ).ToArray();
			for ( var i = 1; i + 1 < points.Length; i++ )
				if ( Vector3.Cross( points[i] - points[0], points[i + 1] - points[0] ).z > .0001f )
					roofTriangles.Add( (points[0], points[i], points[i + 1]) );
		}

		bool Covered( Vector3 p )
		{
			foreach ( var(a, b, c)in roofTriangles )
			{
				var det = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
				if ( MathF.Abs( det ) < .000001f )
					continue;
				var u = ((b.y - c.y) * (p.x - c.x) + (c.x - b.x) * (p.y - c.y)) / det;
				var v = ((c.y - a.y) * (p.x - c.x) + (a.x - c.x) * (p.y - c.y)) / det;
				if ( u >= 0 && v >= 0 && u + v <= 1 && u * a.z + v * b.z + (1 - u - v) * c.z > p.z + .02f )
					return true;
			}

			return false;
		}

		var faces = new List<(Vector3[] V, Vector3 N, float T, Material Material)>();
		foreach ( var face in mesh.FaceHandles )
		{
			var v = mesh.GetFaceVertices( face ).Select( mesh.GetVertexPosition ).ToArray();
			if ( v.Length < 3 )
				continue;
			var normal = Vector3.Zero;
			for ( var i = 1; i + 1 < v.Length; i++ )
				normal += Vector3.Cross( v[i] - v[0], v[i + 1] - v[0] );
			if ( normal.Length < .0001f )
				continue;
			normal = normal.Normal;
			if ( normal.z < -.5f )
				continue;
			// Compound solid roofs may contain the top of an internal base slab.
			// Keeping that buried horizontal face would recreate a floor in the hollow roof.
			if ( normal.z > .9999f )
			{
				var center = v.Aggregate( Vector3.Zero, ( sum, p ) => sum + p ) / v.Length;
				if ( Covered( center ) && v.All( p => Covered( Vector3.Lerp( p, center, .1f ) ) ) )
					continue;
			}

			// End walls belong on the house perimeter, never at the edge of the overhang.
			// Retain internal vertical steps (monitor/sawtooth), but omit the source solid's end caps.
			bool OnPlane( Func<Vector3, float> coordinate, float value ) => v.All( p => MathF.Abs( coordinate( p ) - value ) < .02f );
			if ( MathF.Abs( normal.z ) < .0001f && (OnPlane( p => p.x, x0 ) || OnPlane( p => p.x, x1 ) || OnPlane( p => p.y, y0 ) || OnPlane( p => p.y, y1 )) )
				continue;
			var shortest = Enumerable.Range( 0, v.Length ).Min( i => v[i].Distance( v[(i + 1) % v.Length] ) );
			var t = MathF.Min( Math.Clamp( thickness, .25f, 32 ), shortest * .4f );
			if ( t < .001f )
				continue;
			faces.Add( (v, normal, t, mesh.GetFaceMaterial( face ) ?? surface) );
		}

		// Adjacent panels must use the same inner corner: intersect their offset planes.
		// Extruding each face independently leaves visible cracks at valleys and gable joins.
		var corners = new Dictionary<Vector3, Vector3>();
		foreach ( var point in faces.SelectMany( f => f.V ).Distinct() )
		{
			var a = new double[3, 4];
			for ( var i = 0; i < 3; i++ )
				a[i, i] = .000001;
			foreach ( var f in faces.Where( f => f.V.Contains( point ) ) )
			{
				var n = new[]
				{
					(double)f.N.x,
					(double)f.N.y,
					(double)f.N.z
				};
				for ( var i = 0; i < 3; i++ )
				{
					for ( var j = 0; j < 3; j++ )
						a[i, j] += n[i] * n[j];
					a[i, 3] -= n[i] * f.T;
				}
			}

			for ( var col = 0; col < 3; col++ )
			{
				var pivot = col;
				for ( var row = col + 1; row < 3; row++ )
					if ( Math.Abs( a[row, col] ) > Math.Abs( a[pivot, col] ) )
						pivot = row;
				for ( var j = col; j < 4; j++ )
					(a[col, j], a[pivot, j]) = (a[pivot, j], a[col, j]);
				var scale = a[col, col];
				for ( var j = col; j < 4; j++ )
					a[col, j] /= scale;
				for ( var row = 0; row < 3; row++ )
					if ( row != col )
					{
						var factor = a[row, col];
						for ( var j = col; j < 4; j++ )
							a[row, j] -= factor * a[col, j];
					}
			}

			var delta = new Vector3( (float)a[0, 3], (float)a[1, 3], (float)a[2, 3] );
			// Bound extreme acute corners; all incident faces still share this exact corner.
			var limit = Math.Clamp( thickness, .25f, 32 ) * 4;
			if ( delta.Length > limit )
				delta = delta.Normal * limit;
			corners[point] = point + delta;
		}

		foreach ( var face in faces )
		{
			var v = face.V;
			var inner = v.Select( p => corners[p] ).ToArray();
			kit.BeginPart();
			kit.Material = face.Material;
			kit.Face( v );
			kit.Material = trim;
			kit.Face( inner.Reverse().ToArray() );
			for ( var i = 0; i < v.Length; i++ )
			{
				var j = (i + 1) % v.Length;
				kit.Face( v[i], inner[i], inner[j], v[j] );
			}
		}

		kit.Frame = frame;
	}
}