editor/AdvancedMappingTools/UI/PartTools.cs

Editor tool definitions for a parts-based mapping toolkit. Eight PrimitiveTool editors (House, Wall Parts, Window Parts, Stair Parts, Tunnel, Curved Stairs, Curved Ramp) each build a preview mesh and place generated parts into the scene on click, plus two helper classes: PathTool.Spiral and PartTool (material picking, placing, wall and road linking).

namespace AdvancedMappingTools;

/// <summary>
/// A whole house of independent parts, on a rectangle set by three clicks.
/// </summary>
[ToolCategory( "Buildings" )]
[Title( "House" ), Icon( "home" )]
public sealed class HouseEditor( PrimitiveTool tool ) : RectEditor( tool )
{
	readonly HouseGenerator _generator = new();

	/// <summary>New houses get profiled window frames; placed houses keep what they were saved with.</summary>
	public HouseSettings Settings { get; } = new() { ProfiledFrames = true };

	public override object ExtraSettings => Settings;

	protected override bool PlacesObjects => true;

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Width = RectLength;
		Settings.Depth = RectDepth;
		Settings.WallMaterial = PartTool.Pick( Settings.WallMaterial, SurfaceMaterial );
		Settings.FloorMaterial = PartTool.Pick( Settings.FloorMaterial, SurfaceMaterial );
		Settings.RoofMaterial = PartTool.Pick( Settings.RoofMaterial, SurfaceMaterial );
		Settings.TrimMaterial = PartTool.Pick( Settings.TrimMaterial, TrimMaterial );
		Settings.GlassMaterial = PartTool.Pick( Settings.GlassMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( RectFrame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, RectFrame );
	}
}

/// <summary>
/// Walls of separate panels that connect themselves. Every click finishes a wall and the next one
/// starts at its end, or draw a whole line first. New points snap onto the walls around
/// them, and corners, T junctions and crossings are worked out from there.
/// </summary>
[ToolCategory( "Walls & Floors" )]
[Title( "Wall Parts" ), Icon( "view_week" )]
public sealed class WallPartsEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Trim", "CurveSegments", nameof( ConnectFinished ) };

	readonly ConnectedWallGenerator _generator = new();

	/// <summary>New walls get profiled window frames; placed ones keep what they were saved with.</summary>
	public ConnectedWallSettings Settings { get; } = new() { ProfiledFrames = true };

	public override object ExtraSettings => Settings;

	[Property, Group( "Wall" ), Title( "Single Pieces" ), WideMode, Description( "Every click finishes a wall. Off draws one wall through all points until you create it." )]
	public bool SinglePieces { get; set; } = true;

	protected override int MinPoints => 2;
	protected override bool CanClose => !SinglePieces;
	protected override bool PlacesObjects => true;
	protected override bool PlacesPiecesInChain => SinglePieces;

	protected override IEnumerable<string> Hints => SinglePieces
		? ["Each click: finish a wall piece", "A point near a wall snaps onto it", "Esc: end the chain"]
		: [.. base.Hints, "A point near a wall snaps onto it", "Click the green point: close loop"];

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	protected override bool SnapsToLines => true;

	// This wall has its own Connect switch, one is enough.
	public override bool ConnectFinished
	{
		get => Settings.Connected;
		set => Settings.Connected = value;
	}

	protected override void BuildMesh( MeshKit kit ) { }

	ConnectedWallSettings Prepare()
	{
		Settings.Path = Curve();
		Settings.Material = PartTool.Pick( Settings.Material, SurfaceMaterial );
		Settings.WindowMaterial = PartTool.Pick( Settings.WindowMaterial, SurfaceMaterial );
		Settings.GlassMaterial = PartTool.Pick( Settings.GlassMaterial, SurfaceMaterial );
		return Settings;
	}

	protected override PolygonMesh BuildPreview() => PartService.Plan( _generator, Prepare() ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );

	protected override void PlaceObjects()
	{
		PartTool.PlaceWall( _generator, Prepare() );
	}
}

/// <summary>
/// A window with separate frame bars and glass. The two clicks set its width.
/// </summary>
[ToolCategory( "Doors & Windows" )]
[Title( "Window Parts" ), Icon( "window" )]
public sealed class WindowPartsEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface" };

	readonly WindowPartsGenerator _generator = new();

	/// <summary>New windows get a profiled frame; placed windows keep what they were saved with.</summary>
	public WindowPartsSettings Settings { get; } = new() { ProfiledFrames = true };

	public override object ExtraSettings => Settings;

	protected override bool PlacesObjects => true;

	protected override void BuildMesh( MeshKit kit ) { }

	Transform Frame => new( (Start + End) * 0.5f, Facing );

	void Prepare()
	{
		Settings.Width = Length;
		Settings.FrameMaterial = PartTool.Pick( Settings.FrameMaterial, TrimMaterial );
		Settings.GlassMaterial = PartTool.Pick( Settings.GlassMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( Frame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, Frame );
	}
}

