editor/AdvancedMappingTools/Procedural/ConnectedWallGenerator.cs

Editor side procedural wall generator. ConnectedWallSettings holds the wall line, height, thickness, alignment, openings and materials; ConnectedWallGenerator builds wall panels around openings, drops in doors and windows, adds plinths and junction prisms from the section data. The WallLinker static class re-solves connections between walls in a scene, converts solved corners back to local space, snaps new walls onto existing ones and can weld walls into one mesh.

namespace AdvancedMappingTools;
/// <summary>
/// An opening kept as a value, so a wall can be rebuilt or reconnected without losing it.
/// </summary>
public sealed class WallOpeningSpec
{

	[Property, ResourceType( "vmat" )]
	public string FrameMaterial { get; set; }

	[Property, ResourceType( "vmat" )]
	public string GlassMaterial { get; set; }

	[Property, ResourceType( "vmat" )]
	public string LeafMaterial { get; set; }

	[Property]
	public bool Enabled { get; set; } = true;

	[Property]
	public bool Frame { get; set; } = true;

	[Property]
	public bool Glass { get; set; } = true;

	[Property]
	public bool Leaf { get; set; } = true;

	[Property]
	public float Along { get; set; }

	[Property]
	public float Width { get; set; } = 48.0f;

	[Property]
	public float Sill { get; set; } = 36.0f;

	[Property]
	public float Height { get; set; } = 56.0f;

	[Property]
	public bool Window { get; set; } = true;

	/// <summary>
	/// A door with frame and leaf stands in the opening.
	/// </summary>
	[Property]
	public bool Door { get; set; }
}

public sealed partial class ConnectedWallSettings : GeneratorSettings, IProfiledFrameSettings
{
	/// <summary>
	/// The line of the wall, in the space of the root object.
	/// </summary>
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 256.0f, 0.0f, 0.0f )] );

	/// <summary>
	/// How the wall is cut where it meets others, set by the connection engine.
	/// </summary>
	public List<WallSection> Sections { get; set; } = [];

	/// <summary>
	/// Outlines of the pieces that fill the middle of a junction.
	/// </summary>
	public List<List<Vector3>> Hubs { get; set; } = [];

	/// <summary>
	/// The walls this one is connected to, by object id.
	/// </summary>
	public List<Guid> Neighbours { get; set; } = [];

	[Property, Group( "Placed Openings" ), WideMode]
	public List<WallOpeningSpec> PlacedOpenings { get; set; } = [];

	[Property, Group( "Wall" ), Range( 1, 4096, slider: false ), WideMode]
	public float Height { get; set; } = 128.0f;

	[Property, Group( "Wall" ), Range( 0.25f, 512, slider: false ), WideMode]
	public float Thickness { get; set; } = 8.0f;

	[Property, Group( "Wall" ), WideMode, Description( "Where the wall sits relative to its line." )]
	public WallAlignment Alignment { get; set; } = WallAlignment.Center;

	[Property, Group( "Connection" ), Title( "Connect" ), WideMode, Description( "Off leaves this wall out of every connection." )]
	public bool Connected { get; set; } = true;

	[Property, Group( "Connection" ), Title( "Join" ), WideMode, Description( "How two ends meet at a corner. Overlap leaves both square." )]
	public WallJoinStyle Join { get; set; } = WallJoinStyle.Miter;

	[Property, Group( "Openings" ), Range( 0, 64, slider: false ), WideMode, Description( "Openings spread evenly along the wall." )]
	public int Openings { get; set; } = 0;

	[Property, HideIf( nameof( Openings ), 0 ), Group( "Openings" ), Title( "Opening Width" ), Range( 4, 1024, slider: false ), WideMode]
	public float OpeningWidth { get; set; } = 48.0f;

	[Property, HideIf( nameof( Openings ), 0 ), Group( "Openings" ), Title( "Sill Height" ), Range( 0, 1024, slider: false ), WideMode]
	public float SillHeight { get; set; } = 36.0f;

	[Property, HideIf( nameof( Openings ), 0 ), Group( "Openings" ), Title( "Opening Height" ), Range( 4, 1024, slider: false ), WideMode]
	public float OpeningHeight { get; set; } = 56.0f;

	[Property, HideIf( nameof( Openings ), 0 ), Group( "Openings" ), WideMode, Description( "A window frame with glass in every opening." )]
	public bool Windows { get; set; } = true;

	[Property, Group( "Materials" ), ResourceType( "vmat" ), WideMode]
	public string Material { get; set; }

	[Property, ShowIf( nameof( Windows ), true ), Group( "Materials" ), Title( "Windows" ), ResourceType( "vmat" ), WideMode]
	public string WindowMaterial { get; set; }

	[Property, ShowIf( nameof( Windows ), true ), Group( "Materials" ), Title( "Glass" ), ResourceType( "vmat" ), WideMode]
	public string GlassMaterial { get; set; }

	[Property, ShowIf( nameof( Windows ), true ), Group( "Openings" ), Title( "Profiled Frames" ), WideMode, Description( "Profiled window frames with real corner joints, glass seated in them and glazing bars. Off: the plain bars of earlier versions." )]
	public bool ProfiledFrames { get; set; }

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Openings" ), Title( "Frame Corners" ), WideMode]
	public FrameCorner FrameCorner { get; set; } = FrameCorner.Mitred;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Openings" ), Title( "Frame Profile" ), WideMode]
	public FrameProfile FrameProfile { get; set; } = FrameProfile.Chamfered;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Openings" ), Title( "Frame Detail" ), WideMode]
	public WindowDetail FrameDetail { get; set; } = WindowDetail.Standard;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Openings" ), Title( "Glazing" ), WideMode]
	public Glazing FrameGlazing { get; set; } = Glazing.Double;

	public (float Left, float Right) Faces() => Alignment switch
	{
		WallAlignment.Inside => (Thickness, 0.0f),
		WallAlignment.Outside => (0.0f, -Thickness),
		_ => (Thickness * 0.5f, -Thickness * 0.5f)
	};
}

