editor/AdvancedMappingTools/Editing/TunnelCutter.cs

Editor tool that cuts a tunnel-shaped hole through meshes and terrain along a drawn path. Builds the cut body from the path, subtracts it from overlapping meshes with a mesh-damage check, optionally opens terrain holes, and places a tunnel lining, with undo support.

namespace AdvancedMappingTools;
public enum CutTargets
{
	[Title( "Everything It Touches" )] Touched,
	[Title( "Selected Objects" )] Selected
}

/// <summary>
/// What a cut did: how many meshes got cut, how many came out damaged and were left alone,
/// and how many terrain cells were opened.
/// </summary>
public readonly record struct TunnelCutResult( int Cut, int Refused, int TerrainCells );

/// <summary>
/// Takes the shape of a tunnel out of what stands in a scene.
/// </summary>
public static class TunnelCut
{
	/// <summary>
	/// The body that gets taken away, in world space. It carries on past the ends of the route by
	/// <paramref name="extend"/>, so a cut that starts in the open starts with a clean edge.
	/// </summary>
	public static PolygonMesh Body( TunnelSettings settings, bool lined, float extend, Material material, float grow = 0.0f )
	{
		var kit = new MeshKit { Material = material };
		var track = Track( settings, extend );

		SweepEngine.Build( kit, track, 0, track.Count - 1, TunnelGenerator.Outline( settings, lined, grow ) );

		return kit.Finish();
	}

	static PathTrack Track( TunnelSettings settings, float extend )
	{
		var points = settings.Path.Select( p => new Vector3( p[0], p[1], p[2] ) ).ToList();
		var closed = settings.ClosedPath && points.Count >= 3;

		// A ring has no ends to carry on past.
		if ( !closed && points.Count >= 2 && extend > 0.0f )
		{
			points[0] -= (points[1] - points[0]).Normal * extend;
			points[^1] += (points[^1] - points[^2]).Normal * extend;
		}

		var reach = settings.Width * 0.5f + settings.WallThickness;
		var route = PathFrames.Resample( points, closed, MathF.Min( settings.PathDetail, settings.BayLength * 0.5f ) );

		return PathTrack.Along( route, closed, settings.Orientation ).Plumbed().Reach( reach, reach );
	}

	/// <summary>
	/// The meshes in reach of the body. With a list of objects only those and what hangs under them.
	/// </summary>
	public static List<MeshComponent> Targets( Scene scene, PolygonMesh body, IEnumerable<GameObject> only = null )
	{
		var reach = body.CalculateBounds();
		var meshes = only is null
			? scene.GetAllComponents<MeshComponent>()
			: only.SelectMany( x => x.GetComponentsInChildren<MeshComponent>() );

		return [.. meshes.Distinct().Where( x => x.IsValid() && x.Mesh is not null && x.Mesh.FaceHandles.Any() && x.Mesh.CalculateBounds( x.WorldTransform ).Overlaps( reach ) )];
	}

	/// <summary>
	/// A cut may open what was open. It may never break a body that was closed, or turn its surface
	/// inside out. Faces too small to see do no harm, a face that points the wrong way does.
	/// </summary>
	public static bool Accepts( MeshReport before, MeshReport after )
	{
		if ( before.Shells == before.ClosedShells && after.Shells != after.ClosedShells ) return false;

		static int Bad( MeshReport report ) => report.Count( MeshIssueKind.InvertedShell ) + report.Count( MeshIssueKind.FlippedNeighbour ) + report.Count( MeshIssueKind.SelfIntersection );

		return Bad( after ) <= Bad( before );
	}