/// <summary>
/// Stairs where every step is its own part, between two clicks.
/// </summary>
[ToolCategory( "Stairs & Ramps" )]
[Title( "Stair Parts" ), Icon( "stairs" )]
public sealed class StairPartsEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	readonly StairPartsGenerator _generator = new();

	public StairPartsSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	protected override bool PlacesObjects => true;

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Run = Length;
		Settings.StepMaterial = PartTool.Pick( Settings.StepMaterial, SurfaceMaterial );
		Settings.TrimMaterial = PartTool.Pick( Settings.TrimMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( LineFrame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, LineFrame );
	}
}

/// <summary>
/// A tunnel or vault along a drawn route. Shift and drag lifts a point, so the route can climb, fall or spiral.
/// </summary>
[ToolCategory( "Tunnels & Bridges" )]
[Title( "Tunnel" ), Icon( "subway" )]
public sealed class TunnelEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	readonly TunnelGenerator _generator = new();

	public TunnelSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;
	protected override bool CanClose => true;
	protected override bool PlacesObjects => true;
	protected override bool AllowsHeight => true;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Shift + drag a point: raise or lower it", "Click the green point: close loop"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		var path = SamplePath();

		Settings.Path = [.. path.Select( p => new[] { p.x, p.y, p.z } )];
		Settings.ClosedPath = Closed;
		Settings.WallMaterial = PartTool.Pick( Settings.WallMaterial, SurfaceMaterial );
		Settings.VaultMaterial = PartTool.Pick( Settings.VaultMaterial, SurfaceMaterial );
		Settings.FloorMaterial = PartTool.Pick( Settings.FloorMaterial, SurfaceMaterial );
		Settings.DetailMaterial = PartTool.Pick( Settings.DetailMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}

/// <summary>
/// Stairs that follow a drawn line, turning and climbing with it, or a spiral around a centre.
/// </summary>
[ToolCategory( "Stairs & Ramps" )]
[Title( "Curved Stairs" ), Icon( "stairs" )]
public sealed class CurvedStairEditor : PathEditor
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "CurveSegments" };

	readonly CurvedStairGenerator _generator = new();

	public CurvedStairEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;

	public CurvedStairSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;
	protected override int MaxPoints => Settings.Layout == PathLayout.Spiral ? 2 : int.MaxValue;
	protected override bool ConnectPoints => Settings.Layout == PathLayout.Drawn;
	protected override bool PlacesObjects => true;
	protected override bool AllowsHeight => true;
	protected override bool IsShapeValid => Points.Count >= 2 && Points[0].Position.Distance( Points[1].Position ) > 1.0f;

	protected override IEnumerable<string> Hints => Settings.Layout == PathLayout.Spiral
		? ["Point 1: centre", "Point 2: radius and start"]
		: [.. base.Hints, "Shift + drag a point: raise or lower it"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Path = Settings.Layout == PathLayout.Spiral
			? PathTool.Spiral( Points[0].Position, Points[1].Position, Settings.Turns, Settings.Clockwise )
			: Curve();

		Settings.StepMaterial = PartTool.Pick( Settings.StepMaterial, SurfaceMaterial );
		Settings.TrimMaterial = PartTool.Pick( Settings.TrimMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();

		if ( Settings.Path is not null && Settings.Path.Closed && MathF.Abs( Settings.Rise ) > 0.5f )
			Log.Warning( "Davelix Construct: a closed stair path cannot climb; Rise is ignored. Give the path climbing points instead." );

		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}

/// <summary>
/// A ramp that follows a drawn line, with curbs and sidewalks running along it.
/// </summary>
[ToolCategory( "Stairs & Ramps" )]
[Title( "Curved Ramp" ), Icon( "signal_cellular_0_bar" )]
public sealed class CurvedRampEditor : PathEditor
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "CurveSegments", "Variation" };

	readonly CurvedRampGenerator _generator = new();

	public CurvedRampEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;

	public CurvedRampSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;
	protected override int MaxPoints => Settings.Layout == PathLayout.Spiral ? 2 : int.MaxValue;
	protected override bool ConnectPoints => Settings.Layout == PathLayout.Drawn;
	protected override bool CanClose => Settings.Layout == PathLayout.Drawn;
	protected override bool PlacesObjects => true;
	protected override bool AllowsHeight => true;
	protected override bool IsShapeValid => Points.Count >= 2 && Points[0].Position.Distance( Points[1].Position ) > 1.0f;

	protected override IEnumerable<string> Hints => Settings.Layout == PathLayout.Spiral
		? ["Point 1: centre", "Point 2: radius and start"]
		: [.. base.Hints, "Shift + drag a point: raise or lower it", "Click the green point: close loop"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Path = Settings.Layout == PathLayout.Spiral
			? PathTool.Spiral( Points[0].Position, Points[1].Position, Settings.Turns, Settings.Clockwise )
			: Curve();

		Settings.DeckMaterial = PartTool.Pick( Settings.DeckMaterial, SurfaceMaterial );
		Settings.CurbMaterial = PartTool.Pick( Settings.CurbMaterial, TrimMaterial );
		Settings.SidewalkMaterial = PartTool.Pick( Settings.SidewalkMaterial, SurfaceMaterial );
		Settings.DetailMaterial = PartTool.Pick( Settings.DetailMaterial, TrimMaterial );
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();

		if ( Settings.Path is not null && Settings.Path.Closed && MathF.Abs( Settings.Rise ) > 0.5f )
			Log.Warning( "Davelix Construct: a closed ramp cannot climb; Rise is ignored. Give the path climbing points instead." );

		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}

