Editor UI code for choosing and applying material palettes to architectural layers. It scans generated faces to group by surface roles, shows dialogs to pick materials or override palettes, previews and commits UV and material assignments to the layer palette.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Editor;
using Editor.MeshEditor;
using Sandbox;
using Margin = Sandbox.UI.Margin;
namespace Sunless.Architecture;
public static class ArchLayerPalettePicker
{
public sealed class MaterialIndex
{
public int Index { get; init; }
public string Material { get; init; }
public string Context { get; init; }
public int FaceCount { get; init; }
public int MaterialCount { get; init; } = 1;
public List<ArchFaceDetail> Faces { get; init; } = new();
public List<ArchSurface> Roles { get; init; } = new();
public Vector2 MappingAnchor { get; init; }
}
public static bool CanOpen( ArchTool tool, ArchLayerNode node )
{
return tool is not null && node?.Payload is not null && PaletteOf( node.Payload ) is not null;
}
public static bool HasOverride( ArchLayerNode node )
{
var palette = PaletteOf( node?.Payload );
return palette is not null && (palette.Materials.Count > 0 || palette.TexelScales.Count > 0 || palette.TextureOffsets?.Count > 0);
}
public static void Show( Widget parent, ArchTool tool, ArchLayerNode node )
{
var palette = PaletteOf( node.Payload );
if ( palette is null )
{
return;
}
var indices = Scan( tool, node, palette );
new ArchLayerPaletteDialog( parent, tool, node, palette, indices ).OpenAtCursor();
}
internal static List<MaterialIndex> Scan( ArchTool tool, ArchLayerNode layer, ArchPalette palette )
{
var layerIds = DescendantsAndSelf( layer )
.Select( node => node.Ref?.ItemId ?? 0 )
.Where( id => id != 0 )
.ToHashSet();
var faces = layerIds
.SelectMany( tool.BuiltFaces )
.SelectMany( piece => piece.Faces )
.ToList();
if ( layer.Payload is ArchCutPart cut )
{
var lift = new ArchSelection { Item = cut }.Lift( tool.Plan, tool.Kit );
var everyFace = tool.EveryBuiltPiece().SelectMany( piece => piece.Faces ).ToList();
var coveredFaces = everyFace
.Where( face => cut.Segments.Any( segment => Covers( segment, face.Centre, lift ) ) )
.ToList();
if ( cut.ResolvedDamage == ArchDamageKind.Masonry )
{
var coveredFittings = coveredFaces.Where( IsMasonryFitting ).ToHashSet();
faces = ArchAudit.PhysicallyConnected( everyFace.Where( IsMasonryFitting ) )
.Where( brick => brick.Any( coveredFittings.Contains ) )
.SelectMany( brick => brick )
.Distinct()
.ToList();
}
else
{
faces = faces.Concat( coveredFaces ).Distinct().ToList();
}
}
var contextual = faces
.Select( face => (Face: face, Owner: tool.LayerTree.Find( face.LayerId )?.Payload, Role: ContextRole( tool, layer, face )) )
.Where( entry => entry.Role.HasValue )
.GroupBy( entry => entry.Role.Value )
.Select( group => new MaterialIndex
{
Material = group.GroupBy( entry => entry.Face.Material ).OrderByDescending( material => material.Count() ).First().Key,
Context = ContextName( group.Key, layer.Payload ),
FaceCount = group.Count(),
MaterialCount = group.Select( entry => entry.Face.Material ).Distinct().Count(),
Faces = group.Select( entry => entry.Face ).ToList(),
MappingAnchor = MappingAnchor( group.Select( entry => entry.Face ) ),
Roles = new List<ArchSurface> { group.Key }
} )
.ToList();
var unresolved = faces
.Where( face => !ContextRole( tool, layer, face ).HasValue )
.GroupBy( face => face.Material )
.OrderByDescending( group => group.Count() )
.Select( group =>
{
var roles = group
.SelectMany( face => face.Role.Split( '/', StringSplitOptions.RemoveEmptyEntries ) )
.Select( ParseRole )
.Where( role => role.HasValue )
.Select( role => role.Value )
.Distinct()
.ToList();
return new MaterialIndex
{
Material = group.Key,
Context = roles.Count > 0 ? ContextName( roles[0], layer.Payload ) : "Unclassified Surface",
FaceCount = group.Count(),
MaterialCount = 1,
Faces = group.ToList(),
MappingAnchor = MappingAnchor( group ),
Roles = roles
};
} )
.Where( index => index.Roles.Count > 0 )
.ToList();
var indices = contextual
.Concat( unresolved )
.OrderByDescending( index => index.FaceCount )
.Select( ( index, position ) => new MaterialIndex
{
Index = position,
Material = index.Material,
Context = index.Context,
FaceCount = index.FaceCount,
MaterialCount = index.MaterialCount,
Faces = index.Faces,
MappingAnchor = index.MappingAnchor,
Roles = index.Roles
} )
.ToList();
if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } && indices.Count > 0
&& !indices.Any( index => index.Roles.Contains( ArchSurface.WallBase ) ) )
{
var masonry = indices.FirstOrDefault( index => index.Roles.Contains( ArchSurface.WallExterior ) ) ?? indices[0];
masonry.Roles.Add( ArchSurface.WallBase );
}
if ( indices.Count > 0 )
{
return indices;
}
var roles = palette.Materials.Keys
.Select( ParseRole )
.Where( role => role.HasValue )
.Select( role => role.Value )
.Distinct()
.ToList();
return roles.Select( ( role, index ) => new MaterialIndex
{
Index = index,
Material = palette.TryGet( role, out var path ) ? path : "",
Context = ContextName( role, layer.Payload ),
Roles = new List<ArchSurface> { role }
} ).ToList();
}
static bool IsMasonryFitting( ArchFaceDetail face )
{
return face.Piece
.Split( '/', StringSplitOptions.RemoveEmptyEntries )
.Contains( ArchPieces.Fittings );
}
static ArchSurface? ContextRole( ArchTool tool, ArchLayerNode layer, ArchFaceDetail face )
{
var owner = tool.LayerTree.Find( face.LayerId )?.Payload;
if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry }
|| owner is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } )
{
return ArchSurface.WallBase;
}
var pieces = face.Piece.Split( '/', StringSplitOptions.RemoveEmptyEntries );
if ( pieces.Contains( ArchPieces.Skirting ) )
{
return ArchSurface.Baseboard;
}
if ( pieces.Contains( ArchPieces.Soffit ) )
{
return ArchSurface.Soffit;
}
if ( pieces.Contains( ArchPieces.Ceiling ) )
{
return ArchSurface.Ceiling;
}
if ( pieces.Contains( ArchPieces.Bars ) )
{
return ArchSurface.Grille;
}
if ( pieces.Contains( ArchPieces.Sign ) )
{
return ArchSurface.Sign;
}
if ( pieces.Contains( ArchPieces.Plant ) )
{
return ArchSurface.Plant;
}
if ( owner is ArchPipeBracketPart || pieces.Contains( ArchPieces.Brackets ) && owner is ArchPipePart )
{
return ArchSurface.Bracket;
}
if ( owner is ArchPipePart pipe )
{
return pipe.IsCable || pipe.IsTray ? ArchSurface.Cabling : ArchSurface.Pipework;
}
return null;
}
static string ContextName( ArchSurface role, object owner )
{
if ( owner is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } && role == ArchSurface.WallBase )
{
return "Damage Bricks";
}
return role switch
{
ArchSurface.Baseboard => "Skirting",
ArchSurface.WallExterior => "Exterior Walls",
ArchSurface.WallInterior => "Interior Walls",
ArchSurface.WallBase => "Wall Base",
ArchSurface.WallCap => "Wall Tops",
ArchSurface.Reveal => "Door and Window Reveals",
ArchSurface.Trim => "Wall Trim",
ArchSurface.Sill => "Window Sills",
ArchSurface.Floor => "Floor Surface",
ArchSurface.Ceiling => "Ceiling Surface",
ArchSurface.Roof => "Roof Surface",
ArchSurface.RoofEdge => "Roof Edge",
ArchSurface.Soffit => "Roof Soffit",
ArchSurface.Gutter => "Gutters",
ArchSurface.Foundation => "Foundation",
ArchSurface.StairTread => "Stair Treads",
ArchSurface.StairRiser => "Stair Risers",
ArchSurface.StairStringer => "Stair Stringers",
ArchSurface.WindowFrame => "Window Frames",
ArchSurface.WindowSash => "Window Sashes",
ArchSurface.DoorLeaf => "Door Leaves",
ArchSurface.Pipework => "Pipework",
ArchSurface.Cabling => "Cabling",
ArchSurface.Bracket => "Supports",
_ => SplitName( role.ToString() )
};
}
static string SplitName( string value )
{
return string.Concat( value.Select( ( character, index ) => index > 0 && char.IsUpper( character ) ? $" {character}" : character.ToString() ) );
}
static Vector2 MappingAnchor( IEnumerable<ArchFaceDetail> faces )
{
var weighted = Vector2.Zero;
var weight = 0f;
foreach ( var face in faces )
{
var area = MathF.Max( 1f, face.Area );
var projected = new Vector2(
Vector3.Dot( face.TextureAxisU, face.Centre ),
Vector3.Dot( face.TextureAxisV, face.Centre ) );
weighted += projected * area;
weight += area;
}
return weight > 0f ? weighted / weight : Vector2.Zero;
}
public static IEnumerable<ArchLayerNode> DescendantsAndSelf( ArchLayerNode node )
{
if ( node is null )
{
yield break;
}
yield return node;
foreach ( var child in node.Children )
{
foreach ( var descendant in DescendantsAndSelf( child ) )
{
yield return descendant;
}
}
}
static bool Covers( ArchCutSegment segment, Vector3 point, float lift )
{
return point.z >= segment.BaseHeight + lift - 0.5f
&& point.z <= segment.TopHeight + lift + 0.5f
&& ArchFootprint.Contains( segment.Outline(), new Vector2( point.x, point.y ) );
}
static ArchSurface? ParseRole( string value )
{
return Enum.TryParse<ArchSurface>( value, out var role ) ? role : null;
}
public static ArchPalette PaletteOf( object item )
{
return new ArchSelection { Item = item }.Palette;
}
}
sealed class ArchMaterialSwatch : Widget
{
readonly Pixmap image;
readonly Color? fill;
public ArchMaterialSwatch( Widget parent, Pixmap image, Color? fill = null ) : base( parent )
{
this.image = image;
this.fill = fill;
FixedSize = new Vector2( 44f, 44f );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( LocalRect, 3f );
if ( image is not null )
{
Paint.Draw( LocalRect.Shrink( 2f ), image, 1f );
}
if ( fill is { } colour )
{
Paint.ClearPen();
Paint.SetBrush( colour.WithAlpha( image is null ? 1f : 0.62f ) );
Paint.DrawRect( LocalRect.Shrink( 2f ), 2f );
}
Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
Paint.ClearBrush();
Paint.DrawRect( LocalRect, 3f );
}
}
sealed class ArchLayerMaterialPicker : Widget
{
readonly ArchMapPalette.Document document;
readonly ArchMapPalette.Surface current;
readonly Action<ArchMapPalette.Surface> picked;
public ArchLayerMaterialPicker( ArchMapPalette.Document document, int index, ArchMapPalette.Surface current,
Action<ArchMapPalette.Surface> picked ) : base( null )
{
this.document = document;
this.current = current;
this.picked = picked;
WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
DeleteOnClose = true;
FixedSize = new Vector2( 430f, 420f );
Build( index );
}
public void OpenAtCursor()
{
Position = global::Editor.Application.CursorPosition;
Show();
ConstrainToScreen();
}
void Build( int index )
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
var head = Layout.AddRow();
head.Margin = new Margin( 10, 8, 10, 8 );
head.Add( new Label( $"Choose Material · Material {index}" ), 1 );
head.Add( new Button( "", "close" ) { Clicked = Destroy } );
Layout.AddSeparator();
var grid = new ArchTileGrid( this, ArchTileSize.Card, 8f );
Layout.Add( grid, 1 );
var section = new ArchTileSection
{
Title = string.IsNullOrWhiteSpace( document.Title ) ? document.Map : document.Title,
Note = "Click a material preview to assign it to the checked index."
};
foreach ( var surface in document.Surfaces )
{
var captured = surface;
var missing = !ArchMapPalette.IsImported( captured );
section.Tiles.Add( parent => new ArchTile( parent,
ArchThumb.For( captured.Category, captured.Name, captured.Id ), ShortName( captured.Name ),
missing ? "NOT IMPORTED" : captured.Category, missing, ArchTileSize.Card )
{
ToolTip = captured.MaterialPath,
Current = () => ReferenceEquals( current, captured ),
Clicked = missing ? null : () => Pick( captured )
} );
}
grid.Set( new[] { section } );
}
void Pick( ArchMapPalette.Surface surface )
{
picked?.Invoke( surface );
Destroy();
}
static string ShortName( string name )
{
if ( name.StartsWith( "map_seamless_", StringComparison.OrdinalIgnoreCase ) )
{
return name[13..];
}
if ( name.StartsWith( "map_trim_", StringComparison.OrdinalIgnoreCase ) )
{
return name[9..];
}
return name;
}
}
sealed class ArchPaletteOverridePrompt : Widget
{
readonly Action keep;
readonly Action replace;
public ArchPaletteOverridePrompt( string layerName, int overrideCount, int layerCount, Action keep, Action replace ) : base( null )
{
this.keep = keep;
this.replace = replace;
WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
DeleteOnClose = true;
FixedSize = new Vector2( 430f, 172f );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
var head = Layout.AddRow();
head.Margin = new Margin( 12, 9, 10, 9 );
head.Add( new Label( "Palette Overrides" ), 1 );
head.Add( new Button( "", "close" ) { Clicked = Destroy } );
Layout.AddSeparator();
var body = Layout.AddColumn( 1 );
body.Margin = new Margin( 14, 12, 14, 10 );
body.Spacing = 5;
var message = new Label( $"{layerName} contains {overrideCount} palette {(overrideCount == 1 ? "override" : "overrides")} across {layerCount} {(layerCount == 1 ? "layer" : "layers")}." )
{
WordWrap = true
};
body.Add( message );
var detail = new Label( "Keep the detailed layer choices, or replace them so this palette controls those materials." )
{
WordWrap = true
};
detail.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
body.Add( detail );
Layout.AddSeparator();
var footer = Layout.AddRow();
footer.Margin = new Margin( 12, 8, 12, 10 );
footer.Spacing = 6;
footer.AddStretchCell();
footer.Add( new Button( "Keep Existing Overrides", "layers" ) { Clicked = Keep } );
footer.Add( new Button.Primary( "Replace Overrides", "palette" ) { Clicked = Replace } );
}
public void OpenAtCursor()
{
Position = global::Editor.Application.CursorPosition - new Vector2( 80f, 0f );
Show();
ConstrainToScreen();
}
void Keep()
{
Destroy();
keep?.Invoke();
}
void Replace()
{
Destroy();
replace?.Invoke();
}
}
public sealed class ArchLayerPaletteDialog : Widget
{
sealed class UvMapping
{
public float Scale { get; set; }
public float OffsetX { get; set; }
public float OffsetY { get; set; }
}
sealed record PaletteAssignment( ArchSurface Role, string Path, float TexelScale, Vector2 Offset );
sealed record PaletteConflict( ArchLayerNode Layer, ArchPalette Palette, ArchSurface Role );
readonly ArchTool tool;
readonly ArchLayerNode layer;
readonly ArchPalette target;
readonly List<ArchLayerPalettePicker.MaterialIndex> indices;
readonly List<ArchMapPalette.Document> documents = new();
readonly Dictionary<int, ArchMapPalette.Surface> assignments = new();
readonly Dictionary<int, Material> workingAssignments = new();
readonly Dictionary<int, UvMapping> uvMappings = new();
readonly Dictionary<int, float> previewScales = new();
readonly HashSet<int> selectedIndices = new();
Layout rows;
Label detail;
Button applyOne;
ArchMapPalette.Document document;
int lastClickedIndex = -1;
bool blockout;
public ArchLayerPaletteDialog( Widget parent, ArchTool tool, ArchLayerNode layer, ArchPalette target,
List<ArchLayerPalettePicker.MaterialIndex> indices ) : base( null )
{
this.tool = tool;
this.layer = layer;
this.target = target;
this.indices = indices;
foreach ( var file in ArchMapPalette.Available() )
{
if ( ArchMapPalette.ReadDocument( file ) is { } available )
{
documents.Add( available );
}
}
document = documents.FirstOrDefault( available => available.Surfaces.Any( surface =>
target.Materials.Values.Any( path => path.Equals( surface.MaterialPath, StringComparison.OrdinalIgnoreCase ) ) ) )
?? documents.FirstOrDefault();
blockout = target.Materials.Values.Any( path => path.Contains( ArchBlockout.Folder, StringComparison.OrdinalIgnoreCase ) )
|| document is null;
if ( indices.FirstOrDefault() is { } first )
{
selectedIndices.Add( first.Index );
lastClickedIndex = first.Index;
}
WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
DeleteOnClose = true;
FixedWidth = 780f;
FixedHeight = Math.Clamp( 250f + indices.Count * 72f, 420f, 680f );
Build();
MapAssignments();
RebuildRows();
}
public void OpenAtCursor()
{
Position = global::Editor.Application.CursorPosition - new Vector2( FixedWidth - 110f, 0f );
Show();
ConstrainToScreen();
}
void Build()
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
var head = Layout.AddColumn();
head.Margin = new Margin( 12, 10, 12, 10 );
head.Spacing = 5;
var heading = new Label( $"Apply Palette · {layer.DisplayName}" );
heading.SetStyles( "font-size: 15px; font-weight: 700;" );
head.Add( heading );
detail = new Label( $"{indices.Count} contextual {(indices.Count == 1 ? "surface" : "surfaces")} found" );
detail.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
head.Add( detail );
var paletteRow = head.AddRow();
paletteRow.Spacing = 8;
paletteRow.Add( new Label( "Built-in palette" ) { MinimumWidth = 120 } );
var palettes = new ComboBox( this );
palettes.AddItem( "Blockout Grid", "grid_on", () => ChooseBlockout(), selected: blockout );
foreach ( var available in documents )
{
var captured = available;
var title = string.IsNullOrWhiteSpace( captured.Title ) ? captured.Map : captured.Title;
palettes.AddItem( title, "palette", () => Choose( captured ), selected: ReferenceEquals( document, captured ) );
}
paletteRow.Add( palettes, 1 );
Layout.AddSeparator();
var body = Layout.AddRow( 1 );
var scroll = new ScrollArea( this );
scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
var canvas = new Widget( scroll ) { Layout = Layout.Column() };
canvas.Layout.Margin = 10;
canvas.Layout.Spacing = 2;
scroll.Canvas = canvas;
rows = canvas.Layout;
body.Add( scroll, 1 );
body.AddSeparator();
var working = new Widget( this ) { Layout = Layout.Column() };
working.FixedWidth = 84f;
working.Layout.Margin = new Margin( 8, 10, 8, 8 );
working.Layout.Spacing = 5;
var workingLabel = new Label.Small( "MAPPING" );
workingLabel.ToolTip = "The active temporary palette shared with the Mapping tool";
working.Layout.Add( workingLabel );
var workingPalette = new MaterialPaletteWidget
{
FixedWidth = 64f,
FixedHeight = 204f,
GetActiveMaterial = ActiveMappingMaterial
};
workingPalette.MaterialClicked += AssignWorkingMaterial;
working.Layout.Add( workingPalette );
working.Layout.AddStretchCell();
body.Add( working );
Layout.AddSeparator();
var footer = Layout.AddRow();
footer.Margin = new Margin( 12, 8, 12, 10 );
footer.Spacing = 6;
footer.Add( new Button( "Close", "close" ) { Clicked = Destroy } );
footer.AddStretchCell();
applyOne = new Button.Primary( "", "check" ) { Clicked = ApplySelected };
footer.Add( applyOne );
var every = new Button( "Apply to Every Index", "done_all" ) { Clicked = ApplyEvery };
footer.Add( every );
RefreshApplyLabel();
}
void ChooseBlockout()
{
blockout = true;
document = null;
MapAssignments();
RebuildRows();
}
void Choose( ArchMapPalette.Document chosen )
{
blockout = false;
document = chosen;
MapAssignments();
RebuildRows();
}
void MapAssignments()
{
assignments.Clear();
workingAssignments.Clear();
uvMappings.Clear();
previewScales.Clear();
if ( !blockout && document is not null )
{
MapMaterialAssignments();
}
MapUvAssignments();
}
void MapMaterialAssignments()
{
var resolved = ArchMapPalette.Resolve( document );
foreach ( var index in indices )
{
var assigned = RolesFor( index )
.Where( resolved.ContainsKey )
.Select( role => resolved[role] )
.FirstOrDefault( ArchMapPalette.IsImported )
?? document.Surfaces.FirstOrDefault( ArchMapPalette.IsImported );
if ( assigned is not null )
{
assignments[index.Index] = assigned;
}
var overriddenPath = RolesFor( index )
.Select( role => target.TryGet( role, out var path ) ? path : null )
.FirstOrDefault( path => !string.IsNullOrWhiteSpace( path ) );
if ( string.IsNullOrWhiteSpace( overriddenPath ) )
{
continue;
}
var overriddenSurface = document.Surfaces.FirstOrDefault( surface =>
surface.MaterialPath.Equals( overriddenPath, StringComparison.OrdinalIgnoreCase ) );
if ( overriddenSurface is not null )
{
assignments[index.Index] = overriddenSurface;
continue;
}
var overriddenMaterial = Material.Load( overriddenPath );
if ( overriddenMaterial is not null && overriddenMaterial.IsValid() )
{
workingAssignments[index.Index] = overriddenMaterial;
}
}
}
void MapUvAssignments()
{
foreach ( var index in indices )
{
var scale = RolesFor( index ).Select( target.ScaleFor ).FirstOrDefault( value => value > 0f );
if ( scale <= 0f && assignments.TryGetValue( index.Index, out var surface ) )
{
scale = surface.TexelScale;
}
var offset = RolesFor( index ).Select( target.OffsetFor ).FirstOrDefault( value => !value.IsNearZeroLength );
uvMappings[index.Index] = new UvMapping
{
Scale = scale > 0f ? scale : ArchMesh.TexelScale,
OffsetX = offset.x,
OffsetY = offset.y
};
previewScales[index.Index] = uvMappings[index.Index].Scale;
}
}
void RebuildRows()
{
rows.Clear( true );
if ( indices.Count == 0 )
{
rows.Add( new Label( "This layer has no generated or assigned materials yet." ) );
applyOne.Enabled = false;
return;
}
applyOne.Enabled = true;
foreach ( var index in indices )
{
var captured = index;
var row = new Widget( this ) { Layout = Layout.Column() };
row.Layout.Spacing = 4;
var materialRow = row.Layout.AddRow();
materialRow.Spacing = 8;
var chooseIndex = new Button( captured.Context, selectedIndices.Contains( captured.Index ) ? "check_box" : "check_box_outline_blank" )
{
MinimumWidth = 122,
ToolTip = $"{captured.FaceCount} generated {(captured.FaceCount == 1 ? "face" : "faces")} · {captured.MaterialCount} source {(captured.MaterialCount == 1 ? "material" : "materials")}",
Clicked = () => ToggleIndex( captured.Index )
};
materialRow.Add( chooseIndex );
materialRow.Add( new Button( "", "texture" )
{
FixedWidth = 32f,
ToolTip = $"Edit {captured.Context} faces and linked variations",
Clicked = () => OpenContextFaceTool( captured )
} );
materialRow.Add( CurrentSwatch( row, captured ) );
var currentMaterial = captured.MaterialCount == 1
? ShortMaterial( captured.Material )
: $"{captured.MaterialCount} source materials";
materialRow.Add( new Label( currentMaterial ) );
materialRow.Add( new Label( "→" ) { FixedWidth = 12f } );
materialRow.Add( AssignedSwatch( row, captured ) );
var materialName = workingAssignments.TryGetValue( captured.Index, out var working )
? ShortMaterial( working.ResourcePath )
: blockout
? "Blockout Grid"
: assignments.TryGetValue( captured.Index, out var assigned )
? MaterialName( assigned )
: "Choose material";
var materials = new Button( materialName, "arrow_drop_down" )
{
MinimumWidth = 172,
Enabled = !blockout && document is not null,
Clicked = () => OpenMaterialPicker( captured.Index )
};
materialRow.Add( materials );
var uvRow = row.Layout.AddRow();
uvRow.Spacing = 5;
var serialized = uvMappings[captured.Index].GetSerialized();
serialized.OnPropertyChanged += property => ChangeUvMapping( captured, property );
serialized.OnPropertyFinishEdit += _ => FinishUvMapping( captured );
uvRow.Add( ScaleControl( serialized, captured ) );
uvRow.AddStretchCell();
rows.Add( row );
}
rows.AddStretchCell();
RefreshApplyLabel();
}
Widget ScaleControl( SerializedObject serialized, ArchLayerPalettePicker.MaterialIndex index )
{
var holder = new Widget( this ) { Layout = Layout.Row() };
holder.Layout.Spacing = 2;
holder.Layout.Add( new Button( "", "remove" ) { FixedWidth = 22f, Clicked = () => BumpScale( index, -1 ) } );
holder.Layout.Add( UvControl( serialized, nameof( UvMapping.Scale ), "S", Theme.Blue, true ) );
holder.Layout.Add( new Button( "", "add" ) { FixedWidth = 22f, Clicked = () => BumpScale( index, 1 ) } );
return holder;
}
FloatControlWidget UvControl( SerializedObject serialized, string property, string label, Color colour, bool positive )
{
var control = new FloatControlWidget( serialized.GetProperty( property ) )
{
Label = label,
HighlightColor = colour,
FixedWidth = positive ? 72f : 82f,
FixedHeight = Theme.RowHeight,
ToolTip = "Drag horizontally or type a value"
};
control.MakeRanged( positive ? new Vector2( 0.25f, 8f ) : new Vector2( -4096f, 4096f ), positive ? 0.01f : 0.0025f, positive, false );
return control;
}
void BumpScale( ArchLayerPalettePicker.MaterialIndex index, int direction )
{
var mapping = uvMappings[index.Index];
var steps = new[] { 0.25f, 0.5f, 1f, 2f, 4f, 8f };
var scale = direction < 0
? steps.LastOrDefault( value => value < mapping.Scale - 0.001f, steps[0] )
: steps.FirstOrDefault( value => value > mapping.Scale + 0.001f, steps[^1] );
ChangeScaleAroundAnchor( index, scale );
PreviewUvMapping( index );
CommitUvMapping( index );
}
void ChangeUvMapping( ArchLayerPalettePicker.MaterialIndex index, SerializedProperty property )
{
if ( property.Name == nameof( UvMapping.Scale ) )
{
ChangeScaleAroundAnchor( index, uvMappings[index.Index].Scale );
}
PreviewUvMapping( index );
}
void ChangeScaleAroundAnchor( ArchLayerPalettePicker.MaterialIndex index, float scale )
{
var mapping = uvMappings[index.Index];
var previous = previewScales.GetValueOrDefault( index.Index, mapping.Scale );
var settled = Math.Clamp( scale, 0.01f, 8f );
if ( previous > 0f && !previous.AlmostEqual( settled ) )
{
var compensation = index.MappingAnchor * (1f / previous - 1f / settled);
mapping.OffsetX += compensation.x;
mapping.OffsetY += compensation.y;
}
mapping.Scale = settled;
previewScales[index.Index] = settled;
}
void PreviewUvMapping( ArchLayerPalettePicker.MaterialIndex index )
{
var mapped = AssignmentsFor( new[] { index } ).ToList();
if ( mapped.Count == 0 )
{
return;
}
Write( mapped );
var layerId = layer.Ref?.ItemId ?? 0;
tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
tool.Preview();
}
void FinishUvMapping( ArchLayerPalettePicker.MaterialIndex index )
{
var mapping = uvMappings[index.Index];
mapping.OffsetX = mapping.OffsetX.SnapToGrid( 0.25f );
mapping.OffsetY = mapping.OffsetY.SnapToGrid( 0.25f );
PreviewUvMapping( index );
CommitUvMapping( index );
}
void CommitUvMapping( ArchLayerPalettePicker.MaterialIndex index )
{
var layerId = layer.Ref?.ItemId ?? 0;
tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
tool.Commit( $"Align {index.Context} on {layer.DisplayName}" );
}
Widget CurrentSwatch( Widget parent, ArchLayerPalettePicker.MaterialIndex index )
{
var asset = AssetSystem.FindByPath( index.Material );
if ( asset is not null && !asset.IsDeleted )
{
return new ArchMaterialSwatch( parent, asset.GetAssetThumb( true ) );
}
var surface = documents
.SelectMany( available => available.Surfaces )
.FirstOrDefault( candidate => candidate.MaterialPath.Equals( index.Material, StringComparison.OrdinalIgnoreCase ) );
return surface is null
? new ArchMaterialSwatch( parent, null )
: new ArchMaterialSwatch( parent, ArchThumb.For( surface.Category, surface.Name, surface.Id ) );
}
Widget AssignedSwatch( Widget parent, ArchLayerPalettePicker.MaterialIndex index )
{
if ( workingAssignments.TryGetValue( index.Index, out var material ) )
{
return new ArchMaterialSwatch( parent, AssetSystem.FindByPath( material.ResourcePath )?.GetAssetThumb( true ) );
}
if ( blockout )
{
return new ArchMaterialSwatch( parent, ArchBlockout.Grid(), ArchBlockout.ColourOf( index.Roles.First() ) );
}
return assignments.TryGetValue( index.Index, out var surface )
? new ArchMaterialSwatch( parent, ArchThumb.For( surface.Category, surface.Name, surface.Id ) )
: new ArchMaterialSwatch( parent, null );
}
void Assign( int index, ArchMapPalette.Surface surface )
{
workingAssignments.Remove( index );
assignments[index] = surface;
if ( surface.TexelScale > 0f )
{
uvMappings[index].Scale = surface.TexelScale;
}
RebuildRows();
}
void OpenMaterialPicker( int index )
{
if ( document is null )
{
return;
}
assignments.TryGetValue( index, out var current );
new ArchLayerMaterialPicker( document, index, current, surface => Assign( index, surface ) ).OpenAtCursor();
}
void AssignWorkingMaterial( Material material )
{
if ( material is null || !material.IsValid() || !selectedIndices.Contains( lastClickedIndex ) )
{
return;
}
workingAssignments[lastClickedIndex] = material;
RebuildRows();
}
static Material ActiveMappingMaterial()
{
var path = ProjectCookie.Get( "MeshTool.ActiveMaterial", "" );
return string.IsNullOrWhiteSpace( path ) ? null : Material.Load( path );
}
void ToggleIndex( int index )
{
if ( selectedIndices.Add( index ) )
{
lastClickedIndex = index;
}
else
{
selectedIndices.Remove( index );
if ( lastClickedIndex == index )
{
lastClickedIndex = selectedIndices.Count > 0 ? selectedIndices.First() : -1;
}
}
RebuildRows();
}
void RefreshApplyLabel()
{
applyOne.Enabled = selectedIndices.Count > 0;
var selected = indices.FirstOrDefault( index => selectedIndices.Contains( index.Index ) );
applyOne.Text = selectedIndices.Count switch
{
0 => "Select Surfaces",
1 => $"Apply to {selected?.Context ?? "Surface"}",
_ => $"Apply to {selectedIndices.Count} Surfaces"
};
}
void OpenContextFaceTool( ArchLayerPalettePicker.MaterialIndex index )
{
if ( SceneEditorSession.Active is null )
{
Log.Warning( "Architecture: the Context Face Tool needs an active scene editor session." );
return;
}
tool.Disown();
tool.Regenerate();
var currentIndex = ArchLayerPalettePicker.Scan( tool, layer, target )
.FirstOrDefault( candidate => candidate.Context == index.Context ) ?? index;
var faceVariations = ArchFaceUvMappings.Variations( tool, currentIndex );
var faces = faceVariations.SelectMany( variation => variation ).SelectMany( group => group ).Distinct().ToArray();
if ( faces.Length == 0 )
{
Log.Warning( $"Architecture: no generated faces remain for the selected {layer.DisplayName} surfaces." );
return;
}
SceneEditorSession.Active.Selection.Clear();
foreach ( var face in faces )
{
SceneEditorSession.Active.Selection.Add( face );
}
if ( target.FaceMappings.TryGetValue( index.Context, out var stored ) )
{
ArchFaceUvMappings.Apply( stored, faceVariations );
}
var selectedMaterial = FastTextureMaterial( index );
ArchTextureGroupEditor.Show( this, faceVariations, selectedMaterial, mapping =>
{
Write( AssignmentsFor( new[] { index } ) );
target.FaceMappings[index.Context] = mapping;
var layerId = layer.Ref?.ItemId ?? 0;
tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
tool.Commit( $"Map {index.Context} on {layer.DisplayName}" );
} );
}
Material FastTextureMaterial( ArchLayerPalettePicker.MaterialIndex selected )
{
if ( workingAssignments.TryGetValue( selected.Index, out var working ) && working.IsValid() )
{
return working;
}
if ( blockout )
{
return Material.Load( ArchBlockout.PathFor( selected.Roles.First() ) );
}
return assignments.TryGetValue( selected.Index, out var assigned )
? Material.Load( assigned.MaterialPath )
: null;
}
MeshFace MeshFaceFor( ArchFaceDetail face )
{
if ( !tool.BuiltMeshes.TryGetValue( face.LayerId, out var components ) )
{
return default;
}
var component = components.FirstOrDefault( candidate => ArchAudit.Path( candidate.GameObject ) == face.Piece );
if ( !component.IsValid() || component.Mesh is null )
{
return default;
}
var handle = component.Mesh.FaceHandleFromIndex( face.HandleIndex );
return handle.IsValid ? new MeshFace( component, handle ) : default;
}
void ApplySelected()
{
Apply( indices.Where( index => selectedIndices.Contains( index.Index ) ) );
}
void ApplyEvery()
{
Apply( indices );
}
void Apply( IEnumerable<ArchLayerPalettePicker.MaterialIndex> scope )
{
var assignmentsToApply = AssignmentsFor( scope ).GroupBy( assignment => assignment.Role ).Select( group => group.Last() ).ToList();
if ( assignmentsToApply.Count == 0 )
{
Log.Warning( $"Architecture: no imported palette materials could be applied to {layer.DisplayName}." );
return;
}
var conflicts = ConflictsFor( assignmentsToApply.Select( assignment => assignment.Role ) );
if ( conflicts.Count == 0 )
{
Apply( assignmentsToApply, conflicts, true );
return;
}
new ArchPaletteOverridePrompt( layer.DisplayName, conflicts.Count, conflicts.Select( conflict => conflict.Layer ).Distinct().Count(),
() => Apply( assignmentsToApply, conflicts, false ),
() => Apply( assignmentsToApply, conflicts, true ) ).OpenAtCursor();
}
IEnumerable<PaletteAssignment> AssignmentsFor( IEnumerable<ArchLayerPalettePicker.MaterialIndex> scope )
{
foreach ( var index in scope )
{
if ( workingAssignments.TryGetValue( index.Index, out var working ) && working.IsValid() )
{
foreach ( var role in RolesFor( index ) )
{
yield return AssignmentFor( index, role, working.ResourcePath );
}
continue;
}
if ( blockout )
{
foreach ( var role in RolesFor( index ) )
{
yield return AssignmentFor( index, role, ArchBlockout.PathFor( role ) );
}
continue;
}
if ( !assignments.TryGetValue( index.Index, out var surface ) || !ArchMapPalette.IsImported( surface ) )
{
continue;
}
foreach ( var role in RolesFor( index ) )
{
yield return AssignmentFor( index, role, surface.MaterialPath );
}
}
}
PaletteAssignment AssignmentFor( ArchLayerPalettePicker.MaterialIndex index, ArchSurface role, string path )
{
var mapping = uvMappings[index.Index];
return new PaletteAssignment( role, path, mapping.Scale,
new Vector2( mapping.OffsetX, mapping.OffsetY ) );
}
IEnumerable<ArchSurface> RolesFor( ArchLayerPalettePicker.MaterialIndex index )
{
foreach ( var role in index.Roles )
{
yield return role;
}
if ( layer.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry }
&& !index.Roles.Contains( ArchSurface.WallBase ) )
{
yield return ArchSurface.WallBase;
}
}
List<PaletteConflict> ConflictsFor( IEnumerable<ArchSurface> roles )
{
var affectedRoles = roles.ToHashSet();
var conflicts = new List<PaletteConflict>();
foreach ( var candidate in ArchLayerPalettePicker.DescendantsAndSelf( layer ).Skip( 1 ) )
{
var palette = ArchLayerPalettePicker.PaletteOf( candidate.Payload );
if ( palette is null )
{
continue;
}
foreach ( var role in affectedRoles )
{
var key = role.ToString();
if ( palette.Materials.ContainsKey( key ) || palette.TexelScales.ContainsKey( key ) || palette.TextureOffsets?.ContainsKey( key ) == true )
{
conflicts.Add( new PaletteConflict( candidate, palette, role ) );
}
}
}
return conflicts;
}
void Apply( IReadOnlyList<PaletteAssignment> assignmentsToApply, IReadOnlyList<PaletteConflict> conflicts, bool replace )
{
if ( replace )
{
foreach ( var conflict in conflicts )
{
conflict.Palette.Set( conflict.Role, null );
}
}
Write( assignmentsToApply );
var layerId = layer.Ref?.ItemId ?? 0;
tool.Affect( ArchLayerGroups.Holding( tool.Plan, layerId )?.Id ?? layerId );
tool.Commit( $"Apply Palette to {layer.DisplayName}" );
MapAssignments();
RebuildRows();
}
void Write( IEnumerable<PaletteAssignment> mapped )
{
foreach ( var assignment in mapped )
{
target.Set( assignment.Role, assignment.Path, assignment.TexelScale );
target.SetOffset( assignment.Role, assignment.Offset );
}
ArchStyle.InvalidateCache();
}
static string MaterialName( ArchMapPalette.Surface surface )
{
var name = surface.Name;
if ( name.StartsWith( "map_seamless_", StringComparison.OrdinalIgnoreCase ) )
{
name = name[13..];
}
return $"{name} · {surface.Category}";
}
static string ShortMaterial( string path )
{
if ( string.IsNullOrWhiteSpace( path ) || path == "none" )
{
return "Unassigned";
}
return Path.GetFileNameWithoutExtension( path );
}
}