	public static TunnelCutResult Apply( IEnumerable<MeshComponent> targets, PolygonMesh body, Material material )
	{
		var cut = 0;
		var refused = 0;
		var world = new Transform( Vector3.Zero, Rotation.Identity );

		foreach ( var target in targets )
		{
			var result = MeshCutter.SubtractClean( target.Mesh, body, target.WorldTransform.ToLocal( world ), material );

			if ( result is null ) continue;

			if ( !Accepts( MeshDiagnostics.Analyze( target.Mesh ), MeshDiagnostics.Analyze( result ) ) )
			{
				Log.Warning( $"Davelix Construct: the tunnel was not cut into {target.GameObject.Name}, it would have damaged the mesh." );
				refused++;
				continue;
			}

			target.Mesh = result;
			target.RebuildMesh();
			cut++;
		}

		return new TunnelCutResult( cut, refused, 0 );
	}

	/// <summary>
	/// Open the terrain wherever its surface lies inside the tunnel. A terrain is a sheet and has no
	/// inside to take away, so this is what lets the tunnel come out of a hillside. Returns the cells changed.
	/// </summary>
	public static int OpenTerrain( Terrain terrain, TunnelSettings settings, bool lined, float extend, float grow = 0.0f )
	{
		var storage = terrain.Storage;

		if ( storage is null || storage.ControlMap is null || storage.HeightMap is null ) return 0;

		var track = Track( settings, extend );
		var outline = TunnelGenerator.Outline( settings, lined, grow );
		var size = storage.Resolution;
		var cell = storage.TerrainSize / (size - 1);
		var height = storage.TerrainHeight / ushort.MaxValue;
		var world = terrain.WorldTransform;
		var changed = 0;
		var first = new Vector2Int( size, size );
		var last = new Vector2Int( -1, -1 );

		var range = Reach( outline );

		for ( var y = 0; y < size; y++ )
		{
			for ( var x = 0; x < size; x++ )
			{
				var index = x + y * size;
				var surface = world.PointToWorld( new Vector3( x * cell, y * cell, storage.HeightMap[index] * height ) );

				if ( !Inside( track, outline, range, surface ) ) continue;

				var material = new CompactTerrainMaterial( storage.ControlMap[index] );

				if ( material.IsHole ) continue;

				material.IsHole = true;
				storage.ControlMap[index] = material.Packed;
				changed++;

				first = new Vector2Int( Math.Min( first.x, x ), Math.Min( first.y, y ) );
				last = new Vector2Int( Math.Max( last.x, x ), Math.Max( last.y, y ) );
			}
		}

		if ( changed > 0 ) Refresh( terrain, new RectInt( first, last - first + new Vector2Int( 1, 1 ) ) );

		return changed;
	}

	/// <summary>
	/// Put what a terrain was before back, for undo and redo.
	/// </summary>
	public static void RestoreTerrain( Terrain terrain, uint[] control )
	{
		var size = terrain.Storage.Resolution;

		control.CopyTo( terrain.Storage.ControlMap, 0 );

		Refresh( terrain, new RectInt( new Vector2Int( 0, 0 ), new Vector2Int( size, size ) ) );
	}

	static void Refresh( Terrain terrain, RectInt region )
	{
		terrain.Storage.StateHasChanged();

		// A terrain that was never drawn has no textures yet and reads its storage when it is.
		if ( terrain.ControlMap is not null )
			terrain.ApplyStorageChanges( Terrain.SyncFlags.Control, region );
	}

	static float Reach( List<Vector2> outline ) => outline.Max( p => p.Length );

	/// <summary>
	/// True when a point lies inside the outline carried along a track.
	/// </summary>
	static bool Inside( PathTrack track, List<Vector2> outline, float reach, Vector3 point )
	{
		// A cut with a single station has no stretch to test against.
		if ( track.Count < 2 )
			return false;
		// The last station ends the final stretch; it does not start another one.
		for ( var i = 0; i + 1 < track.Count; i++ )
		{
			var frame = track.Frames[i];
			var offset = point - frame.Position;
			var spacing = track.Frames[i + 1].Distance - frame.Distance;
			// A long segment can contain points far from its first station.
			if ( offset.Length > spacing + reach + frame.Stretch * reach )
				continue;
			var along = offset.Dot( frame.Forward );
			if ( along < 0.0f || along > spacing )
				continue;
			var plain = offset - frame.Forward * along;
			var local = new Vector2( plain.Dot( frame.Left ) / frame.Stretch, plain.z );
			if ( Contains( outline, local ) )
				return true;
		}

		return false;
	}

