Editor/EffigyEditor/EffigyWindow.ObjectMode.cs

Editor UI code for the Effigy model workspace. It builds and manages the Object-mode toolbar, menus and dock interactions, handles switching between Object/Edit/Sculpt/Retopo modes, and implements object operations like move/rotate/scale, duplicate, join, hide, edit, sculpt and related UI checks and callbacks.

Native Interop
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Marionette.EditorTools;

// ============================================================================
//  Model — Object mode.
//
//  THE HALF OF THE MODEL REDESIGN THAT WAS MISSING. On 2026-09-18 Edit mode
//  became a modeller's layout — a toolbar of held tools, menus for the rest,
//  right-click on the model, Space to search, Object | Edit | Retopo at the
//  right, and a Scene / Mesh check / History dock in place of the CAD tree.
//  But it only switched in while a mesh was open. The moment you pressed
//  Object, the two stage tabs ("Edit", "Sculpt") and the CAD feature tree came
//  back, with Origin and three datum planes at the top — so Model still read
//  as CAD every time you were not mid-edit, which is most of the time.
//
//  Object mode now uses the same bar and the same dock. The toolbar holds
//  Move / Rotate / Scale (the body handle), Duplicate, Edit and Sculpt; the
//  menus are Object, Add and Mesh; the mode switch is Object | Edit | Sculpt |
//  Retopo; a right-click on a body opens the Object menu. The dock shows the
//  bodies, the selected one's check and the history, and stays for the whole
//  workspace — Edit mode fills it from the session, Object mode from the
//  studio (EffigyMeshEditPanel.RefreshObject).
//
//  NOTHING HERE IS A NEW OPERATION. Every entry is a feature the CAD strip
//  already adds (Transform, Mirror, Linear pattern, Primitive, Subdivide,
//  Remesh, Sculpt, Mesh edit) or a part-list command (Join, Delete, Hide),
//  reached from where a modeller looks for it. The history still records
//  each one, and CAD still shows the tree.
// ============================================================================

public sealed partial class EffigyWindow
{
	private EffigyStage _objectCoreStage;
	private List<EffigyStage> _objectMenus;

	private EffigyStageTool _objMoveTool, _objRotateTool, _objScaleTool, _objDuplicateTool, _objMirrorTool,
		_objJoinTool, _objDeleteTool, _objHideTool, _objIsolateTool, _objShowAllTool,
		_objEditTool, _objSculptTool, _objRetopoTool, _objSubdivideTool, _objRemeshTool, _objCadTool,
		_objSplineTool, _objBendTool, _objPartTool, _objProfileTool;

	/// <summary>Whether the Model workspace is showing with nothing open — the state this file is for.</summary>
	private bool InObjectMode =>
		CurrentWorkspace == EffigyWorkspace.Model
		&& _viewport is { IsMeshEditing: false, IsSculpting: false };

