editor/AdvancedMappingTools/Editing/CutEditor.cs

Editor tool (CutEditor) in the AdvancedMappingTools builder toolset. Clicking a mesh either subtracts an opening from it (with optional frame geometry) or splits it in two, using PolygonMesh boolean operations, with a gizmo preview of the cut outline and undo support.

namespace AdvancedMappingTools;

public enum CutAction
{
	[Title( "Cut Opening" )] Opening,
	[Title( "Split In Two" )] Split
}

/// <summary>
/// Work on meshes that already exist: click a wall to cut an opening into it,
/// or to split it into two separate objects.
/// </summary>
[ToolCategory( "Cutters" )]
[Title( "Wall Cutter" ), Icon( "content_cut" )]
public sealed class CutEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
	const float Far = 16384.0f;

	protected override int MinPoints => int.MaxValue;

	protected override IEnumerable<string> Hints => ["Click a mesh: apply the cut"];

	[Property, Group( "Cutter" ), WideMode]
	public CutAction Action { get; set; } = CutAction.Opening;

	[Property, Group( "Opening" ), Range( 2, 2048, slider: false ), WideMode]
	public float Width { get; set; } = 56.0f;

	[Property, Group( "Opening" ), Range( 2, 2048, slider: false ), WideMode]
	public float Height { get; set; } = 96.0f;

	[Property, HideIf( nameof( FollowCursorHeight ), true ), Group( "Opening" ), Title( "Sill Height" ), Range( 0, 2048, slider: false ), WideMode, Description( "Measured from the bottom of the clicked mesh." )]
	public float SillHeight { get; set; } = 0.0f;

	[Property, Group( "Opening" ), Title( "Follow Cursor Height" ), WideMode, Description( "Centres the opening on the cursor instead of the sill height." )]
	public bool FollowCursorHeight { get; set; } = false;

	[Property, Group( "Opening" ), WideMode]
	public ArchStyle Arch { get; set; } = ArchStyle.Flat;

	[Property, Group( "Opening" ), Title( "Add Frame" ), WideMode]
	public bool AddFrame { get; set; } = true;

	[Property, ShowIf( nameof( AddFrame ), true ), Group( "Opening" ), Title( "Frame Width" ), Range( 0.25f, 64, slider: false ), WideMode]
	public float FrameWidth { get; set; } = 3.0f;

	[Property, ShowIf( nameof( AddFrame ), true ), Group( "Opening" ), Title( "Frame Depth" ), Range( 0.25f, 256, slider: false ), WideMode, Description( "Half the thickness of the frame, usually a little more than half the wall." )]
	public float FrameDepth { get; set; } = 5.0f;

	[Property, Group( "Opening" ), Title( "Recess" ), Range( 0, 512, slider: false ), WideMode, Description( "How deep the opening goes into the wall. Zero cuts all the way through, anything else makes a niche." )]
	public float Recess { get; set; } = 0.0f;

	protected override void BuildMesh( MeshKit kit ) { }

	public override void OnUpdate( SceneTrace trace )
	{
		var tr = trace.Run();
		var mesh = tr.GameObject?.GetComponent<MeshComponent>();

		if ( !tr.Hit || !mesh.IsValid() || mesh.Mesh is null ) return;

		var normal = tr.Normal.WithZ( 0.0f );
		if ( normal.Length < 0.3f ) return;

		var along = Vector3.Cross( Vector3.Up, normal.Normal ).Normal;
		var point = Snap == SnapMode.Off ? tr.EndPosition : Gizmo.Snap( tr.EndPosition, new Vector3( 1, 1, 0 ) );
		point = point.WithZ( tr.EndPosition.z );

		var floor = mesh.GameObject.GetBounds().Mins.z + SillHeight;
		var bottom = FollowCursorHeight ? point.z - Height * 0.5f : floor;
		var frame = new Transform( point.WithZ( bottom ), Rotation.LookAt( along, Vector3.Up ) );

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Color = Action == CutAction.Split ? Color.Red : Color.Yellow;

		if ( Action == CutAction.Split )
		{
			var bounds = mesh.GameObject.GetBounds();
			Gizmo.Draw.Line( point.WithZ( bounds.Mins.z ), point.WithZ( bounds.Maxs.z ) );
		}
		else
		{
			var outline = OpeningOutline();
			for ( var i = 0; i < outline.Length; i++ )
			{
				var a = outline[i];
				var b = outline[(i + 1) % outline.Length];
				Gizmo.Draw.Line( frame.PointToWorld( new Vector3( a.x, 0.0f, a.y ) ), frame.PointToWorld( new Vector3( b.x, 0.0f, b.y ) ) );
			}
		}

		if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered ) return;

		if ( Action == CutAction.Split ) ApplySplit( mesh, frame );
		else ApplyOpening( mesh, frame, normal );
	}

	Vector2[] OpeningOutline()
	{
		var half = Width * 0.5f;
		var rise = MathF.Min( MeshKit.ArchRise( Arch, Width ), Height - 1.0f );

		var curve = MeshKit.ArchCurve( -half, half, Height - rise, rise, Arch, 12 );

		return [new( -half, 0.0f ), new( half, 0.0f ), .. curve.Reverse()];
	}

	/// <summary>
	/// A copy of a mesh with another one taken out of it.
	/// </summary>
	public static PolygonMesh Subtract( PolygonMesh source, PolygonMesh cutter, Transform relative )
	{
		var result = new PolygonMesh();
		result.SetTransform( source.Transform );
		result.MergeMesh( source, Transform.Zero, out _, out _, out _ );

		if ( !result.PerformBoolean( cutter, relative, PolygonMesh.BooleanOperation.Subtract ) )
			return null;

		result.ComputeFaceTextureCoordinatesFromParameters();

		return result;
	}

	/// <summary>
	/// Everything on the +X side of the frame.
	/// </summary>
	public static PolygonMesh HalfSpace( float sign )
	{
		var kit = new MeshKit();
		var mins = new Vector3( sign > 0.0f ? 0.0f : -Far, -Far, -Far );
		var maxs = new Vector3( sign > 0.0f ? Far : 0.0f, Far, Far );

		kit.Box( mins, maxs );

		return kit.Finish();
	}

	void ApplySplit( MeshComponent mesh, Transform frame )
	{
		var relative = mesh.WorldTransform.ToLocal( frame );

		var first = Subtract( mesh.Mesh, HalfSpace( 1.0f ), relative );
		var second = Subtract( mesh.Mesh, HalfSpace( -1.0f ), relative );

		if ( first is null || second is null )
		{
			Log.Warning( "The cut did not go through this mesh." );
			return;
		}

		using var scope = SceneEditorSession.Scope();
		using ( SceneEditorSession.Active.UndoScope( "Split Mesh" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
		{
			mesh.Mesh = first;
			mesh.RebuildMesh();

			var source = mesh.GameObject;
			var other = new GameObject( source.Parent, true, source.Name );

			other.WorldTransform = source.WorldTransform;
			other.IsStatic = source.IsStatic;

			var copy = other.Components.Create<MeshComponent>( false );
			copy.Mesh = second;
			copy.SmoothingAngle = mesh.SmoothingAngle;
			copy.Enabled = true;
		}
	}

	void ApplyOpening( MeshComponent mesh, Transform frame, Vector3 normal )
	{
		// How deep the cutter reaches: all the way through, or only into the wall for a niche.
		var depth = Recess > 0.01f ? Recess : Far * 0.01f;
		var inward = -normal.WithZ( 0.0f ).Normal;
		var axis = ( frame.PointToWorld( new Vector3( 0, 1, 0 ) ) - frame.Position ).Normal;
		var into = axis.Dot( inward ) > 0.0f ? 1.0f : -1.0f;
		var from = Recess > 0.01f ? -into * 0.5f : -into * depth;
		var to = into * depth;

		var cutter = new MeshKit { Material = SurfaceMaterial };
		cutter.Profile( OpeningOutline(), MathF.Min( from, to ), MathF.Max( from, to ) );

		var body = cutter.Finish();
		var relative = mesh.WorldTransform.ToLocal( frame );

		// The engine only cuts clean with straight cutters.
		var result = Arch == ArchStyle.Flat ? Subtract( mesh.Mesh, body, relative ) : MeshCutter.SubtractClean( mesh.Mesh, body, relative, SurfaceMaterial );

		if ( result is null )
		{
			Log.Warning( "The cut did not go through this mesh." );
			return;
		}

		using var scope = SceneEditorSession.Scope();
		using ( SceneEditorSession.Active.UndoScope( "Cut Opening" ).WithComponentChanges( mesh ).WithGameObjectCreations().Push() )
		{
			mesh.Mesh = result;
			mesh.RebuildMesh();

			if ( !AddFrame ) return;

			var kit = new MeshKit { Material = TrimMaterial };
			var half = Width * 0.5f;

			if ( Arch == ArchStyle.Flat )
			{
				kit.OpeningFrame( -half, half, 0.0f, Height, FrameWidth, FrameDepth, SillHeight > 0.0f );
			}
			else
			{
				var rise = MathF.Min( MeshKit.ArchRise( Arch, Width ), Height - 1.0f );
				kit.ArchFrame( -half, half, 0.0f, Height - rise, rise, Arch, 12, FrameWidth, FrameDepth, SillHeight > 0.0f );
			}

			var trim = kit.Finish();
			var go = new GameObject( mesh.GameObject.Parent, true, "Opening Frame" );

			go.WorldTransform = frame;
			go.IsStatic = true;

			var component = go.Components.Create<MeshComponent>( false );
			component.Mesh = trim;
			component.SmoothingAngle = 40.0f;
			component.Enabled = true;
		}
	}
}