	static bool Contains( List<Vector2> outline, Vector2 point )
	{
		var inside = false;

		for ( var i = 0; i < outline.Count; i++ )
		{
			var a = outline[i];
			var b = outline[(i + 1) % outline.Count];

			if ( a.y > point.y != b.y > point.y && point.x < a.x + (point.y - a.y) / (b.y - a.y) * (b.x - a.x) )
				inside = !inside;
		}

		return inside;
	}
}

/// <summary>
/// Draw a route through what is already there. The shape of a tunnel is taken out of every mesh
/// on the way, as one undo step, and the tunnel can be built straight into the cut.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Tunnel Cutter" ), Icon( "subway" )]
public sealed class TunnelCutterEditor : PathEditor
{
	readonly TunnelGenerator _generator = new();

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

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

	public override object ExtraSettings => Settings;

	[Property, Group( "Cutter" ), WideMode]
	public CutTargets Targets { get; set; } = CutTargets.Touched;

	[Property, Group( "Cutter" ), WideMode, Description( "Builds a tunnel of separate parts into the cut. Off only leaves the opening." )]
	public bool Lining { get; set; } = true;

	[Property, Group( "Cutter" ), Title( "Extend Ends" ), Range( 0, 2048, slider: false ), WideMode, Description( "How far the cut carries on past both ends of the line." )]
	public float ExtendEnds { get; set; } = 0.0f;

	[Property, Group( "Cutter" ), Title( "Open Terrain" ), WideMode, Description( "Opens holes in terrain wherever the tunnel passes through its surface. Undo takes this back as a step of its own." )]
	public bool OpenTerrain { get; set; } = false;

	[Property, Group( "Cutter" ), Title( "Margin" ), Range( 0, 64, slider: false ), WideMode, Description( "How much wider the cut is than the lining, so wall and tunnel do not share a surface. Zero keeps them flush." )]
	public float Margin { get; set; } = 0.0f;

	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", "Create: cut the tunnel"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare()
	{
		Settings.Path = [.. SamplePath().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 TunnelCut.Body( Settings, Lining, ExtendEnds, SurfaceMaterial, Margin );
	}

	List<MeshComponent> _targets = [];
	PolygonMesh _body;

	protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing()
	{
		Prepare();

		var session = SceneEditorSession.Active;

		_body = TunnelCut.Body( Settings, Lining, ExtendEnds, SurfaceMaterial, Margin );
		_targets = TunnelCut.Targets( session.Scene, _body, Targets == CutTargets.Selected ? session.Selection.OfType<GameObject>() : null );

		return [.. _targets.Select( x => x.GameObject ).Distinct()];
	}

	protected override void PlaceObjects()
	{
		var session = SceneEditorSession.Active;
		var result = TunnelCut.Apply( _targets, _body, SurfaceMaterial );

		if ( Lining ) PartTool.Place( _generator, GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( Settings ) ), new Transform( Vector3.Zero, Rotation.Identity ) );

		var cells = 0;

		if ( OpenTerrain )
		{
			foreach ( var terrain in session.Scene.GetAllComponents<Terrain>() )
			{
				if ( terrain.Storage?.ControlMap is null ) continue;

				var before = terrain.Storage.ControlMap.ToArray();
				var opened = TunnelCut.OpenTerrain( terrain, Settings, Lining, ExtendEnds, Margin );

				if ( opened == 0 ) continue;

				var after = terrain.Storage.ControlMap.ToArray();

				session.AddUndo( "Open Terrain", () => TunnelCut.RestoreTerrain( terrain, before ), () => TunnelCut.RestoreTerrain( terrain, after ) );
				cells += opened;
			}
		}

		result = result with { TerrainCells = cells };

		Log.Info( $"Davelix Construct: tunnel cut through {result.Cut} meshes, {result.Refused} left alone, {result.TerrainCells} terrain cells opened." );
	}
}