Editor-side terrain utilities for the architecture system. Defines ArchGround (a probe wrapper and sampler) and static ArchTerrain which reads and edits a scene Terrain heightmap, supports sampling, ray-ground intersection, lifting (Headroom) and flattening/stamping terrain around buildings, drives and roads.
using System;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// The probe comes from ArchScene - generators cannot reach the scene; a missing probe falls back to the caller's datum.
public readonly struct ArchGround
{
readonly Scene scene;
readonly Func<Vector2, float?> probe;
public ArchGround( Scene scene )
{
this.scene = scene;
probe = null;
}
ArchGround( Func<Vector2, float?> probe )
{
scene = null;
this.probe = probe;
}
// For tests: the harness has no terrain, so down-standing geometry could never be measured without this.
public static ArchGround Sampled( Func<Vector2, float?> probe ) => new( probe );
// Buried below grade: a footing ending exactly on it floats the moment the terrain moves.
public static float Embedment( ArchKit kit ) => MathF.Max( 2f, kit?.PierEmbedment ?? 12f );
public float Under( Vector2 point, float datum )
{
if ( probe is not null )
{
return probe( point ) ?? datum;
}
return ArchTerrain.Sample( scene, point, out var height ) ? height : datum;
}
}
// Edits the heightmap in place - no ctrl+z - so stamping is always an explicit action, never the rebuild.
public static class ArchTerrain
{
const float RelookDelay = 1f;
// Bumped whenever the heightmap is written, so anything holding a height read off it knows to ask again.
public static int Stamped { get; private set; }
static Terrain field;
static Scene fieldScene;
static RealTimeSince looked;
// Found once and kept: a marching ray used to re-walk the whole scene per step; no terrain re-looks on a delay.
static Terrain Field( Scene scene )
{
if ( fieldScene == scene && (field.IsValid() || looked < RelookDelay) )
{
return field.IsValid() ? field : null;
}
fieldScene = scene;
field = scene?.GetAllComponents<Terrain>().FirstOrDefault();
looked = 0f;
return field;
}
// Draping reads bilinear: a node snapped to the nearest sample steps every time the mouse crosses a cell.
public static bool Sample( Scene scene, Vector2 world, out float height )
{
height = 0f;
var terrain = Field( scene );
if ( !terrain.IsValid() || terrain.Storage is null )
{
return false;
}
var storage = terrain.Storage;
var resolution = storage.Resolution;
var map = storage.HeightMap;
if ( resolution < 2 || map is null || map.Length < resolution * resolution )
{
return false;
}
var origin = terrain.WorldPosition;
var step = storage.TerrainSize / (resolution - 1);
var maxHeight = MathF.Max( 1f, storage.TerrainHeight );
var fx = Math.Clamp( (world.x - origin.x) / step, 0f, resolution - 1.001f );
var fy = Math.Clamp( (world.y - origin.y) / step, 0f, resolution - 1.001f );
var x = (int)fx;
var y = (int)fy;
var lower = MathX.Lerp( Height( map, resolution, x, y ), Height( map, resolution, x + 1, y ), fx - x );
var upper = MathX.Lerp( Height( map, resolution, x, y + 1 ), Height( map, resolution, x + 1, y + 1 ), fx - x );
height = origin.z + MathX.Lerp( lower, upper, fy - y ) * maxHeight;
return true;
}
// Marched over the heightmap, not collision: sculpted terrain has none, and cursor and drape must agree.
public static bool Ground( Scene scene, Ray ray, out Vector3 hit )
{
hit = default;
var terrain = Field( scene );
if ( !terrain.IsValid() || terrain.Storage is null || MathF.Abs( ray.Forward.z ) < 0.0001f )
{
return false;
}
var origin = terrain.WorldPosition;
var size = terrain.Storage.TerrainSize;
var step = MathF.Max( 16f, size / 1024f );
var reach = size * 3f;
var above = 0f;
for ( var travelled = step; travelled <= reach; travelled += step )
{
if ( !Under( scene, ray, travelled, origin, size ) )
{
above = travelled;
continue;
}
hit = Surface( scene, ray, above, travelled, origin, size );
return true;
}
return false;
}
// Halved down until the crossing is under an inch wide, which is finer than the grid snap that reads it.
static Vector3 Surface( Scene scene, Ray ray, float above, float below, Vector3 origin, float size )
{
for ( var pass = 0; pass < 16 && below - above > 1f; pass++ )
{
var middle = (above + below) * 0.5f;
if ( Under( scene, ray, middle, origin, size ) )
{
below = middle;
continue;
}
above = middle;
}
return ray.Position + ray.Forward * below;
}
// Outside the footprint nothing is under: Sample clamps to the edge, so the horizon must not count.
static bool Under( Scene scene, Ray ray, float travelled, Vector3 origin, float size )
{
var at = ray.Position + ray.Forward * travelled;
if ( at.x < origin.x || at.y < origin.y || at.x > origin.x + size || at.y > origin.y + size )
{
return false;
}
return Sample( scene, new Vector2( at.x, at.y ), out var height ) && at.z < height;
}
static float Height( ushort[] map, int resolution, int x, int y )
{
var index = Math.Clamp( y, 0, resolution - 1 ) * resolution + Math.Clamp( x, 0, resolution - 1 );
return map[index] / (float)ushort.MaxValue;
}
// What a stamp needs UNDER the ground to work with. A heightmap sample cannot go below zero, so terrain left at
// the bottom of its range has nowhere for a road to be carved into and the stamp reads as doing nothing.
public const float Reserve = 64f;
// The whole heightmap lifted and the terrain dropped by the same amount: the surface stays exactly where it is
// in the world - nothing standing on it moves - and the room appears beneath it instead.
public static int Headroom( Scene scene )
{
var terrain = Field( scene );
if ( !terrain.IsValid() || terrain.Storage is null )
{
Log.Warning( "Architecture: no Terrain component in this scene to give headroom." );
return 0;
}
var storage = terrain.Storage;
var resolution = storage.Resolution;
var map = storage.HeightMap;
if ( resolution < 2 || map is null || map.Length < resolution * resolution )
{
Log.Warning( "Architecture: terrain has no usable heightmap." );
return 0;
}
var samples = resolution * resolution;
var maxHeight = MathF.Max( 1f, storage.TerrainHeight );
var wanted = (int)MathF.Ceiling( Reserve / maxHeight * ushort.MaxValue );
var lowest = ushort.MaxValue;
var highest = (ushort)0;
for ( var index = 0; index < samples; index++ )
{
lowest = Math.Min( lowest, map[index] );
highest = Math.Max( highest, map[index] );
}
if ( lowest >= wanted )
{
Log.Info( $"Architecture: the ground already stands {lowest / (float)ushort.MaxValue * maxHeight:0.#} units above the bottom of its range." );
return 0;
}
var lift = wanted - lowest;
// Clamping instead would flatten the peaks to buy room at the bottom, which is a map quietly ruined.
if ( highest + lift > ushort.MaxValue )
{
Log.Warning( $"Architecture: this terrain's height range is too full to lift - raise its Terrain Height above {maxHeight:0.#} first." );
return 0;
}
for ( var index = 0; index < samples; index++ )
{
map[index] = (ushort)(map[index] + lift);
}
var risen = lift / (float)ushort.MaxValue * maxHeight;
terrain.WorldPosition = terrain.WorldPosition.WithZ( terrain.WorldPosition.z - risen );
terrain.SyncGPUTexture();
terrain.UpdateCollision( Terrain.SyncFlags.Height, new RectInt( 0, 0, resolution, resolution ) );
Stamped++;
Log.Info( $"Architecture: opened {risen:0.#} units under the ground - the surface has not moved, and a stamp can now cut into it." );
return samples;
}
public static int Flatten( Scene scene, ArchPlan plan, ArchKit kit )
{
var terrain = Field( scene );
if ( !terrain.IsValid() || terrain.Storage is null )
{
Log.Warning( "Architecture: no Terrain component in this scene to flatten." );
return 0;
}
var storage = terrain.Storage;
var resolution = storage.Resolution;
var map = storage.HeightMap;
if ( resolution < 2 || map is null || map.Length < resolution * resolution )
{
Log.Warning( "Architecture: terrain has no usable heightmap." );
return 0;
}
var size = storage.TerrainSize;
var maxHeight = MathF.Max( 1f, storage.TerrainHeight );
var origin = terrain.WorldPosition;
var step = size / (resolution - 1);
var padding = MathF.Max( 0f, kit.TerrainPadding );
// Drives first, roads second: a drive runs out over the verge, and that stretch of ground belongs to the road.
var terrainRequest = new ArchTerrainRequest( terrain, plan, kit, map, resolution, origin, step, maxHeight );
var contributions = ArchTerrainContributions.Load();
var stamped = contributions.Raise( terrainRequest );
stamped += contributions.Carve( terrainRequest );
// Cuttings after the roads: a cutting only ever lowers what the road carve left.
stamped += contributions.Cut( terrainRequest );
foreach ( var building in plan.Buildings )
{
if ( !Footprint( building, out var min, out var max, out var pad ) )
{
continue;
}
var target = (pad - origin.z) / maxHeight * ushort.MaxValue;
var clamped = (ushort)Math.Clamp( target, 0f, ushort.MaxValue );
var lo = Cell( min - new Vector2( padding, padding ), origin, step, resolution );
var hi = Cell( max + new Vector2( padding, padding ), origin, step, resolution );
for ( var y = lo.y; y <= hi.y; y++ )
{
for ( var x = lo.x; x <= hi.x; x++ )
{
var world = new Vector2( origin.x + x * step, origin.y + y * step );
var weight = Weight( world, min, max, padding );
if ( weight <= 0f )
{
continue;
}
var index = y * resolution + x;
var current = map[index];
map[index] = (ushort)Math.Clamp( current + (clamped - current) * weight, 0f, ushort.MaxValue );
stamped++;
}
}
}
if ( stamped == 0 )
{
return 0;
}
terrain.SyncGPUTexture();
terrain.UpdateCollision( Terrain.SyncFlags.Height, new RectInt( 0, 0, resolution, resolution ) );
Stamped++;
Log.Info( $"Architecture: stamped {stamped} terrain samples under buildings, roads and drives." );
return stamped;
}
static bool Footprint( ArchBuilding building, out Vector2 min, out Vector2 max, out float pad )
{
min = new Vector2( float.MaxValue, float.MaxValue );
max = new Vector2( float.MinValue, float.MinValue );
pad = 0f;
var ground = building.Rooms.Where( room => room.Floor == building.Rooms.Min( entry => entry.Floor ) ).ToList();
if ( ground.Count == 0 )
{
return false;
}
var found = false;
foreach ( var room in ground )
{
foreach ( var point in ArchFloorGen.Footprint( room ) )
{
min = new Vector2( MathF.Min( min.x, point.x ), MathF.Min( min.y, point.y ) );
max = new Vector2( MathF.Max( max.x, point.x ), MathF.Max( max.y, point.y ) );
found = true;
}
}
pad = ground.Min( room => room.BaseHeight );
return found;
}
static (int x, int y) Cell( Vector2 world, Vector3 origin, float step, int resolution )
{
var x = (int)MathF.Round( (world.x - origin.x) / step );
var y = (int)MathF.Round( (world.y - origin.y) / step );
return (Math.Clamp( x, 0, resolution - 1 ), Math.Clamp( y, 0, resolution - 1 ));
}
// Full strength inside the footprint, easing to nothing across the padding skirt.
static float Weight( Vector2 point, Vector2 min, Vector2 max, float padding )
{
var outside = new Vector2(
MathF.Max( MathF.Max( min.x - point.x, point.x - max.x ), 0f ),
MathF.Max( MathF.Max( min.y - point.y, point.y - max.y ), 0f ) );
var distance = outside.Length;
if ( distance <= 0.001f )
{
return 1f;
}
if ( padding <= 0.001f || distance >= padding )
{
return 0f;
}
return Ease( 1f - distance / padding );
}
static float Ease( float t )
{
var clamped = Math.Clamp( t, 0f, 1f );
return clamped * clamped * (3f - 2f * clamped);
}
}