Editor/Layers/ArchParts.cs

Editor utility types for architectural layer parts. Defines ArchPartRecord to store an authored piece record (item id, piece path, collision mode) and static helpers in ArchParts to manipulate piece path strings, split generated paths into owner and piece, find/create records in an ArchPlan, and resolve which collision mode a piece should show or follow.

File AccessReflection
using System.Collections.Generic;
using System.Linq;

namespace Sunless.Architecture;

// What an author said about ONE named piece of a layer - the fascia on a roof, the balustrade on a porch.
// Written only when a piece is given an answer of its own; a piece nobody touched carries no record and
// follows whatever stands above it.
public sealed class ArchPartRecord
{
	public int ItemId { get; set; }
	public string Piece { get; set; } = "";
	public ArchCollisionMode? Collision { get; set; }
}

// A PIECE IS A PATH under its layer - "Gutters", "Gutters/Fascia" - and everything asked about one is asked
// here: what stands above it, what answer it inherits, and where its record lives. A second opinion about
// what "the piece above this one" means is a fascia that follows the roof while the gutters it hangs on say
// something else.
public static class ArchParts
{
	public static string Joined( string piece, string name )
	{
		return string.IsNullOrEmpty( piece ) ? name : $"{piece}/{name}";
	}

	public static string Above( string piece )
	{
		var at = (piece ?? "").LastIndexOf( '/' );

		return at < 0 ? "" : piece[..at];
	}

	public static string Name( string piece )
	{
		var at = (piece ?? "").LastIndexOf( '/' );

		return at < 0 ? piece ?? "" : piece[(at + 1)..];
	}

	// The editor-only key the eye hides by: a piece has no id of its own, so it is named by the layer that
	// built it and where it hangs on that layer.
	public static string Key( int itemId, string piece ) => $"{itemId}:{piece}";

	// A GENERATED PATH read back as a layer and a piece of it - the deepest segment carrying a plan id is the
	// layer that authored it, and everything below that is where the piece hangs. Asked here and nowhere else,
	// because a part filed under a path it composed itself must resolve the same way as one that opened a Part
	// scope: the gutter channel is written straight to "Roof_12/Gutters/Channel" and has to answer to the same
	// record the fascia beside it does.
	public static void Split( string path, out int owner, out string piece )
	{
		var segments = (path ?? "").Split( '/', System.StringSplitOptions.RemoveEmptyEntries );

		for ( var index = segments.Length - 1; index >= 0; index-- )
		{
			if ( !ArchNames.TrySourceId( segments[index], out owner ) )
			{
				continue;
			}

			piece = string.Join( "/", segments[(index + 1)..] );

			return;
		}

		owner = 0;
		piece = "";
	}

	public static ArchPartRecord Find( ArchPlan plan, int itemId, string piece )
	{
		return plan?.Parts.FirstOrDefault( record => record.ItemId == itemId && record.Piece == piece );
	}

	public static ArchPartRecord Record( ArchPlan plan, int itemId, string piece )
	{
		if ( Find( plan, itemId, piece ) is { } held )
		{
			return held;
		}

		var record = new ArchPartRecord { ItemId = itemId, Piece = piece };

		plan.Parts.Add( record );

		return record;
	}

	// The nearest answer at or above this piece, stopping short of the layer itself - a fascia follows the
	// gutters it hangs on before it ever follows the roof they hang on.
	public static ArchCollisionMode? Asked( ArchPlan plan, int itemId, string piece )
	{
		if ( plan is null || plan.Parts.Count == 0 )
		{
			return null;
		}

		for ( var walked = piece; !string.IsNullOrEmpty( walked ); walked = Above( walked ) )
		{
			if ( Find( plan, itemId, walked )?.Collision is { } mode )
			{
				return mode;
			}
		}

		return null;
	}

	// What a row shows and what its menu says it is following, the way ArchCollision.Showing answers it for a
	// layer - one answer, so the badge and the menu cannot disagree about whether this piece owns its mode.
	public static ArchCollisionMode Showing( ArchPlan plan, ArchKit kit, ArchLayerTree layers, int itemId, string piece, out bool own )
	{
		var mine = Find( plan, itemId, piece )?.Collision;

		own = mine.HasValue;

		return mine
			?? Asked( plan, itemId, Above( piece ) )
			?? (layers?.Find( itemId )?.Payload as IArchCollides)?.Collision
			?? ArchCollision.Above( layers?.Find( itemId ) )
			?? kit?.Physics
			?? ArchCollisionMode.Solids;
	}

	// Where a piece with no answer of its own gets one from, named so clearing an override says what will
	// answer instead.
	public static string Following( ArchPlan plan, ArchKit kit, ArchLayerTree layers, int itemId, string piece )
	{
		if ( Above( piece ) is { Length: > 0 } above && Asked( plan, itemId, above ) is { } inherited )
		{
			return $"Follow {Name( above )} — {ArchCollision.Describe( inherited )}";
		}

		var node = layers?.Find( itemId );
		var showing = ArchCollision.Showing( node, kit, out _ );

		return node is null
			? $"Follow the kit — {ArchCollision.Describe( kit?.Physics ?? ArchCollisionMode.Solids )}"
			: $"Follow {node.Name} — {ArchCollision.Describe( showing )}";
	}
}