/// <summary>
/// What tools that build along a path have in common.
/// </summary>
public static class PathTool
{
	/// <summary>
	/// A spiral around a clicked centre, starting at a second clicked point.
	/// The height comes from the Rise of the generator.
	/// </summary>
	public static PathCurve Spiral( Vector3 center, Vector3 start, float turns, bool clockwise )
	{
		var radius = center.WithZ( 0.0f ).Distance( start.WithZ( 0.0f ) );
		var angle = MathF.Atan2( start.y - center.y, start.x - center.x ).RadianToDegree();

		return PathCurve.Helix( center, radius, turns, 0.0f, angle, clockwise );
	}
}

/// <summary>
/// What the part tools have in common.
/// </summary>
public static class PartTool
{
	/// <summary>
	/// A chosen material path, or the path of the material the tool is using.
	/// </summary>
	public static string Pick( string chosen, Material fallback ) =>
		!string.IsNullOrEmpty( chosen ) ? chosen : fallback?.ResourcePath ?? GenContext.DefaultMaterial;

	/// <summary>
	/// Place a wall and connect it to the walls around it, as one undo step for all of them.
	/// </summary>
	public static void PlaceWall( IPartGenerator generator, ConnectedWallSettings settings )
	{
		if ( !PartService.CanStoreParts )
		{
			Log.Warning( "Davelix Construct: restart the editor once. The Davelix Part component is not registered yet, so a wall placed now would lose its data when saved." );
			return;
		}

		var scene = SceneEditorSession.Active.Scene;
		var neighbours = WallLinker.Walls( scene );
		var copy = (ConnectedWallSettings)GeneratorRegistry.Load( generator, GeneratorRegistry.Save( settings ) );
		var root = PartService.Create( generator, copy, new Transform( Vector3.Zero, Rotation.Identity ) );

		if ( neighbours.Count > 0 )
			PartService.Transaction( "Connect Walls", neighbours, () => WallLinker.Reconnect( scene ) );
		else
			WallLinker.Reconnect( scene );

		var selection = SceneEditorSession.Active.Selection;

		selection.Clear();
		selection.Add( root );
	}

	/// <summary>
	/// Place roads and connect them to the roads around them. Undo comes from the tool, which
	/// knows all of them before it starts.
	/// </summary>
	public static void PlaceRoads( RoadGenerator generator, IEnumerable<RoadSettings> roads )
	{
		if ( !PartService.CanStoreParts )
		{
			Log.Warning( "Davelix Construct: restart the editor once. The Davelix Part component is not registered yet, so a road placed now would lose its data when saved." );
			return;
		}

		var placed = new List<GameObject>();
		var copies = roads.Select( road =>
		{
			var copy = (RoadSettings)GeneratorRegistry.Load( generator, GeneratorRegistry.Save( road ) );
			copy.Stretches = [];
			return copy;
		} ).ToList();

		// Solve the junctions first, so each road is built once, already fitted to them.
		var stretches = RoadLinker.StretchesFor( SceneEditorSession.Active.Scene, copies );

		for ( var i = 0; i < copies.Count; i++ )
		{
			copies[i].Stretches = stretches[i];
			placed.Add( PartService.Create( generator, copies[i], new Transform( Vector3.Zero, Rotation.Identity ) ) );
		}

		RoadLinker.Reconnect( SceneEditorSession.Active.Scene );

		var selection = SceneEditorSession.Active.Selection;

		selection.Clear();

		foreach ( var root in placed )
			selection.Add( root );
	}

	public static void Place( IPartGenerator generator, GeneratorSettings settings, Transform world )
	{
		if ( !PartService.CanStoreParts )
		{
			Log.Warning( "Davelix Construct: restart the editor once. The Davelix Part component is not registered yet, so a building placed now would lose its data when saved." );
			return;
		}

		var root = PartService.Create( generator, settings, world );
		var selection = SceneEditorSession.Active.Selection;

		selection.Clear();
		selection.Add( root );
	}
}