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