Editor code that computes and emits geometry for wall openings (windows, doors, grilles, casings, sills, thresholds). It builds meshes and boxes/quads from opening definitions, computes casing boards, grille frames and members, and door leaf requests for the wall generator.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One resolution of what stands in a furnished hole - which grid, how coarse, which way it runs and how far
// proud - so the generator, a report and a test read the same answer rather than three copies of the kit
// arithmetic. Coordinates are the wall's own: x along, y across, z up.
public readonly struct ArchOpeningGrille
{
public OpeningFurniture Furniture { get; init; }
public ArchSurface Surface { get; init; }
// Bars and a gate run UP the hole; a board and a shutter slat run across it.
public bool Upright { get; init; }
public float Border { get; init; }
public float Member { get; init; }
public float Gap { get; init; }
// The y of the face it presents, and how deep it is behind that face.
public float Face { get; init; }
public float Depth { get; init; }
public Vector2 Min { get; init; }
public Vector2 Max { get; init; }
public Vector2 InnerMin => new( Min.x + Border, Min.y + Border );
public Vector2 InnerMax => new( Max.x - Border, Max.y - Border );
public float Pitch => Member + Gap;
public bool Stands => Furniture != OpeningFurniture.None
&& InnerMax.x - InnerMin.x > Member
&& InnerMax.y - InnerMin.y > Member;
// The count comes off ArchDivide over the hole, which is why no opening carries a bar count.
public int Members => ArchDivide.AtMost( Upright ? InnerMax.x - InnerMin.x : InnerMax.y - InnerMin.y, Pitch ).Count;
}
// What a hole in a wall emits - the reveal lining it, the architrave round it, the sill under it, the fitting
// standing in it, the saddle across it and the leaf hung on it. The wall generator opens the hole and asks for
// the joinery; nothing here reads the wall back.
public static class ArchWallOpenings
{
public static ArchSurface BooleanRevealSurface => ArchSurface.Trim;
public static List<List<Vector2>> BooleanRevealLoops( IEnumerable<ArchOpening> openings, float wallHeight )
{
return ArchFootprint.Union( openings
.Select( opening =>
{
var bottom = Math.Clamp( opening.SillHeight, 0f, wallHeight );
var top = Math.Clamp( opening.Top, 0f, wallHeight );
return (IReadOnlyList<Vector2>)ArchFootprint.Rect(
new Vector2( opening.Left, bottom ),
new Vector2( opening.Right, top ) );
} )
.Where( loop => loop[2].x - loop[0].x > 0.01f && loop[2].y - loop[0].y > 0.01f ) ).ToList();
}
public static void BooleanReveals( ArchMesh canvas, IEnumerable<ArchOpening> openings, float half, float wallHeight, ArchBrush brush )
{
foreach ( var loop in BooleanRevealLoops( openings, wallHeight ) )
{
for ( var index = 0; index < loop.Count; index++ )
{
var from = loop[index];
var to = loop[(index + 1) % loop.Count];
var horizontal = MathF.Abs( from.y - to.y ) < 0.01f;
if ( horizontal && (MathF.Abs( from.y ) < 0.01f || MathF.Abs( from.y - wallHeight ) < 0.01f) )
{
continue;
}
canvas.Quad(
new Vector3( from.x, half, from.y ),
new Vector3( from.x, -half, from.y ),
new Vector3( to.x, -half, to.y ),
new Vector3( to.x, half, to.y ),
brush );
}
}
}
// The architrave round the MERGED hole, off the same loops the reveals line. Two cuts biting one wall
// overlap, and a frame per opening stands a jamb across the middle of the opening beside it.
public static List<(Vector2 Min, Vector2 Max)> BooleanCasingBoards( IEnumerable<ArchOpening> openings, ArchKit kit, float wallHeight )
{
var boards = new List<(Vector2 Min, Vector2 Max)>();
var cased = openings.Where( opening => opening.Cased ).ToList();
var authored = cased.Select( opening => opening.CasingWidth ).FirstOrDefault( value => value > 0.1f );
var width = authored > 0.1f ? authored : kit.CasingWidth;
if ( cased.Count == 0 || width <= 0.1f || kit.CasingDepth <= 0.05f )
{
return boards;
}
var loops = BooleanRevealLoops( cased, wallHeight );
var region = loops.Select( loop => (IReadOnlyList<Vector2>)loop ).ToList();
var bite = ArchContact.Bite( kit );
foreach ( var loop in loops )
{
for ( var index = 0; index < loop.Count; index++ )
{
if ( Architrave( region, loop[index], loop[(index + 1) % loop.Count], wallHeight, width, bite ) is { } board )
{
boards.Add( board );
}
}
}
return boards;
}
public static void BooleanCasing( ArchMesh canvas, IReadOnlyList<ArchOpening> openings, ArchKit kit, float half, float wallHeight, ArchBrush brush )
{
var boards = BooleanCasingBoards( openings, kit, wallHeight );
foreach ( var face in new[] { half, -half } )
{
var sign = Math.Sign( face );
var back = ArchContact.Bury( kit, face, sign );
var near = MathF.Min( back, face + sign * kit.CasingDepth );
var far = MathF.Max( back, face + sign * kit.CasingDepth );
foreach ( var board in boards )
{
canvas.Box(
new Vector3( board.Min.x, near, board.Min.y ),
new Vector3( board.Max.x, far, board.Max.y ),
brush );
}
}
}
// One board on one edge of the hole, lying outside it and lapping a bite over the arris. The head and
// the apron run past the jambs so the frame closes at its corners; an edge that dies into the floor or
// the wall head carries nothing, exactly as the reveal beside it does.
static (Vector2 Min, Vector2 Max)? Architrave(
IReadOnlyList<IReadOnlyList<Vector2>> region,
Vector2 from,
Vector2 to,
float wallHeight,
float width,
float bite )
{
var span = to - from;
var middle = (from + to) * 0.5f;
if ( span.Length < 0.01f )
{
return null;
}
var outward = new Vector2( span.y, -span.x ).Normal;
if ( ArchFootprint.Encloses( region, middle + outward * 0.1f ) )
{
outward = -outward;
}
var low = new Vector2( MathF.Min( from.x, to.x ), MathF.Min( from.y, to.y ) );
var high = new Vector2( MathF.Max( from.x, to.x ), MathF.Max( from.y, to.y ) );
if ( MathF.Abs( span.x ) < 0.01f )
{
var jamb = high.x + outward.x * width;
// A jamb reaching the floor line runs ON DOWN into the slab, because the well trim round the hole
// stands proud of it - held at the floor line the board ended in the air over its own nosing.
var foot = low.y < 0.01f ? -width : low.y - width;
return (new Vector2( MathF.Min( jamb, high.x - outward.x * bite ), foot ),
new Vector2( MathF.Max( jamb, high.x - outward.x * bite ), MathF.Min( wallHeight, high.y + width ) ));
}
if ( MathF.Abs( high.y ) < 0.01f || MathF.Abs( high.y - wallHeight ) < 0.01f )
{
return null;
}
var rail = high.y + outward.y * width;
return (new Vector2( low.x - width, MathF.Min( rail, high.y - outward.y * bite ) ),
new Vector2( high.x + width, MathF.Max( rail, high.y - outward.y * bite ) ));
}
public static void Reveals( ArchMesh canvas, ArchOpening opening, float half, float wallHeight, bool sill, ArchBrush brush )
{
var left = opening.Left;
var right = opening.Right;
var bottom = Math.Max( 0f, opening.SillHeight );
var top = Math.Min( wallHeight, opening.Top );
if ( top <= bottom )
{
return;
}
canvas.Quad(
new Vector3( left, -half, bottom ),
new Vector3( left, half, bottom ),
new Vector3( left, half, top ),
new Vector3( left, -half, top ),
brush );
canvas.Quad(
new Vector3( right, half, bottom ),
new Vector3( right, -half, bottom ),
new Vector3( right, -half, top ),
new Vector3( right, half, top ),
brush );
canvas.Quad(
new Vector3( left, half, top ),
new Vector3( right, half, top ),
new Vector3( right, -half, top ),
new Vector3( left, -half, top ),
brush );
// A sill's top face owns this plane; lining the underside too would z-fight.
if ( bottom > 0.01f && !sill )
{
canvas.Quad(
new Vector3( left, -half, bottom ),
new Vector3( right, -half, bottom ),
new Vector3( right, half, bottom ),
new Vector3( left, half, bottom ),
brush );
}
}
public static void Casing( ArchMesh canvas, ArchOpening opening, ArchKit kit, float half, float wallHeight, ArchBrush brush )
{
var width = opening.CasingWidth > 0f ? opening.CasingWidth : kit.CasingWidth;
var depth = kit.CasingDepth;
if ( width <= 0.1f || depth <= 0.05f )
{
return;
}
var left = opening.Left;
var right = opening.Right;
var bottom = Math.Max( 0f, opening.SillHeight );
var top = Math.Min( wallHeight, opening.Top );
var foot = Math.Max( 0f, bottom - width );
// The head is the heavy member: glazed heads run deeper and past the jambs.
var headWidth = opening.IsGlazed ? width * 1.7f : width;
var headDepth = opening.IsGlazed ? depth * 1.7f : depth;
var headRun = opening.IsGlazed ? width * 1.6f : width;
// Bitten into what it lands on: exact coplanar faces are most opening z-fighting.
var bite = ArchContact.Bite( kit );
foreach ( var face in new[] { half, -half } )
{
var sign = Math.Sign( face );
var back = ArchContact.Bury( kit, face, sign );
var near = Math.Min( back, face + sign * depth );
var far = Math.Max( back, face + sign * depth );
var headNear = Math.Min( back, face + sign * headDepth );
var headFar = Math.Max( back, face + sign * headDepth );
canvas.Box( new Vector3( left - width, near, foot ), new Vector3( left + bite, far, top ), brush );
canvas.Box( new Vector3( right - bite, near, foot ), new Vector3( right + width, far, top ), brush );
canvas.Box( new Vector3( left - headRun, headNear, top - bite ), new Vector3( right + headRun, headFar, top + headWidth ), brush );
if ( bottom > 0.01f )
{
canvas.Box( new Vector3( left, near, foot ), new Vector3( right, far, bottom + bite ), brush );
}
}
}
public static void Sill( ArchMesh canvas, ArchOpening opening, ArchKit kit, float half, ArchBrush brush )
{
var depth = kit.SillDepth;
var thickness = kit.SillThickness;
var overshoot = kit.CasingWidth;
var outward = opening.IsGlazed ? depth * 2.2f : depth;
canvas.Box(
new Vector3( opening.Left - overshoot, -half - outward, opening.SillHeight - thickness ),
new Vector3( opening.Right + overshoot, half + depth, opening.SillHeight ),
brush );
if ( !opening.IsGlazed )
{
return;
}
// The drip stops rain tracking back along the underside.
canvas.Box(
new Vector3( opening.Left - overshoot, -half - outward, opening.SillHeight - thickness - 1f ),
new Vector3( opening.Right + overshoot, -half - outward + 1.3f, opening.SillHeight - thickness ),
brush );
}
// What the kind may carry at all is ArchOpeningKinds' answer, reached through ArchOpening.Fitted; everything
// else here is kit stock, which is why no hole carries a bar pitch, a proud or a frame width of its own.
public static ArchOpeningGrille Grille( ArchOpening opening, ArchKit kit, float half, float wallHeight )
{
var fitted = opening.Fitted;
var boarded = fitted == OpeningFurniture.Boarded;
var bite = ArchContact.Bite( kit );
var proud = MathF.Max( bite, kit.GrilleProud );
var bottom = MathF.Max( 0f, opening.SillHeight );
var top = MathF.Min( wallHeight, opening.Top );
var span = ArchContact.Lapped( kit, opening.Left, opening.Right );
var rise = ArchContact.Lapped( kit, bottom, top );
return new ArchOpeningGrille
{
Furniture = fitted,
// A shutter is a curtain of slats rather than a grille, and the palette is told so.
Surface = fitted == OpeningFurniture.Shutter ? ArchSurface.Shutter : ArchSurface.Grille,
Upright = fitted is OpeningFurniture.Bars or OpeningFurniture.Gate,
// Boards are nailed straight across the hole; anything fitted inside one is welded into a frame.
Border = boarded ? 0f : MathF.Max( 0f, kit.GrilleFrame ),
Member = MathF.Max( 1f, boarded ? kit.FloorBoardWidth : kit.GrilleBar ),
Gap = MathF.Max( 0f, boarded ? kit.FloorBoardGap : kit.GrilleGap ),
// A gate hangs IN the frame it closes; a bar, a board and a shutter are fixed over the face of it.
Face = fitted == OpeningFurniture.Gate ? -half + bite : -half - proud,
Depth = proud + bite,
// Boarding laps past the jambs onto the wall it is nailed to; a framed fitting keeps a bite clear of
// the architrave that laps the other way into the hole.
Min = boarded ? new Vector2( span.From, rise.From ) : new Vector2( opening.Left + bite, bottom + bite ),
Max = boarded ? new Vector2( span.To, rise.To ) : new Vector2( opening.Right - bite, top - bite )
};
}
public static void Furniture( ArchMesh canvas, ArchOpening opening, ArchKit kit, float half, float wallHeight, ArchStyle style, ArchPalette[] chain )
{
var grille = Grille( opening, kit, half, wallHeight );
if ( !grille.Stands )
{
return;
}
var brush = style.Brush( grille.Surface, chain );
using ( canvas.Part( ArchPieces.Bars ) )
{
GrilleFrame( canvas, grille, kit, brush );
GrilleMembers( canvas, grille, brush );
}
}
// Head and apron run INTO the jambs by a bite, the way the architrave outside them does: members that merely
// butted would leave a coplanar pair at each corner of the frame.
static void GrilleFrame( ArchMesh canvas, ArchOpeningGrille grille, ArchKit kit, ArchBrush brush )
{
if ( grille.Border < 0.05f )
{
return;
}
var bite = ArchContact.Bite( kit );
var inner = grille.InnerMin;
var outer = grille.InnerMax;
var near = grille.Face;
var far = grille.Face + grille.Depth;
canvas.Box( new Vector3( grille.Min.x, near, grille.Min.y ), new Vector3( inner.x, far, grille.Max.y ), brush );
canvas.Box( new Vector3( outer.x, near, grille.Min.y ), new Vector3( grille.Max.x, far, grille.Max.y ), brush );
canvas.Box( new Vector3( inner.x - bite, near, grille.Min.y ), new Vector3( outer.x + bite, far, inner.y ), brush );
canvas.Box( new Vector3( inner.x - bite, near, outer.y ), new Vector3( outer.x + bite, far, grille.Max.y ), brush );
}
// Through ArchPlanks - the one board emitter - so a burglar bar and a floor board are the same solid, and the
// count comes off ArchDivide over the hole rather than a number typed against it.
static void GrilleMembers( ArchMesh canvas, ArchOpeningGrille grille, ArchBrush brush )
{
var inner = grille.InnerMin;
var outer = grille.InnerMax;
var region = new[] { (IReadOnlyList<Vector2>)ArchFootprint.Rect( inner, outer ) };
var spec = new ArchPlankSpec
{
Width = grille.Member,
Gap = grille.Gap,
Thickness = grille.Depth,
// Four times the diagonal, so no stagger joint can land inside the hole: a bar is one length of steel
// and a shutter slat runs the whole width of the curtain.
Length = (outer - inner).Length * 4f,
Top = grille.Face + grille.Depth,
Yaw = grille.Upright ? 90f : 0f,
Soffit = true
};
ArchPlanks.Fill( canvas, region, null, spec, brush, ArchPlankPlane.Upright );
}
// Slabs stop at the centreline; the saddle spans both faces, standing on the higher finish.
public static float Threshold( ArchMesh canvas, ArchOpening opening, ArchKit kit, float half, float floor, ArchBrush brush )
{
var proud = MathF.Max( 0f, kit.ThresholdProud );
if ( !Saddled( opening ) || proud < 0.05f )
{
return 0f;
}
var lap = MathF.Max( 0f, kit.ThresholdLap );
var top = floor + proud;
var span = ArchContact.Lapped( kit, opening.Left, opening.Right );
canvas.Box(
new Vector3( span.From, -half - lap, ArchContact.Bury( kit, 0f, 1f ) ),
new Vector3( span.To, half + lap, top ),
brush );
return top;
}
// Uncased holes are breaches; a garage's driveway runs over it, so neither saddles.
static bool Saddled( ArchOpening opening )
{
return opening.Cased && opening.SillHeight < 0.5f && opening.Kind != OpeningKind.Garage;
}
public static void Leaves( List<ArchDoorRequest> doors, ArchOpening opening, ArchWall wall, ArchKit kit, float saddle, ArchBrush brush )
{
if ( doors is null )
{
return;
}
// The leaf clears the saddle; standing on it would sweep the face as it swings.
var clearance = saddle + MathF.Max( 0f, kit.DoorUndercut );
var foot = opening.SillHeight + clearance;
var height = opening.Height - clearance;
var thickness = kit.DoorLeafThickness;
if ( height < 1f )
{
return;
}
if ( opening.Kind == OpeningKind.DoubleDoor )
{
var leaf = opening.Width * 0.5f;
doors.Add( Request( opening, wall, new Vector3( opening.Left, 0f, foot ), leaf, height, thickness, false, brush ) );
doors.Add( Request( opening, wall, new Vector3( opening.Right, 0f, foot ), leaf, height, thickness, true, brush ) );
return;
}
var hingeX = opening.FlipHinge ? opening.Right : opening.Left;
doors.Add( Request( opening, wall, new Vector3( hingeX, 0f, foot ), opening.Width, height, thickness, opening.FlipHinge, brush ) );
}
static ArchDoorRequest Request( ArchOpening opening, ArchWall wall, Vector3 hinge, float width, float height, float thickness, bool mirrored, ArchBrush brush )
{
return new ArchDoorRequest
{
Opening = opening,
Wall = wall,
HingeLocal = hinge,
LeafWidth = width,
LeafHeight = height,
LeafThickness = thickness,
Mirrored = mirrored,
Brush = brush
};
}
}