/// <summary>
/// A wall along a line, as independent panels around its openings. Where it meets other walls
/// it ends on the corners the connection engine worked out. It only reads them, so a
/// wall can be rebuilt on its own at any time.
/// </summary>
public sealed partial class ConnectedWallGenerator : IPartGenerator
{
	public const string GeneratorId = "connectedwall";

	const string WallCategory = "wall";
	const float MinimumPanel = 0.05f;
	const float PierClearance = 4.0f;

	public string Id => GeneratorId;
	public string Title => "Wall";
	public Type SettingsType => typeof( ConnectedWallSettings );
	public IReadOnlyList<string> SeedNames => [WallCategory, FrameGenerator.FrameCategory, FrameGenerator.GlassCategory];

	/// <summary>
	/// The sampled line of a wall.
	/// </summary>
	public static PathTrack Line( ConnectedWallSettings s ) => s.Path.Track( 1024.0f );

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (ConnectedWallSettings)settings;
		var root = context.Group( "root", "Wall", "Building", WallCategory );
		if ( s.Path is null || s.Path.SegmentCount == 0 )
			return root;
		var track = Line( s );
		var shape = new WallShape( s, track );
		var sections = s.Sections.Count > 0 ? s.Sections : [new WallSection
		{
			From = 0.0f,
			To = track.Length
		}

		];
		var z0 = track.Frames[0].Position.z;
		var z1 = z0 + s.Height;
		var material = context.Material( s.Material );
		for ( var i = 0; i < sections.Count; i++ )
			root.Add( Section( s, context, shape, sections[i], i, z0, z1, material ) );
		for ( var i = 0; i < s.Hubs.Count; i++ )
		{
			var outline = s.Hubs[i];
			root.Add( context.Solid( $"junction{i:00}", "Junction", "WallJunction", WallCategory, material, kit => kit.Prism( outline, z0, z1 ) ) );
		}

