editor/AdvancedMappingTools/Architecture/BalconyHouseDetails.cs

Editor-side settings and generator pieces for the Balcony Studio part tool in Advanced Mapping Tools. Adds floor repetition for stacked balconies, snapping/docking of a drawn rear edge onto a connected wall face, and cutting door openings in that host wall when the balcony is placed.

namespace AdvancedMappingTools;
public sealed partial class BalconyStudioSettings
{
	[Property, Group( "Repeat" ), WideMode]
	public bool RepeatFloors { get; set; }

	[Property, Group( "Repeat" ), Range( 2, 16, slider: false ), WideMode]
	public int FloorCount { get; set; } = 3;

	[Property, Group( "Repeat" ), Range( 64, 1024, slider: false ), WideMode]
	public float FloorSpacing { get; set; } = 128;

	[Property, Group( "Repeat" ), WideMode]
	public bool CanopyTopOnly { get; set; } = true;

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

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

public sealed partial class BalconyStudioGenerator
{
	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (BalconyStudioSettings)settings;
		if ( !s.RepeatFloors )
			return GenerateSingle( s, context );
		var root = context.Group( "root", Title, "ArchitectureStudio", "architecture" );
		PartPlan Prefix( PartPlan p, string prefix )
		{
			var copy = new PartPlan
			{
				Id = prefix + "/" + 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 = p.MaterialHash,
				Smoothing = p.Smoothing,
				Tint = p.Tint
			};
			foreach ( var c in p.Children )
				copy.Add( Prefix( c, prefix ) );
			return copy;
		}

		for ( var i = 0; i < Math.Clamp( s.FloorCount, 2, 16 ); i++ )
		{
			var copy = (BalconyStudioSettings)GeneratorRegistry.Load( this, GeneratorRegistry.Save( s ) );
			copy.Elevation = s.Elevation + i * Math.Clamp( s.FloorSpacing, 64, 1024 );
			if ( i > 0 && copy.SupportColumns )
			{
				var room = Math.Clamp( s.FloorSpacing, 64, 1024 ) - Math.Clamp( s.Thickness, 1, 64 ) - (s.FloorCovering ? Math.Clamp( s.FloorCoverThickness, .25f, 8 ) : 0);
				if ( room < 4 )
					copy.SupportColumns = false;
				else
					copy.ColumnHeight = MathF.Min( copy.ColumnHeight, room );
			}

			if ( s.CanopyTopOnly && i < Math.Clamp( s.FloorCount, 2, 16 ) - 1 )
				copy.Canopy = false;
			root.Add( Prefix( GenerateSingle( copy, context ), $"floor{i:00}" ) );
		}

		return root;
	}
}

internal static class BalconyWallDock
{
	internal readonly record struct Hit( GameObject Wall, Vector3 Point, Vector3 Outside, float Along );
	internal static Hit? Nearest( Scene scene, Vector3 point, float radius )
	{
		Hit? best = null;
		var nearest = radius;
		foreach ( var wall in WallLinker.Walls( scene ) )
		{
			if ( PartService.Read( wall ).Settings is not ConnectedWallSettings s )
				continue;
			var local = wall.WorldTransform.PointToLocal( point );
			var line = ConnectedWallGenerator.Line( s );
			if ( local.z < line.Frames[0].Position.z - 1 || local.z > line.Frames[0].Position.z + s.Height + 1 )
				continue;
			for ( var i = 0; i + 1 < line.Count; i++ )
			{
				var a = line.Frames[i].Position;
				var b = line.Frames[i + 1].Position;
				var d = (b - a).WithZ( 0 );
				if ( d.Length < .01f )
					continue;
				var t = Math.Clamp( (local - a).WithZ( 0 ).Dot( d ) / d.LengthSquared, 0, 1 );
				var on = a + d * t;
				var side = new Vector3( -d.y, d.x, 0 ).Normal;
				var signed = (local - on).Dot( side );
				var faces = s.Faces();
				var offset = signed > 0 ? faces.Left : faces.Right;
				var face = (on + side * offset).WithZ( local.z );
				var world = wall.WorldTransform.PointToWorld( face );
				var distance = world.Distance( point );
				if ( distance > nearest )
					continue;
				nearest = distance;
				var normal = (wall.WorldTransform.PointToWorld( on + side * (signed > 0 ? 1 : -1) ) - wall.WorldTransform.PointToWorld( on )).Normal;
				best = new Hit( wall, world, normal, line.Frames[i].Distance + t * (line.Frames[i + 1].Distance - line.Frames[i].Distance) );
			}
		}

		return best;
	}
}

public sealed partial class BalconyStudioEditor
{
	[Property, Group( "Wall Docking" ), WideMode]
	public bool SnapToWall { get; set; }

	[Property, Group( "Wall Docking" ), Range( 1, 128, slider: false ), WideMode]
	public float WallSnapDistance { get; set; } = 24;

	[Property, Group( "Balcony Doors" ), Title( "Create Door Openings On Placement" ), WideMode, Description( "Cuts doors in eligible connected host walls when placing. Existing cuts remain editable/toggleable in each wall's Placed Openings list." )]
	public bool CreateDoorOpenings { get; set; }

	[Property, Group( "Balcony Doors" ), Range( 16, 192, slider: false ), WideMode]
	public float DoorWidth { get; set; } = 48;

	[Property, Group( "Balcony Doors" ), Range( 32, 256, slider: false ), WideMode]
	public float DoorHeight { get; set; } = 96;

