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