		if ( s.Plinth )
			AddPlinth( root, s, context, track, sections, z0 );
		WallDetailMaterials.Apply( root, s, context, track );
		return root;
	}

	/// <summary>Where the wall panels begin: on top of the plinth when there is one.</summary>
	static float Floor( ConnectedWallSettings s, float z0 ) => s.Plinth ? z0 + MathF.Min( Math.Clamp( s.PlinthHeight, 1, 256 ), s.Height ) : z0;

	static PartPlan Section( ConnectedWallSettings s, GenContext context, WallShape shape, WallSection section, int index, float z0, float z1, Material material )
	{
		var id = $"section{index:00}";
		var group = context.Group( id, $"Wall Section {index + 1}", "Wall", WallCategory );
		var windowMaterial = context.Material( s.WindowMaterial );
		var glass = context.Material( s.GlassMaterial );
		var cursor = section.From;
		var number = 0;
		// With a plinth the wall stands on it instead of reaching down through it.
		var floor = Floor( s, z0 );
		void Panel( string name, string label, float from, float to, float low, float high )
		{
			low = MathF.Max( low, floor );
			if ( to - from < MinimumPanel || high - low < MinimumPanel )
				return;
			var piece = 0;
			foreach ( var(start, end)in shape.Pieces( from, to ) )
			{
				var partId = $"{id}/{name}{(piece > 0 ? $"_{piece}" : "")}";
				group.Add( context.Solid( partId, label, "WallPanel", WallCategory, material, kit => kit.Prism( shape.Outline( section, start, end ), low, high ) ) );
				piece++;
			}
		}

		var openings = OpeningsIn( s, section ).ToArray();
		if ( openings.Where( ( a, i ) => openings.Skip( i + 1 ).Any( b => MathF.Abs( a.Along - b.Along ) < (a.Width + b.Width) * .5f - .01f ) ).Any() )
			return LayeredSection( s, context, shape, section, index, z0, z1, material, openings );
		foreach ( var opening in openings )
		{
			var half = opening.Width * 0.5f;
			// An opening stays clear of the ends of its section, a corner needs its pier.
			var from = MathF.Max( opening.Along - half, section.From + PierClearance );
			var to = MathF.Min( opening.Along + half, section.To - PierClearance );
			if ( to - from < 1.0f )
				continue;
			Panel( $"pier{number}", $"Pier {number + 1}", cursor, from, z0, z1 );
			Panel( $"below{number}", $"Below Opening {number + 1}", from, to, z0, z0 + opening.Sill );
			Panel( $"above{number}", $"Above Opening {number + 1}", from, to, z0 + opening.Sill + opening.Height, z1 );
			if ( opening.Door )
				group.Add( Door( s, shape, context, $"{id}/door{number}", number, from, to, z0 + opening.Sill, MathF.Min( opening.Height, z1 - z0 - opening.Sill ), windowMaterial, material, opening ) );
			else if ( opening.Window && s.Windows )
				group.Add( Window( s, context, shape, $"{id}/window{number}", number, from, to, z0 + opening.Sill, MathF.Min( opening.Height, z1 - z0 - opening.Sill ), windowMaterial, glass, opening ) );
			cursor = to;
			number++;
		}

		Panel( $"pier{number}", $"Pier {number + 1}", cursor, section.To, z0, z1 );
		return group;
	}

	/// <summary>
	/// A door or window is straight, a wall may bend. It spans the straight line between the two
	/// sides of its opening, and is deep enough to fill the wall where the wall bows away.
	/// </summary>
	static (Vector3 Center, float Yaw, float Width, float Depth) Fit( ConnectedWallSettings s, WallShape shape, float from, float to, float sill )
	{
		var middle = (shape.Left + shape.Right) * 0.5f;
		var start = shape.Track.PlaceAt( from, middle );
		var end = shape.Track.PlaceAt( to, middle );
		var center = (start + end) * 0.5f;
		var chord = (end - start).WithZ( 0.0f );
		var bow = shape.Track.PlaceAt( (from + to) * 0.5f, middle ).WithZ( 0.0f ).Distance( center.WithZ( 0.0f ) );

		return (center.WithZ( sill ), MathF.Atan2( chord.y, chord.x ).RadianToDegree(), chord.Length, s.Thickness + 2.0f + bow * 2.0f);
	}

	static PartPlan Door( ConnectedWallSettings s, WallShape shape, GenContext context, string id, int number, float from, float to, float sill, float height, Material frameMaterial, Material leaf, WallOpeningSpec opening )
	{
		var(center, yaw, width, depth) = Fit( s, shape, from, to, sill );
		var plan = DoorGenerator.Generate( new DoorRequest( id, $"Door {number + 1}", width, height, depth, 3.0f, context.Material( opening.FrameMaterial ?? s.WindowMaterial ), context.Material( opening.LeafMaterial ?? s.Material ) ), context ).At( center, yaw );
		plan.Children.RemoveAll( p => (p.Kind == "FrameBar" && !opening.Frame) || (p.Kind is "DoorLeaf" or "Handle" && !opening.Leaf) );
		return plan;
	}

	static PartPlan Window( ConnectedWallSettings s, GenContext context, WallShape shape, string id, int number, float from, float to, float sill, float height, Material trim, Material glass, WallOpeningSpec opening )
	{
		var(center, yaw, width, depth) = Fit( s, shape, from, to, sill );
		var plan = FrameGenerator.Generate( new FrameRequest( id, $"Window {number + 1}", width, height, depth, 3.0f, FrameStyle.Wooden, 1, 1, opening.Frame, opening.Glass, 0.0f, s.Variation, context.Material( opening.FrameMaterial ?? s.WindowMaterial ), context.Material( opening.GlassMaterial ?? s.GlassMaterial ), FrameGenerator.Spec( s ) ), context ).At( center, yaw );
		if ( !opening.Frame )
			plan.Children.RemoveAll( p => p.Kind == "Frame" );
		return plan;
	}

	/// <summary>
	/// The openings of a section: placed ones first, then the evenly spread ones.
	/// </summary>
	static IEnumerable<WallOpeningSpec> OpeningsIn( ConnectedWallSettings s, WallSection section )
	{
		if ( !s.UseOpenings )
			return[];
		var all = s.PlacedOpenings.Where( x => x.Enabled && x.Along >= section.From && x.Along <= section.To ).ToList();
		var length = section.To - section.From;
		for ( var row = 0; row < (s.OpeningGrid ? Math.Clamp( s.OpeningRows, 1, 16 ) : 1); row++ )
			for ( var i = 0; i < Math.Clamp( s.Openings, 0, 64 ); i++ )
			{
				var window = !s.OpeningGrid || s.AutomaticOpeningKind == WallOpeningKind.Window;
				var sill = (window ? s.SillHeight : 0) + row * Math.Clamp( s.OpeningRowSpacing, 8, 1024 );
				if ( sill + s.OpeningHeight > s.Height || sill < 0 )
					continue;
				var item = new WallOpeningSpec
				{
					Along = section.From + length * (i + .5f) / s.Openings,
					Width = s.OpeningWidth,
					Sill = sill,
					Height = s.OpeningHeight,
					Window = window,
					Door = s.OpeningGrid && s.AutomaticOpeningKind == WallOpeningKind.Door
				};
				if ( all.Any( o => MathF.Abs( o.Along - item.Along ) < (o.Width + item.Width) * .5f && o.Sill < item.Sill + item.Height && o.Sill + o.Height > item.Sill ) )
					continue;
				all.Add( item );
			}

		return all.OrderBy( x => x.Along );
	}

	/// <summary>
	/// The footprint of a wall, addressed by distance along its line.
	/// </summary>
	public sealed class WallShape
	{
		public PathTrack Track { get; }
		public float Left { get; }
		public float Right { get; }

		readonly Vector2[] _leftFace;
		readonly Vector2[] _rightFace;

		public WallShape( ConnectedWallSettings s, PathTrack track, float expansion = 0 )
		{
			Track = track;
			(Left, Right) = s.Faces();
			Left += expansion;
			Right -= expansion;
			_leftFace = [..Enumerable.Range( 0, track.Count ).Select( i => Flat( track.Place( i, Left ) ) )];
			_rightFace = [..Enumerable.Range( 0, track.Count ).Select( i => Flat( track.Place( i, Right ) ) )];
		}

		static Vector2 Flat( Vector3 v ) => new( v.x, v.y );

		static Vector3 Lift( Vector2 v ) => new( v.x, v.y, 0.0f );

		/// <summary>
		/// How far along the wall the point of a face lies.
		/// </summary>
		float AlongFace( Vector2[] face, Vector3 point )
		{
			var target = Flat( point );
			var best = (Distance: float.MaxValue, Along: 0.0f);

			for ( var i = 0; i + 1 < face.Length; i++ )
			{
				var a = face[i];
				var d = face[i + 1] - a;
				var t = d.LengthSquared > 0.0f ? (target - a).Dot( d ) / d.LengthSquared : 0.0f;

				// The first and last piece carry on: a mitre ends past the line of the wall.
				var low = i == 0 ? -8.0f : 0.0f;
				var high = i == face.Length - 2 ? 8.0f : 1.0f;

				t = Math.Clamp( t, low, high );

				var distance = (a + d * t).Distance( target );

				if ( distance < best.Distance )
					best = (distance, Track.Frames[i].Distance + (Track.Frames[i + 1].Distance - Track.Frames[i].Distance) * t);
			}

			return best.Along;
		}

		List<Vector3> Chain( Vector2[] face, float side, float from, float to, Vector3? start, Vector3? end, IReadOnlyList<Vector3> stops )
		{
			var first = start is Vector3 s ? AlongFace( face, s ) : from;
			var last = end is Vector3 e ? AlongFace( face, e ) : to;

			var points = new List<(float Along, Vector3 Point)> { (first, start ?? Lift( Flat( Track.PlaceAt( from, side ) ) )) };

			// The very ends of the line are no corners: a face that runs on past them stays straight.
			for ( var i = 1; i < Track.Count - 1; i++ )
			{
				var distance = Track.Frames[i].Distance;

				if ( distance > first + 0.01f && distance < last - 0.01f )
					points.Add( (distance, Lift( face[i] )) );
			}

			foreach ( var stop in stops )
			{
				var distance = AlongFace( face, stop );

				if ( distance > first + 0.01f && distance < last - 0.01f )
					points.Add( (distance, stop) );
			}

			points.Add( (last, end ?? Lift( Flat( Track.PlaceAt( to, side ) ) )) );

			return [.. points.OrderBy( x => x.Along ).Select( x => x.Point )];
		}

		/// <summary>
		/// A stretch of a wall as outlines. All the way around a closed wall the outline would be a
		/// ring, so that stretch comes in two halves.
		/// </summary>
		public IEnumerable<(float From, float To)> Pieces( float from, float to )
		{
			if ( !Track.Closed || to - from < Track.Length * 0.75f )
			{
				yield return (from, to);
				yield break;
			}

			var middle = (from + to) * 0.5f;

			yield return (from, middle);
			yield return (middle, to);
		}

		/// <summary>
		/// The outline between two distances. At the ends of a section it uses the solved corners.
		/// </summary>
		public List<Vector3> Outline( WallSection section, float from, float to )
		{
			var atStart = from <= section.From + 0.01f;
			var atEnd = to >= section.To - 0.01f;

			var right = Chain( _rightFace, Right, from, to, atStart ? section.FromRight : null, atEnd ? section.ToRight : null, section.RightStops );
			var left = Chain( _leftFace, Left, from, to, atStart ? section.FromLeft : null, atEnd ? section.ToLeft : null, section.LeftStops );

			left.Reverse();

			if ( atEnd ) right.AddRange( section.ToEdge );

			right.AddRange( left );

			if ( atStart ) right.AddRange( section.FromEdge.AsEnumerable().Reverse() );

			return right;
		}
	}
}

