Editor UI helper for architecture preset thumbnails. It stages an ArchPlan into an editor Scene, strips scaffolding, frames and lights it, renders to a Pixmap and draws feature edges; it also caches generated pixmaps and pumps a queue one item per frame.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// An opening is a unit CUT INTO a wall, so its card throws the wall away; a wall treatment IS the wall,
// so its card keeps it. One flag, because the staging is otherwise identical.
public enum ArchPreviewFocus
{
Unit,
Whole
}
// Interest is the box the unit actually occupies, and only the recipe that staged it knows: a wall's END
// CAPS are cut faces and carry the Reveal role, so role alone cannot tell the jamb beside a window from
// the cap 100 units away at the end of the staging run.
public readonly record struct ArchPreviewShot( Vector2 Size, Angles View, ArchPreviewFocus Focus, BBox? Interest );
public interface IArchPreviewWatcher
{
bool Alive { get; }
void PreviewTick( bool advanced );
}
// The frame hook has to hang off a NON-generic type: the event system keys handlers by the type it found
// in the assembly, which for ArchPresetBrowser<T> is the open definition, while every live instance is a
// closed one. Registered on the generic itself, the pump silently never runs and every card stays a
// placeholder forever.
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 );
}
}
}
// Card art comes from the PRODUCTION generators, staged the way a designer stages its turntable - a
// hand-drawn approximation is free to disagree with what the drag actually builds.
public static class ArchPresetPreview
{
// Folded into every cache key: a change to the framing or the clay has to invalidate what it framed.
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 );
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();
// Sized to the CARD's art rect, not to a square: Paint.Draw stretches source onto destination, so a
// square pixmap in an 86x78 tile both squashes the unit and wastes the space it was squashed into.
// A miss queues instead of rendering: eight cards staged the frame a tool opens is a visible stall.
public static Pixmap For( string identity, ArchPreviewShot shot, ArchKit kit, Func<ArchStaged> compose )
{
// Interest belongs in the key: it decides what survives the strip, so two shots that differ only
// by it are two different pictures, and leaving it out serves the first one forever.
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;
}
// One stage per frame, so a browser fills in over a few frames rather than blocking on open.
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;
}
// The card is already rendered by here; a line pass that trips must not cost it its art.
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();
}
}
// The half of a card that is geometry, handed back as the roles left standing so a test can ask what
// survived rather than being shown a picture of it.
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();
}
// The blockout grid rather than the map palette: a role per hue over one metre ruler reads as shape and
// size, where a browser of scanned surfaces reads as a browser of textures. It also makes a face's ROLE
// recoverable from its material, which is the only way Strip can tell wall from fitting.
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;
// A preset card is the UNIT, not the wall it was cut into: the reveal, casing, sill, threshold, leaf,
// sash and glass. The wall is only there because a hole needs something to be a hole in.
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();
// An archway IS its reveal, and a reveal is the one role a wall's own cut faces also wear - so on
// the piece that holds the unit, where the interest clip would leave nothing, the role list has
// the last word and the card keeps its lining. Only there: a wall's END CAPS are painted reveal
// too, and rescued the same way they frame the card on the whole 192-wide elevation.
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 );
// A mesh stripped to nothing still carries bounds, and empty bounds would frame the card on air.
// A node holding pieces is kept whatever its own mesh came to - destroying it takes the unit
// hanging under it down with it.
if ( !piece.Mesh.FaceHandles.Any() )
{
if ( node.Children.Count == 0 )
{
node.Destroy();
}
continue;
}
piece.RebuildMesh();
}
}
const float Crease = 8f;
// Two flat faces of one hue meeting at a right angle are one shape at thumbnail size. Feature edges
// are drawn over the render through the camera's OWN projection, so the line and the pixel it sits on
// cannot disagree about where the geometry is.
static void Edges( Scene scene, CameraComponent camera, Pixmap pixmap )
{
// OrthographicHeight is the FULL vertical extent in world units and the width follows the aspect,
// so one scale carries both axes. Local y runs LEFT, which is why the horizontal is subtracted.
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 )
{
// A stripped mesh leaves half-edges whose face went with the faces that were removed.
if ( !edge.IsValid || !edge.Face.IsValid )
{
continue;
}
var opposite = edge.OppositeEdge;
// One edge is two half-edges; the lower index draws it, or every line is stroked twice.
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 );
}
}
}
// A boundary, a silhouette or a crease - and never an edge whose faces both point away, or the card
// fills with the hidden lines of the far side and reads as a scribble instead of a drawing.
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;
}
// Measured off the SURVIVING half-edges, never off GetBounds: an object whose wall faces were stripped
// still reports the box it had before, so every unit was framed inside a full-width wall and a small
// window sat in the middle of a card sized for one four times its width.
// Judged on the CENTRE: a jamb reveal reaches a little past the hole it lines, and clipping on any
// corner outside would take the casing off with the wall.
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;
// How much of the card's height the staged unit actually covers, asked through the ENGINE's own
// projection rather than ours - the whole question is whether the camera we set up agrees with the
// one that renders it.
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;
// The FACES are the surviving set - the half-edges of a removed face outlive it, so walking
// those measures the wall that was stripped right back into the frame.
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;
}
// Whichever on-screen axis runs out first decides the frame, and across is measured against the card's
// own aspect - a tall sash judged as if the card were square leaves half the tile empty.
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 );
}
}