	[Property, Group( "Balcony Doors" ), WideMode]
	public bool DoorLeaf { get; set; } = true;

	[Property, ReadOnly, Group( "Balcony Doors" ), Title( "Door Placement" ), WideMode]
	public string DoorPlacementSummary => !CreateDoorOpenings ? "Door creation off. Existing wall openings can be disabled in the wall settings." : Points.Count < 3 ? "Draw the rear edge on a connected wall, then set balcony depth." : Doors().Any() ? $"{Doors().Count()} door opening(s) fit the host wall(s)." : "No matching wall area. Check the rear edge and wall height.";
	protected override bool Connects => true;

	protected override Vector3 Dock( Vector3 point ) => DockToWallFace( base.Dock( point ) );
	internal Vector3 DockToWallFace( Vector3 p )
	{
		if ( !SnapToWall || SceneEditorSession.Active is null || Points.Count >= 2 )
			return p;
		return BalconyWallDock.Nearest( SceneEditorSession.Active.Scene, p, WallSnapDistance )?.Point ?? p;
	}

	Transform BalconyFrame
	{
		get
		{
			if ( !SnapToWall || SceneEditorSession.Active is null )
				return RectFrame;
			var a = Points[0].Position;
			var b = Points[1].Position;
			var d = (b - a).WithZ( 0 ).Normal;
			var hit = BalconyWallDock.Nearest( SceneEditorSession.Active.Scene, (a + b) * .5f, WallSnapDistance );
			if ( hit is not { } found )
				return RectFrame;
			var across = new Vector3( -d.y, d.x, 0 );
			return across.Dot( found.Outside ) >= 0 ? new Transform( a, Rotation.LookAt( d, Vector3.Up ) ) : new Transform( b, Rotation.LookAt( -d, Vector3.Up ) );
		}
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( generator, Settings, surface: SurfaceIntegration.Context( Settings, SceneEditorSession.Active?.Scene, BalconyFrame ) ).Flatten( BalconyFrame );
	}

	IEnumerable<(GameObject Wall, WallOpeningSpec Opening)> Doors()
	{
		if ( !CreateDoorOpenings || SceneEditorSession.Active is null || Points.Count < 3 )
			yield break;
		var frame = BalconyFrame;
		var count = Settings.RepeatFloors ? Math.Clamp( Settings.FloorCount, 2, 16 ) : 1;
		var width = MathF.Min( Math.Clamp( DoorWidth, 16, 192 ), RectLength * .75f );
		for ( var i = 0; i < count; i++ )
		{
			var z = Settings.Elevation + Math.Clamp( Settings.Thickness, 1, 64 ) + (Settings.RepeatFloors ? i * Math.Clamp( Settings.FloorSpacing, 64, 1024 ) : 0);
			var position = frame.PointToWorld( new Vector3( RectLength * .5f, 0, z + DoorHeight * .5f ) );
			var hit = BalconyWallDock.Nearest( SceneEditorSession.Active.Scene, position, WallSnapDistance );
			if ( hit is not { } found )
				continue;
			var s = (ConnectedWallSettings)PartService.Read( found.Wall ).Settings;
			if ( !s.UseOpenings )
				continue;
			var sill = found.Wall.WorldTransform.PointToLocal( frame.PointToWorld( new Vector3( RectLength * .5f, 0, z ) ) ).z - ConnectedWallGenerator.Line( s ).Frames[0].Position.z;
			if ( sill < 0 || sill + DoorHeight > s.Height )
				continue;
			yield return (found.Wall, new WallOpeningSpec { Along = found.Along, Width = width, Sill = sill, Height = DoorHeight, Window = false, Door = true, Leaf = DoorLeaf });
		}
	}

	IEnumerable<(GameObject Wall, ConnectedWallSettings Settings)> DoorChanges()
	{
		foreach ( var group in Doors().GroupBy( d => d.Wall ) )
		{
			var s = (ConnectedWallSettings)PartService.Read( group.Key ).Settings;
			foreach ( var(_, opening)in group )
			{
				if ( s.PlacedOpenings.Any( o => o.Enabled && MathF.Abs( o.Along - opening.Along ) < (o.Width + opening.Width) * .5f && MathF.Abs( o.Sill - opening.Sill ) < MathF.Max( o.Height, opening.Height ) ) )
					continue;
				s.PlacedOpenings.Add( opening );
			}

			yield return (group.Key, s);
		}
	}

	protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing() => Doors().Select( d => d.Wall ).Distinct().ToArray();
	protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => DoorChanges().SelectMany( change => PartService.RemovedByRegeneration( change.Wall, RegenerationOptions.Everything, change.Settings ) ).Distinct().ToArray();
	protected override void PlaceObjects()
	{
		Prepare();
		var changes = DoorChanges().ToArray();
		PartTool.Place( generator, GeneratorRegistry.Load( generator, GeneratorRegistry.Save( Settings ) ), BalconyFrame );
		foreach ( var change in changes )
			PartService.Regenerate( change.Wall, RegenerationOptions.Everything, true, change.Settings );
	}

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var h = new HashSet<string>( base.UnusedOptions );
			if ( !SnapToWall )
				h.Add( nameof( WallSnapDistance ) );
			if ( !CreateDoorOpenings )
				h.UnionWith( [nameof( DoorWidth ), nameof( DoorHeight ), nameof( DoorLeaf )] );
			return h;
		}
	}
}