Editor-side elevation cutter and representation for architectural pieces. Computes a 2D elevation (front/side) slice through an ArchBuilding, producing pieces (walls, openings, floors, roofs, platforms, cuts) with sampled profiles for raked surfaces; provides methods to test intersections, sample profiles, and query which piece is at a point.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// The role colours the piece; Item is what the pick highlights against.
public enum ArchElevationRole
{
Wall,
Opening,
Floor,
Platform,
Cut,
Roof
}
public sealed class ArchElevationPiece
{
public ArchElevationRole Role { get; init; }
public object Item { get; init; }
public int Floor { get; init; }
// Along the drawn axis - y in Front, x in Side.
public float From { get; init; }
public float To { get; init; }
public float Foot { get; init; }
public float Head { get; init; }
// Raking samples of the two surfaces, station for station; both null when the piece is a plain band, which is
// nearly everything. Carried rather than derived from one and a thickness, because a WEDGE has no thickness:
// a ramp's underside is level while its top climbs, and a section drawn off an envelope box says slab.
public List<Vector2> Profile { get; init; }
public List<Vector2> Soffit { get; init; }
public bool Rakes => Profile is { Count: >= 2 } && Soffit is { Count: >= 2 };
}
// Front cuts at x and draws in y; Side the reverse. Every plan-to-drawn conversion goes through here - backwards is plausible and wrong.
public readonly struct ArchElevationPlane
{
public ArchViewAxis Axis { get; init; }
public float Depth { get; init; }
public bool IsElevation => Axis is ArchViewAxis.Front or ArchViewAxis.Side;
public float Across( Vector2 point ) => Axis == ArchViewAxis.Front ? point.y : point.x;
public float Along( Vector2 point ) => Axis == ArchViewAxis.Front ? point.x : point.y;
public Vector2 Plan( float across ) => Axis == ArchViewAxis.Front ? new Vector2( Depth, across ) : new Vector2( across, Depth );
public Vector3 At( float across, float height )
{
var plan = Plan( across );
return new Vector3( plan.x, plan.y, height );
}
}
// Resolves ONE building, because that is what the overlay draws, and EVERY storey, because a section exists to show the floors stack.
public static class ArchElevation
{
const float Grain = ArchCarve.Grain;
// Fine enough that a deck break lands near a sample, coarse enough that a long roof stays cheap.
const int Samples = 48;
// The plan and the kit, not the tool: the whole section resolve is answerable without a viewport, and asking
// for one is what kept it out of the harness.
public static ArchElevationCut Cut( ArchPlan plan, ArchKit kit, ArchBuilding building, ArchViewAxis axis, float depth )
{
var plane = new ArchElevationPlane { Axis = axis, Depth = depth };
var pieces = new List<ArchElevationPiece>();
if ( building is not null && plane.IsElevation )
{
var lift = ArchAsks.Lift( plan, building, kit );
foreach ( var room in building.Rooms )
{
Rooms( pieces, plane, room, lift, kit );
}
foreach ( var roof in building.Roofs )
{
Roofs( pieces, plane, roof, lift );
}
foreach ( var platform in building.Platforms )
{
Solid( pieces, plane, platform.Outline(), ArchElevationRole.Platform, platform, platform.Level,
ArchCarvePlane.Level( platform.GradeHeight ), ArchRamp.Deck( platform ) );
}
foreach ( var cut in building.Cuts )
{
foreach ( var segment in cut.Legs() )
{
Solid( pieces, plane, segment.Outline(), ArchElevationRole.Cut, cut, cut.Level,
ArchCut.Floor( cut, segment ), ArchCarvePlane.Level( segment.TopHeight ) );
}
}
}
return new ArchElevationCut
{
Building = building,
Axis = axis,
Depth = depth,
Pieces = pieces
};
}
static void Rooms( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchRoom room, float lift, ArchKit kit )
{
var floor = room.BaseHeight + lift;
if ( room.HasFloor )
{
Add( pieces, plane, ArchFloorGen.Footprint( room ), ArchElevationRole.Floor, room, room.Floor,
floor - MathF.Max( 1f, kit.FloorThickness ), floor );
}
foreach ( var wall in room.Walls )
{
if ( wall.Length < 0.5f )
{
continue;
}
var head = floor + ArchWallSection.Height( wall, room, kit );
if ( !Add( pieces, plane, ArchWallSection.Band( wall, kit ), ArchElevationRole.Wall, wall, room.Floor, floor, head ) )
{
continue;
}
Openings( pieces, plane, wall, room, floor, kit );
}
}
// Only the plane through the HOLE shows the opening - an inch over shows solid wall.
static void Openings( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchWall wall, ArchRoom room, float floor, ArchKit kit )
{
if ( !Meets( wall, plane, out var along ) )
{
return;
}
foreach ( var opening in wall.Openings.Where( entry => along >= entry.Left && along <= entry.Right ) )
{
Add( pieces, plane, ArchWallSection.Band( wall, kit ), ArchElevationRole.Opening, opening, room.Floor,
floor + opening.SillHeight, floor + opening.Top );
}
}
// A wall parallel to the plane never crosses it - the case a straight interpolation would divide by zero on.
static bool Meets( ArchWall wall, ArchElevationPlane plane, out float along )
{
along = 0f;
var start = plane.Along( wall.Start ) - plane.Depth;
var end = plane.Along( wall.End ) - plane.Depth;
if ( MathF.Abs( start - end ) < Grain )
{
return false;
}
var fraction = start / (start - end);
if ( fraction < 0f || fraction > 1f )
{
return false;
}
along = fraction * wall.Length;
return true;
}
static void Roofs( List<ArchElevationPiece> pieces, ArchElevationPlane plane, ArchRoofPart roof, float lift )
{
var outline = roof.Outline();
if ( !Crosses( outline, plane, out var from, out var to ) )
{
return;
}
var thickness = MathF.Max( 1f, roof.Thickness );
var profile = Sampled( plane, from, to, across => ArchRoofPlane.At( roof, plane.Plan( across ) ) + lift );
var soffit = profile.Select( sample => sample.WithY( sample.y - thickness ) ).ToList();
Raking( pieces, ArchElevationRole.Roof, roof, roof.Level, from, to, soffit, profile );
}
// A body between two SURFACES. Level on both, it is the plain band every other piece is; raked on either, it
// carries both traces so a section draws the wedge rather than the box it fits inside.
static void Solid(
List<ArchElevationPiece> pieces,
ArchElevationPlane plane,
IReadOnlyList<Vector2> loop,
ArchElevationRole role,
object item,
int floor,
ArchCarvePlane under,
ArchCarvePlane over )
{
if ( !under.Rakes && !over.Rakes )
{
Add( pieces, plane, loop, role, item, floor, under.Datum, over.Datum );
return;
}
if ( !Crosses( loop, plane, out var from, out var to ) )
{
return;
}
Raking( pieces, role, item, floor, from, to,
Sampled( plane, from, to, across => under.At( plane.Plan( across ) ) ),
Sampled( plane, from, to, across => over.At( plane.Plan( across ) ) ) );
}
static void Raking(
List<ArchElevationPiece> pieces,
ArchElevationRole role,
object item,
int floor,
float from,
float to,
List<Vector2> soffit,
List<Vector2> profile )
{
pieces.Add( new ArchElevationPiece
{
Role = role,
Item = item,
Floor = floor,
From = from,
To = to,
Foot = soffit.Min( point => point.y ),
Head = profile.Max( point => point.y ),
Profile = profile,
Soffit = soffit
} );
}
static List<Vector2> Sampled( ArchElevationPlane plane, float from, float to, Func<float, float> height )
{
var samples = new List<Vector2>();
for ( var step = 0; step <= Samples; step++ )
{
var across = MathX.Lerp( from, to, step / (float)Samples );
samples.Add( new Vector2( across, height( across ) ) );
}
return samples;
}
static bool Add(
List<ArchElevationPiece> pieces,
ArchElevationPlane plane,
IReadOnlyList<Vector2> loop,
ArchElevationRole role,
object item,
int floor,
float foot,
float head )
{
if ( !Crosses( loop, plane, out var from, out var to ) )
{
return false;
}
pieces.Add( new ArchElevationPiece
{
Role = role,
Item = item,
Floor = floor,
From = from,
To = to,
Foot = MathF.Min( foot, head ),
Head = MathF.Max( foot, head )
} );
return true;
}
// An edge lying ON the plane contributes both ends, or a wall exactly at the section depth vanishes.
public static bool Crosses( IReadOnlyList<Vector2> loop, ArchElevationPlane plane, out float from, out float to )
{
from = float.MaxValue;
to = float.MinValue;
if ( loop is not { Count: >= 2 } )
{
return false;
}
for ( var index = 0; index < loop.Count; index++ )
{
var a = loop[index];
var b = loop[(index + 1) % loop.Count];
var here = plane.Along( a ) - plane.Depth;
var next = plane.Along( b ) - plane.Depth;
if ( MathF.Abs( here ) < Grain && MathF.Abs( next ) < Grain )
{
Take( ref from, ref to, plane.Across( a ) );
Take( ref from, ref to, plane.Across( b ) );
continue;
}
if ( here > 0f == next > 0f )
{
continue;
}
Take( ref from, ref to, MathX.Lerp( plane.Across( a ), plane.Across( b ), here / (here - next) ) );
}
return to - from > Grain;
}
static void Take( ref float from, ref float to, float value )
{
from = MathF.Min( from, value );
to = MathF.Max( to, value );
}
}
// Held between frames - re-cutting the whole house sixty times a second is the resolve the overlay refuses.
public sealed class ArchElevationCut
{
public ArchBuilding Building { get; init; }
public ArchViewAxis Axis { get; init; }
public float Depth { get; init; }
public List<ArchElevationPiece> Pieces { get; init; } = new();
public ArchElevationPlane Plane => new() { Axis = Axis, Depth = Depth };
// Smallest area first, so a door beats its wall - the more specific thing wins.
public ArchElevationPiece At( float across, float height )
{
return Pieces
.Where( piece => across >= piece.From && across <= piece.To && height >= piece.Foot && height <= piece.Head )
.OrderBy( piece => (piece.To - piece.From) * (piece.Head - piece.Foot) )
.FirstOrDefault();
}
public bool Matches( ArchBuilding building, ArchViewAxis axis, float depth )
{
return ReferenceEquals( Building, building ) && Axis == axis && MathF.Abs( Depth - depth ) < 0.01f;
}
}