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;
}
}