	/// <summary>
	/// The toolbar and the menus, built once. The same tool objects go on both, so an enabled state
	/// set by <see cref="UpdateObjectChecks"/> is what both show — the reason Edit mode shares its
	/// tools between its toolbar and its menus too.
	/// </summary>
	private void BuildObjectModeTools()
	{
		if ( _objectCoreStage is not null )
			return;

		static EffigyStageTool Tool( EffigyIcon icon, string label, string tip, Action clicked, bool checkable = false ) =>
			new() { Icon = icon, Label = label, Tip = tip, Clicked = clicked, Checkable = checkable };

		_objMoveTool = Tool( EffigyIcon.Transform, "Move", "Move the selected body with a handle (W). Saved as a Transform", () => StartBodyTransform( EffigyViewport.BodyDragMode.Move ), checkable: true );
		_objRotateTool = Tool( EffigyIcon.CircularPattern, "Rotate", "Turn the selected body with a handle (E)", () => StartBodyTransform( EffigyViewport.BodyDragMode.Rotate ), checkable: true );
		_objScaleTool = Tool( EffigyIcon.Primitive, "Scale", "Scale the selected body with a handle (R)", () => StartBodyTransform( EffigyViewport.BodyDragMode.Scale ), checkable: true );
		_objDuplicateTool = Tool( EffigyIcon.LinearPattern, "Duplicate", "A copy of the selected body beside it. Saved as a Linear pattern, so the count and spacing can still be changed", DuplicateBody );
		_objMirrorTool = Tool( EffigyIcon.Mirror, "Mirror", "A mirrored copy of the selected body across a plane", () => AddFeature( NewFeature( ToolKind.Mirror, -1 ) ) );
		_objJoinTool = Tool( EffigyIcon.Boolean, "Join", "Make one body of the selected ones, as a new Import. Ctrl+Z brings them back", () => JoinParts( _viewport?.IdleBodyIds.ToList() ) );
		_objDeleteTool = Tool( EffigyIcon.DeleteGeometry, "Delete", "Remove the selected bodies. Ctrl+Z brings them back", () => DeleteParts( _viewport?.IdleBodyIds.ToList() ) );
		_objHideTool = Tool( EffigyIcon.SelectFace, "Hide", "Hide the selected bodies. They still export", () => ForEachSelectedBody( id => OnPartCommand( id, EffigyPartCommand.ToggleVisibility ) ) );
		_objIsolateTool = Tool( EffigyIcon.SelectFace, "Show only this", "Hide everything except the selected body", () => ForEachSelectedBody( id => OnPartCommand( id, EffigyPartCommand.Isolate ), first: true ) );
		_objShowAllTool = Tool( EffigyIcon.SelectFace, "Show all", "Show every hidden body again", () => { if ( _studio.Bodies.Count > 0 ) OnPartCommand( _studio.Bodies[0].Id, EffigyPartCommand.ShowAll ); } );

		_objEditTool = Tool( EffigyIcon.MeshEdit, "Edit", "Edit the selected body's vertices, edges and faces (Tab). Saved as a Mesh edit", AddMeshEdit );
		_objSculptTool = Tool( EffigyIcon.Sculpt, "Sculpt", "Brush detail onto the selected body in levels. Saved as a Sculpt", () => AddFeature( NewFeature( ToolKind.Sculpt, -1 ) ) );
		_objRetopoTool = Tool( EffigyIcon.SelectFace, "Retopo", "Draw a clean, light mesh over the selected body", StartRetopoFromObject );
		_objSubdivideTool = Tool( EffigyIcon.Subdivide, "Subdivide", "Smooth the selected body with Catmull-Clark subdivision", () => AddFeature( NewFeature( ToolKind.Subdivide, -1 ) ) );
		_objRemeshTool = Tool( EffigyIcon.Remesh, "Remesh", "Reduce a dense body to a triangle budget, keeping its shape", () => AddFeature( NewFeature( ToolKind.Remesh, -1 ) ) );
		_objSplineTool = Tool( EffigyIcon.SplineTool, "Spline", "Draw a curve: click points in the viewport, and it becomes a tube — a cable, a horn, a tail. A Bend or a Sweep can follow it too", AddSpline );
		_objBendTool = Tool( EffigyIcon.Drape, "Bend along curve", "Bend the selected body along the last spline drawn: its length follows the curve", () => AddFeature( NewFeature( ToolKind.CurveDeform, -1 ) ) );
		_objPartTool = Tool( EffigyIcon.Primitive, "Part from the library", "A cog, bolt, rivet, knob, hinge, panel, pipe elbow, strap or buckle, from a few dials", () => AddFeature( NewFeature( ToolKind.Part, -1 ) ) );
		_objProfileTool = Tool( EffigyIcon.Loft, "Profile body", "A body from a front outline and a side outline — how wide and how deep at each height", () => AddFeature( NewFeature( ToolKind.Profile, -1 ) ) );
		_objCadTool = Tool( EffigyIcon.Sketch, "Open in CAD", "The same history as a feature tree, with sketches, extrudes and fillets", () => SetWorkspace( EffigyWorkspace.Cad ) );

		// The toolbar: what you hold. Everything on it is on a menu too.
		_objectCoreStage = new EffigyStage { Name = "Object" };
		foreach ( var tool in new[] { _objMoveTool, _objRotateTool, _objScaleTool, _objDuplicateTool, _objEditTool, _objSculptTool } )
			_objectCoreStage.Add( tool );

		var objectMenu = new EffigyStage { Name = "Object" };
		foreach ( var tool in new[] { _objMoveTool, _objRotateTool, _objScaleTool, null, _objDuplicateTool, _objMirrorTool, _objJoinTool, null, _objHideTool, _objIsolateTool, _objShowAllTool, null, _objDeleteTool } )
			objectMenu.Add( tool );

		// Add: a body from nothing. The primitive shapes come off the feature, as the CAD strip's
		// Primitive dropdown does, so a shape the kernel grows appears here without a second list.
		var add = new EffigyStage { Name = "Add" };
		var shapes = PrimitiveShapes;
		for ( var i = 0; i < shapes.Length; i++ )
		{
			var shape = i;
			add.Add( Tool( EffigyIcon.Primitive, shapes[i], $"Add a {shapes[i].ToLowerInvariant()}", () => AddFeature( NewPrimitive( shape ) ) ) );
		}
		add.Add( null );
		add.Add( _objSplineTool );
		add.Add( _objPartTool );
		add.Add( _objProfileTool );
		add.Add( null );
		add.Add( Tool( EffigyIcon.Primitive, "Import mesh...", "Bring in an .obj, .fbx or .vmdl as a body", () => AddFeature( NewFeature( ToolKind.Import, -1 ) ) ) );
		add.Add( Tool( EffigyIcon.Bone, "Body from Bones", "A skin block-out built on the rig, ready to edit", BodyFromBones ) );
		add.Add( null );
		add.Add( Tool( EffigyIcon.Plane, "Front reference image...", "A picture behind the model to draw against, seen from the front", () => AddReferenceImage( "front" ) ) );
		add.Add( Tool( EffigyIcon.Plane, "Side reference image...", "A picture seen from the side", () => AddReferenceImage( "side" ) ) );
		add.Add( Tool( EffigyIcon.Plane, "Top reference image...", "A picture seen from above", () => AddReferenceImage( "top" ) ) );

		var mesh = new EffigyStage { Name = "Mesh" };
		foreach ( var tool in new[] { _objEditTool, _objSculptTool, _objRetopoTool, null, _objSubdivideTool, _objRemeshTool, _objBendTool, null, _objCadTool } )
			mesh.Add( tool );

		_objectMenus = new List<EffigyStage> { objectMenu, add, mesh };
	}

