Editor tooling for window and door cutters, in a namespace called AdvancedMappingTools. OpeningCutter holds the shared placement code: it finds the frame of a wall or roof face, traces through the mesh with Scene.Trace to get its thickness, builds the cutter geometry with MeshKit and MeshCutter.SubtractClean, then attaches the finished window, door, bay/bow/garden window, dormer or roof window as a child of the wall or roof. WindowCutterSettings and DormerCutterSettings hold the property sheet options. WindowCutterEditor and DormerCutterEditor are BuilderEditor tools that hover a mesh, preview the outline with Gizmo.Draw and place the element on click inside an undo scope.
namespace AdvancedMappingTools;
/// <summary>What the window cutter puts into a wall.</summary>
public enum CutterElement
{
Window,
Door,
[Title( "Opening Only" )] Opening,
[Title( "Bay Window" )] BayWindow,
[Title( "Bow Window" )] BowWindow,
[Title( "Garden Window" )] GardenWindow
}
/// <summary>What the dormer cutter puts onto a roof.</summary>
public enum RoofElement
{
Dormer,
[Title( "Roof Window" )] RoofWindow
}
/// <summary>
/// Options of the window cutter. A plain class so the placing code can be used and tested on its own.
/// </summary>
public sealed class WindowCutterSettings
{
[Property, Group( "Element" ), Title( "Place" ), WideMode, Description( "A window, a door, or only the opening." )]
public CutterElement Element { get; set; } = CutterElement.Window;
[Property, Group( "Element" ), Range( 4, 1024, slider: false ), WideMode]
public float Width { get; set; } = 48;
[Property, Group( "Element" ), Range( 4, 1024, slider: false ), WideMode]
public float Height { get; set; } = 56;
[Property, Group( "Element" ), Title( "Sill Height" ), Range( 0, 2048, slider: false ), WideMode, Description( "Above the bottom of the clicked wall. Doors stand on the bottom." )]
public float SillHeight { get; set; } = 36;
[Property, Group( "Element" ), Title( "Follow Cursor Height" ), WideMode, Description( "Centres the window on the cursor instead of the sill height." )]
public bool FollowCursorHeight { get; set; }
[Property, Group( "Element" ), Title( "Snap To Grid" ), WideMode]
public bool SnapToGrid { get; set; } = true;
[Property, Group( "Element" ), Title( "Bind To Wall" ), WideMode, Description( "The window becomes a child of the wall and moves with it." )]
public bool BindToWall { get; set; } = true;
[Property, Group( "Element" ), Title( "Wall Thickness If Unknown" ), Range( 0.5f, 256, slider: false ), WideMode, Description( "Used when the back of the wall cannot be found." )]
public float FallbackThickness { get; set; } = 8;
[Property, Group( "Element" ), Title( "Size Preset" ), WideMode, Description( "Standard uses width, height and sill height. Tall, Storefront and Slit size the window from the wall height." )]
public WindowKind SizePreset { get; set; } = WindowKind.Standard;
[Property, Group( "Window Type" ), Title( "Opening Type" ), WideMode]
public WindowType OpeningType { get; set; } = WindowType.Fixed;
[Property, Group( "Window Type" ), Title( "Open" ), Range( 0, 1 ), WideMode]
public float Open { get; set; }
[Property, Group( "Window Type" ), WideMode]
public WindowShape Shape { get; set; } = WindowShape.Rectangle;
[Property, Group( "Window Type" ), WideMode]
public ArchStyle Arch { get; set; } = ArchStyle.Flat;
[Property, Group( "Window Type" ), Title( "Transom Light" ), Range( 0, 0.6f ), WideMode]
public float Transom { get; set; }
[Property, Group( "Window Type" ), Title( "Storm Sash" ), WideMode]
public bool Storm { get; set; }
[Property, Group( "Window Type" ), WideMode]
public bool Hardware { get; set; } = true;
[Property, Group( "Frame" ), Title( "Corners" ), WideMode]
public FrameCorner Corner { get; set; } = FrameCorner.Mitred;
[Property, Group( "Frame" ), WideMode]
public FrameProfile Profile { get; set; } = FrameProfile.Chamfered;
[Property, Group( "Frame" ), WideMode]
public WindowDetail Detail { get; set; } = WindowDetail.Standard;
[Property, Group( "Frame" ), WideMode]
public Glazing Glazing { get; set; } = Glazing.Double;
[Property, Group( "Frame" ), Title( "Frame Width" ), Range( 0.5f, 16, slider: false ), WideMode]
public float FrameWidth { get; set; } = 3;
[Property, Group( "Frame" ), Title( "Stands Out Of Wall" ), Range( 0, 16, slider: false ), WideMode]
public float FrameOut { get; set; } = 1;
[Property, Group( "Frame" ), Title( "Panes Across" ), Range( 1, 16, slider: false ), WideMode]
public int Columns { get; set; } = 2;
[Property, Group( "Frame" ), Title( "Panes Up" ), Range( 1, 16, slider: false ), WideMode]
public int Rows { get; set; } = 2;
[Property, Group( "Frame" ), Title( "Window Sill" ), WideMode]
public bool Sill { get; set; } = true;
[Property, Group( "Door" ), Title( "Door Type" ), WideMode, Description( "Hinged leaves, a sliding leaf on a rail inside, a sectional garage door, or barn leaves on a rail outside." )]
public DoorKind DoorKind { get; set; } = DoorKind.Hinged;
[Property, Group( "Door" ), Title( "Movable" ), WideMode, Description( "Hinged leaves become their own objects with a Door component that opens in the game." )]
public bool Movable { get; set; }
[Property, Group( "Door" ), Title( "Swing Angle" ), Range( -180, 180, slider: false ), WideMode, Description( "How far a movable door opens." )]
public float SwingAngle { get; set; } = 90;
[Property, Group( "Door" ), Title( "Door Style" ), WideMode]
public DoorStyle DoorStyle { get; set; } = DoorStyle.Panelled;
[Property, Group( "Door" ), Title( "Leaves" ), Range( 1, 2, slider: false ), WideMode]
public int Leaves { get; set; } = 1;
[Property, Group( "Door" ), Title( "Door Open" ), Range( 0, 1 ), WideMode]
public float DoorOpen { get; set; }
[Property, Group( "Extras" ), Title( "Iron Bars" ), Range( 0, 32, slider: false ), WideMode, Description( "Bars in front of the window. Windows opening outward stay shut behind them." )]
public int IronBars { get; set; }
[Property, Group( "Extras" ), Title( "Boarded Up" ), Range( 0, 16, slider: false ), WideMode, Description( "Planks nailed across the opening." )]
public int BoardedUp { get; set; }
[Property, Group( "Extras" ), WideMode, Description( "Two shutters on the wall beside the window." )]
public bool Shutters { get; set; }
[Property, Group( "Extras" ), Range( 0, 9999, slider: false ), WideMode]
public int Seed { get; set; } = 1;
[Property, Group( "Bay, Bow And Garden" ), Title( "Projection" ), Range( 4, 256, slider: false ), WideMode, Description( "How far the window stands out of the wall." )]
public float Projection { get; set; } = 24;
[Property, Group( "Bay, Bow And Garden" ), Title( "Bow Facets" ), Range( 3, 12, slider: false ), WideMode]
public int BowFacets { get; set; } = 5;
[Property, Group( "Bay, Bow And Garden" ), Title( "Base" ), WideMode, Description( "A floor under the projecting window." )]
public bool Base { get; set; } = true;
[Property, Group( "Bay, Bow And Garden" ), Title( "Roof" ), WideMode, Description( "A roof over it: a slab with overhang, or sloped glass for a garden window." )]
public bool ProjectingRoof { get; set; } = true;
}
/// <summary>Options of the dormer cutter.</summary>
public sealed class DormerCutterSettings
{
[Property, Group( "Element" ), Title( "Place" ), WideMode]
public RoofElement Element { get; set; } = RoofElement.Dormer;
[Property, Group( "Element" ), Title( "Snap To Grid" ), WideMode]
public bool SnapToGrid { get; set; } = true;
[Property, Group( "Element" ), Title( "Bind To Roof" ), WideMode, Description( "The dormer or window becomes a child of the roof and moves and turns with it." )]
public bool BindToRoof { get; set; } = true;
[Property, Group( "Element" ), Title( "Roof Thickness If Unknown" ), Range( 0.5f, 256, slider: false ), WideMode]
public float FallbackThickness { get; set; } = 6;
[Property, Group( "Dormer" ), Title( "Roof Shape" ), WideMode]
public DormerShape Shape { get; set; } = DormerShape.Gable;
[Property, Group( "Dormer" ), Range( 8, 512, slider: false ), WideMode]
public float Width { get; set; } = 48;
[Property, Group( "Dormer" ), Title( "Wall Height" ), Range( 4, 512, slider: false ), WideMode]
public float WallHeight { get; set; } = 32;
[Property, Group( "Dormer" ), Title( "Roof Rise" ), Range( 1, 256, slider: false ), WideMode]
public float RoofRise { get; set; } = 16;
[Property, Group( "Dormer" ), Range( 0, 48, slider: false ), WideMode]
public float Overhang { get; set; } = 4;
[Property, Group( "Dormer" ), Title( "Window" ), WideMode]
public bool DormerWindow { get; set; } = true;
[Property, Group( "Dormer" ), Title( "Window Width" ), Range( 4, 512, slider: false ), WideMode]
public float DormerWindowWidth { get; set; } = 28;
[Property, Group( "Dormer" ), Title( "Window Height" ), Range( 4, 512, slider: false ), WideMode]
public float DormerWindowHeight { get; set; } = 22;
[Property, Group( "Roof Window" ), Title( "Width" ), Range( 8, 512, slider: false ), WideMode]
public float WindowWidth { get; set; } = 40;
[Property, Group( "Roof Window" ), Title( "Length Up The Slope" ), Range( 8, 512, slider: false ), WideMode]
public float WindowLength { get; set; } = 48;
[Property, Group( "Roof Window" ), Title( "Height Above Roof" ), Range( 1, 16, slider: false ), WideMode]
public float Curb { get; set; } = 3;
[Property, Group( "Roof Window" ), Title( "Panes Across" ), Range( 1, 6, slider: false ), WideMode]
public int PanesAcross { get; set; } = 1;
[Property, Group( "Roof Window" ), Title( "Panes Up" ), Range( 1, 6, slider: false ), WideMode]
public int PanesUp { get; set; } = 1;
[Property, Group( "Roof Window" ), Title( "Lining" ), WideMode, Description( "Boards in the cut through the roof." )]
public bool Lining { get; set; } = true;
[Property, Group( "Frame" ), Title( "Corners" ), WideMode]
public FrameCorner Corner { get; set; } = FrameCorner.Mitred;
[Property, Group( "Frame" ), WideMode]
public FrameProfile Profile { get; set; } = FrameProfile.Chamfered;
[Property, Group( "Frame" ), WideMode]
public WindowDetail Detail { get; set; } = WindowDetail.Standard;
[Property, Group( "Frame" ), WideMode]
public Glazing Glazing { get; set; } = Glazing.Double;
[Property, Group( "Frame" ), Title( "Frame Width" ), Range( 0.5f, 16, slider: false ), WideMode]
public float FrameWidth { get; set; } = 3;
[Property, Group( "Frame" ), Title( "Flashing" ), WideMode]
public bool Flashing { get; set; } = true;
[Property, Group( "Frame" ), Title( "Flashing Width" ), Range( 1, 16, slider: false ), WideMode]
public float FlashingWidth { get; set; } = 3;
}
/// <summary>
/// Placing windows and doors into walls and dormers and roof windows onto roofs that already
/// exist: the frame of the element from the clicked face, the thickness from a trace through it,
/// the cut, and the element as a child of the wall or roof.
/// </summary>
public static class OpeningCutter
{
static readonly Material DefaultGlass = Material.Load( "materials/glass/glass_a.vmat" );
/// <summary>
/// How thick a body is behind a point of its surface, along <paramref name="inward"/>: the
/// distance to its first face seen from far behind. The fallback when nothing is found.
/// </summary>
public static float Thickness( GameObject target, Vector3 point, Vector3 inward, float fallback )
{
const float reach = 512.0f;
if ( target?.Scene is not { } scene ) return fallback;
var from = point + inward * reach;
try
{
var hits = scene.Trace.Ray( from, point - inward * 0.5f ).UseRenderMeshes( true ).UsePhysicsWorld( false ).RunAll()
.Where( r => r.Hit && r.GameObject == target && r.Distance < reach - 0.05f ).OrderBy( r => r.Distance );
foreach ( var hit in hits )
return Math.Clamp( reach - hit.Distance, 0.5f, reach );
}
catch ( Exception ) { }
return fallback;
}
/// <summary>
/// The frame of a window in a wall at a point of its face: X along the wall, Z up, Y into the
/// wall, the origin at the foot of the wall below the point. Null when the face is not upright.
/// </summary>
public static Transform? WallFrame( Vector3 point, Vector3 normal, float bottom )
{
var n = normal.WithZ( 0.0f );
if ( n.Length < 0.5f ) return null;
n = n.Normal;
var along = Vector3.Cross( Vector3.Up, n ).Normal;
return new Transform( point.WithZ( bottom ), Rotation.LookAt( along, Vector3.Up ) );
}
/// <summary>The hole in wall coordinates: x0, x1, z0, z1.</summary>
public static (float X0, float X1, float Z0, float Z1) WallHole( WindowCutterSettings s, float cursorZ, float wallHeight = 128 )
{
var w = Math.Clamp( s.Width, 4, 1024 );
var h = Math.Clamp( s.Height, 4, 1024 );
var wall = MathF.Max( wallHeight, 16 );
if ( s.Element == CutterElement.Window && s.SizePreset != WindowKind.Standard )
{
// The same sizes as the Window tool's types.
var (z, height) = s.SizePreset switch
{
WindowKind.Storefront => (0.5f, wall - 2.5f),
WindowKind.Slit => (wall * 0.45f, MathF.Min( 18.0f, wall * 0.25f )),
_ => (wall * 0.12f, wall * 0.62f)
};
return (-w * 0.5f, w * 0.5f, z, z + height);
}
// Doors stand on the foot of the wall; windows on their sill, or around the cursor.
var z0 = s.FollowCursorHeight ? MathF.Max( cursorZ - h * 0.5f, 0 ) : s.Element == CutterElement.Door ? 0 : MathF.Max( s.SillHeight, 0 );
return (-w * 0.5f, w * 0.5f, z0, z0 + h);
}
/// <summary>Windows that open outward cannot open behind bars or boards.</summary>
static bool Blocked( WindowCutterSettings s ) => (s.IronBars > 0 || s.BoardedUp > 0) && s.OpeningType is WindowType.Casement or WindowType.DoubleCasement or WindowType.Awning or WindowType.Accordion or WindowType.PivotHorizontal or WindowType.PivotVertical;
static WindowUnitSpec Unit( WindowCutterSettings s, float thickness, Material trim, Material glass ) => new()
{
Type = s.OpeningType,
Open = Blocked( s ) ? 0 : s.Open,
Transom = s.Transom,
Storm = s.Storm,
Hardware = s.Hardware,
HardwareMaterial = trim,
Look = new WindowSpec
{
Width = Math.Clamp( s.FrameWidth, 0.5f, 16 ),
Depth = thickness + Math.Clamp( s.FrameOut, 0, 16 ) * 2.0f,
Edge = MathF.Min( 0.75f, s.FrameWidth * 0.3f ),
Corner = s.Corner,
Profile = s.Profile,
Detail = s.Detail,
Glazing = s.Glazing,
Columns = s.SizePreset == WindowKind.Storefront ? 3 : Math.Clamp( s.Columns, 1, 16 ),
Rows = s.SizePreset is WindowKind.Storefront or WindowKind.Slit ? 1 : Math.Clamp( s.Rows, 1, 16 ),
BarWidth = MathF.Max( s.FrameWidth * 0.5f, 0.5f ),
Shape = s.Shape,
Arch = s.Arch,
FrameMaterial = trim,
GlassMaterial = glass,
DetailMaterial = trim
}
};
/// <summary>The outline the wall is cut along, in wall coordinates.</summary>
public static Vector2[] WallOutline( WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole )
{
var unit = Unit( s, 8, null, null );
if ( s.Element != CutterElement.Window )
unit.Type = WindowType.Fixed;
if ( s.Element is CutterElement.BayWindow or CutterElement.BowWindow or CutterElement.GardenWindow )
return WindowKit.Rect( hole.X0, hole.X1, hole.Z0, hole.Z1 );
if ( s.Element == CutterElement.Door )
{
// A door is square, and its opening reaches a little below the foot of the wall so no
// sliver of wall stays under the threshold.
unit.Look.Shape = WindowShape.Rectangle;
unit.Look.Arch = ArchStyle.Flat;
var z0 = hole.Z0 <= 0.01f ? -1.0f : hole.Z0;
return WindowKit.Rect( hole.X0, hole.X1, z0, hole.Z1 );
}
return WindowUnit.Outline( hole.X0, hole.X1, hole.Z0, hole.Z1, unit );
}
/// <summary>
/// Cuts the opening into the wall and builds the window or door in it. Returns the new object,
/// a child of the wall when bound, or null for an opening only.
/// </summary>
public static GameObject PlaceInWall( MeshComponent wall, Vector3 point, Vector3 normal, float cursorZ, WindowCutterSettings s, Material trim, Material glass = null )
{
if ( !wall.IsValid() || wall.Mesh is null ) throw new InvalidOperationException( "Click a wall made of a mesh." );
// The wall's own size, from its mesh: object bounds are not known yet in a scene that has not drawn.
var bounds = wall.Mesh.CalculateBounds( wall.WorldTransform );
var bottom = bounds.Mins.z;
var frame = WallFrame( point, normal, bottom ) ?? throw new InvalidOperationException( "Click the side of a wall, not its top or bottom." );
var thickness = Thickness( wall.GameObject, point, -normal.WithZ( 0 ).Normal, Math.Clamp( s.FallbackThickness, 0.5f, 256 ) );
var hole = WallHole( s, cursorZ - bottom, bounds.Size.z );
var outline = WallOutline( s, hole );
// The cut: the outline straight through the wall.
var cutter = new MeshKit { Material = wall.Mesh.FaceHandles.Select( f => wall.Mesh.GetFaceMaterial( f ) ).FirstOrDefault() };
WindowKit.Loft( cutter, [.. outline.Select( p => new Vector3( p.x, -1.0f, p.y ) )], [.. outline.Select( p => new Vector3( p.x, thickness + 1.0f, p.y ) )] );
var relative = wall.WorldTransform.ToLocal( frame );
var reveal = cutter.Material;
var result = MeshCutter.SubtractClean( wall.Mesh, cutter.Finish(), relative, reveal ) ?? throw new InvalidOperationException( "The opening did not cut through this wall." );
wall.Mesh = result;
wall.RebuildMesh();
if ( s.Element == CutterElement.Opening )
return null;
var kit = new MeshKit { Material = trim };
var front = -Math.Clamp( s.FrameOut, 0, 16 );
var unit = Unit( s, thickness, trim, glass ?? DefaultGlass );
List<(Transform Hinge, PolygonMesh Mesh, float Turn)> leaves = null;
if ( s.Element == CutterElement.Door )
{
unit.Look.Shape = WindowShape.Rectangle;
unit.Look.Arch = ArchStyle.Flat;
if ( s.Movable && s.DoorKind == DoorKind.Hinged )
leaves = [];
WindowUnit.BuildDoor( kit, hole.X0, hole.X1, hole.Z0, hole.Z1, front, new DoorSpec { Look = unit.Look, Kind = s.DoorKind, Style = s.DoorStyle, Leaves = s.Leaves, Open = s.DoorOpen, Hardware = s.Hardware, LeafMaterial = trim, HardwareMaterial = trim, Mount = Math.Clamp( s.FrameOut, 0, 16 ), SeparateLeaves = leaves } );
}
else if ( s.Element is CutterElement.BayWindow or CutterElement.BowWindow or CutterElement.GardenWindow )
{
Projecting( kit, s, hole, unit.Look, trim, glass ?? DefaultGlass );
}
else
{
WindowUnit.Build( kit, hole.X0, hole.X1, hole.Z0, hole.Z1, front, unit );
if ( s.Sill && hole.Z0 > 1.6f && s.Shape != WindowShape.Round )
{
kit.BeginPart();
WindowKit.Sill( kit, hole.X0, hole.X1, hole.Z0, 0, Math.Clamp( s.FrameOut, 0, 16 ) + 2.5f, 1.5f, 2.0f );
}
Extras( kit, s, hole, front, unit.Look.Width, trim );
}
var name = s.Element switch { CutterElement.Door => "Door", CutterElement.BayWindow => "Bay Window", CutterElement.BowWindow => "Bow Window", CutterElement.GardenWindow => "Garden Window", _ => "Window" };
var made = Attach( wall.GameObject, frame, kit.Finish(), name, s.BindToWall );
if ( leaves is not null )
{
// Each hinged leaf its own object at its hinge, opening in the game.
var doors = new List<Sandbox.Mapping.Door>();
foreach ( var (hinge, mesh, turn) in leaves )
{
var leaf = new GameObject( made, true, "Door Leaf" );
leaf.LocalTransform = hinge;
leaf.IsStatic = false;
var component = leaf.Components.Create<MeshComponent>( false );
component.Mesh = mesh;
component.SmoothingAngle = 40.0f;
component.Enabled = true;
var door = leaf.Components.Create<Sandbox.Mapping.Door>();
door.TargetAngle = s.SwingAngle * turn;
doors.Add( door );
}
if ( doors.Count == 2 )
{
doors[0].LinkedDoor = doors[1];
doors[1].LinkedDoor = doors[0];
}
}
return made;
}
/// <summary>
/// Iron bars in front of the frame, boards in front of them, shutters on the wall beside the hole.
/// </summary>
static void Extras( MeshKit kit, WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole, float front, float frame, Material trim )
{
kit.Material = trim;
var outsideStorm = s.Storm && s.OpeningType is not (WindowType.Casement or WindowType.DoubleCasement or WindowType.Awning or WindowType.Accordion or WindowType.PivotHorizontal or WindowType.PivotVertical);
var left = hole.X0 + frame;
var right = hole.X1 - frame;
var bars = Math.Clamp( s.IronBars, 0, 32 );
for ( var b = 1; b <= bars; b++ )
{
var x = left + (right - left) * b / (bars + 1f);
kit.BeginPart();
kit.Box( new Vector3( x - 0.5f, front - 1.0f, hole.Z0 + 0.05f ), new Vector3( x + 0.5f, front, hole.Z1 ) );
}
var random = new Random( s.Seed );
var boards = Math.Clamp( s.BoardedUp, 0, 16 );
for ( var b = 0; b < boards; b++ )
{
var z = hole.Z0 + frame + (hole.Z1 - hole.Z0 - frame * 2.0f) * (b + 0.5f) / boards;
var tilt = ((float)random.NextDouble() - 0.5f) * 8.0f;
var y = front - (bars > 0 ? 2.0f : 1.0f) - (outsideStorm ? 3.0f : 0.0f);
kit.BeginPart();
kit.Beam( new Vector3( hole.X0 - 4.0f, y, z - tilt ), new Vector3( hole.X1 + 4.0f, y, z + tilt ), 1.5f, 5.0f );
}
if ( s.Shutters )
{
var half = (hole.X1 - hole.X0) * 0.5f;
kit.BeginPart();
kit.Box( new Vector3( hole.X0 - half, -1.0f, hole.Z0 ), new Vector3( hole.X0 - 0.01f, 0, hole.Z1 ) );
kit.BeginPart();
kit.Box( new Vector3( hole.X1 + 0.01f, -1.0f, hole.Z0 ), new Vector3( hole.X1 + half, 0, hole.Z1 ) );
}
}
/// <summary>
/// A window standing out of the wall: facets of glass in frames between corner posts, on a base,
/// under a roof. Bay: three facets, the sides at 45 degrees; bow: a curve of facets; garden: a box
/// with glass sides and a sloped glass top. Wall coordinates: the wall face at y = 0, outside -Y.
/// </summary>
static void Projecting( MeshKit kit, WindowCutterSettings s, (float X0, float X1, float Z0, float Z1) hole, WindowSpec look, Material trim, Material glass )
{
float x0 = hole.X0, x1 = hole.X1, z0 = hole.Z0, z1 = hole.Z1;
var width = x1 - x0;
var p = Math.Clamp( s.Projection, 4, 256 );
var points = new List<Vector2>();
switch ( s.Element )
{
case CutterElement.BowWindow:
{
var n = Math.Clamp( s.BowFacets, 3, 12 );
for ( var k = 0; k <= n; k++ )
points.Add( new Vector2( x0 + width * k / n, -p * MathF.Sin( MathF.PI * k / n ) ) );
break;
}
case CutterElement.GardenWindow:
points.AddRange( [new( x0, 0 ), new( x0, -p ), new( x1, -p ), new( x1, 0 )] );
break;
default:
{
var side = MathF.Min( p, width * 0.35f );
points.AddRange( [new( x0, 0 ), new( x0 + side, -p ), new( x1 - side, -p ), new( x1, 0 )] );
break;
}
}
var depth = MathF.Max( look.Width + 1.0f, 3.0f );
var facet = WindowUnit.Copy( look );
facet.Depth = depth;
facet.Shape = WindowShape.Rectangle;
facet.Arch = ArchStyle.Flat;
facet.Columns = 1;
facet.FrameMaterial = trim;
facet.GlassMaterial = glass;
// Gaps at the joints so the frames meet the corner posts and not each other.
Vector2 Dir( int i ) => (points[i + 1] - points[i]).Normal;
var gaps = new float[points.Count];
for ( var i = 1; i + 1 < points.Count; i++ )
{
var turn = MathF.Acos( Math.Clamp( Vector2.Dot( Dir( i - 1 ), Dir( i ) ), -1, 1 ) );
gaps[i] = depth * MathF.Tan( turn * 0.5f ) + 0.3f;
}
var origin = kit.Frame;
kit.Material = trim;
for ( var i = 0; i + 1 < points.Count; i++ )
{
var d = Dir( i );
var length = (points[i + 1] - points[i]).Length;
var start = gaps[i];
var end = length - gaps[i + 1];
if ( end - start < 2.0f ) continue;
kit.Frame = origin.ToWorld( new Transform( new Vector3( points[i].x, points[i].y, 0 ), Rotation.LookAt( new Vector3( d.x, d.y, 0 ), Vector3.Up ) ) );
WindowKit.Build( kit, start, end, z0, z1, 0, facet );
kit.Frame = origin;
}
// Corner posts fill the joints between the facets.
for ( var i = 1; i + 1 < points.Count; i++ )
{
var j = points[i];
var d0 = Dir( i - 1 );
var d1 = Dir( i );
var n0 = new Vector2( -d0.y, d0.x );
var n1 = new Vector2( -d1.y, d1.x );
var a = j - d0 * gaps[i];
var b = j + d1 * gaps[i];
Vector3 V( Vector2 q ) => new( q.x, q.y, 0 );
kit.Material = trim;
kit.Prism( [V( a ), V( j ), V( b ), V( b + n1 * depth ), V( a + n0 * depth )], z0, z1 );
}
var middle = new Vector2( (x0 + x1) * 0.5f, 0 );
if ( s.Base )
{
kit.Material = trim;
kit.Prism( [.. points.Select( q => new Vector3( q.x, q.y, 0 ) )], z0 - MathF.Min( 6.0f, MathF.Max( z0, 0.5f ) ), z0 );
}
if ( !s.ProjectingRoof ) return;
if ( s.Element == CutterElement.GardenWindow )
{
// A sloped glass top, high at the wall.
var rise = MathF.Min( p * 0.5f, 12.0f );
kit.Material = glass;
kit.Hex( new Vector3( x0, 0, z1 ), new Vector3( x0, -p, z1 ), new Vector3( x1, -p, z1 ), new Vector3( x1, 0, z1 ),
new Vector3( x0, 0, z1 + rise + 0.5f ), new Vector3( x0, -p, z1 + 0.5f ), new Vector3( x1, -p, z1 + 0.5f ), new Vector3( x1, 0, z1 + rise + 0.5f ) );
return;
}
// A roof slab reaching a little past the facets, never into the wall.
var over = 2.0f;
var roof = points.Select( q => q.y > -0.01f ? new Vector2( q.x + MathF.Sign( q.x - middle.x ) * over, 0 ) : q + (q - middle).Normal * over ).ToList();
kit.Material = trim;
kit.Prism( [.. roof.Select( q => new Vector3( q.x, q.y, 0 ) )], z1, z1 + 4.0f );
}
static GameObject Attach( GameObject host, Transform frame, PolygonMesh mesh, string name, bool bind )
{
var go = new GameObject( bind ? host : host.Parent, true, name );
go.WorldTransform = frame;
go.IsStatic = host.IsStatic;
var component = go.Components.Create<MeshComponent>( false );
component.Mesh = mesh;
component.SmoothingAngle = 40.0f;
component.Enabled = true;
return go;
}
/// <summary>
/// The frame of a dormer on a sloped face at a point: X along the eave, Y up the slope seen
/// from above, Z up; the roof surface rises toward +Y with the returned pitch. Null when the face
/// is too flat or too steep to be a roof.
/// </summary>
public static (Transform Frame, float Pitch)? RoofFrame( Vector3 point, Vector3 normal )
{
var n = normal.Normal;
if ( n.z < 0.15f || n.z > 0.985f ) return null;
var downhill = n.WithZ( 0.0f ).Normal;
var along = Vector3.Cross( Vector3.Up, downhill ).Normal;
return (new Transform( point, Rotation.LookAt( along, Vector3.Up ) ), MathF.Acos( Math.Clamp( n.z, -1, 1 ) ) * 180.0f / MathF.PI);
}
/// <summary>
/// The frame of a roof window lying in the slope at a point: X along the eave, Z up the slope,
/// -Y out of the roof.
/// </summary>
public static Transform SlopeFrame( Vector3 point, Vector3 normal )
{
var n = normal.Normal;
var along = Vector3.Cross( Vector3.Up, n.WithZ( 0.0f ).Normal ).Normal;
var up = Vector3.Cross( n, along ).Normal;
if ( up.z < 0 ) up = -up;
if ( Vector3.Dot( Vector3.Cross( along, up ), n ) < 0 ) along = -along;
return new Transform( point, Rotation.LookAt( along, up ) );
}
/// <summary>
/// Puts a dormer or a roof window onto a roof at a point of a sloped face, turned to that face.
/// A roof window cuts a real opening through the roof.
/// </summary>
public static GameObject PlaceOnRoof( MeshComponent roof, Vector3 point, Vector3 normal, DormerCutterSettings s, Material surface, Material trim, Material glass = null )
{
if ( !roof.IsValid() || roof.Mesh is null ) throw new InvalidOperationException( "Click a roof made of a mesh." );
var slope = RoofFrame( point, normal ) ?? throw new InvalidOperationException( "Click a sloped roof face." );
glass ??= DefaultGlass;
var kit = new MeshKit { Material = trim };
var look = new WindowSpec { Width = Math.Clamp( s.FrameWidth, 0.5f, 16 ), Corner = s.Corner, Profile = s.Profile, Detail = s.Detail, Glazing = s.Glazing, FrameMaterial = trim, GlassMaterial = glass, DetailMaterial = trim };
if ( s.Element == RoofElement.Dormer )
{
var spec = new DormerSpec
{
Shape = s.Shape,
Width = s.Width,
WallHeight = s.WallHeight,
Pitch = slope.Pitch,
RoofRise = s.RoofRise,
Overhang = s.Overhang,
Window = s.DormerWindow,
WindowWidth = s.DormerWindowWidth,
WindowHeight = s.DormerWindowHeight,
Flashing = s.Flashing,
FlashingWidth = s.FlashingWidth,
BodyMaterial = trim,
RoofMaterial = surface ?? trim,
TrimMaterial = trim,
Frame = new WindowSpec { Width = 2.0f, Depth = 2.5f, Corner = s.Corner, Profile = s.Profile, Detail = s.Detail, Glazing = s.Glazing, FrameMaterial = trim, GlassMaterial = glass, DetailMaterial = trim }
};
DormerKit.Build( kit, spec );
return Attach( roof.GameObject, slope.Frame, kit.Finish(), "Dormer", s.BindToRoof );
}
// A roof window: the opening through the roof, a frame standing on it, flashing, lining.
var frame = SlopeFrame( point, normal );
var thickness = Thickness( roof.GameObject, point, -normal.Normal, Math.Clamp( s.FallbackThickness, 0.5f, 256 ) );
var w = Math.Clamp( s.WindowWidth, 8, 512 );
var l = Math.Clamp( s.WindowLength, 8, 512 );
float x0 = -w * 0.5f, x1 = w * 0.5f, z0 = -l * 0.5f, z1 = l * 0.5f;
var cutter = new MeshKit { Material = trim };
cutter.Box( new Vector3( x0, -1.0f, z0 ), new Vector3( x1, thickness + 1.0f, z1 ) );
var result = MeshCutter.SubtractClean( roof.Mesh, cutter.Finish(), roof.WorldTransform.ToLocal( frame ), trim ) ?? throw new InvalidOperationException( "The opening did not cut through this roof." );
roof.Mesh = result;
roof.RebuildMesh();
var bar = MathF.Min( look.Width, MathF.Min( w, l ) * 0.2f );
var lap = bar * 0.5f;
var curb = Math.Clamp( s.Curb, 1, 16 );
look.Width = bar;
look.Depth = curb;
look.Edge = MathF.Min( 0.75f, bar * 0.3f );
look.Columns = Math.Clamp( s.PanesAcross, 1, 6 );
look.Rows = Math.Clamp( s.PanesUp, 1, 6 );
WindowKit.Build( kit, x0 - lap, x1 + lap, z0 - lap, z1 + lap, -curb, look );
if ( s.Flashing )
{
kit.BeginPart();
var sheet = Math.Clamp( s.FlashingWidth, 1, 16 );
// A hair above the roof, so its thin edge never touches the roof faces.
WindowKit.Flashing( kit, x0 - lap, x1 + lap, z0 - lap, z1 + lap, -0.05f, sheet, sheet, MathF.Min( curb * 0.6f, 2 ) );
}
if ( s.Lining && thickness > 0.6f )
{
kit.BeginPart();
WindowKit.Lining( kit, x0, x1, z0, z1, 0, thickness, MathF.Min( 0.5f, MathF.Min( w, l ) * 0.05f ) );
}
return Attach( roof.GameObject, frame, kit.Finish(), "Roof Window", s.BindToRoof );
}
}
/// <summary>
/// Puts windows, doors or plain openings into any wall: hover a wall to see where it goes, click to
/// cut the opening and place the element, bound to the wall.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Window Cutter" ), Icon( "window" )]
public sealed class WindowCutterEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
protected override int MinPoints => int.MaxValue;
protected override IEnumerable<string> Hints => ["Hover a wall: the outline shows where it goes", "Click: cut the opening and place the window or door"];
public WindowCutterSettings Settings { get; } = new();
public override object ExtraSettings => Settings;
protected override void BuildMesh( MeshKit kit ) { }
public override void OnUpdate( SceneTrace trace )
{
var tr = trace.Run();
var mesh = tr.GameObject?.GetComponent<MeshComponent>();
if ( !tr.Hit || !mesh.IsValid() || mesh.Mesh is null ) return;
var point = Settings.SnapToGrid ? Gizmo.Snap( tr.EndPosition, new Vector3( 1, 1, 0 ) ).WithZ( tr.EndPosition.z ) : tr.EndPosition;
var n = tr.Normal.WithZ( 0 );
if ( n.Length < 0.5f ) return;
point -= n.Normal * Vector3.Dot( point - tr.EndPosition, n.Normal );
var wallBounds = mesh.Mesh.CalculateBounds( mesh.WorldTransform );
var bottom = wallBounds.Mins.z;
if ( OpeningCutter.WallFrame( point, tr.Normal, bottom ) is not { } frame ) return;
var hole = OpeningCutter.WallHole( Settings, point.z - bottom, wallBounds.Size.z );
var outline = OpeningCutter.WallOutline( Settings, hole );
var thickness = OpeningCutter.Thickness( mesh.GameObject, point, -n.Normal, Settings.FallbackThickness );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.Color = Settings.Element == CutterElement.Door ? Color.Orange : Color.Cyan;
for ( var i = 0; i < outline.Length; i++ )
{
var a = outline[i];
var b = outline[(i + 1) % outline.Length];
Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, 0, a.y ) ), frame.PointToWorld( new Vector3( b.x, 0, b.y ) ) );
Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, thickness, a.y ) ), frame.PointToWorld( new Vector3( b.x, thickness, b.y ) ) );
if ( i % Math.Max( 1, outline.Length / 4 ) == 0 )
Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, 0, a.y ) ), frame.PointToWorld( new Vector3( a.x, thickness, a.y ) ) );
}
if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered ) return;
try
{
using var scope = SceneEditorSession.Scope();
using ( SceneEditorSession.Active.UndoScope( $"Place {Settings.Element}" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
OpeningCutter.PlaceInWall( mesh, point, tr.Normal, point.z, Settings, TrimMaterial );
}
catch ( Exception ex )
{
Log.Warning( $"Window Cutter: {ex.Message}" );
}
}
}
/// <summary>
/// Puts dormers or roof windows onto any roof: hover a sloped face, the dormer turns to it; click to
/// place it, bound to the roof. Roof windows cut a real opening.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Dormer Cutter" ), Icon( "roofing" )]
public sealed class DormerCutterEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
protected override int MinPoints => int.MaxValue;
protected override IEnumerable<string> Hints => ["Hover a sloped roof face: the dormer turns to it", "Click: place it on the roof"];
public DormerCutterSettings Settings { get; } = new();
public override object ExtraSettings => Settings;
protected override void BuildMesh( MeshKit kit ) { }
public override void OnUpdate( SceneTrace trace )
{
var tr = trace.Run();
var mesh = tr.GameObject?.GetComponent<MeshComponent>();
if ( !tr.Hit || !mesh.IsValid() || mesh.Mesh is null ) return;
var point = tr.EndPosition;
if ( Settings.SnapToGrid )
{
var snapped = Gizmo.Snap( point, new Vector3( 1, 1, 0 ) );
// Back onto the face along the vertical.
var n = tr.Normal.Normal;
point = MathF.Abs( n.z ) > 0.01f ? snapped.WithZ( point.z - Vector3.Dot( snapped.WithZ( point.z ) - point, n ) / n.z ) : point;
}
if ( OpeningCutter.RoofFrame( point, tr.Normal ) is not { } slope ) return;
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2;
Gizmo.Draw.Color = Color.Cyan;
var tan = MathF.Tan( slope.Pitch * MathF.PI / 180.0f );
Vector3 On( Transform f, float x, float y, float z ) => f.PointToWorld( new Vector3( x, y, y * tan + z ) );
if ( Settings.Element == RoofElement.Dormer )
{
var h = Settings.Width * 0.5f;
var top = Settings.WallHeight + 3 + (Settings.Shape is DormerShape.Shed or DormerShape.Flat ? 0 : Settings.RoofRise);
var back = top / MathF.Max( tan, 0.05f );
var f = slope.Frame;
Gizmo.Draw.Line( On( f, -h, 0, 0 ), On( f, h, 0, 0 ) );
Gizmo.Draw.Line( On( f, h, 0, 0 ), On( f, h, back, 0 ) );
Gizmo.Draw.Line( On( f, -h, 0, 0 ), On( f, -h, back, 0 ) );
Gizmo.Draw.Line( f.PointToWorld( new Vector3( -h, 0, Settings.WallHeight ) ), f.PointToWorld( new Vector3( h, 0, Settings.WallHeight ) ) );
Gizmo.Draw.Line( f.PointToWorld( new Vector3( -h, 0, 0 ) ), f.PointToWorld( new Vector3( -h, 0, Settings.WallHeight ) ) );
Gizmo.Draw.Line( f.PointToWorld( new Vector3( h, 0, 0 ) ), f.PointToWorld( new Vector3( h, 0, Settings.WallHeight ) ) );
}
else
{
var f = OpeningCutter.SlopeFrame( point, tr.Normal );
var w = Settings.WindowWidth * 0.5f;
var l = Settings.WindowLength * 0.5f;
Vector3[] corners = [f.PointToWorld( new Vector3( -w, 0, -l ) ), f.PointToWorld( new Vector3( w, 0, -l ) ), f.PointToWorld( new Vector3( w, 0, l ) ), f.PointToWorld( new Vector3( -w, 0, l ) )];
for ( var i = 0; i < 4; i++ )
Gizmo.Draw.Line( corners[i], corners[(i + 1) % 4] );
}
if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered ) return;
try
{
using var scope = SceneEditorSession.Scope();
using ( SceneEditorSession.Active.UndoScope( $"Place {Settings.Element}" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
OpeningCutter.PlaceOnRoof( mesh, point, tr.Normal, Settings, SurfaceMaterial, TrimMaterial );
}
catch ( Exception ex )
{
Log.Warning( $"Dormer Cutter: {ex.Message}" );
}
}
}