Editor/UI/ArchLayerPalettePicker.cs
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 );
}
}