Editor/UI/ArchPresetPreview.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
public enum ArchPreviewFocus {
Unit,
Whole
}
public readonly record struct ArchPreviewShot( Vector2 Size, Angles View, ArchPreviewFocus Focus, BBox? Interest );
public interface IArchPreviewWatcher {
bool Alive { get; }
void PreviewTick( bool advanced );
}
// Must be non-generic — the event system keys by open type definition
public static class ArchPreviewPump {
static readonly List<IArchPreviewWatcher> watchers = new();
public static void Watch( IArchPreviewWatcher watcher ) {
watchers.Add( watcher );
}
[EditorEvent.Frame]
public static void Frame() {
watchers.RemoveAll( watcher => !watcher.Alive );
if ( watchers.Count == 0 ) {
return;
}
var advanced = ArchPresetPreview.Pump();
foreach ( var watcher in watchers.ToList() ) {
watcher.PreviewTick( advanced );
}
}
}
public static class ArchPresetPreview {
// Bumped to invalidate the preview cache
const int Recipe = 6;
const float Padding = 1.06f;
public static readonly Angles Elevation = new( 0f, 90f, 0f );
public static readonly Angles Quarter = new( 20f, 52f, 0f );
// Underside view for cornices and crowns
public static readonly Angles Raised = new( -14f, 52f, 0f );
sealed class Pending {
public string Key { get; init; }
public ArchPreviewShot Shot { get; init; }
public ArchKit Kit { get; init; }
public Func<ArchStaged> Compose { get; init; }
}
static readonly Dictionary<string, Pixmap> cache = new();
static readonly HashSet<string> failed = new();
static readonly List<Pending> queue = new();
// Queues on miss to avoid stalling when many cards open at once
public static Pixmap For( string identity, ArchPreviewShot shot, ArchKit kit, Func<ArchStaged> compose ) {
var key = $"{Recipe}|{identity}|{shot.Size.x:0}x{shot.Size.y:0}|{shot.View}|{shot.Focus}|{shot.Interest?.Size}";
if ( cache.TryGetValue( key, out var hit ) ) {
return hit;
}
if ( failed.Contains( key ) || queue.Any( entry => entry.Key == key ) ) {
return null;
}
queue.Add( new Pending { Key = key, Shot = shot, Kit = kit, Compose = compose } );
return null;
}
public static bool Pump() {
if ( queue.Count == 0 ) {
return false;
}
var next = queue[0];
queue.RemoveAt( 0 );
var pixmap = Render( next );
if ( pixmap is null ) {
failed.Add( next.Key );
return true;
}
cache[next.Key] = pixmap;
return true;
}
public static void Invalidate( string identity = null ) {
queue.Clear();
if ( string.IsNullOrWhiteSpace( identity ) ) {
cache.Clear();
failed.Clear();
return;
}
foreach ( var key in cache.Keys.Where( key => key.Contains( $"|{identity}|" ) ).ToList() ) {
cache.Remove( key );
}
foreach ( var key in failed.Where( key => key.Contains( $"|{identity}|" ) ).ToList() ) {
failed.Remove( key );
}
}
static Pixmap Render( Pending request ) {
Scene scene = null;
try {
var staged = request.Compose();
if ( staged.Plan is null ) {
return null;
}
scene = Scene.CreateEditorScene();
using ( scene.Push() ) {
Staged( scene, staged, request.Kit, request.Shot );
Light( scene );
if ( !Bounds( scene, out var bounds ) ) {
return null;
}
var camera = Aim( scene, request.Shot.View, bounds, request.Shot.Size.x / request.Shot.Size.y );
var pixmap = new Pixmap( (int)request.Shot.Size.x, (int)request.Shot.Size.y );
if ( !camera.RenderToPixmap( pixmap ) ) {
return null;
}
try {
Edges( scene, camera, pixmap );
} catch ( Exception exception ) {
Log.Warning( $"Architecture: could not line {request.Key}: {exception.Message}" );
}
return pixmap;
}
} catch ( Exception exception ) {
Log.Warning( $"Architecture: could not preview {request.Key}: {exception.Message}" );
return null;
} finally {
scene?.Destroy();
}
}
public static List<ArchSurface> Staged( Scene scene, ArchStaged staged, ArchKit kit, ArchPreviewShot shot ) {
Dress( scene, staged.Plan, kit );
if ( shot.Focus == ArchPreviewFocus.Unit ) {
Strip( scene, shot.Interest );
}
return Standing( scene );
}
static List<ArchSurface> Standing( Scene scene ) {
var roles = ArchBlockout.Roles.ToDictionary(
pair => ArchBlockout.PathFor( pair.Role ),
pair => pair.Role,
StringComparer.OrdinalIgnoreCase );
return scene.GetAllObjects( true )
.Select( node => node.Components.Get<MeshComponent>() )
.Where( piece => piece is { Mesh: not null } )
.SelectMany( piece => piece.Mesh.FaceHandles.Select( face => piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
.Where( path => path is not null && roles.ContainsKey( path ) )
.Select( path => roles[path] )
.Distinct()
.ToList();
}
// Blockout palette so roles are recoverable from materials for stripping
static void Dress( Scene scene, ArchPlan plan, ArchKit kit ) {
var dressed = kit.Palette;
try {
kit.Palette = Grid();
ArchScene.Generate( scene, plan, kit );
} finally {
kit.Palette = dressed;
}
}
static ArchPalette Grid() {
if ( grid is not null ) {
return grid;
}
grid = new ArchPalette();
ArchBlockout.Apply( grid );
return grid;
}
static ArchPalette grid;
static readonly ArchSurface[] Scaffolding =
{
ArchSurface.WallExterior,
ArchSurface.WallInterior,
ArchSurface.WallCap,
ArchSurface.WallBase,
ArchSurface.Siding,
ArchSurface.Wainscot,
ArchSurface.Baseboard,
ArchSurface.Foundation,
ArchSurface.Floor,
ArchSurface.Ceiling
};
static void Strip( Scene scene, BBox? interest ) {
var dropped = Scaffolding.Select( ArchBlockout.PathFor ).ToHashSet( StringComparer.OrdinalIgnoreCase );
foreach ( var node in scene.GetAllObjects( true ).ToList() ) {
if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
continue;
}
var scaffold = piece.Mesh.FaceHandles
.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath )
|| Outside( piece, face, interest ) )
.ToList();
// On the openings piece, keep reveal faces if stripping would remove everything
if ( scaffold.Count == piece.Mesh.FaceHandles.Count() && node.Name == ArchPieces.Openings ) {
scaffold = piece.Mesh.FaceHandles
.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
.ToList();
}
if ( scaffold.Count == 0 ) {
continue;
}
piece.Mesh.RemoveFaces( scaffold );
if ( !piece.Mesh.FaceHandles.Any() ) {
if ( node.Children.Count == 0 ) {
node.Destroy();
}
continue;
}
piece.RebuildMesh();
}
}
const float Crease = 8f;
static void Edges( Scene scene, CameraComponent camera, Pixmap pixmap ) {
var rotation = camera.WorldRotation;
var origin = camera.WorldPosition;
var scale = pixmap.Height / camera.OrthographicHeight;
var middle = new Vector2( pixmap.Width, pixmap.Height ) * 0.5f;
Vector2 Project( Vector3 world ) {
var local = rotation.Inverse * (world - origin);
return new Vector2( middle.x - local.y * scale, middle.y - local.z * scale );
}
var forward = rotation.Forward;
var lines = new List<(Vector2 From, Vector2 To)>();
foreach ( var node in scene.GetAllObjects( true ) ) {
if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
continue;
}
var mesh = piece.Mesh;
var placed = piece.WorldTransform;
foreach ( var edge in mesh.HalfEdgeHandles ) {
if ( !edge.IsValid || !edge.Face.IsValid ) {
continue;
}
var opposite = edge.OppositeEdge;
if ( opposite.IsValid && opposite.Face.IsValid && opposite.Index < edge.Index ) {
continue;
}
if ( !Feature( mesh, edge, opposite, placed, forward ) ) {
continue;
}
mesh.GetEdgeVertices( edge, out var from, out var to );
lines.Add( (
Project( placed.PointToWorld( mesh.GetVertexPosition( from ) ) ),
Project( placed.PointToWorld( mesh.GetVertexPosition( to ) ) )) );
}
}
if ( lines.Count == 0 ) {
return;
}
using ( Paint.ToPixmap( pixmap ) ) {
Paint.Antialiasing = true;
Paint.ClearBrush();
Paint.SetPen( Color.Black.WithAlpha( 0.55f ), 1f );
foreach ( var line in lines ) {
Paint.DrawLine( line.From, line.To );
}
}
}
// Boundary, silhouette or crease — skips back-facing pairs
static bool Feature( PolygonMesh mesh, HalfEdgeMesh.HalfEdgeHandle edge, HalfEdgeMesh.HalfEdgeHandle opposite, Transform placed, Vector3 forward ) {
mesh.ComputeFaceNormal( edge.Face, out var near );
var facing = Vector3.Dot( placed.NormalToWorld( near ), forward ) < 0f;
if ( !opposite.IsValid || !opposite.Face.IsValid || opposite.Face == edge.Face ) {
return facing;
}
mesh.ComputeFaceNormal( opposite.Face, out var far );
var behind = Vector3.Dot( placed.NormalToWorld( far ), forward ) < 0f;
if ( !facing && !behind ) {
return false;
}
return facing != behind || Vector3.GetAngle( near, far ) > Crease;
}
static void Light( Scene scene ) {
var key = new GameObject( true, "key" );
key.SetParent( scene );
var sun = key.Components.GetOrCreate<DirectionalLight>();
sun.WorldRotation = Rotation.From( 44f, -142f, 0f );
sun.LightColor = Color.White * 3.4f;
sun.SkyColor = new Color( 0.4f, 0.45f, 0.55f ) * 2.4f;
sun.Shadows = false;
var fill = new GameObject( true, "fill" );
fill.SetParent( scene );
var bounce = fill.Components.GetOrCreate<DirectionalLight>();
bounce.WorldRotation = Rotation.From( -12f, 38f, 0f );
bounce.LightColor = Color.White * 1.2f;
bounce.SkyColor = Color.Black;
bounce.Shadows = false;
}
// Judged on face centre — corners can extend past interest box legitimately
static bool Outside( MeshComponent piece, HalfEdgeMesh.FaceHandle face, BBox? interest ) {
if ( interest is not { } box ) {
return false;
}
return !box.Contains( piece.WorldTransform.PointToWorld( piece.Mesh.GetFaceCenter( face ) ) );
}
public static BBox? Framed( Scene scene ) => Bounds( scene, out var bounds ) ? bounds : null;
public static float Fills( Scene scene, ArchPreviewShot shot ) {
if ( !Bounds( scene, out var bounds ) ) {
return 0f;
}
var camera = Aim( scene, shot.View, bounds, shot.Size.x / shot.Size.y );
var top = camera.PointToScreenNormal( bounds.Center + Vector3.Up * bounds.Size.z * 0.5f ).y;
var bottom = camera.PointToScreenNormal( bounds.Center - Vector3.Up * bounds.Size.z * 0.5f ).y;
return MathF.Abs( bottom - top );
}
static bool Bounds( Scene scene, out BBox bounds ) {
var found = false;
bounds = default;
foreach ( var node in scene.GetAllObjects( true ) ) {
if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
continue;
}
var mesh = piece.Mesh;
var placed = piece.WorldTransform;
// Walk faces not half-edges — removed faces leave stale half-edges
foreach ( var face in mesh.FaceHandles ) {
foreach ( var vertex in mesh.GetFaceVertices( face ) ) {
var point = placed.PointToWorld( mesh.GetVertexPosition( vertex ) );
bounds = found ? bounds.AddPoint( point ) : BBox.FromPositionAndSize( point, 0f );
found = true;
}
}
}
return found;
}
static CameraComponent Aim( Scene scene, Angles view, BBox bounds, float aspect ) {
var node = new GameObject( true, "camera" );
node.SetParent( scene );
var camera = node.Components.GetOrCreate<CameraComponent>();
camera.BackgroundColor = new Color( 0.085f, 0.09f, 0.1f );
camera.Orthographic = true;
camera.OrthographicHeight = Extent( bounds, view.ToRotation(), aspect ) * Padding;
camera.ZNear = 1f;
camera.ZFar = 40000f;
var reach = bounds.Size.Length + 512f;
node.WorldRotation = view.ToRotation();
node.WorldPosition = bounds.Center - node.WorldRotation.Forward * reach;
return camera;
}
static float Extent( BBox bounds, Rotation rotation, float aspect ) {
var needed = 0f;
foreach ( var corner in bounds.Corners ) {
var local = rotation.Inverse * (corner - bounds.Center);
needed = MathF.Max( needed, MathF.Max( MathF.Abs( local.y ) / aspect, MathF.Abs( local.z ) ) );
}
return MathF.Max( 1f, needed * 2f );
}
}