/// <summary>
/// Keeps the walls of a scene connected. Every wall stays its own object, a change only
/// touches settings and each wall then regenerates itself.
/// </summary>
public static class WallLinker
{
	static readonly ConnectedWallGenerator Generator = new();

	public static List<GameObject> Walls( Scene scene ) =>
		[.. scene.GetAllComponents<AmtPart>()
			.Where( x => PartReconciler.IsAlive( x ) && x.Generator == ConnectedWallGenerator.GeneratorId && !string.IsNullOrEmpty( x.Settings ) )
			.Select( x => x.GameObject )];

	static ConnectedWallSettings Read( GameObject wall ) => (ConnectedWallSettings)PartService.Read( wall ).Settings;

	/// <summary>
	/// The walls a change to this one may touch. For undo.
	/// </summary>
	public static List<GameObject> Network( GameObject wall ) => Walls( wall.Scene );

	/// <summary>
	/// Work out all connections again and rebuild the walls whose ends changed. Parts changed by
	/// hand keep their changes, walls that were not touched are not rebuilt at all.
	/// </summary>
	public static List<GameObject> Reconnect( Scene scene )
	{
		// A fixed order: which of two equal walls runs through must not change from run to run.
		var walls = Walls( scene ).OrderBy( x => x.Id ).ToList();
		var settings = walls.Select( Read ).ToList();
		var linked = Enumerable.Range( 0, walls.Count ).Where( i => settings[i].Connected ).ToList();
		var inputs = linked.Select( i =>
		{
			var s = settings[i];
			var world = walls[i].WorldTransform;
			var(left, right) = s.Faces();
			var line = ConnectedWallGenerator.Line( s );
			var path = line.Frames.Take( s.Path.Closed ? line.Count - 1 : line.Count ).Select( x => world.PointToWorld( x.Position ) ).ToList();
			// The solver works in world units, settings and generated geometry in local units.
			var tangent = (line.Frames[1].Position - line.Frames[0].Position).WithZ( 0 ).Normal;
			var normal = new Vector3( -tangent.y, tangent.x, 0 );
			var worldTangent = (world.PointToWorld( tangent ) - world.Position).WithZ( 0 ).Normal;
			var worldNormal = (world.PointToWorld( normal ) - world.Position).WithZ( 0 );
			var widthScale = worldNormal.Dot( new Vector3( -worldTangent.y, worldTangent.x, 0 ) );
			var a = left * widthScale;
			var b = right * widthScale;
			var endZ = world.PointToWorld( line.Frames[0].Position + Vector3.Up * s.Height ).z;
			return new WallInput( path, s.Path.Closed, MathF.Max( a, b ), MathF.Min( a, b ), s.Join, MathF.Min( path[0].z, endZ ), MathF.Max( path[0].z, endZ ) );
		} ).ToList();
		var solved = WallNetwork.Solve( inputs );
		var changed = new List<GameObject>();
		for ( var n = 0; n < linked.Count; n++ )
		{
			var i = linked[n];
			var wall = walls[i];
			var s = settings[i];
			var solution = solved[n];
			var world = wall.WorldTransform;
			Vector3? Local( Vector3? point ) => point is Vector3 p ? world.PointToLocal( p ).WithZ( 0.0f ) : null;
			var localLine = ConnectedWallGenerator.Line( s );
			float LocalDistance( float distance )
			{
				var walked = 0.0f;
				for ( var k = 1; k < localLine.Count; k++ )
				{
					var previous = localLine.Frames[k - 1];
					var current = localLine.Frames[k];
					var span = world.PointToWorld( previous.Position ).Distance( world.PointToWorld( current.Position ) );
					if ( span <= 0.00001f )
						continue;
					if ( distance <= walked + span || k == localLine.Count - 1 )
						return previous.Distance + (distance - walked) / span * (current.Distance - previous.Distance);
					walked += span;
				}

				return 0;
			}

			var mirrored = world.Scale.x * world.Scale.y < 0;
			var sections = solution.Sections.Select( x => new WallSection { From = LocalDistance( x.From ), To = LocalDistance( x.To ), FromLeft = Local( mirrored ? x.FromRight : x.FromLeft ), FromRight = Local( mirrored ? x.FromLeft : x.FromRight ), ToLeft = Local( mirrored ? x.ToRight : x.ToLeft ), ToRight = Local( mirrored ? x.ToLeft : x.ToRight ), LeftStops = [..(mirrored ? x.RightStops : x.LeftStops).Select( p => world.PointToLocal( p ).WithZ( 0.0f ) )], RightStops = [..(mirrored ? x.LeftStops : x.RightStops).Select( p => world.PointToLocal( p ).WithZ( 0.0f ) )], FromEdge = [..(mirrored ? x.FromEdge.AsEnumerable().Reverse() : x.FromEdge).Select( p => world.PointToLocal( p ).WithZ( 0.0f ) )], ToEdge = [..(mirrored ? x.ToEdge.AsEnumerable().Reverse() : x.ToEdge).Select( p => world.PointToLocal( p ).WithZ( 0.0f ) )] } ).ToList();
			var hubs = solution.Hubs.Select( x => x.Select( p => world.PointToLocal( p ).WithZ( 0.0f ) ).ToList() ).ToList();
			var neighbours = solution.Neighbours.Select( x => walls[linked[x]].Id ).OrderBy( x => x ).ToList();
			string Cuts() => System.Text.Json.JsonSerializer.Serialize( s.Sections ) + System.Text.Json.JsonSerializer.Serialize( s.Hubs );
			var before = Cuts();
			s.Sections = sections;
			s.Hubs = hubs;
			s.Neighbours = neighbours;
			if ( before == Cuts() )
			{
				PartReconciler.RootPart( wall ).Settings = GeneratorRegistry.Save( s );
				continue;
			}

			PartService.Regenerate( wall, RegenerationOptions.Everything, true, s );
			changed.Add( wall );
		}

		scene.ProcessDeletes();
		return changed;
	}

