Editor UI code for EffigyWindow workspaces and mesh edit UI. It manages workspace switching (CAD, Model/Sculpt, Paint, Rig, Clothing), stage bars, dock layouts, rig tools and mesh-edit operations and interactions with the viewport and studio.
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.EditorTools;
// ============================================================================
// Workspaces — CAD, Sculpt, Paint, Rig.
//
// WHY THIS FILE EXISTS AT ALL. Every toolset the editor grew went onto the one
// stage bar, and the only thing that said which was showing was a word painted
// small at the right of the tab row. Rig did not even get that: its tools lived
// in a right-hand dock while every other toolset lived in the bar, so starting
// a rig was a different KIND of action from starting a sculpt for no reason a
// user could see. EffigyWorkspaceBar's header has the rest of that argument.
//
// WHAT A WORKSPACE DRIVES. Three things, and the point of putting them together
// is that they were previously three unrelated decisions made in three places:
//
// 1. THE STAGE BAR — which stage set is on it. Already existed as
// EffigyBarMode; this file adds the fourth member and its stage set.
// 2. THE DOCKS — Materials matters in Paint, the rig tree matters in Rig, and
// neither wants to be open the rest of the time. Per-workspace, remembered.
// 3. THE VIEWPORT'S INPUT — which of its eleven partial files owns a click.
// Enforced AT THE DOOR (see LeaveCurrentWorkspace) rather than by each
// handler re-checking the others, which is what it grew into.
//
// NO SEPARATE WINDOWS, AND NO SECOND VIEWPORT. That was the first idea and it
// is the wrong one. Each viewport is a live scene render, so four of them is
// four times the GPU for panes nobody is looking at; worse, one mesh behind four
// cameras means four selections, four undo stacks and four answers to "which
// body is active", and the undo snapshot in EffigyWindow is window-global. And
// the pipeline is not actually linear — paint reveals a bad face and you go back
// to re-extrude it — so separate windows would tax the move people make most.
// One window, one document, four ways of looking at it.
//
// THE WORKSPACE IS NOT STORED. It is derived from _barMode, which the enter and
// finish paths already maintain and already treat as the truth. A second field
// would be a second thing to keep in step, and the one bug this whole change is
// meant to make impossible is chrome disagreeing with mode.
// ============================================================================
public sealed partial class EffigyWindow
{
private EffigyWorkspaceBar _workspaceBar;
/// <summary>The rig stage set, built once at startup alongside the other four.</summary>
private List<EffigyStage> _rigStages;
private EffigyStageTool _boneTool, _boneFromPartTool, _boneAssignTool, _boneMirrorTool, _boneDeleteTool;
private EffigyStageTool _boneSoftTool, _softPreviewTool, _softRestTool;
private EffigyStageTool _rigSubdivideTool, _rigRemeshTool;
/// <summary>Which part-studio stage was last looked at in the Rig workspace, so leaving and
/// coming back lands where you were — same courtesy _partStage does for CAD.</summary>
private int _rigStage;
/// <summary>
/// Which workspace the window is in, derived rather than stored.
///
/// SKETCH READS AS CAD. A sketch is opened inside CAD and finished again — the stage bar goes
/// somewhere else for the duration, but you did not leave the workspace, and lighting a
/// different pill in the switcher while someone draws a rectangle would be the switcher lying
/// about a thing it exists to state.
/// </summary>
private EffigyWorkspace CurrentWorkspace => BarMode switch
{
EffigyBarMode.Sculpt => EffigyWorkspace.Model,
EffigyBarMode.MeshEdit => EffigyWorkspace.Model,
EffigyBarMode.Paint => EffigyWorkspace.Paint,
EffigyBarMode.Rig => EffigyWorkspace.Rig,
EffigyBarMode.Clothing => EffigyWorkspace.Clothing,
_ => EffigyWorkspace.Cad,
};
/// <summary>
/// The bar mode, and the one place that notices it changed.
///
/// A PROPERTY RATHER THAN THE FIELD IT WAS, because six different methods assign it and every
/// one of them would otherwise have to remember to re-light the switcher and re-lay the docks
/// afterwards. Five of them remembering and one forgetting is precisely the failure this
/// change exists to remove, and "you must also call SyncWorkspace" is a rule that only holds
/// until the next tool gets added. Here it cannot be forgotten because there is nowhere to
/// forget it.
/// </summary>
private EffigyBarMode BarMode
{
get => _barMode;
set
{
if ( _barMode == value )
return;
var before = CurrentWorkspace;
_barMode = value;
var after = CurrentWorkspace;
SyncViewportMode();
if ( before != after )
{
ApplyWorkspaceDocks( before, after );
// The Features dock's contents follow the workspace too: the Model panel in Model,
// the CAD tree everywhere else. Here for the same reason the docks are — six
// methods assign the mode, and this cannot be forgotten by any of them.
SyncModelDock();
SyncModelChrome();
}
if ( _workspaceBar is not null )
_workspaceBar.Selected = after;
}
}
private EffigyBarMode _barMode = EffigyBarMode.Part;
/// <summary>
/// Push the workspace down onto the viewport's input. In the rig workspace a click is a bone
/// or a whole part (Assign / Bone from Part need both); CAD idle selection runs everywhere
/// else. See EffigyViewport.RigMode.
///
/// NOT ONLY FROM THE BarMode SETTER, which is where it belongs and is not sufficient on its
/// own: that setter returns early when the mode has not changed, so a hotload taken while the
/// rig workspace was already open would leave a freshly-defaulted RigMode at false with nothing
/// left to switch it back on - the workspace would look right and behave as though it were CAD.
/// Called from RebuildStages for exactly that, and it is cheap enough to call from anywhere.
/// </summary>
private void SyncViewportMode()
{
if ( _viewport is not null )
_viewport.RigMode = CurrentWorkspace == EffigyWorkspace.Rig;
}
// --- switching ------------------------------------------------------------------------------
/// <summary>
/// A workspace was asked for. This is the whole of what a pill click means.
///
/// ASKED FOR, NOT SET. Sculpt and Paint can both refuse at the door — a sculpt feature whose
/// cage did not build, a body whose UVs cannot carry paint — and when they do, EnterSculpt and
/// EnterPaint leave the bar mode alone and put the reason in the prompt. Because the switcher
/// paints from CurrentWorkspace, which is derived from that mode, a refusal simply leaves the
/// old pill lit. Nothing here has to know which refusals exist.
/// </summary>
private void SetWorkspace( EffigyWorkspace workspace )
{
if ( _viewport is null || _studio is null )
return;
// Re-clicking the workspace you are already in is not a no-op: it is the only way to ask
// for the dock layout back after dragging it around, and it is what someone reaches for
// when a panel has gone missing. Everything downstream is idempotent.
if ( workspace == CurrentWorkspace )
{
ApplyWorkspaceDocks( workspace, workspace, force: true );
return;
}
switch ( workspace )
{
case EffigyWorkspace.Cad:
LeaveCurrentWorkspace();
ShowPartStages( force: true );
break;
case EffigyWorkspace.Model:
EnterSculptWorkspace();
break;
case EffigyWorkspace.Paint:
EnterPaintWorkspace();
break;
case EffigyWorkspace.Rig:
EnterRig();
break;
case EffigyWorkspace.Clothing:
EnterClothing();
break;
}
// The switcher is painted from CurrentWorkspace, and CurrentWorkspace only moved if the
// entry above actually took. A refusal leaves the pill where it was, which is the honest
// answer — see the summary.
if ( _workspaceBar is not null )
_workspaceBar.Selected = CurrentWorkspace;
RefreshTutorial();
}
/// <summary>
/// Close whatever session owns the pointer right now.
///
/// THE EXCLUSIVITY, IN ONE PLACE. Sketching, sculpting, painting and the bone tool all claim a
/// left-click in the viewport, and each of the four entry points used to carry its own partial
/// list of the other three to shut down first — EnterSculpt finished sketches and cancelled the
/// bone tool, AddPaint finished sketches and sculpts, EnterPaint did both again, and nothing
/// anywhere finished a paint before arming a bone. Four hand-maintained lists of three is four
/// chances to miss one, and the one that was missed is exactly the pair that could both be live.
///
/// Every entry point now calls this instead. Adding a fifth pointer-owning mode later means
/// adding one line here rather than finding four lists.
/// </summary>
private void LeaveCurrentWorkspace()
{
if ( _viewport is null )
return;
if ( _viewport.IsSketching )
FinishSketch();
if ( _viewport.IsSculpting )
FinishSculpt();
if ( _viewport.IsMeshEditing )
FinishMeshEdit();
if ( _viewport.IsPainting )
FinishPaint();
// The material brush has no feature and no session to commit, so leaving it is only a
// matter of disarming it - but it MUST be disarmed, or the ring keeps taking clicks in
// whatever workspace you switched to.
if ( _viewport.IsMaterialBrushing )
LeaveMaterialBrush();
if ( _viewport.IsWeightPainting )
FinishWeightPaint();
// The note pen (grease pencil) owns the click the same way every brush does, and it is the
// one mode none of the entries below ever disarmed - so it stayed armed across a switch,
// and the next sketch or sculpt also scribbled notes. Notes commit per stroke, so leaving
// the pen is only a disarm, never a commit. UpdateNoteChecks puts the CAD bar's pen tick
// back, so it is not still lit as armed when you come back.
if ( _viewport.IsNoting )
{
_viewport.EndNotes();
UpdateNoteChecks();
}
_rigPanel?.CancelBoneTool();
// The soft preview solves against the rig every frame, and its whole point is the rig
// workspace; leaving it would leave the bones sagging and swinging while you model. Stopped
// rather than merely hidden - the bones snap back to their authored pose.
if ( _viewport.SoftPreviewRunning )
_viewport.StopSoftPreview();
// The rig bar is the one mode with nothing to finish — no feature, no session, just a stage
// set on the bar — so leaving it is only a matter of not still claiming to be in it. The
// CAD stages are what everything falls back to.
if ( BarMode is EffigyBarMode.Rig or EffigyBarMode.Clothing )
BarMode = EffigyBarMode.Part;
}
// --- entering sculpt and paint from the switcher ---------------------------------------------
/// <summary>
/// "Sculpt" as a workspace, given that sculpting is a thing you do to a FEATURE.
///
/// This is the one place the switcher is not a plain mode flip, and it is worth being explicit
/// about why: there is no ambient state called sculpting. EnterSculpt needs a SculptFeature,
/// which needs a body under it and a spot in the tree. So the pill has to resolve one, and the
/// order it tries is the order that makes the fewest surprises:
///
/// 1. The sculpt you were last in this session, if it is still in the tree. Coming back to
/// where you were is what leaving and returning should mean.
/// 2. The only sculpt in the document, if there is exactly one. With one candidate there is
/// nothing to choose and asking would be ceremony.
/// 3. Otherwise land on the workspace's own bar — Subdivide and Sculpt — which is where those
/// tools live now that they have left CAD. The brushes arrive once a sculpt is open.
///
/// Deliberately NOT "the last sculpt in the tree" for case 2 when there are several: which of
/// three sculpts you meant is a real question, and guessing at it silently rolls the model back
/// to a different place than you expected. With several and no memory, the home bar is the
/// answer that cannot be wrong about your intent.
/// </summary>
private void EnterSculptWorkspace()
{
LeaveCurrentWorkspace();
// Back into the edit you left, when an edit is the last thing done here and no sculpt is
// remembered — the Model pill should come back to where you were, whichever mode that was.
if ( _lastMeshEditFeature is not null && _lastSculptFeature is null && _studio.Features.Contains( _lastMeshEditFeature ) )
{
EnterMeshEdit( _lastMeshEditFeature );
return;
}
var sculpts = _studio.Features.OfType<SculptFeature>().ToList();
var target = _lastSculptFeature is not null && sculpts.Contains( _lastSculptFeature )
? _lastSculptFeature
: sculpts.Count == 1 ? sculpts[0] : null;
if ( target is not null )
{
EnterSculpt( target );
return;
}
// Nothing to re-enter: land on the sculpt workspace's own bar, where Subdivide and Sculpt
// live. The brushes arrive once a sculpt is actually open, not with the workspace itself.
ShowSculptHome();
}
/// <summary>"Paint" as a workspace. Same three-step resolution as sculpt, for the same reason —
/// see EnterSculptWorkspace, which carries the argument for both.</summary>
private void EnterPaintWorkspace()
{
LeaveCurrentWorkspace();
var paints = _studio.Features.OfType<PaintFeature>().ToList();
var target = _lastPaintFeature is not null && paints.Contains( _lastPaintFeature )
? _lastPaintFeature
: paints.Count == 1 ? paints[0] : null;
if ( target is not null )
{
EnterPaint( target );
return;
}
ShowPaintHome();
}
/// <summary>
/// Land on the Sculpt workspace's own bar, with no feature open.
///
/// BarMode.Sculpt with nothing sculpting is a state that did not exist before the landing bars:
/// the workspace now has two faces — this home (Subdivide and Sculpt) and the brushes, which
/// <see cref="EnterSculpt"/> swaps to once a feature is open. RebuildStages tells the two apart
/// by asking the viewport whether a sculpt is actually running.
/// </summary>
private void ShowSculptHome()
{
if ( _stageBar is null )
return;
BarMode = EffigyBarMode.Sculpt;
// Object mode's bar — toolbar, menus and the mode switch — in place of the two stage tabs
// that used to land here. See EffigyWindow.ObjectMode.cs.
ShowObjectModeBar();
SetPrompt( _studio.Bodies.Count == 0
? "Model: Add ▸ Cube to start with a body, or press Edit to start a mesh from nothing (Shift+A adds a cube inside)."
: "Model: click a body, then Move, Rotate or Scale it, press Edit to move its vertices, or Sculpt to brush detail on. Right-click a body for everything else." );
}
/// <summary>The Paint workspace's home — UV Project and Paint — with no feature open.</summary>
private void ShowPaintHome()
{
if ( _stageBar is null )
return;
BarMode = EffigyBarMode.Paint;
_stageBar.Mode = "PAINT";
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _paintHomeStages );
SetPrompt( _studio.Bodies.Count == 0
? "Paint needs a body — draw a sketch and extrude it, or add a primitive first."
: "Paint: add a UV Project to unwrap the mesh, or a Paint to brush colour on." );
}
/// <summary>
/// The sculpt and paint features last entered, so the switcher can come back to them.
///
/// SEPARATE FROM _sculptFeature and _paintFeature, which are cleared on Finish — those answer
/// "what am I editing", this answers "what was I editing", and the switcher needs the second
/// one precisely when the first is null. Held as a reference and checked against the tree
/// before use, because Undo can take the feature away without telling anyone.
/// </summary>
private SculptFeature _lastSculptFeature;
private PaintFeature _lastPaintFeature;
// --- the rig workspace ------------------------------------------------------------------------
/// <summary>
/// The rig tools, moved out of the panel's header and onto the bar.
///
/// SAME ACTIONS, NOT A SECOND COPY. Every one of these calls straight into EffigyRigPanel,
/// which still owns the skeleton, the pending chain and every refusal — see the block above
/// CancelBoneTool there. The panel keeps its buttons too: a bar tool and a panel button running
/// one method is one control reachable from wherever you are looking, and the panel is where
/// you are looking when you have just clicked a bone in the tree.
///
/// THREE TOOLS AND TWO STAGES, not the four-stage set the other workspaces run to. There is no
/// Pose stage because there is no posing yet — the gizmo in EffigyViewport drags a bone that
/// already exists and that is all — and a tab with nothing behind it is the thing this editor's
/// icon set already refuses to draw. Stages get added when tools do.
/// </summary>
private List<EffigyStage> BuildRigStages()
{
var bones = new EffigyStage { Name = "Bones" };
_boneTool = new EffigyStageTool
{
Icon = EffigyIcon.Bone,
Label = "Add Bone",
Tip = "Click the model to place a bone. Click again to extend a chain from it. "
+ "Select a bone first to branch a new chain from ITS tail.",
Checkable = true,
Clicked = () =>
{
_rigPanel?.ToggleBoneTool();
UpdateRigChecks();
},
};
bones.Add( _boneTool );
_boneFromPartTool = new EffigyStageTool
{
Icon = EffigyIcon.Bone,
Label = "Bone from Part",
Tip = "Measure the selected part and add a bone down its longest axis, already pinned "
+ "to it. Select a bone first to parent the new one.",
Clicked = () =>
{
MakeBonesFromSelectedParts();
UpdateRigChecks();
},
};
bones.Add( _boneFromPartTool );
_boneDeleteTool = new EffigyStageTool
{
Icon = EffigyIcon.CutTool,
Label = "Delete",
Tip = "Delete the selected bone. Its children re-parent to its parent.",
// No RecordUndo here, unlike most destructive buttons in this window: DeleteBone fires
// RigChanging on its way in, and that IS RecordUndo (wired in BuildDocks). Recording
// again would put two identical snapshots on the stack and cost two Ctrl+Zs to undo one
// delete. Same reason Mirror does not record either.
Clicked = () =>
{
_rigPanel?.DeleteSelected();
UpdateRigChecks();
},
};
bones.Add( _boneDeleteTool );
var bind = new EffigyStage { Name = "Bind" };
_boneAssignTool = new EffigyStageTool
{
Icon = EffigyIcon.BoneBind,
Label = "Assign Body",
Tip = "Select a bone, select a part, then press this to pin them. "
+ "With no part selected it arms click-to-assign in the viewport. "
+ "Anything left unassigned falls back to the nearest bone.",
Checkable = true,
Clicked = () =>
{
_rigPanel?.ToggleAssignBodyTool();
UpdateRigChecks();
},
};
bind.Add( _boneAssignTool );
_boneMirrorTool = new EffigyStageTool
{
Icon = EffigyIcon.Mirror,
Label = "Mirror",
Tip = "Mirror the selected bone and everything under it across the centre line, "
+ "onto the same parent.",
Clicked = () =>
{
_rigPanel?.MirrorSelected();
UpdateRigChecks();
},
};
bind.Add( _boneMirrorTool );
// --- Soft ---
//
// The stage the kernel has been waiting for. SoftBone, SoftPose and SoftSolver have been
// written, tested and shipped to the game assembly since before this bar existed, and
// RigDiagnostics has been checking soft bones the whole time - it will tell you one has a
// zero cone - while nothing in the editor could make a bone soft for it to complain about.
//
// THE NUMBERS ARE NOT HERE. Stiffness, damping, weight and cone are properties of ONE bone,
// the way its head and tail are, so they live in the rig panel's inspector beside them
// rather than on a bar that is about verbs. What the bar gets is the verb - make this bone
// soft - and the two controls that only make sense while something is wobbling.
var soft = new EffigyStage { Name = "Soft" };
_boneSoftTool = new EffigyStageTool
{
Icon = EffigyIcon.BoneSoft,
Label = "Make Soft",
Tip = "Let the selected bone lag and swing behind the pose. "
+ "Stiffness, damping, weight and cone are in the Rig panel under the bone's head and tail.",
Checkable = true,
Clicked = () =>
{
_rigPanel?.ToggleSelectedSoft();
UpdateRigChecks();
},
};
soft.Add( _boneSoftTool );
_softPreviewTool = new EffigyStageTool
{
Icon = EffigyIcon.SoftPreview,
Label = "Preview",
Tip = "Run the soft-bone solver on the rig, so you can see the swing while you tune it. "
+ "Drag the part around to push the bones.",
Checkable = true,
Clicked = () =>
{
ToggleSoftPreview();
UpdateRigChecks();
},
};
soft.Add( _softPreviewTool );
_softRestTool = new EffigyStageTool
{
Icon = EffigyIcon.SoftRest,
Label = "Rest",
Tip = "Forget the motion and put every soft bone back on its pose. "
+ "What you want after flinging the model about.",
Clicked = () =>
{
_viewport?.RestSoftPreview();
UpdateRigChecks();
},
};
soft.Add( _softRestTool );
var weights = new EffigyStage { Name = "Weights" };
weights.Add( new EffigyStageTool
{
Icon = EffigyIcon.BoneBind,
Label = "Paint Weights",
Tip = "Paint which bone owns which part of the mesh. The ramp is a texture atlas, "
+ "not vertex colour — pick a bone in the Rig tree first.",
Checkable = true,
Clicked = ToggleWeightPaint,
} );
// --- Mesh ---
//
// Subdivide, on the rig bar, because the mesh being too coarse to bend is a RIGGING
// problem and it is discovered here — you paint a shoulder, drag the arm, and watch the
// elbow crease into a hinge instead of a bend. The fix for that is more loops across the
// joint, and until now the only door to one was the Sculpt workspace: a detour that reads
// as "you are about to sculpt" when you are not, and that rolls the model back to a sculpt
// feature on the way.
//
// SAME FEATURE, DIFFERENT DEFAULT. This adds the same SubdivideFeature the Sculpt bar
// adds — one implementation, one tree row, undoable and editable like any other — but with
// All Faces ticked, which is the linear form that adds density and leaves every vertex
// where it is. The whole-body Catmull-Clark the sculpt bar defaults to SMOOTHS, pulling
// the cage onto its limit surface: exactly right when you are about to sculpt the
// silhouette, and exactly wrong when you have already bound bones to that silhouette and
// only want it to bend. The tick is in the dialog either way, so the default is a starting
// point rather than a decision taken away.
var mesh = new EffigyStage { Name = "Mesh" };
_rigSubdivideTool = new EffigyStageTool
{
Icon = EffigyIcon.Subdivide,
Label = "Subdivide",
Tip = "Add loops to the selected part so it bends instead of creasing. "
+ "Adds a Subdivide set to All Faces — density without moving the shape.",
Clicked = AddRigSubdivide,
};
mesh.Add( _rigSubdivideTool );
// AND ITS OPPOSITE, on the same bar, because the rig workspace is where BOTH halves of "this
// mesh is the wrong density" are discovered. Too coarse creases at the elbow; too dense and
// the weight brush is a slideshow, auto-skin takes a minute, and the compile writes a model
// nothing wants. The second is what an imported part is, every time.
_rigRemeshTool = new EffigyStageTool
{
Icon = EffigyIcon.Remesh,
Label = "Remesh",
Tip = "Reduce the selected part to a triangle budget so it can be weighted and posed. "
+ "Keeps the shape; returns triangles.",
Clicked = AddRigRemesh,
};
mesh.Add( _rigRemeshTool );
var ready = new EffigyStage { Name = "Export" };
ready.Add( new EffigyStageTool
{
Icon = EffigyIcon.BoneBind,
Label = "Check model",
Tip = "Everything that would make this fail as a PLAYERMODEL rather than as a mesh — weights the exporter will not carry, unweighted vertices, wrong scale, feet off the floor. Run it before Make Player",
Clicked = CheckPlayermodel,
} );
return new List<EffigyStage> { bones, bind, soft, weights, mesh, ready };
}
/// <summary>
/// Run the playermodel readiness check and put it in the console, worst first.
///
/// Runs the real <see cref="Playermodel.Build"/> first rather than checking the studio as it
/// stands: binding, fitting and twist spreading all change the weights, so a check before them
/// passes models that fail after — which is precisely the class of problem this exists to catch.
/// </summary>
private void CheckPlayermodel()
{
if ( _studio.Rig.Count == 0 )
{
SetPrompt( "Check model: there is no rig yet. Make bones from the bodies first." );
return;
}
List<Playermodel.Finding> findings;
try
{
findings = Playermodel.Check( Playermodel.Build( _studio ), DmxWriter.MaxInfluences );
}
catch ( Exception e )
{
SetPrompt( $"Check model: the playermodel could not even be built — {e.Message}" );
return;
}
if ( findings.Count == 0 )
{
SetPrompt( "Check model: nothing to report. This is ready to be a playermodel." );
Log.Info( "[pm] check: nothing to report." );
return;
}
foreach ( var finding in findings )
{
var line = $"[pm] {finding.Severity}: {finding.Problem}. {finding.Remedy}";
if ( finding.Severity == Playermodel.Severity.Note )
Log.Info( line );
else
Log.Warning( line );
}
var problems = findings.Count( f => f.Severity == Playermodel.Severity.Problem );
var warnings = findings.Count( f => f.Severity == Playermodel.Severity.Warning );
SetPrompt( problems > 0
? $"Check model: {problems} problem(s) would stop this working as a playermodel — see the console. Worst: {findings[0].Problem}."
: warnings > 0
? $"Check model: it will work, but {warnings} thing(s) will look wrong — see the console. First: {findings[0].Problem}."
: $"Check model: {findings.Count} note(s) in the console, nothing wrong." );
}
/// <summary>
/// The Rig bar's Subdivide — a SubdivideFeature over the selected part, defaulted to density
/// rather than smoothing. See the Mesh stage above for why the default differs from Sculpt's.
///
/// GOES THROUGH AddFeature like every other creation button, so the undo step, the rollback
/// placement and the dialog are the ones the rest of the editor already agreed on. What it
/// does NOT do is leave the rig workspace: the feature lands in the tree, the studio rebuilds
/// under the skeleton, and the bones stay put because Subdivide replaces a body's mesh in
/// place and never its id — which is the id the bone assignments are keyed on.
/// </summary>
private void AddRigSubdivide()
{
// AddFeature refuses a Subdivide with nothing picked rather than quietly densifying every
// part in the document, and says so in the prompt. The bar can do better than a prompt
// after the fact: the tool is locked until something is selected, the same way Bone from
// Part is, so the refusal is visible before the click. Kept as a guard anyway — a stale
// check is how a locked button gets clicked.
if ( _viewport is null || _viewport.IdleFaces.Count == 0 && _viewport.IdleBodyIds.Count == 0 )
{
SetPrompt( "Subdivide needs to know which part — click one in the Parts list on the left, "
+ "or pick faces in the viewport, then press Subdivide again." );
return;
}
var feature = (SubdivideFeature)NewFeature( ToolKind.Subdivide, -1 );
// Only when the selection is a whole part. A face pick is already the local, linear form —
// AllFaces is not even read then — and ticking it would be writing a setting the feature
// will ignore, which is worse than leaving it alone because the dialog would still show it.
if ( _viewport.IdleFaces.Count == 0 )
feature.AllFaces.Value = true;
AddFeature( feature );
}
/// <summary>
/// The Rig bar's Remesh — a RemeshFeature over the selected part.
///
/// SAME SHAPE AS <see cref="AddRigSubdivide"/> and for the same reasons: through AddFeature so
/// the undo step and the dialog are the shared ones, locked behind a selection so the refusal
/// is visible before the click rather than as a prompt after it, and it does not leave the
/// workspace, because Remesh replaces a body's mesh and never its id.
///
/// WHAT IT DOES NOT DO is pick a budget for you. A part with no bones on it yet has no obvious
/// right answer and the dialog opens on the question — see RemeshFeature.Target for why the
/// default is a percentage rather than a count.
/// </summary>
private void AddRigRemesh()
{
if ( _viewport is null || _viewport.IdleBodyIds.Count == 0 )
{
SetPrompt( "Remesh needs to know which part — click one in the Parts list on the left, "
+ "or click the solid in the viewport, then press Remesh again." );
return;
}
AddFeature( NewFeature( ToolKind.Remesh, -1 ) );
}
/// <summary>
/// Start or stop the soft-bone preview, and say so in the prompt.
///
/// The prompt is doing real work here rather than narrating: the preview is driven by gravity
/// and by the pose gizmo and by nothing else, which is the right design (see
/// EffigyViewport.SoftPreview.cs) but is not guessable from a button called Preview. A rig that
/// sags an inch and stops looks like a broken preview until you know that settling is the
/// point.
/// </summary>
private void ToggleWeightPaint()
{
if ( _viewport is { IsWeightPainting: true } )
{
FinishWeightPaint();
return;
}
EnterWeightPaint();
}
private void EnterWeightPaint()
{
if ( _viewport is null || _studio is null || _rigPanel is not { HasBones: true } rig )
{
SetPrompt( "Paint Weights needs a skeleton — place a bone first." );
return;
}
if ( !rig.HasSelectedBone )
{
SetPrompt( "Pick a bone in the Rig tree, then press Paint Weights." );
return;
}
BarMode = EffigyBarMode.Rig;
var (mesh, ranges) = _studio.ToMeshWithBodies();
if ( !NormalBake.Measure( mesh ).CanBake )
UVUnwrap.Unwrap( mesh );
var weights = SkinBinder.BindBodies( mesh, ranges, rig.BodyBoneMap, rig.Skeleton );
weights = SkinBinder.SmoothWeights( mesh, weights );
if ( _studio.WeightPaint is { Count: > 0 } layer && layer.CanApply( mesh, out _ ) )
layer.Apply( mesh, weights, rig.Skeleton, out _ );
var session = new WeightPaintSession( mesh, weights, rig.Skeleton, _studio.WeightPaint )
{
Bone = rig.SelectedBoneIndex,
Radius = 0.25f,
};
session.Radius = session.SuggestedRadius;
_viewport.BeginWeightPaint( session );
_weightBar?.Bind( session, rig.SelectedBoneName );
SetPrompt( $"Painting {rig.SelectedBoneName}. The ramp is a texture, not vertex colour. Drag on the mesh." );
}
private void FinishWeightPaint()
{
if ( _viewport is null || !_viewport.IsWeightPainting )
return;
if ( _viewport.WeightSession is not null )
_studio.WeightPaint = _viewport.WeightSession.Layer;
_viewport.EndWeightPaint();
_weightBar?.Bind( null, null );
RebuildStudio();
}
private void ToggleSoftPreview()
{
if ( _viewport is null )
return;
SetPrompt( _viewport.ToggleSoftPreview()
? "Soft preview: gravity is pulling the soft bones. Drag a bone to make the ones below it swing. "
+ "Rest puts them back."
: "" );
}
/// <summary>
/// Push the panel's state onto the bar.
///
/// The panel is the one that knows, and it changes its mind without being asked — Escape closes
/// a chain, clicking a different bone in the tree disarms an assign — so this is wired to its
/// ToolStateChanged as well as being called after every tool click. A tick that can go stale
/// on the two commonest gestures in the workspace is worse than no tick.
/// </summary>
private void UpdateRigChecks()
{
if ( _rigPanel is null )
return;
var hasBone = _rigPanel.HasSelectedBone;
if ( _boneTool is not null )
{
_boneTool.Checked = _rigPanel.BoneToolActive;
// The panel's own button rewrites itself to "Branch from 'upper_arm'" when a bone is
// selected, because that is the gesture nobody discovers on their own. Worth carrying
// onto the bar for the same reason — but on the TIP rather than the label, which has a
// measured button around it that would jump width on every selection change.
_boneTool.Tip = hasBone
? $"Click the model to extend a new chain from '{_rigPanel.SelectedBoneName}'. Escape when done."
: "Click the model to place a bone. Click again to extend the chain. "
+ "Select a bone first to branch from its tail.";
}
var hasParts = _viewport is { IdleBodyIds.Count: > 0 };
if ( _boneFromPartTool is not null )
{
_boneFromPartTool.Enabled = hasParts;
_boneFromPartTool.DisabledReason = hasParts
? null
: "Select a part first — in the Parts list or the viewport";
}
// A face pick counts here where it does not for Bone from Part: Subdivide takes faces as
// well as bodies, and picking the four faces across an elbow is the precise version of what
// this button is for. Measuring a bone out of a face selection is not a thing.
var hasGeometry = hasParts || _viewport is { IdleFaces.Count: > 0 };
if ( _rigSubdivideTool is not null )
{
_rigSubdivideTool.Enabled = hasGeometry;
_rigSubdivideTool.DisabledReason = hasGeometry
? null
: "Select a part first — in the Parts list, or pick faces in the viewport";
}
// Remesh keys off hasParts, not hasGeometry. A face pick names its body and Subdivide takes
// that as a valid target, but there is no per-face Remesh — the quadrics are summed over the
// whole surface and a boundary drawn through a face selection would be preserved as a border,
// which is the opposite of what picking a region would mean.
if ( _rigRemeshTool is not null )
{
_rigRemeshTool.Enabled = hasParts;
_rigRemeshTool.DisabledReason = hasParts
? null
: "Select a part first — in the Parts list, or click the solid in the viewport";
}
if ( _boneAssignTool is not null )
{
_boneAssignTool.Checked = _rigPanel.AssigningBody;
_boneAssignTool.Tip = hasBone && hasParts
? $"Pin the selected part(s) to '{_rigPanel.SelectedBoneName}'."
: hasBone
? "Click bodies in the viewport to pin them to the selected bone. "
+ "Anything left unassigned falls back to the nearest bone."
: "Select a bone, select a part, then press Assign.";
}
if ( _boneSoftTool is not null )
_boneSoftTool.Checked = _rigPanel.SelectedBoneIsSoft;
if ( _softPreviewTool is not null )
_softPreviewTool.Checked = _viewport?.SoftPreviewRunning ?? false;
// Preview and Rest are about the rig as a whole rather than the selection, so they follow a
// different rule from the three below: something to simulate, not something selected. A rig
// with no soft bones would preview a skeleton that cannot move, which is a button that
// appears to do nothing.
var anySoft = _rigPanel.SoftBoneCount > 0;
foreach ( var tool in new[] { _softPreviewTool, _softRestTool } )
{
if ( tool is null )
continue;
tool.Enabled = anySoft;
tool.DisabledReason = anySoft ? null : "Nothing is soft yet - select a bone and press Make Soft";
}
// Assign, Mirror and Delete all act on "the selected bone" and do nothing without one. The
// lock reason is the tooltip, the same contract the CAD stages' starter lock uses.
foreach ( var tool in new[] { _boneAssignTool, _boneMirrorTool, _boneDeleteTool, _boneSoftTool } )
{
if ( tool is null )
continue;
tool.Enabled = hasBone;
tool.DisabledReason = hasBone ? null : "Select a bone first — in the viewport or the Rig panel";
}
_stageBar?.Refresh();
}
/// <summary>
/// Enter the rig workspace.
///
/// THE ONLY WORKSPACE WITH NOTHING TO OPEN. Sculpt and paint are scoped to a feature and roll
/// the model back to it; a rig is one skeleton over the whole finished studio, owned by the
/// panel for the life of the window. So this is a plain mode change — which is what makes it
/// the odd one out in the other direction, and worth saying rather than leaving as an absence
/// somebody later reads as a missing rollback.
/// </summary>
private void EnterRig()
{
if ( _viewport is null || _stageBar is null )
return;
LeaveCurrentWorkspace();
// A dialog and the rig bar would be two things claiming the model at once, the same
// argument EnterSculpt makes for closing it.
_dialog?.Close();
BarMode = EffigyBarMode.Rig;
// Whatever was picked in CAD is still lit, and in a workspace where faces can no longer be
// clicked a highlighted face is a selection you cannot clear by clicking off it. It is also
// what the face-drag arrow hangs off, which is one more thing over the model competing for
// a click meant for a bone.
_viewport.ClearIdleSelection();
_stageBar.Mode = "RIG";
// NO FINISH BUTTON. Finish means "commit what you have been doing to the feature tree", and
// there is no feature here to commit to — every bone edit already landed on the skeleton
// when it was made. A green tick that only changed which tools were on screen would be
// teaching the wrong thing about what green means in this editor.
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _rigStages, _rigStage );
UpdateRigChecks();
SetPrompt( _rigPanel is { HasBones: true }
? "Rig: click a bone to select it, or place more with Add Bone."
: "Rig: press Add Bone and click the model to place your first bone." );
}
// --- dock layouts ----------------------------------------------------------------------------
/// <summary>
/// Which docks each workspace opens with.
///
/// A STARTING POINT, NOT A CAGE. Whatever you do to the docks while you are in a workspace is
/// captured on the way out and restored on the way back (see ApplyWorkspaceDocks), so these
/// values only decide what the FIRST visit looks like. A layout table that overrode the user
/// every time would be the tool rearranging itself under someone who had already arranged it.
///
/// Tutorial and Console are deliberately absent. They are not about which part of the pipeline
/// you are in — they are open because you opened them — so a workspace switch leaves them
/// exactly as it found them.
/// </summary>
private static readonly Dictionary<EffigyWorkspace, (bool Features, bool Materials, bool Rig)> WorkspaceDocks = new()
{
// The feature tree is on in three of four: it is the document's history, and going back to
// re-extrude something is the move that made a single window right in the first place.
[EffigyWorkspace.Cad] = (Features: true, Materials: false, Rig: false),
[EffigyWorkspace.Model] = (Features: true, Materials: false, Rig: false),
[EffigyWorkspace.Paint] = (Features: true, Materials: true, Rig: false),
// Rig is the exception. The skeleton tree wants the right-hand side to itself, and by the
// time you are placing bones the feature tree is history you are no longer editing.
[EffigyWorkspace.Rig] = (Features: false, Materials: false, Rig: true),
// Clothing wants both: the tree because a garment IS a feature you go back and retune, and
// Materials because a fabric is the thing you reach for straight after the garment fits.
// The rig tree stays shut - Wearer fills the skeleton, and a garment reads it without
// anyone needing to look at it.
[EffigyWorkspace.Clothing] = (Features: true, Materials: true, Rig: false),
};
/// <summary>What the docks looked like the last time each workspace was left.</summary>
private readonly Dictionary<EffigyWorkspace, (bool Features, bool Materials, bool Rig)> _workspaceDocks = new();
/// <summary>
/// Capture the layout the old workspace is being left in, then lay out the new one.
///
/// The capture is what makes the table above a default rather than a rule: open the Materials
/// browser while sculpting and it is still there next time you sculpt, without any of this
/// having to know you did it.
/// </summary>
private void ApplyWorkspaceDocks( EffigyWorkspace from, EffigyWorkspace to, bool force = false )
{
if ( _rigPanel is null || _materialsPanel is null )
return; // Still building. BuildDocks lays out the first workspace itself.
if ( from != to )
_workspaceDocks[from] = ReadDockState();
var wanted = _workspaceDocks.TryGetValue( to, out var remembered ) && !force
? remembered
: WorkspaceDocks[to];
SetDockIfChanged( "Features", wanted.Features );
SetDockIfChanged( "Materials", wanted.Materials );
SetDockIfChanged( "Rig", wanted.Rig );
// The one dock a workspace is actually ABOUT gets raised, not merely opened — it may be
// tabbed behind another on the same edge, and an open-but-hidden panel is the same as a
// closed one to the person looking for it.
switch ( to )
{
case EffigyWorkspace.Paint: DockManager.RaiseDock( "Materials" ); break;
case EffigyWorkspace.Rig: DockManager.RaiseDock( "Rig" ); break;
case EffigyWorkspace.Clothing: DockManager.RaiseDock( "Features" ); break;
}
// The View menu's ticks were written for the layout that just went away.
SyncDockChecks();
}
private (bool Features, bool Materials, bool Rig) ReadDockState() =>
(DockManager.IsDockOpen( "Features" ),
DockManager.IsDockOpen( "Materials" ),
DockManager.IsDockOpen( "Rig" ));
/// <summary>Asked before set, because SetDockState on a dock already in that state still costs
/// a relayout — and four of those per switch is a visible flicker on a window this size.
/// </summary>
private void SetDockIfChanged( string title, bool open )
{
if ( DockManager.IsDockOpen( title ) != open )
DockManager.SetDockState( title, open );
}
}
// ============================================================================
// Model > Edit — direct polygon editing.
//
// Built the way Sculpt is: a feature in the tree (MeshEditFeature) holds the result, a session
// (MeshEditSession, in the kernel) holds the working mesh, selection and undo while you work, and
// Finish commits the session to the feature as ONE step on the document's undo stack.
//
// THE OPERATION STAYS OPEN. Extrude, Inset, Loop cut and Solidify run as a preview the moment you
// press them, and their number sits on the floating bar, re-running the operation live, until
// Done or Cancel. Pressing another tool accepts the one that was open.
// ============================================================================
public sealed partial class EffigyWindow
{
private List<EffigyStage> _meshEditStages;
private MeshEditFeature _meshEditFeature;
private MeshEditFeature _lastMeshEditFeature;
private EffigyMeshEditBar _meshEditBar;
/// <summary>Edit mode's toolbar. Its tools are the same objects the menus hold.</summary>
private EffigyStage _meshCoreStage;
private EffigyStageTool _meshVertexTool, _meshEdgeTool, _meshFaceTool;
private EffigyStageTool _meshExtrudeTool, _meshExtrudeNormalsTool, _meshExtrudeIndividualTool, _meshInsetTool, _meshLoopCutTool;
private EffigyStageTool _meshMergeTool, _meshDissolveTool, _meshDeleteTool;
private EffigyStageTool _meshMirrorTool, _meshSnapTool, _meshWrapTool;
private EffigyStageTool _meshKnifeTool, _meshSoftTool, _meshSoftConnectedTool, _meshSoftShapeTool;
private EffigyStageTool _meshWeightsTool;
private EffigyStageTool _meshLassoTool, _meshBisectTool, _meshDuplicateTool, _meshBridgeTool, _meshExtractTool, _meshSeparateTool;
private EffigyStageTool _meshXrayTool, _meshMoveTool, _meshRotateTool, _meshScaleTool, _meshSlideTool, _meshBevelTool;
private EffigyStageTool _meshDrapeTool, _meshFabricTool;
private EffigyStageTool _meshSeamTool, _meshUnseamTool, _meshUnwrapTool;
private EffigyStageTool _meshVertexSlideTool;
private EffigyStageTool _meshSubdivideTool, _meshSmoothTool, _meshSymmetrizeTool;
private EffigyStageTool _meshNormalizeWeightsTool, _meshSmoothWeightsTool, _meshMirrorWeightsTool;
private EffigyStageTool _meshShrinkFattenTool, _meshGrowTool, _meshShrinkSelectionTool;
private EffigyStageTool _meshLinkedTool, _meshSimilarTool, _meshPathTool, _meshFillTool, _meshCircleTool;
private EffigyStageTool _meshRetopoTool, _meshRelaxTool, _meshEvenQuadsTool, _meshFinishRetopoTool, _meshStripTool;
private EffigyStageTool _modMirrorTool, _modArrayTool, _modSubdivideTool, _modSolidifyTool;
private EffigyStageTool _meshSplitEdgeTool, _meshSharpenTool;
private EffigyStageTool _meshTriangulateTool, _meshPokeFacesTool, _meshLimitedDissolveTool, _meshFlattenTool;
private EffigyStageTool _meshConnectTool, _meshLoopCircleTool, _meshLoopSpaceTool, _meshOrganicRelaxTool;
private EffigyStageTool _meshTrisToQuadsTool, _meshBevelVerticesTool;
private EffigyStageTool _meshHideTool, _meshHideOthersTool, _meshUnhideTool, _meshRandomSelectTool, _meshTrianglesTool, _meshNgonsTool, _meshInteriorTool;
private EffigyStageTool _meshDeleteFacesTool, _meshDeleteEdgesTool, _meshMergeFirstTool, _meshMergeLastTool, _meshMergePivotTool, _meshRandomizeTool, _meshDecimateTool;
private EffigyStageTool _meshLoosePartsTool, _meshByMaterialTool, _meshCreaseTool, _meshUncreaseTool;
private readonly List<EffigyStageTool> _meshTypedTools = new();
private readonly List<EffigyStageTool> _meshPrimitiveTools = new();
private const char MeshKeyAddPrimitive = (char)1;
private EffigyStageTool _meshRotateEdgeTool, _meshRotateEdgeBackTool, _meshSubdivideEdgesTool, _meshFillHolesTool, _meshBeautifyTool;
private EffigyStageTool _meshSharpSelectTool, _meshMirrorSelectTool, _meshLooseSelectTool, _meshToPivotTool;
private EffigyStageTool _meshProjectUVsTool, _meshPlanarUVsTool, _meshTrimRowTool, _meshTrimRowsTool, _meshPipeTool, _meshScatterTool;
/// <summary>How many rows the trim sheet has, for Trim row. Eight is the usual sheet.</summary>
private int _meshTrimRows = 8;
/// <summary>Units per tile for world-scale mapping. 32 matches s&box's tiling materials at
/// 512 px on 16 px per unit; change it with Project UVs and the trims follow.</summary>
private float _meshUnitsPerTile = 32f;
/// <summary>Which fabric <see cref="StartMeshDrape"/> hangs the cloth as. Cycled by the Fabric tool.</summary>
private Fabric _meshFabric = Fabric.Cotton;
private const char MeshKeyLoopCut = (char)18;
private const char MeshKeyBevel = (char)2;
private const char MeshKeyXray = (char)26;
private const char MeshKeySeam = (char)5;
private const char MeshKeyShrinkFatten = (char)19;
private const char MeshKeySimilar = (char)20;
private const char MeshKeyInvert = (char)9;
private const char MeshKeyRecalculate = (char)14;
private const char MeshKeySplit = (char)25;
private const char MeshKeySearch = ' ';
private const char MeshKeyExtrudeNormals = (char)30;
private const char MeshKeyTriangulate = (char)31;
private const char MeshKeyPokeFaces = (char)28;
private const char MeshKeyLimitedDissolve = (char)29;
private const char MeshKeyTrisToQuads = (char)16;
private const char MeshKeyBevelVertices = (char)22;
private const char MeshKeySoftConnected = (char)15;
private const char MeshKeyHide = (char)8;
private const char MeshKeyHideOthers = (char)4;
private const char MeshKeyUnhide = (char)21;
private const char MeshKeyCrease = (char)3;
private void ToggleMeshKnife()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
AcceptMeshOp();
// One armed tool at a time, or two previews chase the same cursor.
_viewport.MeshLoopCutArmed = false;
_viewport.MeshKnifeArmed = !_viewport.MeshKnifeArmed;
_viewport.MeshKnifeBisect = false;
SetPrompt( _viewport.MeshKnifeArmed
? "Knife: click points on the model. Each click cuts from the one before. Enter or Esc puts it down."
: "Knife put down." );
OnMeshEditChanged();
}
private void CopyMeshWeights()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
var source = OtherBodiesMesh( withSkin: true );
if ( source is null || !source.IsRigged )
{
SetPrompt( "Copy weights: no other body here carries skin weights. Rig the character first (Rig workspace), or import a rigged one." );
return;
}
RunMeshOp( "Copy weights", s => SetPrompt( $"Copy weights: {s.TransferWeights( source ):N0} vertices now follow the body under them." ) );
}
private void OnMeshPolyBuild( Vec3 at, bool closeTriangle, float weld ) =>
RunMeshOp( "Poly build", s => s.PolyBuild( at, closeTriangle, weld ) );
/// <summary>
/// Start or stop drawing new topology over this body. Starting makes the body as it stands the
/// surface everything snaps to; stopping keeps what was drawn in this body (Finish retopo is
/// the one that lifts it out).
/// </summary>
private void ToggleMeshRetopo()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsRetopologizing )
{
session.EndRetopo();
SetPrompt( "Retopology off. What you drew is still part of this body — Separate (P) or Delete it if you did not mean to keep it." );
OnMeshEditChanged();
UpdateMeshModeSwitch();
return;
}
RunMeshOp( "Retopo", s => s.BeginRetopo( MeshSize() * 0.003f ) );
UpdateMeshModeSwitch();
if ( session.IsRetopologizing )
SetPrompt( "Retopo: Ctrl+click four points to lay the first quad, then Ctrl+click beside the selected edge to grow a strip. The ghost shows the next face — red means it would be refused. Click an open edge to grow from it instead." );
}
private void OnMeshPolyStroke( List<Vec3> stroke, float width, float weld ) =>
RunMeshOp( "Strip", s => s.PolyBuildStroke( stroke, width, weld ) );
private void ToggleMeshStrip()
{
if ( _viewport is null )
return;
_viewport.RetopoStrip = !_viewport.RetopoStrip;
SetPrompt( _viewport.RetopoStrip
? "Strip brush: Ctrl+drag along the form to lay a strip of quads. Start on the selected edge to carry on from it."
: "Strip brush off: Ctrl+click lays one face at a time." );
OnMeshEditChanged();
}
private void SetMeshPivot()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( Editor.Application.IsKeyDown( KeyCode.Shift ) )
{
session.Pivot = Vec3.Zero;
SetPrompt( "Pivot back at the origin." );
}
else if ( session.AffectedVertices().Count == 0 )
SetPrompt( "Pivot: select something to put the pivot at. Shift-click puts it back at the origin." );
else
{
session.PivotToSelection();
SetPrompt( "Pivot moved to the selection. Spin and Screw now turn round it." );
}
OnMeshEditChanged();
}
/// <summary>
/// Change one of the edit's live modifiers: one document undo step, the history marked to
/// rebuild, and the preview over the cage refreshed straight away.
/// </summary>
/// <summary>The live modifier pass for the preview, fed the session's creases rather than the
/// feature's, which only catch up when the edit is committed.</summary>
private Func<PolyMesh, PolyMesh> MeshModifiersFor( MeshEditFeature feature )
{
if ( !feature.HasModifiers )
return null;
return mesh => feature.ApplyModifiers( mesh, _viewport?.MeshEditSession?.Creases ?? feature.Creases );
}
private void SetMeshModifier( string what, Action<MeshEditFeature> change )
{
if ( _meshEditFeature is not { } feature || _viewport is null )
return;
RecordUndo();
change( feature );
_studio.MarkDirty( feature );
_viewport.MeshEditModifiers = MeshModifiersFor( feature );
_viewport.RefreshMeshEditPreview();
SetPrompt( what ?? DescribeMeshModifiers() );
OnMeshEditChanged();
}
private void ToggleModMirror() => SetMeshModifier( null, f => f.MirrorX.Value = !f.MirrorX.Value );
private void CycleModArray() => SetMeshModifier( null, f => f.ArrayCount.Value = f.ArrayCount.Clamped >= 5 ? 1 : f.ArrayCount.Clamped + 1 );
private void CycleModSubdivide() => SetMeshModifier( null, f => f.SubdivideLevels.Value = (f.SubdivideLevels.Clamped + 1) % 4 );
private void ToggleModSolidify() => SetMeshModifier( null, f => f.SolidifyThickness.Value = f.SolidifyThickness.Value > 0f ? 0f : MeshSize() * 0.01f );
/// <summary>A line saying what the live modifiers are now, for the prompt.</summary>
private string DescribeMeshModifiers()
{
if ( _meshEditFeature is not { HasModifiers: true } f )
return "No live modifiers: the body is exactly the mesh you edit.";
var parts = new List<string>();
if ( f.MirrorX.Value ) parts.Add( "mirrored across X" );
if ( f.ArrayCount.Clamped > 1 ) parts.Add( $"{f.ArrayCount.Clamped} copies along X" );
if ( f.SubdivideLevels.Clamped > 0 ) parts.Add( $"subdivided {f.SubdivideLevels.Clamped}x" );
if ( f.SolidifyThickness.Value > 0f ) parts.Add( $"{f.SolidifyThickness.Value:0.###} thick" );
return $"Live: {string.Join( ", ", parts )}. You edit the cage (the wire); the body gets the result. Change the numbers in the feature's settings.";
}
private void ToggleMeshEvenQuads()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.EvenQuads = !session.EvenQuads;
SetPrompt( session.EvenQuads
? "Even quads: each new face is square to the edge it grows from, wherever you click."
: "Free quads: each new face reaches exactly to the click." );
OnMeshEditChanged();
}
private void FinishMeshRetopo()
{
RunMeshOp( "Finish retopo", s => s.SeparateRetopo() );
UpdateMeshModeSwitch();
if ( _viewport?.MeshEditSession is { IsRetopologizing: false, Separated.Count: > 0 } )
SetPrompt( "Retopology lifted out — it becomes its own body when you press Finish. The sculpt under it is untouched." );
}
private void ToggleMeshCircle()
{
if ( _viewport is null )
return;
_viewport.MeshCircle = !_viewport.MeshCircle;
if ( _viewport.MeshCircle )
SetPrompt( "Circle select: hold the mouse and paint over what you want. Ctrl paints it off, [ and ] resize, C or Esc to stop." );
OnMeshEditChanged();
}
private void ToggleMeshLasso()
{
if ( _viewport is null )
return;
_viewport.MeshLasso = !_viewport.MeshLasso;
OnMeshEditChanged();
}
private void ArmMeshBisect()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
AcceptMeshOp();
_viewport.MeshKnifeArmed = true;
_viewport.MeshKnifeBisect = true;
SetPrompt( "Bisect: click two points on the model. The slice runs all the way through along that line, into the screen." );
OnMeshEditChanged();
}
private void OnMeshBisectCut( Vec3 from, Vec3 to, Vec3 view )
{
var normal = Vec3.Cross( to - from, view );
if ( normal.LengthSquared < 1e-12f )
{
SetPrompt( "Bisect: the two points were too close. Click two points further apart." );
return;
}
RunMeshOp( "Bisect", s => s.Bisect( from, normal ) );
OnMeshEditChanged();
}
private void OnMeshKnifeCut( Vec3 from, Vec3 to, Vec3 view, bool throughAll ) =>
RunMeshOp( "Knife", s => s.Knife( from, to, view, throughAll ) );
private void SaveMeshSelection()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.AffectedVertices().Count == 0 )
{
SetPrompt( "Select something to save first." );
return;
}
var name = $"Selection {session.NamedSelections.Count + 1}";
session.SaveSelection( name );
SetPrompt( $"Saved as {name}. Recall selection brings it back." );
OnMeshEditChanged();
}
private void RecallMeshSelection()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.NamedSelections.Count == 0 )
{
SetPrompt( "Nothing is saved yet. Select something and press Save selection." );
return;
}
var how = Editor.Application.IsKeyDown( KeyCode.Shift ) ? MeshEditSession.Combine.Add
: Editor.Application.IsKeyDown( KeyCode.Control ) ? MeshEditSession.Combine.Remove
: MeshEditSession.Combine.Replace;
var menu = new Menu( _viewport );
menu.AddHeading( "Saved selections" );
foreach ( var name in session.NamedSelections.Keys )
{
var captured = name;
menu.AddOption( captured, null, () => RunMeshOp( "Recall", s => s.RecallSelection( captured, how ) ) );
}
menu.AddSeparator();
foreach ( var name in session.NamedSelections.Keys )
{
var captured = name;
menu.AddOption( $"Forget {captured}", "delete", () => { session.ForgetSelection( captured ); OnMeshEditChanged(); } );
}
menu.OpenAtCursor();
}
private void ToggleMeshSoft()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.SoftRadius = session.SoftRadius > 0f ? 0f : MeshSize() * 0.15f;
OnMeshEditChanged();
}
private void ToggleMeshSoftConnected()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.SoftConnected = !session.SoftConnected;
if ( session.SoftConnected && session.SoftRadius <= 0f )
session.SoftRadius = MeshSize() * 0.15f;
SetPrompt( session.SoftConnected
? "Soft falloff follows the surface: nearby but unconnected parts stay put."
: "Soft falloff reaches through space." );
OnMeshEditChanged();
}
private void CycleMeshSoftShape()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
var shapes = Enum.GetValues<MeshEditSession.SoftFalloff>();
session.SoftShape = shapes[(Array.IndexOf( shapes, session.SoftShape ) + 1) % shapes.Length];
SetPrompt( $"Soft falloff shape: {session.SoftShape}." );
OnMeshEditChanged();
}
private void ScaleMeshSoft( float factor )
{
if ( _viewport is { MeshCircle: true } )
{
_viewport.MeshCircleRadius = Math.Clamp( _viewport.MeshCircleRadius * factor, 0.005f, 1f );
OnMeshEditChanged();
return;
}
if ( _viewport?.MeshEditSession is not { SoftRadius: > 0f } session )
return;
session.SoftRadius *= factor;
SetPrompt( $"Soft falloff reaches {session.SoftRadius:0.##} units." );
OnMeshEditChanged();
}
private void SetMeshHandle( EffigyViewport.BodyDragMode mode )
{
if ( _viewport is null )
return;
_viewport.MeshHandleMode = mode;
OnMeshEditChanged();
}
private void ToggleMeshXray()
{
if ( _viewport is null )
return;
_viewport.MeshXray = !_viewport.MeshXray;
OnMeshEditChanged();
}
/// <summary>Bevel with the width on the bar. Four segments rounds; Shift makes it one, a chamfer.</summary>
private void StartBevel()
{
var segments = Editor.Application.IsKeyDown( KeyCode.Shift ) ? 1 : 4;
StartMeshOp( segments == 1 ? "Chamfer" : "Bevel", MeshSize() * 0.02f, ( s, v ) => s.Bevel( v, segments ) );
}
private string _meshOpName;
/// <summary>Called once the viewport exists. Kept here so the whole of Edit mode is in one place.</summary>
private void BuildMeshEditBar()
{
if ( _viewport is null || _meshEditBar is not null )
return;
_meshEditBar = new EffigyMeshEditBar( _viewport.Canvas )
{
Accepted = AcceptMeshOp,
Cancelled = CancelMeshOp,
};
_viewport.AddMeshEditOverlay( _meshEditBar );
_viewport.MeshEditChanged = OnMeshEditChanged;
_viewport.MeshEditKeyPressed = MeshEditKey;
_viewport.MeshKnifeCut = OnMeshKnifeCut;
_viewport.MeshPolyBuild = OnMeshPolyBuild;
_viewport.MeshPolyStroke = OnMeshPolyStroke;
_viewport.MeshBisectCut = OnMeshBisectCut;
_viewport.MeshLoopCutPlaced = PlaceLoopCut;
_viewport.MeshEditContextMenuRequested = OpenMeshContextMenu;
}
/// <summary>Edit mode's bar: the toolbar of held tools, with every tool group as a menu above.</summary>
private void ShowMeshEditBar()
{
_stageBar.Mode = null;
_stageBar.SetFinish( null, null );
_stageBar.SetStages( new[] { _meshCoreStage } );
_stageBar.SetMenus( MeshMenus() );
_stageBar.MenuOpening = UpdateMeshEditChecks;
UpdateMeshModeSwitch();
ShowMeshEditPanel( true );
}
/// <summary>
/// The left dock is the Model panel for the whole workspace now, not only while editing —
/// the CAD tree coming back between edits was what kept Model reading as CAD. The flag is
/// kept for the callers that meant "an edit started or ended": both are a refresh.
/// </summary>
private void ShowMeshEditPanel( bool editing )
{
_ = editing;
SyncModelDock();
}
/// <summary>Fill the Model panel from the edit session while one is open, from the studio
/// otherwise. Nothing happens while the panel is hidden — in CAD, say.</summary>
private void RefreshMeshEditPanel()
{
if ( _meshPanel is not { Visible: true } || _studio is null )
return;
if ( _viewport?.MeshEditSession is { } session )
_meshPanel.Refresh( session, _studio.Bodies, _meshEditFeature?.LastBodyId );
else
_meshPanel.RefreshObject( _studio.Bodies, _viewport?.IdleBodyIds, _studio.Features, _studio.RollbackIndex );
// The badge in the header reads the same mesh.
RefreshModelCheck();
}
/// <summary>Undo until the step at <paramref name="index"/> in the history is gone too.</summary>
private void UndoMeshTo( int index )
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
CancelMeshOp();
while ( session.UndoCount > index && session.Undo() ) { }
_viewport.RefreshMeshEditPreview();
OnMeshEditChanged();
}
/// <summary>
/// Object | Edit | Retopo. Picking Object is how you leave — the edit is kept, exactly as the
/// old Finish button kept it — so there is no separate button to hunt for.
/// </summary>
private void UpdateMeshModeSwitch()
{
var retopo = _viewport?.MeshEditSession is { IsRetopologizing: true };
// The workspace-wide switch — Object | Edit | Sculpt | Retopo — lit for where we are, and
// the chrome for the mode: Retopo has its own palette.
UpdateModelModeSwitch( retopo ? "Retopo" : "Edit" );
ShowModelChrome( retopo ? "Retopo" : "Edit" );
}
/// <summary>
/// Space: find any tool by name. A menu with a filter box on top, refilled on every keystroke —
/// the way s&box's own node graph finds a node — listing each match with the menu it lives in,
/// so searching also teaches where things are.
/// </summary>
private void OpenMeshToolSearch()
{
// The catalog is the search now, whenever Model's chrome is up; the menu below is what it
// was before, and what the bar still gets if the chrome is ever taken down.
if ( _modelHeader is { Visible: true } )
{
OpenModelCatalog();
return;
}
if ( _viewport?.MeshEditSession is null )
return;
UpdateMeshEditChecks();
var menu = new Menu( _viewport );
var row = new Widget( menu ) { Layout = Layout.Row() };
row.Layout.Margin = 6;
var search = new MenuSearchEdit( row ) { PlaceholderText = "Search every tool: grid fill, rip, bevel…", MinimumWidth = 280f };
search.TextChanged += text => FillMeshToolSearch( menu, text );
// Enter runs the first match that can run, so "gr⏎" is a grid fill.
search.ReturnPressed += () =>
{
if ( _meshSearchFirst is { } first )
{
menu.Close();
first.Clicked?.Invoke();
}
};
row.Layout.Add( search );
menu.AddWidget( row );
FillMeshToolSearch( menu, null );
menu.OpenAtCursor();
search.Focus();
}
private EffigyStageTool _meshSearchFirst;
/// <summary>A line edit that lives in a menu: a click in it must not close the menu.</summary>
private sealed class MenuSearchEdit : LineEdit
{
public MenuSearchEdit( Widget parent ) : base( parent ) { }
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
e.Accepted = true;
}
}
private void FillMeshToolSearch( Menu menu, string filter )
{
var visible = menu.Visible;
// The same trick the node graph plays to refill an open menu without it flickering shut.
var setFlag = typeof( Widget ).GetMethod( "SetFlag",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic );
if ( visible )
setFlag?.Invoke( menu, new object[] { 15, false } );
menu.RemoveMenus();
menu.RemoveOptions();
var shown = 0;
var seen = new HashSet<EffigyStageTool>();
_meshSearchFirst = null;
foreach ( var stage in MeshMenus() )
{
foreach ( var tool in stage.Tools )
{
if ( tool is null || !seen.Add( tool ) )
continue;
if ( !string.IsNullOrWhiteSpace( filter )
&& tool.Label.IndexOf( filter, StringComparison.OrdinalIgnoreCase ) < 0
&& (tool.Tip ?? "").IndexOf( filter, StringComparison.OrdinalIgnoreCase ) < 0 )
continue;
var run = tool.Clicked;
var option = menu.AddOption( $"{tool.Label} · {stage.Name}", null, () => run?.Invoke() );
if ( _meshSearchFirst is null && tool.Enabled && !string.IsNullOrWhiteSpace( filter ) )
_meshSearchFirst = tool;
option.Enabled = tool.Enabled;
option.Checkable = tool.Checkable;
option.Checked = tool.Checked;
option.ToolTip = tool.Enabled ? tool.Tip : tool.DisabledReason ?? tool.Tip;
// A long list is a list nobody reads; the filter is how you get to the rest.
if ( ++shown >= 24 )
break;
}
if ( shown >= 24 )
break;
}
if ( shown == 0 )
menu.AddOption( "No tool by that name", null, null ).Enabled = false;
if ( visible )
{
setFlag?.Invoke( menu, new object[] { 15, true } );
menu.AdjustSize();
menu.Update();
}
}
private List<EffigyStage> _meshMenus;
private List<EffigyStage> _meshMenusBuiltFrom;
/// <summary>
/// The menu bar: the same tools as the stages, regrouped by what they work on — Blender's
/// Select / Add / Vertex / Edge / Face / Mesh — rather than by the order they were written in.
/// A tool is picked from its old group by label (Array and Smooth are both an operation and a
/// modifier, so the group matters), and any tool not placed here lands at the end of Mesh, so
/// a tool added later can never go missing from the menus. "-" is a divider.
/// </summary>
private List<EffigyStage> MeshMenus()
{
if ( _meshMenus is not null && ReferenceEquals( _meshMenusBuiltFrom, _meshEditStages ) )
return _meshMenus;
var placed = new HashSet<EffigyStageTool>();
EffigyStage Menu( string name, params string[] picks )
{
var menu = new EffigyStage { Name = name };
foreach ( var pick in picks )
{
if ( pick == "-" )
{
if ( menu.Tools.Count > 0 && menu.Tools[^1] is not null )
menu.Tools.Add( null );
continue;
}
var split = pick.Split( '/' );
var group = split.Length > 1 ? split[0] : null;
var label = split[^1];
foreach ( var stage in _meshEditStages )
{
if ( group is not null && stage.Name != group )
continue;
var tool = stage.Tools.Find( t => t is not null && t.Label == label );
if ( tool is null )
continue;
menu.Tools.Add( tool );
placed.Add( tool );
break;
}
}
if ( menu.Tools.Count > 0 && menu.Tools[^1] is null )
menu.Tools.RemoveAt( menu.Tools.Count - 1 );
return menu;
}
var menus = new List<EffigyStage>
{
Menu( "Select", "All", "Invert", "Grow", "Shrink", "-", "Linked", "Similar", "Path", "Checker", "Random", "Select/Mirror", "-", "Save selection", "Recall selection", "-", "Non-manifold", "Border", "Sharp edges", "Triangles", "N-gons", "Interior", "Loose", "-", "Lasso", "Circle", "X-ray", "-", "Hide", "Hide others", "Unhide" ),
Menu( "Add", "Add Cube", "Add Sphere", "Add Cylinder", "Add Plane", "Add Tube", "Add Wedge", "-", "Extrude", "Extrude along normals", "Extrude individual", "Inset", "Bevel", "Loop cut", "Knife", "Bisect", "-", "Fill", "Grid fill", "Bridge", "-", "Add/Duplicate", "Add/Array", "Spin", "Screw", "Pivot here", "-", "Add/Subdivide" ),
Menu( "Vertex", "Merge", "Merge first", "Merge last", "Merge at pivot", "By distance", "Connect", "Bevel vertices", "-", "Vertex slide", "Clean up/Smooth", "Clean up/Relax", "Clean up/Flatten", "Loop circle", "Loop space", "To sphere", "Randomize" ),
Menu( "Edge", "Edge slide", "Rip", "Rotate edge", "Subdivide edges", "Pipe", "-", "Loop circle", "Loop space", "-", "Make hard", "Harden creases", "Crease", "Clear crease", "-", "Mark seam", "Clear seam", "-", "Delete edges" ),
Menu( "Face", "Triangulate", "Tris to quads", "Beautify", "Poke faces", "Fill holes", "Scatter", "-", "Split", "Separate", "Loose parts", "By material", "Extract", "-", "Flip", "Fix normals", "-", "Solidify", "-", "Delete faces only" ),
Menu( "Transform", "Move X", "Move Y", "Move Z", "-", "Rotate X", "Rotate Y", "Rotate Z", "-", "Scale by", "Flatten X", "Flatten Y", "Flatten Z", "-", "Pivot here", "To pivot", "Snap to grid" ),
Menu( "Mesh", "Move", "Rotate", "Scale", "Soft", "Connected", "Falloff shape", "-", "Shrink/Fatten", "Shear", "Bend", "-", "Symmetrize", "Mirror X", "Snap", "Shrinkwrap", "-", "Dissolve", "Limited dissolve", "Decimate", "Delete", "Delete loose" ),
Menu( "UV", "Unwrap", "Mark seam", "Clear seam", "-", "Project UVs", "Project from above", "-", "Trim row", "Trim rows" ),
Menu( "Modifiers", "Modifiers/Live mirror", "Modifiers/Array", "Modifiers/Smooth", "Crease", "Clear crease", "Modifiers/Thickness" ),
Menu( "Retopo", "Retopo/Retopo", "Even quads", "Strip brush", "Relax", "Finish retopo" ),
Menu( "Skin", "Copy weights", "Smooth weights", "Mirror weights", "Fix weights" ),
Menu( "Cloth", "Drape", "Cotton" ),
};
// Anything the lists above do not name still has to be reachable.
var mesh = menus.Find( m => m.Name == "Mesh" );
var first = true;
foreach ( var stage in _meshEditStages )
{
foreach ( var tool in stage.Tools )
{
if ( tool is null || placed.Contains( tool ) || !placed.Add( tool ) )
continue;
if ( first )
{
mesh.Tools.Add( null );
first = false;
}
mesh.Tools.Add( tool );
}
}
_meshMenus = menus;
_meshMenusBuiltFrom = _meshEditStages;
return menus;
}
/// <summary>A tool from the menus by its label, or null.</summary>
private EffigyStageTool MeshToolNamed( string label )
{
var split = label.Split( '/' );
var group = split.Length > 1 ? split[0] : null;
var name = split[^1];
foreach ( var stage in _meshEditStages )
{
if ( group is not null && stage.Name != group )
continue;
foreach ( var tool in stage.Tools )
if ( tool.Label == name )
return tool;
}
return null;
}
/// <summary>
/// The right-click menu: what can be done to what is selected, by element — Blender's context
/// menu. "-" is a divider. The tools are the menu bar's own, so their state is the same.
/// </summary>
private void OpenMeshContextMenu()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
UpdateMeshEditChecks();
var labels = session.IsRetopologizing
? new[] { "Relax", "Even quads", "Strip brush", "-", "Grow", "Linked", "Invert", "-", "Delete", "Finish retopo" }
: session.Mode switch
{
EditElement.Vertex => new[] { "Connect", "Bevel vertices", "-", "Loop circle", "Loop space", "-", "Merge", "Merge first", "Merge last", "By distance", "Vertex slide", "Smooth", "Clean up/Relax", "Flatten", "To sphere", "Randomize", "-", "Fill", "Rip", "-", "Grow", "Shrink", "Linked", "Invert", "-", "Hide", "Hide others", "Unhide", "-", "Dissolve", "Delete" },
EditElement.Edge => new[] { "Extrude", "-", "Loop cut", "Bevel", "Edge slide", "-", "Loop circle", "Loop space", "-", "Bridge", "Fill", "Grid fill", "Rip", "Rotate edge", "Subdivide edges", "Pipe", "-", "Make hard", "Crease", "Mark seam", "-", "Grow", "Linked", "Invert", "-", "Hide", "Hide others", "Unhide", "-", "Dissolve", "Delete edges", "Delete" },
_ => new[] { "Extrude", "Extrude along normals", "Extrude individual", "Inset", "Duplicate", "Subdivide", "-", "Triangulate", "Tris to quads", "Beautify", "Poke faces", "-", "Split", "Separate", "Flip", "Fix normals", "-", "Grow", "Shrink", "Linked", "Similar", "Invert", "-", "Hide", "Hide others", "Unhide", "-", "Delete faces only", "Delete" },
};
var tools = new List<EffigyStageTool>();
foreach ( var label in labels )
{
if ( label == "-" )
{
if ( tools.Count > 0 && tools[^1] is not null )
tools.Add( null );
}
else if ( MeshToolNamed( label ) is { } tool )
tools.Add( tool );
}
var menu = new Menu( _viewport );
menu.AddHeading( session.IsRetopologizing ? "Retopology" : session.Mode.ToString() );
EffigyStageBar.AddTools( menu, tools );
menu.OpenAtCursor();
}
/// <summary>The Model workspace's Edit stage on its home bar: the one button that starts editing.</summary>
private EffigyStage BuildMeshEditHomeStage()
{
var stage = new EffigyStage { Name = "Edit" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.MeshEdit,
Label = "Edit mesh",
Tip = "Edit a body's vertices, edges and faces directly. Saved as a Mesh edit in the history",
Clicked = AddMeshEdit,
} );
return stage;
}
private List<EffigyStage> BuildMeshEditStages()
{
var select = new EffigyStage { Name = "Select" };
_meshVertexTool = MeshTool( select, EffigyIcon.SelectVertex, "Vertex", "Pick vertices (1)", () => SetMeshElement( EditElement.Vertex ), checkable: true );
_meshEdgeTool = MeshTool( select, EffigyIcon.SelectEdge, "Edge", "Pick edges (2). Alt+click selects a whole loop, Ctrl+Alt+click the ring around it", () => SetMeshElement( EditElement.Edge ), checkable: true );
_meshFaceTool = MeshTool( select, EffigyIcon.SelectFace, "Face", "Pick faces (3)", () => SetMeshElement( EditElement.Face ), checkable: true );
MeshTool( select, EffigyIcon.SelectTool, "All", "Select everything (A). Drag on empty space for a box; click empty space for nothing", MeshSelectAll );
_meshLassoTool = MeshTool( select, EffigyIcon.SplineTool, "Lasso", "Drag on empty space draws a free lasso instead of a box", ToggleMeshLasso, checkable: true );
MeshTool( select, EffigyIcon.SelectEdge, "Non-manifold", "Select every open border and three-way junction — on something meant to be solid, each one is a hole or a bad join", () => RunMeshOp( "Non-manifold", s => s.SelectNonManifold() ) );
MeshTool( select, EffigyIcon.SelectEdge, "Border", "Swap the selected faces for the loop of edges round their edge — ready to bridge, extrude or mark as a seam", () => RunMeshOp( "Border", s => s.SelectBoundaryLoop() ) );
_meshSharpSelectTool = MeshTool( select, EffigyIcon.SelectEdge, "Sharp edges", "Select every edge where the faces meet at more than 30° — the edges to crease, harden or bevel", () => RunMeshOp( "Sharp edges", s => s.SelectSharpEdges( 30f ) ) );
_meshMirrorSelectTool = MeshTool( select, EffigyIcon.Mirror, "Mirror", "Add the selection's twin across X, so both sides get the same edit", () => RunMeshOp( "Mirror", s => s.SelectMirror() ) );
MeshTool( select, EffigyIcon.SelectVertex, "Save selection", "Keep this selection under a name, to get back with Recall — the ear, the fingers, the hem", SaveMeshSelection );
MeshTool( select, EffigyIcon.SelectVertex, "Recall selection", "Select a saved selection again. Shift adds it, Ctrl takes it away", RecallMeshSelection );
_meshLooseSelectTool = MeshTool( select, EffigyIcon.SelectVertex, "Loose", "Select the vertices no face uses, to see what Delete loose would remove", () => RunMeshOp( "Loose", s => s.SelectLoose() ) );
MeshTool( select, EffigyIcon.SelectFace, "Checker", "Drop every other face (or vertex) of the selection in a checkerboard — for alternating panels and studs", () => RunMeshOp( "Checker", s => s.CheckerDeselect() ) );
_meshCircleTool = MeshTool( select, EffigyIcon.CircleTool, "Circle", "Paint the selection on with a circle brush: hold the mouse and sweep. Ctrl paints it off; [ and ] resize (C)", ToggleMeshCircle, checkable: true );
_meshGrowTool = MeshTool( select, EffigyIcon.SelectTool, "Grow", "Take in everything touching the selection (+). Click from one face to spread over a region without dragging a box over geometry you cannot see", () => RunMeshOp( "Grow", s => s.GrowSelection() ) );
_meshShrinkSelectionTool = MeshTool( select, EffigyIcon.SelectTool, "Shrink", "Drop the selection's border, leaving its interior (-)", () => RunMeshOp( "Shrink", s => s.ShrinkSelection() ) );
_meshLinkedTool = MeshTool( select, EffigyIcon.SelectTool, "Linked", "Take in every piece the selection touches (L). One click on an ear takes the whole ear when it is its own piece", () => RunMeshOp( "Linked", s => s.SelectLinked() ) );
_meshSimilarTool = MeshTool( select, EffigyIcon.SelectTool, "Similar", "Add every face pointing the same way as the picked faces, within 10 degrees (Shift+L)", () => RunMeshOp( "Similar", s => s.SelectSimilar( MeshEditSession.Similarity.Normal, 10f ) ) );
MeshTool( select, EffigyIcon.SelectTool, "Invert", "Select everything that is not selected, and nothing that is (Ctrl+I)", () => RunMeshOp( "Invert", s => s.InvertSelection() ) );
_meshPathTool = MeshTool( select, EffigyIcon.SelectTool, "Path", "Pick two vertices (or two faces) and take the shortest line between them — where a seam or a waistline should run", () => RunMeshOp( "Path", s => s.SelectShortestPath() ) );
_meshXrayTool = MeshTool( select, EffigyIcon.ProfileInspectorTool, "X-ray", "See through the model: box select picks what is behind too (Alt+Z)", ToggleMeshXray, checkable: true );
_meshHideTool = MeshTool( select, EffigyIcon.SelectFace, "Hide", "Hide the selected faces so they neither show nor get in the way (H). Alt+H brings everything back", () => RunMeshOp( "Hide", s => s.Hide() ) );
_meshHideOthersTool = MeshTool( select, EffigyIcon.SelectFace, "Hide others", "Hide everything except the selection — the way to work on a hand without the rest of the body in the way (Shift+H)", () => RunMeshOp( "Hide others", s => s.Hide( unselected: true ) ) );
_meshUnhideTool = MeshTool( select, EffigyIcon.SelectFace, "Unhide", "Show every hidden face again, selected so it can go straight back (Alt+H)", () => RunMeshOp( "Unhide", s => s.Unhide() ) );
_meshRandomSelectTool = MeshTool( select, EffigyIcon.SelectVertex, "Random", "Select half of what is showing, at random — for scattering, or roughing up part of a surface", () => RunMeshOp( "Random", s => s.SelectRandom( 0.5f, Environment.TickCount ) ) );
_meshTrianglesTool = MeshTool( select, EffigyIcon.SelectFace, "Triangles", "Select every triangle — the odd faces in a quad model, worth finding before they cause a pinch", () => RunMeshOp( "Triangles", s => s.SelectFacesBySides( 3 ) ) );
_meshNgonsTool = MeshTool( select, EffigyIcon.SelectFace, "N-gons", "Select every face with five or more sides — what a subdivide or an export will not like", () => RunMeshOp( "N-gons", s => s.SelectFacesBySides( 5, orMore: true ) ) );
_meshInteriorTool = MeshTool( select, EffigyIcon.SelectFace, "Interior", "Select faces that point into the model — the leftovers inside a join or boolean, and faces that will show black", () => RunMeshOp( "Interior", s => s.SelectInteriorFaces() ) );
var move = new EffigyStage { Name = "Transform" };
_meshMoveTool = MeshTool( move, EffigyIcon.Transform, "Move", "Move handle (G)", () => SetMeshHandle( EffigyViewport.BodyDragMode.Move ), checkable: true );
_meshRotateTool = MeshTool( move, EffigyIcon.CircularPattern, "Rotate", "Rotate handle (R)", () => SetMeshHandle( EffigyViewport.BodyDragMode.Rotate ), checkable: true );
_meshScaleTool = MeshTool( move, EffigyIcon.Primitive, "Scale", "Scale handle (S)", () => SetMeshHandle( EffigyViewport.BodyDragMode.Scale ), checkable: true );
// Typed transforms: the number on the bar is the whole gesture, for when "up by exactly
// two" matters more than a drag. Hold Ctrl while dragging a handle to snap it instead.
_meshTypedTools.Clear();
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Transform, "Move X", "Move the selection along X by exactly this much. Type the number", () => StartMeshOp( "Move X", MeshSize() * 0.05f, ( s, v ) => s.Move( new Vec3( v, 0, 0 ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Transform, "Move Y", "Move the selection along Y by exactly this much", () => StartMeshOp( "Move Y", MeshSize() * 0.05f, ( s, v ) => s.Move( new Vec3( 0, v, 0 ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Transform, "Move Z", "Move the selection along Z by exactly this much", () => StartMeshOp( "Move Z", MeshSize() * 0.05f, ( s, v ) => s.Move( new Vec3( 0, 0, v ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.CircularPattern, "Rotate X", "Turn the selection about X through its centre by this many degrees. Shift-click turns about the pivot", () => StartMeshOp( "Rotate X", 15f, ( s, v ) => s.Rotate( new Vec3( 1, 0, 0 ), v, Editor.Application.IsKeyDown( KeyCode.Shift ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.CircularPattern, "Rotate Y", "Turn the selection about Y through its centre by this many degrees. Shift-click turns about the pivot", () => StartMeshOp( "Rotate Y", 15f, ( s, v ) => s.Rotate( new Vec3( 0, 1, 0 ), v, Editor.Application.IsKeyDown( KeyCode.Shift ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.CircularPattern, "Rotate Z", "Turn the selection about Z through its centre by this many degrees. Shift-click turns about the pivot", () => StartMeshOp( "Rotate Z", 15f, ( s, v ) => s.Rotate( new Vec3( 0, 0, 1 ), v, Editor.Application.IsKeyDown( KeyCode.Shift ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Primitive, "Scale by", "Scale the selection about its centre by this factor: 2 doubles it, 0.5 halves it. Shift-click scales about the pivot", () => StartMeshOp( "Scale", 1.5f, ( s, v ) => s.Scale( new Vec3( v, v, v ), Editor.Application.IsKeyDown( KeyCode.Shift ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Primitive, "Flatten X", "Squash the selection flat along X, onto the plane through its centre — 0 on that axis, the rest untouched", () => RunMeshOp( "Scale", s => s.Scale( new Vec3( 0, 1, 1 ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Primitive, "Flatten Y", "Squash the selection flat along Y", () => RunMeshOp( "Scale", s => s.Scale( new Vec3( 1, 0, 1 ) ) ) ) );
_meshTypedTools.Add( MeshTool( move, EffigyIcon.Primitive, "Flatten Z", "Squash the selection flat along Z", () => RunMeshOp( "Scale", s => s.Scale( new Vec3( 1, 1, 0 ) ) ) ) );
_meshSoftTool = MeshTool( move, EffigyIcon.SculptSmooth, "Soft", "Soft falloff: moving pulls nearby vertices along too. [ and ] change its reach (O)", ToggleMeshSoft, checkable: true );
_meshSoftConnectedTool = MeshTool( move, EffigyIcon.SculptSmooth, "Connected", "Soft falloff along the surface only, so a lip moves without the other lip and a finger without the one beside it (Alt+O)", ToggleMeshSoftConnected, checkable: true );
_meshSoftShapeTool = MeshTool( move, EffigyIcon.SculptSmooth, "Falloff shape", "Cycle the soft falloff's curve: Smooth, Sphere, Root, Inverse Square, Sharp, Linear, Constant", CycleMeshSoftShape );
_meshSlideTool = MeshTool( move, EffigyIcon.LoopCut, "Edge slide", "Slide the selected edge loop towards one neighbour or the other", () => StartMeshOp( "Edge slide", 0.25f, ( s, v ) => s.EdgeSlide( v ) ) );
_meshShrinkFattenTool = MeshTool( move, EffigyIcon.Shell, "Shrink/Fatten", "Move the selection along its own normals — thicken a limb, or pull a surface in, without changing its shape (Alt+S)", () => StartMeshOp( "Shrink/Fatten", MeshSize() * 0.02f, ( s, v ) => s.ShrinkFatten( v ) ) );
MeshTool( move, EffigyIcon.Transform, "Shear", "Lean the selection over along X, more the higher up it is. 1 is 45 degrees", () => StartMeshOp( "Shear", 0.5f, ( s, v ) => s.Shear( v ) ) );
MeshTool( move, EffigyIcon.CircularPattern, "Bend", "Curl the selection up along its length in X, by this many degrees. The -X end stays put — tails, toes, horns", () => StartMeshOp( "Bend", 90f, ( s, v ) => s.Bend( v ) ) );
_meshVertexSlideTool = MeshTool( move, EffigyIcon.SelectVertex, "Vertex slide", "Slide the selected vertices along the edge that runs left-to-right across the view. Orbit so the edge you want lies across the screen, then scrub", StartVertexSlide );
var add = new EffigyStage { Name = "Add" };
_meshExtrudeTool = MeshTool( add, EffigyIcon.Extrude, "Extrude", "Pull the selected faces out and build walls to them (E)", () => StartMeshOp( "Extrude", MeshSize() * 0.1f, ( s, v ) => s.Extrude( v ) ) );
_meshExtrudeNormalsTool = MeshTool( add, EffigyIcon.Extrude, "Extrude along normals", "Pull the selected faces along their own normals. The faces stay together (Alt+E)", () => StartMeshOp( "Extrude Along Normals", MeshSize() * 0.1f, ( s, v ) => s.ExtrudeAlongNormals( v ) ) );
_meshExtrudeIndividualTool = MeshTool( add, EffigyIcon.Extrude, "Extrude individual", "Pull each selected face along its own normal. The faces pull apart", () => StartMeshOp( "Extrude Individual", MeshSize() * 0.1f, ( s, v ) => s.ExtrudeIndividual( v ) ) );
_meshInsetTool = MeshTool( add, EffigyIcon.Inset, "Inset", "Shrink the selected faces inward, leaving a ring of faces around them (I)", () => StartMeshOp( "Inset", MeshSize() * 0.03f, ( s, v ) => s.Inset( v ) ) );
_meshLoopCutTool = MeshTool( add, EffigyIcon.LoopCut, "Loop cut", "Cut a new loop across the ring of faces. Move over the model and click where you want it, or select one edge first (Ctrl+R)", StartLoopCut, checkable: true );
_meshKnifeTool = MeshTool( add, EffigyIcon.CutTool, "Knife", "Click points on the model; each click cuts from the last. X-ray cuts through the back too. Enter or Esc to stop (K)", ToggleMeshKnife, checkable: true );
_meshBisectTool = MeshTool( add, EffigyIcon.MirrorTool, "Bisect", "Click two points: the model is sliced all the way through along that line", ArmMeshBisect );
_meshDuplicateTool = MeshTool( add, EffigyIcon.LinearPattern, "Duplicate", "Copy the selected faces in place; move the copy with the handle (Shift+D)", () => RunMeshOp( "Duplicate", s => s.Duplicate() ) );
_meshBridgeTool = MeshTool( add, EffigyIcon.Loft, "Bridge", "Join two open rims with a band of faces — even with different edge counts. Select an edge loop round each hole", () => RunMeshOp( "Bridge", s => s.BridgeSelectedLoops() ) );
MeshTool( add, EffigyIcon.Transform, "Pivot here", "Put the pivot — what Spin and Screw turn round — at the middle of the selection. Shift-click puts it back at the origin", SetMeshPivot );
MeshTool( add, EffigyIcon.CircularPattern, "Spin", "Sweep the selected edges round the up axis through the pivot, for this many degrees — a lathe. Select a rim and spin it into a bowl or a button", () => StartMeshOp( "Spin", 360f, ( s, v ) => s.Spin( v, Math.Max( 4, (int)MathF.Round( MathF.Abs( v ) / 15f ) ) ) ) );
MeshTool( add, EffigyIcon.CircularPattern, "Screw", "One full turn of Spin that climbs as it goes — this far up Z. Horns, springs, threads", () => StartMeshOp( "Screw", MeshSize() * 0.25f, ( s, v ) => s.Spin( 360f, 24, v ) ) );
MeshTool( add, EffigyIcon.LinearPattern, "Array", "Repeat the selected faces in a row along X, this many in all — teeth, rivets, buttons", () => StartMeshOp( "Array", 3f, ( s, v ) => s.ArrayFaces( (int)MathF.Round( v ) ) ) );
MeshTool( add, EffigyIcon.Boolean, "Split", "Tear the selected faces loose from the rest where they stand, so moving them opens a gap (Y)", () => RunMeshOp( "Split", s => s.SplitFaces() ) );
MeshTool( add, EffigyIcon.Subdivide, "Grid fill", "Cap a hole with a grid of quads that flows with its rim, instead of one big face — for eye sockets, necks and cuffs. The rim needs an even number of edges; the number turns the grid round it", () => StartMeshOp( "Grid fill", 0f, ( s, v ) => s.GridFill( (int)MathF.Round( v ) ) ) );
MeshTool( add, EffigyIcon.CutTool, "Rip", "Tear the mesh open along the selected edges, so moving them opens a slit — a mouth, a pocket, the front of a jacket (V)", () => RunMeshOp( "Rip", s => s.Rip() ) );
_meshFillTool = MeshTool( add, EffigyIcon.SelectFace, "Fill", "Cap a hole with one face: select the vertices or edges round it. Three or four loose vertices make a face too (F)", () => RunMeshOp( "Fill", s => s.Fill() ) );
_meshFillHolesTool = MeshTool( add, EffigyIcon.SelectFace, "Fill holes", "Cap every open hole with up to this many sides, in one go. Bigger openings are left alone: they are more likely a missing side than a hole", () => StartMeshOp( "Fill holes", 4f, ( s, v ) => s.FillHoles( Math.Max( 3, (int)MathF.Round( v ) ) ) ) );
_meshSubdivideEdgesTool = MeshTool( add, EffigyIcon.Subdivide, "Subdivide edges", "Cut the selected edges into this many pieces. Two opposite edges of a quad make a strip across it; all four make a grid. Nothing is left with a crack", () => StartMeshOp( "Subdivide edges", 2f, ( s, v ) => s.SubdivideEdges( Math.Max( 1, (int)MathF.Round( v ) - 1 ) ) ) );
_meshRotateEdgeTool = MeshTool( add, EffigyIcon.CircularPattern, "Rotate edge", "Turn the selected edge one corner round the two faces it separates — the fix for a diagonal running the wrong way. Shift-click turns it the other way", () => RunMeshOp( "Rotate edge", s => s.RotateEdge( Editor.Application.IsKeyDown( KeyCode.Shift ) ) ) );
_meshBeautifyTool = MeshTool( add, EffigyIcon.SelectFace, "Beautify", "Flip the diagonals between the selected triangles wherever that makes them better shaped — what to run after a triangulate leaves slivers", () => RunMeshOp( "Beautify", s => s.BeautifyFaces() ) );
_meshPipeTool = MeshTool( add, EffigyIcon.CircularPattern, "Pipe", "Build a tube of this radius along the selected edges — cables, pipes, rails, branches. Rings never twist, ends are capped, and the guide edges stay. Shift-click makes it six-sided", () => StartMeshOp( "Pipe", MeshSize() * 0.01f, ( s, v ) => s.Pipe( MathF.Max( v, 0.001f ), Editor.Application.IsKeyDown( KeyCode.Shift ) ? 6 : 8 ) ) );
_meshScatterTool = MeshTool( add, EffigyIcon.Primitive, "Scatter", "Scatter this many copies of another body over the selected faces (or the whole surface) — rocks over ground, tufts over a field. Stood on the surface, turned at random, sized 80–120%, nothing steeper than 45°. The copies become a body of their own. Shift-click keeps them upright", StartMeshScatter );
foreach ( var (kind, tip) in new[]
{
(MeshEditSession.PrimitiveKind.Cube, "a cube"), (MeshEditSession.PrimitiveKind.Sphere, "a sphere"), (MeshEditSession.PrimitiveKind.Cylinder, "a cylinder"),
(MeshEditSession.PrimitiveKind.Plane, "a flat square"), (MeshEditSession.PrimitiveKind.Tube, "a tube"), (MeshEditSession.PrimitiveKind.Wedge, "a wedge"),
} )
{
var captured = kind;
_meshPrimitiveTools.Add( MeshTool( add, EffigyIcon.Primitive, $"Add {kind}", $"Drop {tip} into this mesh at the pivot, this big across, as its own piece — bridge it, merge it or leave it loose. The new faces are selected, so Move works straight away (Shift+A)", () => StartMeshOp( $"Add {captured}", MeshSize() > 1e-3f && _viewport?.MeshEditSession is { Mesh.FaceCount: > 0 } ? MeshSize() * 0.25f : 16f, ( s, v ) => s.AddPrimitive( captured, v ) ) ) );
}
_meshTypedTools.Add( MeshTool( add, EffigyIcon.Transform, "Snap to grid", "Snap the selected vertices onto a grid of this many units — 16 makes a wall piece's edges land where the next piece meets them", () => StartMeshOp( "Snap to grid", 16f, ( s, v ) => s.SnapToGrid( MathF.Max( v, 0.001f ) ) ) ) );
_meshToPivotTool = MeshTool( add, EffigyIcon.Transform, "To pivot", "Move the selection so its centre lands on the pivot — the other half of Pivot here", () => RunMeshOp( "To pivot", s => s.SelectionToPivot() ) );
_meshSubdivideTool = MeshTool( add, EffigyIcon.Subdivide, "Subdivide", "Split the selected faces into four, for somewhere you want more detail. With nothing selected it subdivides and smooths the whole body. Skin weights come with it", SubdivideMesh );
_meshBevelTool = MeshTool( add, EffigyIcon.Fillet, "Bevel", "Round off the selected edges. The number is the width; Shift-click for a flat chamfer (Ctrl+B)", StartBevel );
var clean = new EffigyStage { Name = "Clean up" };
_meshMergeTool = MeshTool( clean, EffigyIcon.Merge, "Merge", "Merge the selected vertices at their centre (M)", () => RunMeshOp( "Merge", s => s.MergeAtCentre() ) );
_meshMergeFirstTool = MeshTool( clean, EffigyIcon.Merge, "Merge first", "Merge the selected vertices onto the first one you clicked", () => RunMeshOp( "Merge", s => s.Merge( MeshEditSession.MergeTarget.First ) ) );
_meshMergeLastTool = MeshTool( clean, EffigyIcon.Merge, "Merge last", "Merge the selected vertices onto the last one you clicked", () => RunMeshOp( "Merge", s => s.Merge( MeshEditSession.MergeTarget.Last ) ) );
_meshMergePivotTool = MeshTool( clean, EffigyIcon.Merge, "Merge at pivot", "Merge the selected vertices at the pivot — Pivot here puts it where you want", () => RunMeshOp( "Merge", s => s.Merge( MeshEditSession.MergeTarget.Pivot ) ) );
MeshTool( clean, EffigyIcon.Merge, "By distance", "Weld vertices that sit on top of each other", () => StartMeshOp( "Merge by distance", MeshSize() * 0.001f, ( s, v ) => s.MergeByDistance( v ) ) );
_meshDissolveTool = MeshTool( clean, EffigyIcon.Dissolve, "Dissolve", "Remove the selected edges or vertices and join the faces around them", () => RunMeshOp( "Dissolve", s => s.Dissolve() ) );
_meshLimitedDissolveTool = MeshTool( clean, EffigyIcon.Dissolve, "Limited dissolve", "Merge coplanar faces across the entire mesh to simplify it", () => RunMeshOp( "Limited dissolve", s => s.LimitedDissolve() ) );
MeshTool( clean, EffigyIcon.DeleteGeometry, "Delete loose", "Remove vertices no face uses — invisible, but still exported", () => RunMeshOp( "Delete loose", s => s.DeleteLoose() ) );
_meshDeleteTool = MeshTool( clean, EffigyIcon.DeleteGeometry, "Delete", "Delete what is selected, leaving a hole (X)", () => RunMeshOp( "Delete", s => s.Delete() ) );
_meshDeleteFacesTool = MeshTool( clean, EffigyIcon.DeleteGeometry, "Delete faces only", "Delete exactly the selected faces, keeping the edges and vertices other faces still use", () => RunMeshOp( "Delete faces", s => s.DeleteOnlyFaces() ) );
_meshDeleteEdgesTool = MeshTool( clean, EffigyIcon.Dissolve, "Delete edges", "Remove the selected edges and join the faces on either side into one, instead of leaving a hole. Faces selected: every edge between them goes", () => RunMeshOp( "Delete edges", s => s.DeleteEdgesKeepFaces() ) );
_meshRandomizeTool = MeshTool( clean, EffigyIcon.SculptSmooth, "Randomize", "Jitter the selected vertices along their normals by up to this much — quick roughness for rock, bark or cloth", () => StartMeshOp( "Randomize", MeshSize() * 0.01f, ( s, v ) => s.Randomize( v ) ) );
_meshDecimateTool = MeshTool( clean, EffigyIcon.Dissolve, "Decimate", "Cut the selected faces (or the whole body) down to this share of their triangles, keeping the shape. The rest of the body is untouched", () => StartMeshOp( "Decimate", 0.5f, ( s, v ) => s.DecimateSelection( Math.Clamp( v, 0.01f, 0.99f ) ) ) );
_meshSplitEdgeTool = MeshTool( clean, EffigyIcon.Boolean, "Make hard", "Make the selected edges shade hard, by unwelding the model along them. Merge by distance puts it back", SplitMeshEdges );
_meshSharpenTool = MeshTool( clean, EffigyIcon.Chamfer, "Harden creases", "Make every edge that creases more than this angle shade hard, in one go — for a model that should read as hard-surface", () => StartMeshOp( "Harden creases", 40f, ( s, v ) => s.SplitEdgesByAngle( v ) ) );
_meshSmoothTool = MeshTool( clean, EffigyIcon.SculptSmooth, "Smooth", "Relax the selected vertices towards their neighbours — what you want straight after a subdivide. Open rims stay put. Nothing selected smooths everything", () => StartMeshOp( "Smooth", 0.5f, ( s, v ) => s.Smooth( v ) ) );
_meshOrganicRelaxTool = MeshTool( clean, EffigyIcon.SculptSmooth, "Relax", "Relax vertices along the surface without collapsing volume — LoopTools Relax", () => StartMeshOp( "Relax", 0.5f, ( s, v ) => s.Relax( v ) ) );
_meshLoopCircleTool = MeshTool( clean, EffigyIcon.CircularPattern, "Loop circle", "Turn the selected vertices into a regular circle on their best-fit plane — LoopTools Circle", () => RunMeshOp( "Circle", s => s.LoopCircle() ) );
_meshLoopSpaceTool = MeshTool( clean, EffigyIcon.LinearPattern, "Loop space", "Distribute the selected vertices evenly along their edge loop — LoopTools Space", () => RunMeshOp( "Space", s => s.LoopSpace() ) );
_meshConnectTool = MeshTool( clean, EffigyIcon.CutTool, "Connect", "Connect two selected vertices by cutting an edge across their shared face (J)", () => RunMeshOp( "Connect", s => s.ConnectVertices() ) );
_meshFlattenTool = MeshTool( clean, EffigyIcon.SculptSmooth, "Flatten", "Flatten the selected vertices onto their best-fit plane", () => RunMeshOp( "Flatten", s => s.FlattenFaces() ) );
MeshTool( clean, EffigyIcon.SculptSmooth, "To sphere", "Round the selected vertices off towards a ball — for a head, an eye or a knuckle blocked out as a cube. 1 is a full sphere", () => StartMeshOp( "To sphere", 1f, ( s, v ) => s.ToSphere( v ) ) );
MeshTool( clean, EffigyIcon.Mirror, "Fix normals", "Make the selected faces (or all of them) agree with each other and face outward — the fix for faces that show black (Shift+N)", () => RunMeshOp( "Recalculate normals", s => s.RecalculateNormals() ) );
MeshTool( clean, EffigyIcon.Mirror, "Flip", "Turn the selected faces (or all of them) inside out", () => RunMeshOp( "Flip normals", s => s.FlipNormals() ) );
_meshTriangulateTool = MeshTool( clean, EffigyIcon.SelectFace, "Triangulate", "Split the selected faces into triangles (Ctrl+T)", () => RunMeshOp( "Triangulate", s => s.TriangulateFaces() ) );
_meshPokeFacesTool = MeshTool( clean, EffigyIcon.SelectVertex, "Poke faces", "Add a vertex in the center of the selected faces and triangulate them (Alt+P)", () => RunMeshOp( "Poke Faces", s => s.PokeFaces() ) );
_meshTrisToQuadsTool = MeshTool( clean, EffigyIcon.SelectFace, "Tris to quads", "Join neighbouring selected triangles back into quads — the undo for a triangulated import (Alt+J)", () => RunMeshOp( "Tris to Quads", s => s.TrisToQuads() ) );
_meshBevelVerticesTool = MeshTool( clean, EffigyIcon.Chamfer, "Bevel vertices", "Cut the corner off the selected vertices and cap each notch with a flat face. The number is how far down each edge to cut (Ctrl+Shift+B)", () => StartMeshOp( "Bevel Vertices", MeshSize() * 0.02f, ( s, v ) => s.BevelVertices( v ) ) );
var surface = new EffigyStage { Name = "Surface" };
_meshSymmetrizeTool = MeshTool( surface, EffigyIcon.Mirror, "Symmetrize", "Make the model symmetric: keep the +X half and replace the other with a mirror of it. Cuts along X = 0 first, so faces crossing the middle are split rather than lost. Shift-click keeps the -X half instead", SymmetrizeMesh );
_meshMirrorTool = MeshTool( surface, EffigyIcon.Mirror, "Mirror X", "Edit both sides at once: moving a vertex moves its partner across X = 0", ToggleMeshMirror, checkable: true );
var modifiers = new EffigyStage { Name = "Modifiers" };
_meshCreaseTool = MeshTool( modifiers, EffigyIcon.Chamfer, "Crease", "Keep the selected edges sharp under the Smooth modifier (Shift+E). The number is how many levels the fold holds for: 1 for one level, 2 for two; a fraction softens it", () => StartMeshOp( "Crease", 1f, ( s, v ) => s.Crease( Math.Max( v, 0f ) ) ) );
_meshUncreaseTool = MeshTool( modifiers, EffigyIcon.Chamfer, "Clear crease", "Let the selected edges round off again under Smooth. Nothing selected clears every crease", () => RunMeshOp( "Clear crease", s => s.ClearCreases() ) );
_modMirrorTool = MeshTool( modifiers, EffigyIcon.Mirror, "Live mirror", "Show and output this mesh mirrored across X, joined down the middle — model half, get the whole. Unlike Symmetrize it stays live: keep editing the half", ToggleModMirror, checkable: true );
_modArrayTool = MeshTool( modifiers, EffigyIcon.LinearPattern, "Array", "Repeat the whole mesh along X, live. Each click adds a copy, up to five, then off", CycleModArray, checkable: true );
_modSubdivideTool = MeshTool( modifiers, EffigyIcon.Subdivide, "Smooth", "Show and output the mesh subdivided and smoothed, live — block out in a few quads, see the smooth result. Each click is one more level, up to three, then off", CycleModSubdivide, checkable: true );
_modSolidifyTool = MeshTool( modifiers, EffigyIcon.Solidify, "Thickness", "Give the surface a thickness, live — draw a garment as a sheet and see it as cloth. Set the exact value in the feature's settings", ToggleModSolidify, checkable: true );
var retopo = new EffigyStage { Name = "Retopo" };
_meshRetopoTool = MeshTool( retopo, EffigyIcon.Shrinkwrap, "Retopo", "Draw a clean, light mesh over this body: Ctrl+click places, and everything sticks to the surface. The body itself is not changed", ToggleMeshRetopo, checkable: true );
_meshEvenQuadsTool = MeshTool( retopo, EffigyIcon.SelectFace, "Even quads", "Each new face is square to the edge it grows from, however far you click. Off: faces reach exactly to the click", ToggleMeshEvenQuads, checkable: true );
_meshStripTool = MeshTool( retopo, EffigyIcon.Loft, "Strip brush", "Ctrl+drag lays a whole strip of square quads along the stroke — round a limb, down a jaw. Start on the selected edge to continue from it", ToggleMeshStrip, checkable: true );
_meshRelaxTool = MeshTool( retopo, EffigyIcon.SculptSmooth, "Relax", "Even out the new mesh and put it back on the surface. Works on the selection, or all of it", () => StartMeshOp( "Relax", 0.5f, ( s, v ) => s.RelaxRetopo( v ) ) );
_meshFinishRetopoTool = MeshTool( retopo, EffigyIcon.Boolean, "Finish retopo", "Lift the new mesh out as a body of its own. The sculpt under it stays exactly as it was", FinishMeshRetopo );
_meshSnapTool = MeshTool( surface, EffigyIcon.Shrinkwrap, "Snap", "Dragged vertices stick to the surface of the other bodies — for fitting clothing", ToggleMeshSnap, checkable: true );
_meshExtractTool = MeshTool( surface, EffigyIcon.Shell, "Extract", "Clothing start: copy the selected faces of the body, lift them off the skin by this gap and make them a body of their own", () => StartMeshOp( "Extract garment", MeshSize() * 0.005f, ( s, v ) => s.ExtractGarment( v ) ) );
_meshSeparateTool = MeshTool( surface, EffigyIcon.Boolean, "Separate", "Move the selected faces out into a body of their own (P)", () => RunMeshOp( "Separate", s => s.Separate() ) );
_meshLoosePartsTool = MeshTool( surface, EffigyIcon.Boolean, "Loose parts", "Split every disconnected piece off into a body of its own. The biggest piece stays", () => RunMeshOp( "Separate loose parts", s => s.SeparateLooseParts() ) );
_meshByMaterialTool = MeshTool( surface, EffigyIcon.Boolean, "By material", "Split the body into one body per material. The busiest material stays", () => RunMeshOp( "Separate by material", s => s.SeparateByMaterial() ) );
_meshWeightsTool = MeshTool( surface, EffigyIcon.BoneBind, "Copy weights", "Give this mesh the skin weights of the rigged body under it, so a garment moves with the body", CopyMeshWeights );
_meshWrapTool = MeshTool( surface, EffigyIcon.Shrinkwrap, "Shrinkwrap", "Pull the selection (or everything) onto the other bodies, keeping this gap", () => StartMeshOp( "Shrinkwrap", MeshSize() * 0.01f, ( s, v ) => s.Shrinkwrap( v, MeshSize() ) ) );
MeshTool( surface, EffigyIcon.Solidify, "Solidify", "Give the surface a thickness — the last step of a garment", () => StartMeshOp( "Solidify", MeshSize() * 0.01f, ( s, v ) => s.Solidify( v ) ) );
var weights = new EffigyStage { Name = "Weights" };
_meshSmoothWeightsTool = MeshTool( weights, EffigyIcon.SculptSmooth, "Smooth weights", "Relax the skin weights across the selection — the fix for a bound elbow that creases instead of bending. Nothing selected smooths the whole body", () => StartMeshOp( "Smooth weights", 0.5f, ( s, v ) => s.SmoothWeights( v ) ) );
_meshMirrorWeightsTool = MeshTool( weights, EffigyIcon.Mirror, "Mirror weights", "Copy the weights from the +X half onto the -X half, swapping the rig's left and right bones. What you run after Symmetrize. Shift-click copies the other way", MirrorMeshWeights );
_meshNormalizeWeightsTool = MeshTool( weights, EffigyIcon.BoneBind, "Fix weights", "Cut every vertex down to the four bones the exporter will actually write, strongest first, summing to one — so what you see here is what the game deforms", NormalizeMeshWeights );
var cloth = new EffigyStage { Name = "Cloth" };
_meshDrapeTool = MeshTool( cloth, EffigyIcon.Drape, "Drape", "Let the cloth fall under gravity onto the other bodies. What you have selected is pinned — a collar, a waistband — and the number is how many seconds it settles for", StartMeshDrape );
_meshFabricTool = MeshTool( cloth, EffigyIcon.Fabric, "Cotton", "What the cloth is made of: cotton hangs soft, denim and leather hold their folds, stretch clings. Click to change", CycleMeshFabric );
var uv = new EffigyStage { Name = "UV" };
_meshSeamTool = MeshTool( uv, EffigyIcon.Seam, "Mark seam", "Cut the texture along the selected edges (Ctrl+E). The unwrap is never allowed to cross a seam, so this is where you decide the texture may break — Alt+click takes a whole loop", () => RunMeshOp( "Mark seam", s => s.MarkSeam() ) );
_meshUnseamTool = MeshTool( uv, EffigyIcon.Seam, "Clear seam", "Unmark the selected edges. With nothing selected, clears every seam on the mesh", ClearMeshSeam );
_meshProjectUVsTool = MeshTool( uv, EffigyIcon.Unwrap, "Project UVs", "Map the selected faces (or all of them) at a world scale, so a tiling material repeats every this many units — the same on every map prop. Each face takes the axis it most faces", () => StartMeshOp( "Project UVs", _meshUnitsPerTile, ( s, v ) => { _meshUnitsPerTile = MathF.Max( v, 0.01f ); s.ProjectUVs( _meshUnitsPerTile ); } ) );
_meshPlanarUVsTool = MeshTool( uv, EffigyIcon.Unwrap, "Project from above", "Map the selected faces straight down at this many units per tile — one continuous texture for a floor, a road, a sign. Shift-click projects along X instead", () => StartMeshOp( "Project UVs", _meshUnitsPerTile, ( s, v ) => { _meshUnitsPerTile = MathF.Max( v, 0.01f ); s.ProjectUVs( _meshUnitsPerTile, Editor.Application.IsKeyDown( KeyCode.Shift ) ? new Vec3( 1, 0, 0 ) : new Vec3( 0, 0, 1 ) ); } ) );
_meshTrimRowTool = MeshTool( uv, EffigyIcon.Unwrap, "Trim row", "Map the selected faces onto one row of a trim sheet: the number is which row, counting from the top. Runs along the face at the Project UVs scale", () => StartMeshOp( "Trim row", 1f, ( s, v ) => { var row = Math.Clamp( (int)MathF.Round( v ), 1, _meshTrimRows ); s.MapToTrim( (row - 1) / (float)_meshTrimRows, row / (float)_meshTrimRows, _meshUnitsPerTile ); } ) );
_meshTrimRowsTool = MeshTool( uv, EffigyIcon.Unwrap, "Trim rows", "How many rows the trim sheet has — set it once for the sheet you use", () => { _meshTrimRows = Math.Max( 1, _meshTrimRows == 8 ? 4 : _meshTrimRows == 4 ? 16 : 8 ); SetPrompt( $"Trim sheet: {_meshTrimRows} rows. Trim row picks one." ); } );
_meshUnwrapTool = MeshTool( uv, EffigyIcon.Unwrap, "Unwrap", "Lay the mesh out flat so it can be painted or baked (U), cutting at your seams and wherever the surface turns more than this angle", StartMeshUnwrap );
// The toolbar: the tools you hold rather than look up, always on screen. Everything —
// these included — is also in the menus above it.
_meshCoreStage = new EffigyStage { Name = "Tools" };
foreach ( var tool in new[] { _meshVertexTool, _meshEdgeTool, _meshFaceTool, _meshCircleTool, _meshLassoTool,
_meshMoveTool, _meshRotateTool, _meshScaleTool, _meshExtrudeTool, _meshExtrudeNormalsTool, _meshExtrudeIndividualTool, _meshInsetTool, _meshBevelTool, _meshLoopCutTool, _meshKnifeTool,
_meshXrayTool, _meshMirrorTool, _meshSnapTool } )
_meshCoreStage.Add( tool );
return new List<EffigyStage> { select, move, add, clean, surface, modifiers, retopo, weights, cloth, uv };
}
private static EffigyStageTool MeshTool( EffigyStage stage, EffigyIcon icon, string label, string tip, Action clicked, bool checkable = false )
{
var tool = new EffigyStageTool { Icon = icon, Label = label, Tip = tip, Clicked = clicked, Checkable = checkable };
stage.Add( tool );
return tool;
}
/// <summary>Add a Mesh edit on the selected body (or the only one) and step straight in, the way
/// Paint does: its one input is "which body", which the viewport selection already names.</summary>
private void AddMeshEdit()
{
if ( _viewport is null )
return;
LeaveCurrentWorkspace();
var feature = new MeshEditFeature();
// No body yet: the edit starts a new one, as Blender starts on an empty scene. Add ▸ Cube
// inside the edit is the first thing to do, and the prompt says so.
if ( _studio.Bodies.Count == 0 )
{
feature.FromScratch.Value = true;
feature.Name = "Mesh";
}
RecordUndo();
ApplyIdleGeometrySelection( feature );
InsertAtRollback( feature );
RebuildStudio();
EnterMeshEdit( feature );
}
private void EnterMeshEdit( MeshEditFeature feature )
{
if ( feature is null || _viewport is null )
return;
if ( BarMode == EffigyBarMode.MeshEdit && ReferenceEquals( _meshEditFeature, feature ) && _viewport.IsMeshEditing )
return;
LeaveCurrentWorkspace();
var index = _studio.Features.IndexOf( feature );
if ( index >= 0 && _studio.RollbackIndex != index + 1 )
{
_rollbackBeforeEdit ??= _studio.RollbackIndex;
_studio.RollbackIndex = index + 1;
RebuildStudio();
}
if ( feature.LastInput is null || feature.Error is not null && feature.Edited is null )
{
SetPrompt( feature.Error ?? "This mesh edit has no body under it yet. Pick one body, then try again." );
return;
}
BuildMeshEditBar();
_meshEditFeature = feature;
_lastMeshEditFeature = feature;
BarMode = EffigyBarMode.MeshEdit;
ShowMeshEditBar();
_dialog?.Close();
var session = new MeshEditSession( feature.LastInput, feature.Edited );
session.LoadCreases( feature.Creases );
_viewport.BeginMeshEdit( session );
_uvPanel?.Bind( session );
_viewport.MeshEditModifiers = MeshModifiersFor( feature );
_meshEditBar.Bind( session );
UpdateMeshEditChecks();
SetPrompt( "Edit: click to select, drag the arrows to move. E extrudes, I insets, Ctrl+R loop cuts, X deletes." );
}
/// <summary>Commit and leave. The whole session is one step on the document's undo stack.</summary>
private void FinishMeshEdit()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
session.Accept();
var feature = _meshEditFeature;
if ( feature is not null && session.HasChanges )
{
RecordUndo();
session.CommitTo( feature );
_studio.MarkDirty( feature );
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
}
CloseMeshEdit();
ShowSculptHome();
RestoreRollbackAfterEdit();
RebuildStudio();
}
/// <summary>End the session without committing — the feature was undone out of the tree.</summary>
private void CloseMeshEdit()
{
_viewport?.EndMeshEdit();
_meshEditBar?.Bind( null );
_uvPanel?.Bind( null );
ShowMeshEditPanel( false );
_meshOpName = null;
_meshEditFeature = null;
}
// --- operations -----------------------------------------------------------------------------
private float MeshSize()
{
var d = _viewport?.MeshEditSession?.Mesh.BoundsDiagonal ?? 1f;
return d > 1e-4f ? d : 1f;
}
/// <summary>Start a scrubbed operation: preview it now, put its number on the bar.</summary>
private void StartMeshOp( string name, float amount, Action<MeshEditSession, float> run )
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
AcceptMeshOp();
// Rounded to something typeable: 0.1 of a 34.64-inch diagonal is 3.464, and nobody wants to
// read that on a field they are about to nudge.
amount = MathF.Round( amount, amount >= 1f ? 1 : 3 );
if ( !TryPreview( session, name, s => run( s, amount ) ) )
return;
_meshOpName = name;
_meshEditBar?.ShowOperation( name, amount, v => TryPreview( session, name, s => run( s, v ) ) );
OnMeshEditChanged();
}
private bool TryPreview( MeshEditSession session, string name, Action<MeshEditSession> run )
{
try
{
session.Preview( name, run );
_viewport?.RefreshMeshEditPreview();
_meshEditBar?.Refresh();
return true;
}
catch ( Exception e ) when ( e is InvalidOperationException or ArgumentException )
{
// A refusal is a sentence for the reader, not a stack trace — every kernel operation
// throws with one. The preview has already put the mesh back.
if ( !session.IsPreviewing )
_meshEditBar?.ShowOperation( null, 0f, null );
SetPrompt( $"{name}: {e.Message}" );
return false;
}
}
private void AcceptMeshOp()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.Accept();
_meshOpName = null;
_meshEditBar?.ShowOperation( null, 0f, null );
OnMeshEditChanged();
}
private void CancelMeshOp()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.Cancel();
_meshOpName = null;
_meshEditBar?.ShowOperation( null, 0f, null );
_viewport.RefreshMeshEditPreview();
OnMeshEditChanged();
}
/// <summary>An operation with no number: run it now, as one step.</summary>
private void RunMeshOp( string name, Action<MeshEditSession> run )
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
AcceptMeshOp();
try
{
run( session );
}
catch ( Exception e ) when ( e is InvalidOperationException or ArgumentException )
{
SetPrompt( $"{name}: {e.Message}" );
return;
}
_viewport.RefreshMeshEditPreview();
OnMeshEditChanged();
}
/// <summary>
/// Arm the loop cut: the loop follows the cursor over the model and a click places it. An edge
/// already selected skips the hover and cuts there straight away, which is what a keyboard-driven
/// user expects after Alt+clicking a loop.
/// </summary>
private void StartLoopCut()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( _viewport.MeshLoopCutArmed )
{
_viewport.MeshLoopCutArmed = false;
OnMeshEditChanged();
return;
}
if ( session.IsPreviewing )
AcceptMeshOp();
if ( session.SelectedEdges.Count == 1 )
{
PlaceLoopCut( session.SelectedEdges.First() );
return;
}
_viewport.MeshKnifeArmed = false;
_viewport.MeshLoopCutArmed = true;
SetPrompt( "Loop cut: move over the model to see where the loop lands, then click to place it. Escape puts it down." );
OnMeshEditChanged();
}
/// <summary>An edge was chosen — by the click, or by already being selected. The cut itself is
/// the usual scrubbed operation, so the loop can still be slid before it is accepted.</summary>
private void PlaceLoopCut( EdgeKey edge )
{
StartMeshOp( "Loop cut", 0.5f, ( s, v ) => s.LoopCut( edge, Math.Clamp( v, 0.02f, 0.98f ) ) );
}
private void SetMeshElement( EditElement element )
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.SetMode( element );
OnMeshEditChanged();
}
private void MeshSelectAll()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
var all = session.Mode switch
{
EditElement.Vertex => session.SelectedVertices.Count >= session.Mesh.VertexCount,
EditElement.Edge => false,
_ => session.SelectedFaces.Count >= session.Mesh.FaceCount,
};
// A second A clears, the way Blender's used to — "select all" pressed on everything
// selected has only one useful meaning.
if ( all )
session.ClearSelection();
else
session.SelectAll();
OnMeshEditChanged();
}
private void ToggleMeshMirror()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
session.MirrorX = !session.MirrorX;
OnMeshEditChanged();
}
/// <summary>
/// Hang the cloth. The pins are whatever is selected and the floor is every other body, which is
/// the garment case exactly: select the collar, press Drape, scrub the seconds until the shirt
/// sits right. The collider is passed in rather than taken from Snap, so draping does not turn
/// drag-snapping on behind the user's back.
/// </summary>
private void StartMeshDrape()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
var floor = session.SnapTarget ?? OtherBodiesMesh();
if ( session.AffectedVertices().Count == 0 && floor is null )
{
SetPrompt( "Drape needs something to hold the cloth up: select the vertices that pin it — a collar, a waistband — or add the body it should land on." );
return;
}
var thickness = MeshSize() * 0.004f;
StartMeshOp( "Drape", 1f, ( s, v ) => s.Drape( v, _meshFabric, thickness, floor ) );
}
/// <summary>Cotton, denim, leather, stretch — and the button wears the name of the one it is on,
/// so the setting is readable without opening anything.</summary>
private void CycleMeshFabric()
{
var all = (Fabric[])Enum.GetValues( typeof( Fabric ) );
_meshFabric = all[(Array.IndexOf( all, _meshFabric ) + 1) % all.Length];
if ( _meshFabricTool is not null )
_meshFabricTool.Label = _meshFabric.ToString();
SetPrompt( $"Fabric: {_meshFabric}. Drape again to see it hang differently." );
OnMeshEditChanged();
}
/// <summary>
/// Vertex slide. The rail is chosen against the view's right, which is the closest thing to
/// Blender's "drag towards the edge you want" that a scrubbed number can be: the direction is
/// read ONCE when the tool starts, so orbiting mid-scrub does not change which edge you are on.
/// </summary>
private void StartVertexSlide()
{
if ( _viewport is null )
return;
var direction = _viewport.CameraRight;
StartMeshOp( "Vertex slide", 0.25f, ( s, v ) => s.VertexSlide( v, direction ) );
}
/// <summary>
/// Subdivide, saying what it will cost first. This is the one operation that can turn a workable
/// mesh into an unworkable one in a single click, so past a limit it asks for the click twice:
/// the first press reports the cost and the second does it.
/// </summary>
private void SubdivideMesh()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.IsPreviewing )
AcceptMeshOp();
var (vertices, faces) = session.PredictSubdivide();
var where = session.SelectedFaces.Count > 0
? $"{session.SelectedFaces.Count} face(s)"
: "the whole body";
// Confirm only when it is genuinely large. Asking every time trains you to click through it.
const int heavy = 100_000;
if ( faces > heavy && _meshSubdivideWarned != faces )
{
_meshSubdivideWarned = faces;
SetPrompt( $"Subdividing {where} would make {faces:N0} faces and {vertices:N0} vertices, which will be slow to work with. Press Subdivide again to go ahead." );
return;
}
_meshSubdivideWarned = -1;
RunMeshOp( "Subdivide", s => s.Subdivide() );
SetPrompt( $"Subdivided {where}: {session.Mesh.FaceCount:N0} faces, {session.Mesh.VertexCount:N0} vertices." );
}
private int _meshSubdivideWarned = -1;
/// <summary>
/// Symmetrize. Destructive in a way the other tools are not — half the model is replaced — so it
/// says which half it kept afterwards, and Shift picks the other one.
/// </summary>
private void SymmetrizeMesh()
{
var keepPositive = !Editor.Application.IsKeyDown( KeyCode.Shift );
RunMeshOp( "Symmetrize", s => s.Symmetrize( keepPositive ) );
if ( _viewport?.MeshEditSession is { } session )
SetPrompt( $"Symmetrized about X = 0, keeping the {(keepPositive ? "+X" : "-X")} half: {session.Mesh.FaceCount:N0} faces. Shift-click to keep the other side instead." );
}
/// <summary>
/// Cut the weights down to what the exporter will really write. Says how many vertices were
/// affected, because that number is the interesting part: anything above zero was deforming
/// differently in the game from what the viewport was showing.
/// </summary>
private void NormalizeMeshWeights()
{
RunMeshOp( "Fix weights", s =>
{
var changed = s.NormalizeWeights( DmxWriter.MaxInfluences );
SetPrompt( changed == 0
? "Weights are already what the exporter will write: four bones a vertex, summing to one."
: $"Fixed {changed:N0} vertex(es) that carried more influences than the exporter writes — those were deforming differently in game." );
} );
}
/// <summary>
/// Mirror weights, pairing the rig's bones by name. The kernel does not know what a bone is
/// called, so the L/R map is built here and handed in.
/// </summary>
private void MirrorMeshWeights()
{
if ( _viewport?.MeshEditSession is null )
return;
var partner = BoneMirrorMap( out var pairs );
if ( pairs == 0 )
SetPrompt( "No left/right bone pairs in this rig, so weights will mirror onto the same bones. That is right for a spine and wrong for arms." );
var fromPositive = !Editor.Application.IsKeyDown( KeyCode.Shift );
RunMeshOp( "Mirror weights", s =>
{
var copied = s.MirrorWeights( partner, fromPositive );
SetPrompt( copied == 0
? "No vertex on the far side lined up with one on this side. Symmetrize the geometry first — mirrored weights need mirrored vertices."
: $"Mirrored weights onto {copied:N0} vertex(es) across {pairs} left/right bone pair(s)." );
} );
}
/// <summary>
/// Each bone's mirror partner, by name. The Citizen names its sides with an `_L`/`_R` suffix
/// (`arm_upper_L`), which is the convention this follows; a bone whose partner is not in the rig
/// maps to itself, which is the right answer for a spine and a visible-enough wrong one for a
/// limb that was named by hand.
/// </summary>
private Func<int, int> BoneMirrorMap( out int pairs )
{
var bones = _studio.Rig.Bones;
var byName = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );
for ( var i = 0; i < bones.Count; i++ )
if ( !string.IsNullOrEmpty( bones[i].Name ) )
byName[bones[i].Name] = i;
var map = new int[bones.Count];
var found = 0;
for ( var i = 0; i < bones.Count; i++ )
{
map[i] = i;
var name = bones[i].Name;
if ( string.IsNullOrEmpty( name ) )
continue;
// `arm_upper_L` <-> `arm_upper_R`, and the same with a trailing number (`finger_L1`).
var flipped = FlipSideSuffix( name );
if ( flipped is not null && byName.TryGetValue( flipped, out var other ) )
{
map[i] = other;
found++;
}
}
pairs = found / 2;
return bone => bone >= 0 && bone < map.Length ? map[bone] : bone;
}
/// <summary>`..._L` becomes `..._R` and back, including when digits follow the side letter.
/// Null when the name does not name a side.</summary>
private static string FlipSideSuffix( string name )
{
for ( var i = name.Length - 1; i >= 1; i-- )
{
if ( char.IsDigit( name[i] ) )
continue;
var c = name[i];
if ( (c != 'L' && c != 'R' && c != 'l' && c != 'r') || name[i - 1] != '_' )
return null;
var swapped = c switch { 'L' => 'R', 'R' => 'L', 'l' => 'r', _ => 'l' };
return name[..i] + swapped + name[(i + 1)..];
}
return null;
}
/// <summary>Split, saying how many edges genuinely came apart — which is not always all of them,
/// because a cut that does not reach a boundary leaves its ends joined.</summary>
private void SplitMeshEdges()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
var asked = session.SelectedEdges.Count;
RunMeshOp( "Make hard", s =>
{
var split = s.SplitEdges();
SetPrompt( split == asked
? $"{split} edge(s) now shade hard."
: $"{split} of {asked} edge(s) came apart. The rest are still joined at their ends — a cut only separates where it reaches a boundary or another cut." );
} );
}
/// <summary>Unmark the selected edges, or — with nothing selected — every seam on the mesh, which
/// is the way out of a marking session that went wrong.</summary>
private void ClearMeshSeam()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.Seams.Count == 0 )
{
SetPrompt( "There are no seams to clear." );
return;
}
if ( session.SelectedEdges.Count == 0 )
{
var all = session.Seams.Count;
RunMeshOp( "Clear seams", s => s.ClearSeams() );
SetPrompt( $"Cleared all {all} seam edge(s)." );
return;
}
RunMeshOp( "Clear seam", s => s.MarkSeam( false ) );
}
/// <summary>
/// Unwrap, scrubbing the charting angle. The report is worth saying out loud — the island count
/// is how you tell a good unwrap from one that shattered, and a skipped face is a degenerate one
/// that will bake as a hole.
/// </summary>
private void StartMeshUnwrap()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.Seams.Count == 0 )
SetPrompt( "Unwrapping on the angle alone. Mark seams first to choose where the texture may break." );
StartMeshOp( "Unwrap", 66f, ( s, v ) =>
{
var report = s.Unwrap( Math.Clamp( v, 1f, 179f ) );
SetPrompt( $"Unwrap: {report}" );
} );
}
/// <summary>Scatter another body over this one. One other body is used as it is; more than one
/// asks which, then the count scrubs like any other operation.</summary>
private void StartMeshScatter()
{
if ( _viewport?.MeshEditSession is null )
return;
var upright = Editor.Application.IsKeyDown( KeyCode.Shift );
var others = _studio.Bodies.Where( b => b.Id != _meshEditFeature?.LastBodyId && b.Mesh is { FaceCount: > 0 } ).ToList();
void Go( PolyMesh prop, string name )
{
StartMeshOp( "Scatter", 20f, ( s, v ) =>
{
var placed = s.Scatter( prop, Math.Max( 1, (int)MathF.Round( v ) ), seed: 1, minScale: 0.8f, maxScale: 1.2f, alignToSurface: !upright, maxSlopeDegrees: 45f );
SetPrompt( $"{placed} copies of {name} scattered. They are a body of their own once you leave the edit." );
} );
}
if ( others.Count == 0 )
{
SetPrompt( "Scatter needs another body to scatter — the rock, the tuft, the crate. There is only this one." );
return;
}
if ( others.Count == 1 )
{
Go( others[0].Mesh, others[0].Name );
return;
}
var menu = new Menu( _viewport );
menu.AddHeading( "Scatter which body?" );
foreach ( var body in others )
{
var captured = body;
menu.AddOption( captured.Name ?? captured.Id, null, () => Go( captured.Mesh, captured.Name ?? captured.Id ) );
}
menu.OpenAtCursor();
}
/// <summary>Snap onto every OTHER body — the one being edited cannot be its own target, or a
/// dragged vertex would stick to where it already is.</summary>
private void ToggleMeshSnap()
{
if ( _viewport?.MeshEditSession is not { } session )
return;
if ( session.SnapTarget is not null )
{
session.SnapTarget = null;
OnMeshEditChanged();
return;
}
var target = OtherBodiesMesh();
if ( target is null )
{
SetPrompt( "Snap needs another body to stick to — a character to fit clothing onto, say. There is only this one." );
UpdateMeshEditChecks();
return;
}
session.SnapTarget = target;
session.SnapOffset = MeshSize() * 0.005f;
SetPrompt( "Snap on: dragged vertices land on the nearest surface of the other bodies." );
OnMeshEditChanged();
}
private PolyMesh OtherBodiesMesh( bool withSkin = false )
{
var skip = _meshEditFeature?.LastBodyId;
var combined = new PolyMesh();
var skin = withSkin ? new SkinWeights() : null;
// Everything built, not just what the rollback shows: fitting a shirt to a body that is
// further down the history is the normal case, not the exception.
foreach ( var body in _studio.Bodies )
{
if ( body.Id == skip || body.Mesh is null || body.Mesh.FaceCount == 0 )
continue;
var offset = combined.VertexCount;
combined.Positions.AddRange( body.Mesh.Positions );
// Weights only count when every contributing body has them — half a skinned source
// would hand the garment bone 0 wherever it lay over the unrigged half.
if ( skin is not null )
{
if ( body.Mesh.IsRigged )
skin.Vertices.AddRange( body.Mesh.Skin.Vertices );
else
skin = null;
}
foreach ( var face in body.Mesh.Faces )
{
var indices = new int[face.Indices.Length];
for ( var i = 0; i < indices.Length; i++ )
indices[i] = face.Indices[i] + offset;
combined.AddFace( indices, (Vec2[])face.UVs.Clone(), face.Material );
}
}
combined.Skin = skin;
return combined.FaceCount == 0 ? null : combined;
}
/// <summary>Shrinkwrap and snap both need the target; wrap is useless without one.</summary>
private void OnMeshEditChanged()
{
UpdateMeshEditChecks();
_meshEditBar?.Refresh();
RefreshMeshEditPanel();
_uvPanel?.Refresh();
}
/// <summary>Ticks on the pick-mode and toggle buttons, and greyed tools that say what they need —
/// a tool that cannot use the selection says what to pick instead of doing nothing.</summary>
private void UpdateMeshEditChecks()
{
var session = _viewport?.MeshEditSession;
if ( _meshVertexTool is not null )
{
_meshVertexTool.Checked = session?.Mode == EditElement.Vertex;
_meshEdgeTool.Checked = session?.Mode == EditElement.Edge;
_meshFaceTool.Checked = session?.Mode == EditElement.Face;
_meshMirrorTool.Checked = session?.MirrorX ?? false;
_meshSnapTool.Checked = session?.SnapTarget is not null;
_meshXrayTool.Checked = _viewport?.MeshXray ?? false;
_meshKnifeTool.Checked = _viewport?.MeshKnifeArmed ?? false;
_meshSoftTool.Checked = session is { SoftRadius: > 0f };
_meshSoftConnectedTool.Checked = session is { SoftConnected: true };
_meshMoveTool.Checked = _viewport?.MeshHandleMode == EffigyViewport.BodyDragMode.Move;
_meshRotateTool.Checked = _viewport?.MeshHandleMode == EffigyViewport.BodyDragMode.Rotate;
_meshScaleTool.Checked = _viewport?.MeshHandleMode == EffigyViewport.BodyDragMode.Scale;
var faces = session?.SelectedFaces.Count ?? 0;
var edges = session?.SelectedEdges.Count ?? 0;
var verts = session?.AffectedVertices().Count ?? 0;
Need( _meshExtrudeTool, faces > 0 || edges > 0, "Select one or more faces (3) or edges (2) to extrude" );
Need( _meshExtrudeNormalsTool, faces > 0, "Select one or more faces (3) to extrude" );
Need( _meshExtrudeIndividualTool, faces > 0, "Select one or more faces (3) to extrude" );
Need( _meshInsetTool, faces > 0, "Select one or more faces (3) to inset" );
_meshLoopCutTool.Checked = _viewport?.MeshLoopCutArmed ?? false;
Need( _meshBevelTool, edges > 0 || faces > 0, "Select the edges (2) to round off, or faces (3) to round their border" );
Need( _meshDuplicateTool, faces > 0, "Select faces (3) to duplicate" );
Need( _meshSeparateTool, faces > 0, "Select the faces (3) to split off into their own body" );
Need( _meshExtractTool, faces > 0, "Select the body's faces (3) the garment should cover" );
Need( _meshBridgeTool, edges >= 6, "Select an edge loop round each of two open holes (Alt+click)" );
_meshLassoTool.Checked = _viewport?.MeshLasso ?? false;
_meshCircleTool.Checked = _viewport?.MeshCircle ?? false;
_meshRetopoTool.Checked = session is { IsRetopologizing: true };
_meshEvenQuadsTool.Checked = session is { EvenQuads: true };
_meshStripTool.Checked = _viewport?.RetopoStrip ?? false;
_modMirrorTool.Checked = _meshEditFeature is { MirrorX.Value: true };
_modArrayTool.Checked = _meshEditFeature?.ArrayCount.Clamped > 1;
_modSubdivideTool.Checked = _meshEditFeature?.SubdivideLevels.Clamped > 0;
_modSolidifyTool.Checked = _meshEditFeature?.SolidifyThickness.Value > 0f;
Need( _meshSlideTool, edges > 0, "Select an edge loop (Alt+click an edge in Edge mode) to slide" );
Need( _meshVertexSlideTool, verts > 0, "Select the vertices (1) to slide along their edges" );
Need( _meshMergeTool, verts >= 2, "Select two or more vertices to merge" );
Need( _meshDissolveTool, session is not null && session.Mode != EditElement.Face && verts > 0, "Select edges (2) or vertices (1) to dissolve" );
Need( _meshDeleteTool, verts > 0, "Select something to delete" );
Need( _meshDeleteFacesTool, faces > 0, "Select faces (3) to delete" );
Need( _meshDeleteEdgesTool, edges > 0 || faces > 1, "Select edges (2) to remove, or neighbouring faces (3) to join" );
Need( _meshMergeFirstTool, verts >= 2, "Click two or more vertices (1), in the order that matters" );
Need( _meshMergeLastTool, verts >= 2, "Click two or more vertices (1), in the order that matters" );
Need( _meshMergePivotTool, verts >= 2, "Select two or more vertices to merge" );
Need( _meshRandomizeTool, verts > 0, "Select the vertices to jitter" );
Need( _meshDecimateTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to decimate" );
Need( _meshHideTool, verts > 0, "Select what to hide (H)" );
Need( _meshHideOthersTool, verts > 0, "Select what to keep showing (Shift+H)" );
Need( _meshUnhideTool, session is { HasHiddenFaces: true }, "Nothing is hidden" );
Need( _meshRandomSelectTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to select" );
Need( _meshTrianglesTool, session is { Mesh.FaceCount: > 0 }, "There are no faces" );
Need( _meshNgonsTool, session is { Mesh.FaceCount: > 0 }, "There are no faces" );
Need( _meshInteriorTool, session is { Mesh.FaceCount: > 0 }, "There are no faces" );
Need( _meshLoosePartsTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to split" );
Need( _meshCreaseTool, edges > 0 || faces > 0, "Select the edges (2) that should stay sharp when smoothed (Shift+E)" );
Need( _meshRotateEdgeTool, edges > 0, "Select the edge (2) to turn" );
Need( _meshSubdivideEdgesTool, edges > 0 || faces > 0, "Select the edges (2) to cut" );
Need( _meshFillHolesTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to fill" );
Need( _meshBeautifyTool, faces > 1, "Select the triangles (3) to tidy" );
Need( _meshSharpSelectTool, session is { Mesh.FaceCount: > 0 }, "There are no edges" );
Need( _meshMirrorSelectTool, verts > 0, "Select something to mirror the selection of" );
Need( _meshLooseSelectTool, session is { Mesh.VertexCount: > 0 }, "There are no vertices" );
Need( _meshToPivotTool, verts > 0, "Select what to move to the pivot" );
Need( _meshProjectUVsTool, session is { Mesh.FaceCount: > 0 }, "There are no faces to map" );
Need( _meshPlanarUVsTool, session is { Mesh.FaceCount: > 0 }, "There are no faces to map" );
Need( _meshTrimRowTool, faces > 0, "Select the faces (3) to put on a trim row" );
Need( _meshPipeTool, edges > 0, "Select the run of edges (2) to build a tube along" );
Need( _meshScatterTool, session is { Mesh.FaceCount: > 0 } && _studio.Bodies.Count( b => b.Id != _meshEditFeature?.LastBodyId && b.Mesh is { FaceCount: > 0 } ) > 0, "Scatter needs another body to scatter over this one — add the rock first" );
foreach ( var typed in _meshTypedTools )
Need( typed, verts > 0, "Select what to move" );
Need( _meshUncreaseTool, edges > 0 || faces > 0 || session is { Creases.Count: > 0 }, "There are no creases" );
Need( _meshByMaterialTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to split" );
Need( _meshWrapTool, session is not null && _studio.Bodies.Count > 1, "Needs another body to wrap onto" );
Need( _meshSubdivideTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to subdivide" );
Need( _meshSmoothTool, session is { Mesh.VertexCount: > 0 }, "There is nothing to smooth" );
Need( _meshShrinkFattenTool, verts > 0, "Select what you want to move along its normals" );
Need( _meshGrowTool, verts > 0, "Select something to grow from" );
Need( _meshShrinkSelectionTool, verts > 0, "There is no selection to shrink" );
Need( _meshLinkedTool, verts > 0, "Select something to grow from" );
Need( _meshSimilarTool, session is { SelectedFaces.Count: > 0 }, "Select a face (3) to match against" );
Need( _meshPathTool, session is { SelectedVertices.Count: 2 } || session is { SelectedFaces.Count: 2 }, "Pick exactly two vertices (1) or two faces (3)" );
Need( _meshFillTool, verts >= 3, "Select the vertices or edges round a hole" );
Need( _meshTriangulateTool, faces > 0, "Select faces (3) to triangulate (Ctrl+T)" );
Need( _meshPokeFacesTool, faces > 0, "Select faces (3) to poke (Alt+P)" );
Need( _meshTrisToQuadsTool, faces > 1, "Select neighbouring triangles (3) to join (Alt+J)" );
Need( _meshBevelVerticesTool, verts > 0, "Select the vertices (1) whose corners to cut (Ctrl+Shift+B)" );
Need( _meshLimitedDissolveTool, session is { Mesh.FaceCount: > 0 }, "There are no faces to dissolve" );
Need( _meshFlattenTool, verts >= 3, "Select 3 or more vertices to flatten" );
Need( _meshConnectTool, verts == 2, "Select exactly two vertices sharing a face to connect (J)" );
Need( _meshLoopCircleTool, verts >= 3, "Select at least 3 vertices or an edge loop to make circular" );
Need( _meshLoopSpaceTool, verts >= 3, "Select at least 3 vertices or an edge loop to space evenly" );
Need( _meshOrganicRelaxTool, session is { Mesh.VertexCount: > 0 }, "There is nothing to relax" );
Need( _meshRelaxTool, session is { IsRetopologizing: true }, "Start Retopo first" );
Need( _meshEvenQuadsTool, session is { IsRetopologizing: true }, "Start Retopo first" );
Need( _meshStripTool, session is { IsRetopologizing: true }, "Start Retopo first" );
Need( _meshFinishRetopoTool, session is { IsRetopologizing: true }, "Start Retopo first" );
Need( _meshSplitEdgeTool, edges > 0, "Select the edges (2) that should shade hard" );
Need( _meshSharpenTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to harden" );
Need( _meshSymmetrizeTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to symmetrize" );
var hasWeights = session is { Mesh.IsRigged: true };
Need( _meshNormalizeWeightsTool, hasWeights, "This body has no skin weights yet — rig it first" );
Need( _meshSmoothWeightsTool, hasWeights, "This body has no skin weights yet — rig it first" );
Need( _meshMirrorWeightsTool, hasWeights, "This body has no skin weights yet — rig it first" );
Need( _meshSeamTool, edges > 0, "Select the edges (2) to cut the texture along. Alt+click takes a whole loop" );
Need( _meshUnseamTool, session is { Seams.Count: > 0 }, "There are no seams to clear" );
Need( _meshUnwrapTool, session is { Mesh.FaceCount: > 0 }, "There is nothing to unwrap" );
Need( _meshDrapeTool, session is not null && (verts > 0 || _studio.Bodies.Count > 1),
"Select the vertices that pin the cloth up, or add the body it should land on" );
}
_stageBar?.Refresh();
RefreshModelChrome();
}
private static void Need( EffigyStageTool tool, bool ok, string reason )
{
if ( tool is null )
return;
tool.Enabled = ok;
tool.DisabledReason = ok ? null : reason;
}
// --- keys -----------------------------------------------------------------------------------
/// <summary>Edit mode's keys. Each existing viewport shortcut that shares a key calls this first
/// and stops if it answered — the same guard-per-mode arrangement X already uses for sculpt and
/// paint.</summary>
private const char MeshKeyEnter = (char)13;
private const char MeshKeyEscape = (char)27;
private bool MeshEditKey( char key )
{
if ( _viewport?.MeshEditSession is null )
return false;
switch ( key )
{
case '1': SetMeshElement( EditElement.Vertex ); return true;
case '2': SetMeshElement( EditElement.Edge ); return true;
case '3': SetMeshElement( EditElement.Face ); return true;
case 'E': _meshExtrudeTool?.Clicked?.Invoke(); return true;
case 'I': _meshInsetTool?.Clicked?.Invoke(); return true;
case MeshKeyLoopCut: StartLoopCut(); return true;
case MeshKeyBevel: StartBevel(); return true;
case MeshKeyXray: ToggleMeshXray(); return true;
case MeshKeySeam: RunMeshOp( "Mark seam", s => s.MarkSeam() ); return true;
case 'U': StartMeshUnwrap(); return true;
case 'K': ToggleMeshKnife(); return true;
case 'D': RunMeshOp( "Duplicate", s => s.Duplicate() ); return true;
case 'P': RunMeshOp( "Separate", s => s.Separate() ); return true;
case 'O': ToggleMeshSoft(); return true;
case MeshKeySoftConnected: ToggleMeshSoftConnected(); return true;
case MeshKeyHide: RunMeshOp( "Hide", s => s.Hide() ); return true;
case MeshKeyAddPrimitive: OpenMeshAddMenu(); return true;
case MeshKeyCrease: _meshCreaseTool?.Clicked?.Invoke(); return true;
case MeshKeyHideOthers: RunMeshOp( "Hide others", s => s.Hide( unselected: true ) ); return true;
case MeshKeyUnhide: RunMeshOp( "Unhide", s => s.Unhide() ); return true;
case '[': ScaleMeshSoft( 0.8f ); return true;
case ']': ScaleMeshSoft( 1.25f ); return true;
case 'G': case 'W': SetMeshHandle( EffigyViewport.BodyDragMode.Move ); return true;
case 'R': SetMeshHandle( EffigyViewport.BodyDragMode.Rotate ); return true;
case 'S': SetMeshHandle( EffigyViewport.BodyDragMode.Scale ); return true;
case 'X': RunMeshOp( "Delete", s => s.Delete() ); return true;
case 'M': RunMeshOp( "Merge", s => s.MergeAtCentre() ); return true;
case 'A': MeshSelectAll(); return true;
case '+': case '=': RunMeshOp( "Grow", s => s.GrowSelection() ); return true;
case '-': RunMeshOp( "Shrink", s => s.ShrinkSelection() ); return true;
case MeshKeySimilar: RunMeshOp( "Similar", s => s.SelectSimilar( MeshEditSession.Similarity.Normal, 10f ) ); return true;
case 'L': RunMeshOp( "Linked", s => s.SelectLinked() ); return true;
case 'F': RunMeshOp( "Fill", s => s.Fill() ); return true;
case 'C': ToggleMeshCircle(); return true;
case 'J': RunMeshOp( "Connect", s => s.ConnectVertices() ); return true;
case 'V': RunMeshOp( "Rip", s => s.Rip() ); return true;
case MeshKeySplit: RunMeshOp( "Split", s => s.SplitFaces() ); return true;
case MeshKeySearch: OpenMeshToolSearch(); return true;
case MeshKeyExtrudeNormals: _meshExtrudeNormalsTool?.Clicked?.Invoke(); return true;
case MeshKeyTriangulate: RunMeshOp( "Triangulate", s => s.TriangulateFaces() ); return true;
case MeshKeyPokeFaces: RunMeshOp( "Poke Faces", s => s.PokeFaces() ); return true;
case MeshKeyLimitedDissolve: RunMeshOp( "Limited dissolve", s => s.LimitedDissolve() ); return true;
case MeshKeyTrisToQuads: RunMeshOp( "Tris to Quads", s => s.TrisToQuads() ); return true;
case MeshKeyBevelVertices: _meshBevelVerticesTool?.Clicked?.Invoke(); return true;
case MeshKeyEscape when _viewport.MeshCircle: ToggleMeshCircle(); return true;
case MeshKeyInvert: RunMeshOp( "Invert", s => s.InvertSelection() ); return true;
case MeshKeyRecalculate: RunMeshOp( "Recalculate normals", s => s.RecalculateNormals() ); return true;
case MeshKeyShrinkFatten: _meshShrinkFattenTool?.Clicked?.Invoke(); return true;
case MeshKeyEnter when _viewport.MeshKnifeArmed:
case MeshKeyEscape when _viewport.MeshKnifeArmed:
ToggleMeshKnife();
return true;
case MeshKeyEnter when _viewport.MeshLoopCutArmed:
case MeshKeyEscape when _viewport.MeshLoopCutArmed:
_viewport.MeshLoopCutArmed = false;
OnMeshEditChanged();
return true;
case MeshKeyEnter: AcceptMeshOp(); return true;
case MeshKeyEscape:
if ( _viewport.MeshEditSession.IsPreviewing )
CancelMeshOp();
else
{
_viewport.MeshEditSession.ClearSelection();
OnMeshEditChanged();
}
return true;
}
return false;
}
[Shortcut( "effigy.mesh.inset", "I", typeof( EffigyViewport ) )]
private void ShortcutMeshInset() => MeshEditKey( 'I' );
[Shortcut( "effigy.mesh.loopcut", "CTRL+R", typeof( EffigyViewport ) )]
private void ShortcutMeshLoopCut() => MeshEditKey( MeshKeyLoopCut );
[Shortcut( "effigy.mesh.bevel", "CTRL+B", typeof( EffigyViewport ) )]
private void ShortcutMeshBevel() => MeshEditKey( MeshKeyBevel );
[Shortcut( "effigy.mesh.xray", "ALT+Z", typeof( EffigyViewport ) )]
private void ShortcutMeshXray() => MeshEditKey( MeshKeyXray );
[Shortcut( "effigy.mesh.seam", "CTRL+E", typeof( EffigyViewport ) )]
private void ShortcutMeshSeam() => MeshEditKey( MeshKeySeam );
[Shortcut( "effigy.mesh.unwrap", "U", typeof( EffigyViewport ) )]
private void ShortcutMeshUnwrap() => MeshEditKey( 'U' );
[Shortcut( "effigy.mesh.shrinkfatten", "ALT+S", typeof( EffigyViewport ) )]
private void ShortcutMeshShrinkFatten() => MeshEditKey( MeshKeyShrinkFatten );
[Shortcut( "effigy.mesh.grow", "+", typeof( EffigyViewport ) )]
private void ShortcutMeshGrow() => MeshEditKey( '+' );
[Shortcut( "effigy.mesh.shrinkselection", "-", typeof( EffigyViewport ) )]
private void ShortcutMeshShrinkSelection() => MeshEditKey( '-' );
[Shortcut( "effigy.mesh.linked", "L", typeof( EffigyViewport ) )]
private void ShortcutMeshLinked() => MeshEditKey( 'L' );
[Shortcut( "effigy.mesh.similar", "SHIFT+L", typeof( EffigyViewport ) )]
private void ShortcutMeshSimilar() => MeshEditKey( MeshKeySimilar );
[Shortcut( "effigy.mesh.circle", "C", typeof( EffigyViewport ) )]
private void ShortcutMeshCircle() => MeshEditKey( 'C' );
[Shortcut( "effigy.mesh.search", "SPACE", typeof( EffigyViewport ) )]
private void ShortcutMeshSearch()
{
if ( MeshEditKey( MeshKeySearch ) )
return;
// Object mode and sculpt have a catalog too.
if ( _modelHeader is { Visible: true } )
OpenModelCatalog();
}
[Shortcut( "effigy.mesh.extrude_normals", "ALT+E", typeof( EffigyViewport ) )]
private void ShortcutMeshExtrudeNormals() => MeshEditKey( MeshKeyExtrudeNormals );
[Shortcut( "effigy.mesh.triangulate", "CTRL+T", typeof( EffigyViewport ) )]
private void ShortcutMeshTriangulate() => MeshEditKey( MeshKeyTriangulate );
[Shortcut( "effigy.mesh.poke_faces", "ALT+P", typeof( EffigyViewport ) )]
private void ShortcutMeshPokeFaces() => MeshEditKey( MeshKeyPokeFaces );
[Shortcut( "effigy.mesh.tris_to_quads", "ALT+J", typeof( EffigyViewport ) )]
private void ShortcutMeshTrisToQuads() => MeshEditKey( MeshKeyTrisToQuads );
[Shortcut( "effigy.mesh.bevel_vertices", "CTRL+SHIFT+B", typeof( EffigyViewport ) )]
private void ShortcutMeshBevelVertices() => MeshEditKey( MeshKeyBevelVertices );
[Shortcut( "effigy.mesh.split", "Y", typeof( EffigyViewport ) )]
private void ShortcutMeshSplit() => MeshEditKey( MeshKeySplit );
[Shortcut( "effigy.mesh.rip", "V", typeof( EffigyViewport ) )]
private void ShortcutMeshRip() => MeshEditKey( 'V' );
[Shortcut( "effigy.mesh.fill", "F", typeof( EffigyViewport ) )]
private void ShortcutMeshFill() => MeshEditKey( 'F' );
[Shortcut( "effigy.mesh.connect", "J", typeof( EffigyViewport ) )]
private void ShortcutMeshConnect() => MeshEditKey( 'J' );
[Shortcut( "effigy.mesh.invert", "CTRL+I", typeof( EffigyViewport ) )]
private void ShortcutMeshInvert() => MeshEditKey( MeshKeyInvert );
[Shortcut( "effigy.mesh.recalculatenormals", "SHIFT+N", typeof( EffigyViewport ) )]
private void ShortcutMeshRecalculate() => MeshEditKey( MeshKeyRecalculate );
[Shortcut( "effigy.mesh.duplicate", "SHIFT+D", typeof( EffigyViewport ) )]
private void ShortcutMeshDuplicate() => MeshEditKey( 'D' );
[Shortcut( "effigy.mesh.separate", "P", typeof( EffigyViewport ) )]
private void ShortcutMeshSeparate() => MeshEditKey( 'P' );
[Shortcut( "effigy.mesh.knife", "K", typeof( EffigyViewport ) )]
private void ShortcutMeshKnife() => MeshEditKey( 'K' );
[Shortcut( "effigy.mesh.crease", "SHIFT+E", typeof( EffigyViewport ) )]
private void ShortcutMeshCrease() => MeshEditKey( MeshKeyCrease );
[Shortcut( "effigy.mesh.add_primitive", "SHIFT+A", typeof( EffigyViewport ) )]
private void ShortcutMeshAddPrimitive() => MeshEditKey( MeshKeyAddPrimitive );
/// <summary>Shift+A: the primitives, as a menu at the cursor.</summary>
private void OpenMeshAddMenu()
{
if ( _viewport?.MeshEditSession is null )
return;
var menu = new Menu( _viewport );
menu.AddHeading( "Add" );
foreach ( var tool in _meshPrimitiveTools )
{
var captured = tool;
menu.AddOption( captured.Label, null, () => captured.Clicked?.Invoke() );
}
menu.OpenAtCursor();
}
[Shortcut( "effigy.mesh.hide", "H", typeof( EffigyViewport ) )]
private void ShortcutMeshHide() => MeshEditKey( MeshKeyHide );
[Shortcut( "effigy.mesh.hide_others", "SHIFT+H", typeof( EffigyViewport ) )]
private void ShortcutMeshHideOthers() => MeshEditKey( MeshKeyHideOthers );
[Shortcut( "effigy.mesh.unhide", "ALT+H", typeof( EffigyViewport ) )]
private void ShortcutMeshUnhide() => MeshEditKey( MeshKeyUnhide );
[Shortcut( "effigy.mesh.soft_connected", "ALT+O", typeof( EffigyViewport ) )]
private void ShortcutMeshSoftConnected() => MeshEditKey( MeshKeySoftConnected );
[Shortcut( "effigy.mesh.soft", "O", typeof( EffigyViewport ) )]
private void ShortcutMeshSoft() => MeshEditKey( 'O' );
[Shortcut( "effigy.mesh.move", "G", typeof( EffigyViewport ) )]
private void ShortcutMeshMove() => MeshEditKey( 'G' );
[Shortcut( "effigy.mesh.scale", "S", typeof( EffigyViewport ) )]
private void ShortcutMeshScale() => MeshEditKey( 'S' );
[Shortcut( "effigy.mesh.selectall", "A", typeof( EffigyViewport ) )]
private void ShortcutMeshSelectAll() => MeshEditKey( 'A' );
[Shortcut( "effigy.mesh.delete", "DEL", typeof( EffigyViewport ) )]
private void ShortcutMeshDelete() => MeshEditKey( 'X' );
/// <summary>Undo inside the session while one is open, for the same reason sculpt owns undo: the
/// studio's stack restores a feature list the live session may not survive.</summary>
private bool StepMeshEditHistory( bool redo )
{
if ( _viewport?.MeshEditSession is not { } session )
return false;
if ( session.IsPreviewing )
{
CancelMeshOp();
return true;
}
if ( !(redo ? session.Redo() : session.Undo()) )
SetPrompt( redo ? "Nothing to redo in this edit." : "Nothing to undo in this edit. Finish, then Ctrl+Z undoes the whole edit." );
_viewport.RefreshMeshEditPreview();
OnMeshEditChanged();
return true;
}
}