	/// <summary>
	/// Object mode's bar. Called by ShowSculptHome — the Model workspace's landing — in place of
	/// the two stage tabs it used to put up.
	/// </summary>
	private void ShowObjectModeBar()
	{
		if ( _stageBar is null )
			return;

		BuildObjectModeTools();

		_stageBar.Mode = null;
		_stageBar.SetFinish( null, null );
		_stageBar.SetStages( new[] { _objectCoreStage } );
		_stageBar.SetMenus( _objectMenus );
		_stageBar.MenuOpening = UpdateObjectChecks;
		UpdateModelModeSwitch( "Object" );
		ShowModelChrome( "Object" );
		UpdateObjectChecks();
		SyncModelDock();
	}

	/// <summary>
	/// Object | Edit | Sculpt | Retopo, with <paramref name="active"/> lit. One switch for the
	/// whole workspace: Edit mode and the sculpt brushes show the same four, so changing mode is
	/// always the same click in the same place, and leaving a mode is picking the next one.
	/// </summary>
	private void UpdateModelModeSwitch( string active )
	{
		if ( _stageBar is null )
			return;

		var segments = new[]
		{
			new EffigyModeSegment { Label = "Object", Tip = "Whole bodies: move, copy, join, delete. Leaving an edit or a sculpt keeps it", Active = active == "Object", Clicked = () => SwitchModelMode( "Object" ) },
			new EffigyModeSegment { Label = "Edit", Tip = "Edit vertices, edges and faces (Tab)", Active = active == "Edit", Clicked = () => SwitchModelMode( "Edit" ) },
			new EffigyModeSegment { Label = "Sculpt", Tip = "Brush detail onto the body in levels", Active = active == "Sculpt", Clicked = () => SwitchModelMode( "Sculpt" ) },
			new EffigyModeSegment { Label = "Retopo", Tip = "Draw a clean, light mesh over the body", Active = active == "Retopo", Clicked = () => SwitchModelMode( "Retopo" ) },
		};

		_stageBar.SetSegments( segments );

		if ( _modelHeader is not null )
		{
			_modelHeader.Modes.Clear();
			_modelHeader.Modes.AddRange( segments );
			_modelHeader.Update();
		}
	}

	/// <summary>
	/// The mode switch's one handler. Leaving is always a commit, never a discard — the old Finish
	/// buttons kept the work, and so does this — and entering is what the Object-mode tool of the
	/// same name does, so the switch and the toolbar cannot disagree about what "Edit" means.
	/// </summary>
	private void SwitchModelMode( string mode )
	{
		if ( _viewport is null )
			return;

		var editing = _viewport.IsMeshEditing;
		var retopo = editing && _viewport.MeshEditSession is { IsRetopologizing: true };
		var sculpting = _viewport.IsSculpting;

		switch ( mode )
		{
			case "Object":
				if ( editing ) FinishMeshEdit();
				else if ( sculpting ) FinishSculpt();
				else ShowSculptHome();
				return;

			case "Edit":
				if ( retopo ) { ToggleMeshRetopo(); UpdateModelModeSwitch( "Edit" ); return; }
				if ( editing ) return;
				if ( sculpting ) FinishSculpt();
				AddMeshEdit();
				return;

			case "Sculpt":
				if ( sculpting ) return;
				if ( editing ) FinishMeshEdit();
				AddFeature( NewFeature( ToolKind.Sculpt, -1 ) );
				return;

			case "Retopo":
				if ( retopo ) return;
				if ( sculpting ) FinishSculpt();
				StartRetopoFromObject();
				return;
		}
	}