	/// <summary>
	/// Put a point on a wall line near it, on the end if one is close. That is what makes
	/// a new wall connect: it starts or ends on the old one, at the height of its foot.
	/// A click on a wall hits its top, a new wall must not start up there.
	/// </summary>
	public static Vector3 Snap( Scene scene, Vector3 point, float radius, bool stack = false ) => TrySnap( scene, point, radius, stack, out var result ) ? result : point;

	public static bool TrySnap( Scene scene, Vector3 point, float radius, bool stack, out Vector3 snapped )
	{
		var flat = point.WithZ( 0.0f );
		var best = float.MaxValue;
		var result = point;
		foreach ( var wall in Walls( scene ) )
		{
			var s = Read( wall );
			var line = ConnectedWallGenerator.Line( s );
			var world = wall.WorldTransform;
			var reach = radius + s.Thickness * .5f * MathF.Max( MathF.Abs( world.Scale.x ), MathF.Abs( world.Scale.y ) );
			var footZ = world.PointToWorld( line.Frames[0].Position ).z;
			var endZ = world.PointToWorld( line.Frames[0].Position + Vector3.Up * s.Height ).z;
			var topZ = MathF.Max( footZ, endZ );
			footZ = MathF.Min( footZ, endZ );
			// A click on a wall from above lands on its top too, and that has to stay a T junction.
			// Only a tool that was told to stack looks at the top edge.
			var plane = stack && MathF.Abs( point.z - topZ ) < MathF.Abs( point.z - footZ ) ? topZ : footZ;
			var last = line.Closed ? line.Count - 1 : line.Count;
			// Ends win over the side of a wall, they are what a corner is made of.
			foreach ( var i in new[]
			{
				0,
				last - 1
			}

	 )
			{
				var end = world.PointToWorld( line.Frames[i].Position ).WithZ( 0.0f );
				var horizontal = end.Distance( flat );
				var vertical = plane - point.z;
				var distance = MathF.Sqrt( horizontal * horizontal * 0.0625f + vertical * vertical );
				if ( end.Distance( flat ) > reach || distance >= best )
					continue;
				best = distance;
				result = end.WithZ( plane );
			}

			for ( var i = 0; i + 1 < line.Count; i++ )
			{
				var a = world.PointToWorld( line.Frames[i].Position ).WithZ( 0.0f );
				var b = world.PointToWorld( line.Frames[i + 1].Position ).WithZ( 0.0f );
				var d = b - a;
				var t = d.LengthSquared > 0.0f ? Math.Clamp( (flat - a).Dot( d ) / d.LengthSquared, 0.0f, 1.0f ) : 0.0f;
				var on = a + d * t;
				var horizontal = on.Distance( flat );
				var vertical = plane - point.z;
				var distance = MathF.Sqrt( horizontal * horizontal + vertical * vertical );
				if ( horizontal > reach || distance >= best )
					continue;
				best = distance;
				result = on.WithZ( plane );
			}
		}

		snapped = result;
		return best < float.MaxValue;
	}

	/// <summary>
	/// One connected mesh out of walls, on request. The walls stay as they are. Faces where two
	/// walls meet exactly disappear, which leaves one body without anything inside it.
	/// </summary>
	public static PolygonMesh Weld( IEnumerable<GameObject> walls, Material material )
	{
		var kit = new MeshKit { Material = material };

		foreach ( var wall in walls )
		{
			var s = Read( wall );
			var track = ConnectedWallGenerator.Line( s );
			var shape = new ConnectedWallGenerator.WallShape( s, track );
			var z0 = track.Frames[0].Position.z;
			var world = wall.WorldTransform;

			kit.Frame = world;

			foreach ( var section in s.Sections.Count > 0 ? s.Sections : [new WallSection { From = 0.0f, To = track.Length }] )
			{
				foreach ( var (from, to) in shape.Pieces( section.From, section.To ) )
					kit.Prism( shape.Outline( section, from, to ), z0, z0 + s.Height );
			}

			foreach ( var hub in s.Hubs )
				kit.Prism( hub, z0, z0 + s.Height );
		}

		return kit.Finish();
	}
}