editor/AdvancedMappingTools/Placement/WallOpeningEditor.cs

An editor tool, Wall Opening in the Doors & Windows category. It raycasts to a wall made by ConnectedWallGenerator, snaps the hit along the wall line, previews the opening with gizmo lines, and on click adds a Window/Door/Doorway entry to ConnectedWallSettings.PlacedOpenings, then regenerates the wall inside an undo scope. It also draws cross-section lines per wall frame and warns when the target is not a connected wall or openings are disabled.

namespace AdvancedMappingTools;
public enum WallOpeningKind
{
	Window,
	[Title( "Door" )] Door,
	[Title( "Doorway (No Door)" )] Doorway
}

/// <summary>
/// Cuts a door or window opening into an existing connected wall, at the place clicked.
/// The opening is kept in the wall settings, so reconnecting or rebuilding the wall keeps it.
/// </summary>
[ToolCategory( "Doors & Windows" )]
[Title( "Wall Opening" ), Icon( "content_cut" )]
public sealed partial class WallOpeningEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{

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

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

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

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

	public override bool ChangesOtherSettings( string property ) => property is nameof( RepeatGrid ) or nameof( Kind ) or nameof( Frame ) or nameof( Glass ) or nameof( DoorLeaf );

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var h = new HashSet<string>( base.UnusedOptions );
			if ( Kind != WallOpeningKind.Window )
				h.UnionWith( [nameof( Sill ), nameof( Height ), nameof( Glass )] );
			else
				h.Add( nameof( DoorHeight ) );
			if ( Kind != WallOpeningKind.Door )
				h.Add( nameof( DoorLeaf ) );
			if ( Kind == WallOpeningKind.Doorway )
				h.Add( nameof( Frame ) );
			if ( !Frame || Kind == WallOpeningKind.Doorway )
				h.Add( nameof( OpeningFrameSurface ) );
			if ( !Glass || Kind != WallOpeningKind.Window )
				h.Add( nameof( OpeningGlassSurface ) );
			if ( !DoorLeaf || Kind != WallOpeningKind.Door )
				h.Add( nameof( OpeningLeafSurface ) );
			if ( !RepeatGrid )
				h.UnionWith( [nameof( Rows ), nameof( Columns ), nameof( RowSpacing ), nameof( ColumnSpacing )] );
			return h;
		}
	}
	const float CornerAngle = 25.0f;
	const float CornerClearance = 4.0f;

	protected override int MinPoints => int.MaxValue;

	protected override IEnumerable<string> Hints => ["Click a wall built with Use Connections or Wall Parts: cut the opening", "Other meshes: use Wall Cutter"];

	[Property, Group( "Materials" ), Title( "Opening Frame" ), WideMode]
	public Material OpeningFrameSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Opening Glass" ), WideMode]
	public Material OpeningGlassSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Door Leaf" ), WideMode]
	public Material OpeningLeafSurface { get; set; }

	[Property, Group( "Opening" ), WideMode, Description( "Window: frame and glass above the sill. Door: frame and leaf from the floor. Doorway: an open passage from the floor." )]
	public WallOpeningKind Kind { get; set; } = WallOpeningKind.Door;

	[Property, Group( "Opening" ), Range( 4, 1024, slider: false ), WideMode]
	public float Width { get; set; } = 48.0f;

	[Property, Group( "Door" ), Title( "Door Height" ), Range( 4, 1024, slider: false ), WideMode]
	public float DoorHeight { get; set; } = 96.0f;

	[Property, Group( "Window" ), Range( 0, 1024, slider: false ), WideMode]
	public float Sill { get; set; } = 36.0f;

	[Property, Group( "Window" ), Range( 4, 1024, slider: false ), WideMode]
	public float Height { get; set; } = 56.0f;

	bool IsWindow => Kind == WallOpeningKind.Window;
	float Low => IsWindow ? Sill : 0.0f;
	float High => Low + (IsWindow ? Height : DoorHeight);

	float GridSize => Snap switch
	{
		SnapMode.Custom => CustomGridSize,
		SnapMode.Editor when EditorScene.GizmoSettings.SnapToGrid => EditorScene.GizmoSettings.GridSpacing,
		_ => 0.0f
	};

	protected override void BuildMesh( MeshKit kit ) { }

	public override void OnUpdate( SceneTrace trace )
	{
		var tr = trace.Run();
		if ( !tr.Hit || tr.GameObject is null )
			return;
		var wall = PartService.RootOf( tr.GameObject );
		var part = wall.IsValid() ? wall.Components.Get<AmtPart>() : null;
		// Say so, or a click that cannot work looks like a broken tool.
		if ( part is null || part.Generator != ConnectedWallGenerator.GeneratorId )
		{
			DrawLabel( tr.EndPosition, "Only walls built with Use Connections. Use Wall Cutter here.", Color.Yellow );
			return;
		}

		var settings = (ConnectedWallSettings)PartService.Read( wall ).Settings;
		if ( !settings.UseOpenings )
		{
			DrawLabel( tr.EndPosition, "Wall openings are disabled on this wall. Enable Use Openings first.", Color.Yellow );
			return;
		}

		var local = wall.WorldTransform.PointToLocal( tr.EndPosition );
		var line = ConnectedWallGenerator.Line( settings );
		var along = Along( settings, local );
		if ( GridSize > 0.0f )
			along = Math.Clamp( MathF.Round( along / GridSize ) * GridSize, 0.0f, line.Length );
		along = ClearOfCorners( line, along, settings.Thickness );
		var half = Width * 0.5f;
		var taken = settings.PlacedOpenings.Any( x => x.Enabled && MathF.Abs( x.Along - along ) < x.Width * 0.5f + half && x.Sill < High && x.Sill + x.Height > Low );
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = taken ? Color.Red : Color.Yellow;
		var world = wall.WorldTransform;
		foreach ( var frame in line.Frames )
			Gizmo.Draw.Line( world.PointToWorld( frame.Place( settings.Thickness * -0.5f, 0.0f ) ), world.PointToWorld( frame.Place( settings.Thickness * 0.5f, 0.0f ) ) );
		// Where the opening will be, sitting on the grid.
		var candidates = Candidates( settings, along );
		foreach ( var opening in candidates )
		{
			var a = world.PointToWorld( line.PlaceAt( opening.Along - opening.Width * .5f, 0 ) + Vector3.Up * opening.Sill );
			var b = world.PointToWorld( line.PlaceAt( opening.Along + opening.Width * .5f, 0 ) + Vector3.Up * opening.Sill );
			var top = world.PointToWorld( line.PlaceAt( opening.Along - opening.Width * .5f, 0 ) + Vector3.Up * (opening.Sill + opening.Height) );
			var delta = top - a;
			Gizmo.Draw.Line( a, b );
			Gizmo.Draw.Line( b, b + delta );
			Gizmo.Draw.Line( b + delta, top );
			Gizmo.Draw.Line( top, a );
		}

		if ( candidates.Count == 0 && !taken )
		{
			DrawLabel( tr.EndPosition, "No opening fits here. Check dimensions and wall height.", Color.Yellow );
			return;
		}

		if ( taken )
		{
			DrawLabel( tr.EndPosition, "There is an opening here already.", Color.Red );
			return;
		}

		if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered )
			return;
		settings.PlacedOpenings.AddRange( Candidates( settings, along ) );
		var undo = SceneEditorSession.Active.UndoScope( "Wall Opening" ).WithGameObjectChanges( [wall], GameObjectUndoFlags.All ).WithGameObjectCreations();
		var removed = PartService.RemovedByRegeneration( wall, RegenerationOptions.Everything, settings );
		if ( removed.Count > 0 )
			undo = undo.WithGameObjectDestructions( removed );
		using ( SceneEditorSession.Scope() )
		using ( undo.Push() )
		{
			PartReconciler.RootPart( wall ).Settings = GeneratorRegistry.Save( settings );
			PartService.Regenerate( wall, RegenerationOptions.Everything, true, settings );
		}
	}

	/// <summary>
	/// A straight door cannot stand in a corner: it moves along the wall until it clears it.
	/// </summary>
	float ClearOfCorners( PathTrack line, float along, float thickness )
	{
		var clear = Width * 0.5f + thickness * 0.5f + CornerClearance;

		for ( var i = 1; i + 1 < line.Count; i++ )
		{
			var before = (line.Frames[i].Position - line.Frames[i - 1].Position).WithZ( 0.0f );
			var after = (line.Frames[i + 1].Position - line.Frames[i].Position).WithZ( 0.0f );

			if ( before.LengthSquared <= 0.0f || after.LengthSquared <= 0.0f || Vector3.GetAngle( before, after ) < CornerAngle ) continue;

			var corner = line.Frames[i].Distance;

			if ( MathF.Abs( along - corner ) < clear )
				along = along < corner ? corner - clear : corner + clear;
		}

		return along;
	}

	/// <summary>
	/// How far along the wall line the clicked point lies.
	/// </summary>
	static float Along( ConnectedWallSettings s, Vector3 local )
	{
		var line = ConnectedWallGenerator.Line( s );
		var flat = local.WithZ( 0.0f );
		var best = float.MaxValue;
		var result = 0.0f;

		for ( var i = 0; i + 1 < line.Count; i++ )
		{
			var a = line.Frames[i].Position.WithZ( 0.0f );
			var b = line.Frames[i + 1].Position.WithZ( 0.0f );
			var d = b - a;
			var t = d.LengthSquared > 0.0f ? Math.Clamp( (flat - a).Dot( d ) / d.LengthSquared, 0.0f, 1.0f ) : 0.0f;
			var on = a + d * t;
			var distance = on.Distance( flat );

			if ( distance >= best ) continue;

			best = distance;
			result = line.Frames[i].Distance + t * (line.Frames[i + 1].Distance - line.Frames[i].Distance);
		}

		return result;
	}
}