	/// <summary>Retopo from outside an edit: open the body in Edit mode, then switch retopology on.</summary>
	private void StartRetopoFromObject()
	{
		if ( _viewport is null )
			return;

		if ( !_viewport.IsMeshEditing )
			AddMeshEdit();

		if ( _viewport.MeshEditSession is { IsRetopologizing: false } )
			ToggleMeshRetopo();

		UpdateModelModeSwitch( _viewport.MeshEditSession is { IsRetopologizing: true } ? "Retopo" : "Edit" );
	}

	/// <summary>
	/// Move / Rotate / Scale: the body handle, which only exists while a Transform's dialog is open
	/// (EffigyViewport.BodyDrag has the argument). With one open, this just switches the handle;
	/// otherwise it adds a Transform on the selection and opens on that handle.
	/// </summary>
	private void StartBodyTransform( EffigyViewport.BodyDragMode mode )
	{
		if ( _viewport is null || _studio is null )
			return;

		if ( _dialog?.Feature is not TransformFeature || !_viewport.BodyDragEnabled )
		{
			if ( _viewport.IdleBodyIds.Count == 0 )
			{
				SetPrompt( "Click a body first, then Move, Rotate or Scale it." );
				return;
			}

			AddFeature( NewFeature( ToolKind.Transform, -1 ) );
		}

		_viewport.SetBodyDragMode( mode );
		UpdateObjectChecks();
		_viewport.Update();
	}

	/// <summary>
	/// A copy beside the original: a Linear pattern of two, spaced by the body's own width, so the
	/// copy lands clear of it rather than on top. The pattern's count and spacing stay editable in
	/// the history, which is more than a plain copy would give.
	/// </summary>
	private void DuplicateBody()
	{
		if ( _viewport is null || _studio is null )
			return;

		var ids = _viewport.IdleBodyIds;

		if ( ids.Count == 0 )
		{
			SetPrompt( "Click a body first, then Duplicate it." );
			return;
		}

		var feature = new LinearPatternFeature { Name = "Duplicate" };
		feature.Count.Value = 2;

		var width = 0f;

		foreach ( var body in _studio.Bodies.Where( b => ids.Contains( b.Id ) ) )
		{
			if ( body.Mesh is not { } mesh || mesh.Positions.Count == 0 )
				continue;

			var min = mesh.Positions.Min( p => p.x );
			var max = mesh.Positions.Max( p => p.x );
			width = MathF.Max( width, max - min );
		}

		if ( width > 0f )
			feature.Spacing.Value = width * 1.25f;

		AddFeature( feature );
	}

	private void ForEachSelectedBody( Action<string> action, bool first = false )
	{
		if ( _viewport is null )
			return;

		var ids = _viewport.IdleBodyIds.ToList();

		if ( ids.Count == 0 )
		{
			SetPrompt( "Click a body first." );
			return;
		}

		if ( first )
		{
			action( ids[0] );
			return;
		}

		foreach ( var id in ids )
			action( id );
	}

	/// <summary>
	/// Enabled states and ticks for the Object tools, from the selection and the open dialog. Run
	/// before a menu opens and after every selection change, the way Edit mode's checks are.
	/// </summary>
	private void UpdateObjectChecks()
	{
		if ( _objectCoreStage is null || _viewport is null || _studio is null )
			return;

		var selected = _viewport.IdleBodyIds.Count;
		var bodies = _studio.Bodies.Count;
		var handle = _dialog?.Feature is TransformFeature && _viewport.BodyDragEnabled;
		var mode = _viewport.CurrentBodyDragMode;

		const string pickOne = "Click a body first";

		Need( _objMoveTool, selected > 0 || handle, pickOne );
		Need( _objRotateTool, selected > 0 || handle, pickOne );
		Need( _objScaleTool, selected > 0 || handle, pickOne );
		Need( _objDuplicateTool, selected > 0, pickOne );
		Need( _objMirrorTool, selected > 0, pickOne );
		Need( _objJoinTool, selected > 1, "Select two or more bodies (Ctrl+click) to join" );
		Need( _objDeleteTool, selected > 0, pickOne );
		Need( _objHideTool, selected > 0, pickOne );
		Need( _objIsolateTool, selected > 0 && bodies > 1, bodies > 1 ? pickOne : "There is only one body" );
		Need( _objShowAllTool, _studio.HiddenBodyIds.Count > 0, "Nothing is hidden" );
		Need( _objEditTool, true, null );
		Need( _objSculptTool, bodies > 0, "Add a body first" );
		Need( _objRetopoTool, bodies > 0, "Add a body first" );
		Need( _objSubdivideTool, selected > 0, pickOne );
		Need( _objRemeshTool, selected > 0, pickOne );
		Need( _objBendTool, selected > 0 && _studio.Features.OfType<SplineFeature>().Any(), selected == 0 ? pickOne : "Draw a Spline first (Add ▸ Spline)" );

		_objMoveTool.Checked = handle && mode == EffigyViewport.BodyDragMode.Move;
		_objRotateTool.Checked = handle && mode == EffigyViewport.BodyDragMode.Rotate;
		_objScaleTool.Checked = handle && mode == EffigyViewport.BodyDragMode.Scale;

		_stageBar?.Refresh();
		RefreshModelChrome();
	}

