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 );
}
}