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