	/// <summary>
	/// Right-click on a body in Object mode: select it and open the Object menu on it, the way
	/// Edit mode's right-click opens the menu for the element under the mouse.
	/// </summary>
	private void OpenObjectMenu( Body body )
	{
		if ( body is null || _viewport is null )
			return;

		BuildObjectModeTools();

		// Ctrl keeps the selection, so a right-click on a second body can Join the two.
		if ( !Editor.Application.IsKeyDown( KeyCode.Control ) || !_viewport.IdleBodyIds.Contains( body.Id ) )
		{
			var ids = Editor.Application.IsKeyDown( KeyCode.Control )
				? _viewport.IdleBodyIds.Append( body.Id ).ToList()
				: new List<string> { body.Id };

			_viewport.SelectBodies( ids );
		}

		UpdateObjectChecks();

		var menu = new Menu( this );
		menu.AddHeading( body.Name ?? "Body" );
		EffigyStageBar.AddTools( menu, _objectMenus[0].Tools );
		menu.AddSeparator();
		EffigyStageBar.AddTools( menu, _objectMenus[2].Tools.Where( t => t is not null && t != _objCadTool ) );
		menu.OpenAtCursor();
	}

	/// <summary>
	/// The left dock follows the workspace: the Model panel in Model, the CAD tree everywhere
	/// else. Edit mode fills the panel from its session and Object mode from the studio, so the
	/// same three headings are there whichever mode you are in.
	/// </summary>
	private void SyncModelDock()
	{
		if ( _meshPanel is null )
			return;

		var model = CurrentWorkspace == EffigyWorkspace.Model;

		_meshPanel.Visible = model;

		if ( _featureTree is not null )
			_featureTree.Visible = !model;

		if ( _partsPanel is not null )
			_partsPanel.Visible = !model;

		if ( model )
			RefreshMeshEditPanel();
	}

	/// <summary>
	/// The Model panel's delegates. Assigned at construction and again after a hotload, since a
	/// lambda compiled into the dead assembly is a Scene row that selects nothing.
	/// </summary>
	private void BindModelDock()
	{
		if ( _meshPanel is null )
			return;

		_meshPanel.RunOp = RunMeshOp;
		_meshPanel.UndoTo = UndoMeshTo;

		// Object mode's half of the panel: the Scene list selects like the Parts list (Ctrl adds
		// or removes), a right-click on a row is the Object menu, History rows open their feature,
		// and a fix under the check opens the body in Edit mode with that tool run.
		_meshPanel.SelectBody = id =>
		{
			if ( _viewport is null )
				return;

			var ids = Editor.Application.IsKeyDown( KeyCode.Control )
				? _viewport.IdleBodyIds.Contains( id )
					? _viewport.IdleBodyIds.Where( i => i != id ).ToList()
					: _viewport.IdleBodyIds.Append( id ).ToList()
				: new List<string> { id };

			_viewport.SelectBodies( ids );
		};

		_meshPanel.BodyMenu = id => OpenObjectMenu( _studio?.Bodies.FirstOrDefault( b => b.Id == id ) );
		_meshPanel.OpenFeature = EditFeature;
		_meshPanel.RunTool = RunObjectFix;
	}

	/// <summary>A fix button under the Object-mode check: open the body in Edit mode with that
	/// tool run, since every fix is an edit.</summary>
	private void RunObjectFix( string tool )
	{
		if ( _viewport is null )
			return;

		if ( !_viewport.IsMeshEditing )
			AddMeshEdit();

		if ( !_viewport.IsMeshEditing )
			return;

		MeshToolNamed( tool )?.Clicked?.Invoke();
	}
}