An editor-side service that collects and records architectural parts produced by generators. It builds ArchBuiltPart records with meshes, doors, glass panes, ownership and collision metadata, splits nested pieces into separate parts, and exposes frames and a final list for caching or scene construction.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public sealed class ArchDoorRequest
{
public ArchOpening Opening { get; init; }
public ArchWall Wall { get; init; }
public Vector3 HingeLocal { get; init; }
// Wall leaves inherit yaw from the wall's node; anything else sets its own.
public Rotation Rotation { get; init; } = Rotation.Identity;
public float LeafWidth { get; init; }
public float LeafHeight { get; init; }
public float LeafThickness { get; init; }
public bool Mirrored { get; init; }
public ArchBrush Brush { get; init; }
}
// Breakable glazing lives on its own canvas so the scene can fragment it later.
public sealed class ArchGlassRequest
{
public ArchOpening Opening { get; init; }
public ArchMesh Canvas { get; init; }
}
public sealed class ArchBuiltPart
{
public string Path { get; init; }
public Transform NodeTransform { get; init; }
public ArchMesh Canvas { get; init; }
public List<ArchDoorRequest> Doors { get; } = new();
public List<ArchGlassRequest> Glass { get; } = new();
// The layer that authored this, and the named piece of it this part IS - "" for the layer's own body,
// "Gutters/Fascia" for a piece hung inside another. Together they are how a piece is named in the stack,
// hidden by the eye and given a collision mode of its own.
public int Owner { get; init; }
public string Piece { get; init; } = "";
// What the SOURCE part itself asked to collide as - its own answer only, never an inherited one, because the
// inheritance walk needs to know where the chain actually stops.
public ArchCollisionMode? Asked { get; init; }
// The unit or room this piece was drawn under, for a source that is no layer of its own - a floor group, a
// junction. A layer takes its parent from the STACK instead.
public ArchCollisionMode? Scope { get; init; }
// What it was drawn from, so the stack can be walked up from it.
public object Source { get; init; }
// Settled before the cache is asked, because it is folded into Key: a parent's mode changing has to re-cook every
// child that was following it.
public ArchCollisionMode Resolved { get; set; } = ArchCollisionMode.Complex;
// The whole part, not just its own mesh: a leaf and a pane hang off this node as children, and a hinge flipped or
// a door started open moves neither vertex of the wall around it.
public ulong Key
{
get
{
var hash = ArchHash.Fold( Canvas.Content, NodeTransform.Position );
hash = ArchHash.Fold( hash, NodeTransform.Rotation.Forward );
foreach ( var door in Doors )
{
hash = ArchHash.Fold( hash, door.Opening?.Id ?? 0 );
hash = ArchHash.Fold( hash, door.Opening?.StartOpen ?? false );
hash = ArchHash.Fold( hash, (int)(door.Opening?.Kind ?? default) );
hash = ArchHash.Fold( hash, door.HingeLocal );
hash = ArchHash.Fold( hash, door.Rotation.Forward );
hash = ArchHash.Fold( hash, door.LeafWidth );
hash = ArchHash.Fold( hash, door.LeafHeight );
hash = ArchHash.Fold( hash, door.LeafThickness );
hash = ArchHash.Fold( hash, door.Mirrored );
hash = ArchHash.Fold( hash, door.Brush.Material?.Name );
}
foreach ( var pane in Glass )
{
hash = ArchHash.Fold( hash, pane.Opening?.Id ?? 0 );
hash = ArchHash.Fold( hash, pane.Canvas?.Content ?? 0ul );
}
// A collision mode moves no vertex, so without this the cache calls the part unchanged and the toggle -
// on this part OR on anything it inherits from - never reaches the engine.
return ArchHash.Fold( hash, (int)Resolved );
}
}
}
public sealed class ArchOutputService
{
readonly List<ArchBuiltPart> parts = new();
// The node transform of every host that split into pieces. Its own canvas can come out empty - a porch is
// nothing but its pieces - and the frame those pieces are drawn in still has to reach the object they hang on.
readonly Dictionary<string, Transform> frames = new();
readonly ArchPlan plan;
// Ambient for everything drawn under one unit or one room, so a building set to None takes its pillars, trims and
// porches down with it and no Draw call has to be handed the chain by hand. A part's own answer still wins.
ArchCollisionMode? scope;
public ArchOutputService( ArchPlan plan = null )
{
this.plan = plan;
}
public IReadOnlyDictionary<string, Transform> Frames => frames;
public IDisposable Within( ArchCollisionMode? asked )
{
var held = scope;
scope = asked ?? held;
return new ScopeReset( this, held );
}
readonly struct ScopeReset( ArchOutputService output, ArchCollisionMode? restore ) : IDisposable
{
public void Dispose() => output.scope = restore;
}
public ArchOutputService Draw<TPart>(
string path,
Transform nodeTransform,
Transform projection,
TPart source,
bool welded,
Action<ArchMesh, TPart, ArchBuiltPart> create )
{
var canvas = new ArchMesh( projection ).Splitting();
if ( welded )
{
canvas.Welded();
}
ArchParts.Split( path, out var owner, out var piece );
var part = new ArchBuiltPart
{
Path = path,
NodeTransform = nodeTransform,
Canvas = canvas,
Source = source,
Owner = owner,
Piece = piece,
Asked = ArchParts.Asked( plan, owner, piece ) ?? (source as IArchCollides)?.Collision,
Scope = scope
};
create( canvas, source, part );
File( part, canvas, source );
return this;
}
public ArchOutputService Add( string path, Transform nodeTransform, ArchMesh canvas )
{
if ( canvas is null || !canvas.Emitted )
{
return this;
}
ArchParts.Split( path, out var owner, out var piece );
File( new ArchBuiltPart
{
Path = path,
NodeTransform = nodeTransform,
Canvas = canvas,
Owner = owner,
Piece = piece,
Asked = ArchParts.Asked( plan, owner, piece ),
Scope = scope
}, canvas, null );
return this;
}
// The generators' own pieces: weld the canvas, skip an empty one, record it.
public ArchOutputService Add( string path, Transform nodeTransform, Action<ArchMesh> create )
{
var canvas = new ArchMesh( Transform.Zero ).Welded().Splitting();
create( canvas );
return Add( path, nodeTransform, canvas );
}
// The host's own body, then one part per named piece it opened, nested as deep as the generator named them.
// An empty host is not filed - the cache reads a part with no mesh standing as a node that has vanished and
// rebuilds the whole scene cold - so the frame it was drawn in is recorded instead.
void File( ArchBuiltPart part, ArchMesh canvas, object source )
{
if ( !canvas.IsEmpty )
{
parts.Add( part );
}
if ( canvas.Pieces.Count == 0 )
{
return;
}
frames[part.Path] = part.NodeTransform;
// A leaf and a pane hang off the node the wall was drawn on, and a wall that put its whole body into a
// piece would otherwise take its doors down with it.
var orphaned = canvas.IsEmpty && (part.Doors.Count > 0 || part.Glass.Count > 0) ? part : null;
foreach ( var piece in canvas.Pieces )
{
Filed( part, source, piece, "", ref orphaned );
}
}
// `under` is relative to the host's OBJECT path; the record is keyed by where the piece hangs on its LAYER,
// which is the host's own piece path plus that. The two differ for a part filed under a composed path.
void Filed( ArchBuiltPart host, object source, ArchMeshPiece piece, string under, ref ArchBuiltPart orphaned )
{
var below = ArchParts.Joined( under, piece.Name );
var named = ArchParts.Joined( host.Piece, below );
var part = new ArchBuiltPart
{
Path = $"{host.Path}/{below}",
NodeTransform = Transform.Zero,
Canvas = piece.Canvas,
Source = source,
Owner = host.Owner,
Piece = named,
Asked = ArchParts.Asked( plan, host.Owner, named ) ?? host.Asked,
Scope = host.Scope
};
if ( !piece.Canvas.IsEmpty )
{
if ( orphaned is not null )
{
part.Doors.AddRange( orphaned.Doors );
part.Glass.AddRange( orphaned.Glass );
orphaned = null;
}
parts.Add( part );
}
foreach ( var nested in piece.Canvas.Pieces )
{
Filed( host, source, nested, below, ref orphaned );
}
}
public IReadOnlyList<ArchBuiltPart> Create()
{
return parts.ToList();
}
}