Editor window for the Effigy part-studio. Builds menus, toolbars, stage bars, docks and manages UI state like palettes, preview materials, sketch/sculpt/paint stages and editor-wide services; routes asset double-clicks into the document load path and wires many panel delegates.
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
// Effigy.Skeleton, not Sandbox.Skeleton - the engine has a Skeleton type of its own, and every
// Skeleton named here is the CAD one the rig panel builds and the exporters write out.
using Skeleton = Effigy.Skeleton;
namespace Marionette.EditorTools;
// ============================================================================
// Color palettes for theming — swap at runtime via the toolbar dropdown.
// Each palette defines every color the UI touches. The Onshape-faithful one
// is the default; the rest are alternatives.
// ============================================================================
internal sealed class EffigyPalette
{
public string Name;
public Color Bg, Chrome, Chrome2, Border, Text, TextDim, Accent, AccentSoft, ViewportBg;
public static readonly EffigyPalette OnshapeLight = new()
{
Name = "Onshape Light",
Bg = new( 0.914f, 0.922f, 0.929f ),
Chrome = new( 0.957f, 0.961f, 0.969f ),
Chrome2 = new( 0.925f, 0.933f, 0.945f ),
Border = new( 0.827f, 0.843f, 0.863f ),
Text = new( 0.125f, 0.141f, 0.165f ),
TextDim = new( 0.439f, 0.467f, 0.498f ),
Accent = new( 0.039f, 0.518f, 0.780f ),
AccentSoft = new( 0.867f, 0.925f, 0.973f ),
ViewportBg = new( 0.867f, 0.878f, 0.894f ),
};
public static readonly EffigyPalette OnshapeDark = new()
{
Name = "Onshape Dark",
Bg = new( 0.106f, 0.118f, 0.129f ),
Chrome = new( 0.149f, 0.161f, 0.176f ),
Chrome2 = new( 0.173f, 0.188f, 0.204f ),
Border = new( 0.082f, 0.090f, 0.102f ),
Text = new( 0.843f, 0.855f, 0.867f ),
TextDim = new( 0.545f, 0.565f, 0.588f ),
Accent = new( 0.247f, 0.663f, 0.910f ),
AccentSoft = new( 0.110f, 0.204f, 0.267f ),
ViewportBg = new( 0.208f, 0.224f, 0.239f ),
};
public static readonly EffigyPalette Blender = new()
{
Name = "Blender",
Bg = new( 0.165f, 0.165f, 0.165f ),
Chrome = new( 0.208f, 0.208f, 0.208f ),
Chrome2 = new( 0.235f, 0.235f, 0.235f ),
Border = new( 0.122f, 0.122f, 0.122f ),
Text = new( 0.839f, 0.839f, 0.839f ),
TextDim = new( 0.533f, 0.533f, 0.533f ),
Accent = new( 0.306f, 0.541f, 0.890f ),
AccentSoft = new( 0.161f, 0.235f, 0.345f ),
ViewportBg = new( 0.220f, 0.220f, 0.220f ),
};
public static readonly EffigyPalette Fusion = new()
{
Name = "Fusion",
Bg = new( 0.145f, 0.153f, 0.161f ),
Chrome = new( 0.184f, 0.192f, 0.204f ),
Chrome2 = new( 0.208f, 0.216f, 0.227f ),
Border = new( 0.106f, 0.114f, 0.122f ),
Text = new( 0.878f, 0.886f, 0.894f ),
TextDim = new( 0.522f, 0.533f, 0.545f ),
Accent = new( 0.000f, 0.600f, 0.863f ),
AccentSoft = new( 0.086f, 0.200f, 0.286f ),
ViewportBg = new( 0.251f, 0.259f, 0.271f ),
};
public static readonly EffigyPalette[] All = { OnshapeLight, OnshapeDark, Blender, Fusion };
}
// ============================================================================
// The main Effigy dock window — Onshape-faithful layout with:
// Top: square feature-creation icon buttons floating over the viewport's top edge
// Left: flat feature tree (origin/planes → features → bodies)
// Center: 3D viewport with reference planes, origin, orbit camera
// Right: parameter panel for the selected feature
// Bottom: Part-studio-style tabs
//
// Registered under Marionette in the Tools menu. Opens from Tools, or by
// double-clicking a .effigy part studio in the asset browser.
// ============================================================================
[EditorForAssetType( "effigy" )]
[EditorApp( "Effigy", "editor/effigy_icon.png", "Parametric modelling, subdivision, and rig-ready mesh export" )]
public sealed partial class EffigyWindow : DockWindow, IAssetEditor
{
// --- opening a .effigy from the asset browser -------------------------------------------
//
// A part studio is a plain text document rather than a GameResource, so there is no asset
// type class to hang this off - the attribute above is the whole registration, which is the
// same way ShaderGraph claims .shdrgrph. Everything below just routes the double-click into
// the load path the File menu already uses.
/// <summary>One document per window. Opening a second .effigy reuses this window, which is
/// what makes the unsaved-work prompt in AssetOpen meaningful.</summary>
public bool CanOpenMultipleAssets => false;
public void AssetOpen( Asset asset )
{
var path = asset?.AbsolutePath;
if ( string.IsNullOrEmpty( path ) )
return;
Raise();
// Asked before the open, not after - the work about to be thrown away belongs to the
// document being replaced, exactly as it does on File > Open.
ConfirmDiscard( () => LoadDocument( path ) );
}
/// <summary>Nothing in a part studio is addressable by member name, so there is nothing to
/// jump to. Present because IAssetEditor requires it.</summary>
public void SelectMember( string memberName )
{
}
// --- core state -------------------------------------------------------------------------
private PartStudio _studio;
private EffigyViewport _viewport;
// --- palette / theming ------------------------------------------------------------------
private EffigyPalette _palette = EffigyPalette.OnshapeDark;
private int _paletteIndex = 1; // start dark
// --- panels -----------------------------------------------------------------------------
/// <summary>Whether the viewport currently holds preview geometry — drives the one-shot
/// camera framing in RebuildStudio.</summary>
private bool _hasPreview;
/// <summary>The last preview built for the plain material-slot path, kept so a move can rewrite
/// its vertex buffers instead of building a fresh Model every frame. Invalidated (null) whenever
/// the preview takes the paint, checker or section path.</summary>
private EffigyPreview.LivePreview _livePreview;
/// <summary>
/// The fast-path state for a Transform drag, or null when none is live. While the handle is
/// held the only thing that changes is a rigid-plus-scale transform over some bodies, so the
/// light rebuild moves a model on the GPU instead of re-running the history — which deep-clones
/// the whole mesh twice a frame and is what made dragging a dense import choppy. Captured when
/// the handle is grabbed, dropped when it is released.
/// </summary>
private TransformDrag _transformDrag;
private EffigyFeatureTreePanel _featureTree;
private EffigyFeatureDialog _dialog;
private EffigyPartsPanel _partsPanel;
private EffigyMaterialsPanel _materialsPanel;
private EffigyRigPanel _rigPanel;
private EffigyVariablesPanel _variablesPanel;
private EffigyUVPanel _uvPanel;
private Widget _leftPanel;
private EffigyMeshEditPanel _meshPanel;
// --- View menu dock toggles ----------------------------------------------------------------
/// <summary>The View menu's one-per-dock checkable options, kept so their ticks can be pointed
/// back at what DockManager actually has open. Held as fields because the menu is built before
/// the docks exist, so the ticks cannot be right at the moment they are created.</summary>
private Option _featuresDockOption, _materialsDockOption, _rigDockOption, _tutorialDockOption,
_consoleDockOption, _variablesDockOption, _uvDockOption;
/// <summary>Set while SyncDockChecks writes the ticks. Assigning Checked fires Toggled just as
/// a click does, and without this the sync would turn straight round and re-issue SetDockState
/// for every dock it was only supposed to be reading.</summary>
private bool _syncingDockChecks;
/// <summary>
/// The tool chrome: a row of stage tabs over the tools belonging to the selected stage, docked
/// above the viewport.
///
/// ONE BAR WHERE THERE WERE THREE STRIPS. EffigyStageBar's header carries the whole argument;
/// the short of it is that fifty anonymous 54px squares taking turns in one floating spot
/// could not say which of the three sets was showing, could not afford to label any of them,
/// and covered the corner of the part while failing to.
/// </summary>
private EffigyStageBar _stageBar;
/// <summary>The ADD/REMOVE mode strip, shown only while a feature that HAS a Result is open.
/// See EffigyResultStrip for why it is on the canvas rather than in the dialog.</summary>
private EffigyResultStrip _resultStrip;
/// <summary>
/// The stage sets, one per mode, built once at startup and kept.
///
/// DATA, NOT WIDGETS. The bar paints from these and never owns them, so entering a sketch is
/// handing it a different list rather than tearing down and rebuilding a row of buttons — and
/// a tool's live state (which variant is on its face, whether it is armed) survives every
/// stage change and every mode swap, because it was never on a widget to begin with.
/// </summary>
private List<EffigyStage> _partStages;
private List<EffigyStage> _sketchStages;
private List<EffigyStage> _sculptStages;
private List<EffigyStage> _paintStages;
private List<EffigyStage> _sculptHomeStages;
private List<EffigyStage> _paintHomeStages;
private EffigyStageTool _subdivideHomeTool, _sculptHomeTool, _paintHomeTool;
// Which mode's stages the bar is showing lives in EffigyWindow.Workspaces.cs, as the BarMode
// PROPERTY rather than a plain field: assigning it is also what re-lights the workspace
// switcher and re-lays the docks, and six methods assign it. See the comment there.
/// <summary>The sketch tools by the kind they arm, so a tool armed from a shortcut can have its
/// tick put on the right button — which may be sitting on a stage nobody is looking at.</summary>
private readonly List<(EffigyStageTool Tool, SketchToolKind Kind, int Variant)> _sketchTools = new();
private EffigyStageTool _constructionTool;
private EffigyStageTool _inspectorTool;
/// <summary>
/// The feature tools, by the feature they make, so the tutorial can light one up.
///
/// SAFE TO HOLD, which the button dictionary this replaced was not. That one had to be cleared
/// and refilled inside the strip refresh and nowhere else, because the strip was torn down
/// whenever the document gained its first sketch and every button held from before belonged to
/// a widget that was gone. These are data objects owned for the life of the window; there is
/// no torn-down widget left for a stale reference to point at.
/// </summary>
private readonly Dictionary<ToolKind, EffigyStageTool> _featureTools = new();
/// <summary>Which tool the tutorial is currently asking for, if any. Held as the target
/// rather than as a button for the reason above.</summary>
private EffigyToolTarget? _highlightedTool;
private EffigyTutorial _tutorial;
private EffigyTutorialPanel _tutorialPanel;
private EffigyConsolePanel _consolePanel;
private DockWidget _centralDock;
private StatusBar _statusWidget;
private Editor.Label _statusInfoLabel;
private Editor.Label _promptLabel;
/// <summary>
/// The open window, for console diagnostics to talk to.
///
/// A ConCmd is static and the studio it needs to inspect is not, and there is no other route
/// from the console to the live document. Only ever read by effigy_dump_tree - nothing in the
/// tool's own behaviour depends on it, so a stale one after a crash costs a wrong dump and
/// nothing more.
/// </summary>
// Public rather than internal: the diagnostics that read the open document live outside this
// library, in whatever project mounts it, and this is the only handle they have on it.
public static EffigyWindow Current;
/// <summary>The live part studio, for effigy_dump_tree to read. Read-only by intent - the
/// diagnostic prints, it does not touch the document.</summary>
public PartStudio DiagnosticStudio => _studio;
/// <summary>Which stage set the bar is showing, for the sketch probe.
///
/// EnterSketch swaps the mode BEFORE it does anything else, so this is the cheapest evidence in
/// the editor about whether entering a sketch actually happened: still Part means EnterSketch
/// never ran, Sketch with no active sketch means it ran and BeginSketch did not take. Reading
/// it off a screenshot is what this replaces, and a screenshot cannot tell those two apart when
/// the swap itself is the thing in doubt.
///
/// Kept in the shape the probe already speaks — two bools — because the modes are exclusive by
/// construction now and "both true" has become unrepresentable rather than merely unlikely.</summary>
internal (bool Feature, bool Sketch) DiagnosticStripState
=> (BarMode == EffigyBarMode.Part, BarMode == EffigyBarMode.Sketch);
/// <summary>The feature whose sketch is open, if any - so the probe can say whether the window
/// and the viewport agree about that.</summary>
internal string DiagnosticSketchFeature => ActiveSketchFeature()?.Name;
/// <summary>The viewport, for the drop probe — which needs to ask it about its canvas and its
/// camera from a static console command, with no drag in progress to carry the question.
/// </summary>
internal EffigyViewport DiagnosticViewport => _viewport;
public EffigyWindow()
{
Current = this;
DeleteOnClose = true;
Size = new Vector2( 1440, 900 );
if ( AppIcon() is { } icon )
SetWindowIcon( icon );
else
SetWindowIcon( "view_in_ar" );
_studio = new PartStudio();
// The engine boolean, in front of the kernel before anything can ask for a cut. Remove was
// wired end to end and waiting on exactly this one translation; see EffigyMeshBoolean.
EffigyMeshBoolean.Install();
BuildMenuBar();
BuildDocks();
BuildToolbar();
BuildStatusBar();
// Last session's palette and grid choice, now that the viewport exists to receive them.
// ApplyPalette runs inside this.
RestoreSettings();
// A window that has only just opened has nothing to lose, and anything during startup that
// went through RebuildStudio has already set the flag. Without this, closing an untouched
// Effigy asks whether to save an empty studio — the fastest way to teach someone to click
// through the very prompt that exists to save their work.
MarkClean();
// Opened, not started. The panel shows its start screen and waits — being dropped into
// step one of something you never asked for is the reason tutorials get resented, and the
// checkbox on that screen is how someone says never again.
if ( EffigyTutorial.OpenOnStartup )
DockManager.SetDockState( "Tutorial", true );
// After the docks, because BuildMenuBar ran before them and after StateCookie, because a
// restored layout is the case where the ticks would otherwise be furthest from the truth.
SyncDockChecks();
Show();
}
/// <summary>
/// Green-man / oak-face mark for the window tab. The Tools menu itself only takes a Material
/// Icon name (see the EditorApp attribute) — a pixmap there would go blank — so this is the
/// place a custom drawing actually shows.
/// </summary>
internal static Pixmap AppIcon()
{
var root = Project.Current?.GetRootPath();
if ( string.IsNullOrEmpty( root ) )
return null;
foreach ( var rel in new[]
{
Path.Combine( "Editor", "EffigyEditor", "effigy_icon.png" ),
Path.Combine( "Assets", "editor", "effigy_icon.png" ),
} )
{
var path = Path.Combine( root, rel );
if ( File.Exists( path ) )
return Pixmap.FromFile( path );
}
return null;
}
// --- menu bar ---------------------------------------------------------------------------
private void BuildMenuBar()
{
var file = MenuBar.FindOrCreateMenu( "File" );
file.Clear();
file.AddOption( "New Studio", "common/new.png", NewStudio );
file.AddOption( "Open...", "folder_open", Open );
file.AddOption( "Import OBJ...", "file_open", ImportObj );
file.AddOption( "Body from Bones", "accessibility_new", BodyFromBones );
file.AddSeparator();
file.AddOption( "Save", "common/save.png", Save, "editor.save" );
file.AddOption( "Save As...", "save_alt", SaveAs );
file.AddSeparator();
file.AddOption( "Export OBJ", "file_download", ExportObj );
file.AddOption( "Compile .vmdl", "build", CompileVmdl );
file.AddOption( "Compile Playermodel", "directions_walk", CompilePlayermodel );
file.AddOption( "Make Player", "sports_esports", MakePlayer );
file.AddOption( "Animation Clips...", "movie", OpenAnimClips );
file.AddOption( "Collision Report", "fitness_center", ReportCollision );
var lods = file.AddOption( "Export with LODs", "layers" );
lods.Checkable = true;
lods.Checked = _exportLods;
lods.Toggled += on => { _exportLods = on; Log.Info( on ? "[Effigy] Compile .vmdl will write half- and quarter-detail LODs for a static model, switching with distance" : "[Effigy] Compile .vmdl writes one level of detail" ); };
file.AddSeparator();
file.AddOption( "Close", "close", Close );
var edit = MenuBar.FindOrCreateMenu( "Edit" );
edit.Clear();
edit.AddOption( "Undo", "undo", Undo, "editor.undo" );
edit.AddOption( "Redo", "redo", Redo, "editor.redo" );
edit.AddSeparator();
// The same four the feature tree's own right-click menu carries. They are mirrored here
// because the menu bar is where someone who has never right-clicked the tree goes looking,
// and because a feature can be selected while focus is somewhere the tree is not.
edit.AddOption( "Delete Feature", "delete", DeleteSelectedFeature );
edit.AddOption( "Move Feature Up", "arrow_upward", MoveFeatureUp );
edit.AddOption( "Move Feature Down", "arrow_downward", MoveFeatureDown );
edit.AddOption( "Suppress / Unsuppress Feature", "block", ToggleSuppressFeature );
// EVERYTHING TO DO WITH THE SCULPT MASK, behind one line. Five flat entries for actions that
// only work while a Sculpt feature is open was five-fifteenths of this menu given over to a
// mode most sessions never enter; the submenu keeps them reachable without making them the
// first thing the menu shows.
edit.AddSeparator();
var mask = edit.AddMenu( "Sculpt Mask", "brush" );
mask.AddOption( "Invert", "flip", InvertSculptMask );
mask.AddOption( "Clear", "layers_clear", ClearSculptMask );
mask.AddOption( "Mask Everything", "select_all", ProtectAllSculpt );
mask.AddOption( "Mask All But the Piece Under the Cursor", "touch_app", MaskAllButLinked );
mask.AddOption( "Grow Mask", "open_in_full", () => GrowSculptMask( false ) );
mask.AddOption( "Shrink Mask", "close_fullscreen", () => GrowSculptMask( true ) );
mask.AddSeparator();
mask.AddOption( "Switch Between Painting and Erasing", "brush", ToggleSculptMaskErase );
mask.AddOption( "Hide / Show Held Geometry", "visibility_off", ToggleHideMasked );
edit.AddSeparator();
edit.AddOption( "Settings...", "settings", OpenSettings );
// The Help menu exists for exactly one thing, and that is fine. Until it was added there
// was no way to start the tutorial again after dismissing it, and a tutorial you can only
// ever see once is one nobody dares skip.
var help = MenuBar.FindOrCreateMenu( "Help" );
help.Clear();
help.AddOption( "Start House Tutorial", "school", StartTutorial );
help.AddOption( "Start Rigging Tutorial", "accessibility", StartRigTutorial );
help.AddOption( "Start Hand Tutorial", "back_hand", StartHandTutorial );
help.AddOption( "Start Playermodel Tutorial", "directions_walk", StartPlayermodelTutorial );
var view = MenuBar.FindOrCreateMenu( "View" );
view.Clear();
view.AddOption( "Frame Camera", "center_focus_strong", () => _viewport?.FrameCamera() );
view.AddOption( "Normal to Sketch Plane\tN", "straighten", () => _viewport?.ViewNormalToSketchPlane() );
view.AddOption( "Shade Material Slots", "palette", ToggleMaterialShading );
view.AddOption( "Show Sketch Constraints", "rule", ToggleConstraintMarks );
view.AddOption( "Add Point Light", "wb_incandescent", AddViewportLight );
// "restart_alt" is a Material SYMBOLS name and s&box ships classic Material Icons, so it
// was drawing nothing at all - see EffigyIcons for why that whole class of name is unsafe.
view.AddOption( "Reset Origin", "settings_backup_restore", () => _viewport?.ResetOrigin() );
// EVERY DOCK GETS A LINE HERE, and the Material Browser was the one that did not have one.
// It is registered, it is wired up, and until now the only way to see it was a default
// layout that happened to open it - so anyone who closed the tab had lost it for good.
//
// The ticks are set from DockManager itself (SyncDockChecks, run once the layout exists)
// rather than hardcoded here: this menu is built BEFORE the docks are, and a hardcoded
// tick is a lie the moment a saved layout restores something different.
view.AddSeparator();
_featuresDockOption = AddDockOption( view, "Feature Tree", "account_tree", "Features" );
_materialsDockOption = AddDockOption( view, "Material Browser", "palette", "Materials" );
_rigDockOption = AddDockOption( view, "Rig", "polyline", "Rig" );
_tutorialDockOption = AddDockOption( view, "Tutorial", "school", "Tutorial" );
_variablesDockOption = AddDockOption( view, "Variables", "tag", "Variables" );
_uvDockOption = AddDockOption( view, "UV Editor", "texture", "UV" );
view.AddOption( "Section View", "content_cut", ToggleSectionView );
// Pictures to model against. One entry per view to add, then the list to adjust or drop.
var reference = view.AddMenu( "Reference Images", "image" );
reference.AddOption( "Add Front Image...", "add_photo_alternate", () => AddReferenceImage( "front" ) );
reference.AddOption( "Add Side Image...", "add_photo_alternate", () => AddReferenceImage( "side" ) );
reference.AddOption( "Add Top Image...", "add_photo_alternate", () => AddReferenceImage( "top" ) );
if ( _studio.ReferenceImages.Count > 0 )
{
reference.AddSeparator();
foreach ( var image in _studio.ReferenceImages )
{
var captured = image;
var name = $"{captured.View}: {System.IO.Path.GetFileName( captured.Path )}";
var one = reference.AddMenu( name, captured.Visible ? "image" : "hide_image" );
one.AddOption( captured.Visible ? "Hide" : "Show", captured.Visible ? "visibility_off" : "visibility", () => EditReferenceImage( captured, i => i.Visible = !i.Visible ) );
one.AddOption( "Bigger (+25%)", "zoom_in", () => EditReferenceImage( captured, i => i.Height *= 1.25f ) );
one.AddOption( "Smaller (-20%)", "zoom_out", () => EditReferenceImage( captured, i => i.Height *= 0.8f ) );
one.AddOption( "Fainter", "opacity", () => EditReferenceImage( captured, i => i.Opacity = MathF.Max( 0.1f, i.Opacity - 0.15f ) ) );
one.AddOption( "Stronger", "opacity", () => EditReferenceImage( captured, i => i.Opacity = MathF.Min( 1f, i.Opacity + 0.15f ) ) );
one.AddOption( "Up", "arrow_upward", () => EditReferenceImage( captured, i => i.Offset = new Vec2( i.Offset.x, i.Offset.y + i.Height * 0.05f ) ) );
one.AddOption( "Down", "arrow_downward", () => EditReferenceImage( captured, i => i.Offset = new Vec2( i.Offset.x, i.Offset.y - i.Height * 0.05f ) ) );
one.AddOption( "Left", "arrow_back", () => EditReferenceImage( captured, i => i.Offset = new Vec2( i.Offset.x - i.Height * 0.05f, i.Offset.y ) ) );
one.AddOption( "Right", "arrow_forward", () => EditReferenceImage( captured, i => i.Offset = new Vec2( i.Offset.x + i.Height * 0.05f, i.Offset.y ) ) );
one.AddOption( "Further back", "flip_to_back", () => EditReferenceImage( captured, i => i.Depth += i.Height * 0.25f ) );
one.AddOption( "Closer", "flip_to_front", () => EditReferenceImage( captured, i => i.Depth -= i.Height * 0.25f ) );
one.AddSeparator();
one.AddOption( "Remove", "delete", () => EditReferenceImage( captured, null ) );
}
}
_consoleDockOption = AddDockOption( view, "Console", "terminal", "Console" );
// The whole panel arrangement as a first-class, saved thing. s&box remembers the last layout
// you happened to leave on its own, but "the layout I like" is a different object from "the
// layout I was last using" — one is something you set once and come back to, the other is
// wherever a drag left you. These two make the first one explicit and persistent.
view.AddSeparator();
view.AddOption( "Set Default Panel View", "bookmark_add", SetDefaultPanelView );
view.AddOption( "Reset Default Panel View", "settings_backup_restore", ResetDefaultPanelView );
// Named views, same list Onshape puts on the cube. The cube itself is gone — this camera
// flies rather than orbiting a locked-up model — but snapping to a plane is still useful.
view.AddSeparator();
foreach ( var standard in new[]
{
EffigyViewport.StandardView.Isometric,
EffigyViewport.StandardView.Top,
EffigyViewport.StandardView.Bottom,
EffigyViewport.StandardView.Front,
EffigyViewport.StandardView.Back,
EffigyViewport.StandardView.Left,
EffigyViewport.StandardView.Right,
} )
{
var v = standard;
view.AddOption( v.ToString(), "videocam", () => _viewport?.SetStandardView( v ) );
}
// The palette list used to sit here as four checkable options. It lives in Edit > Settings
// now, as a dropdown — one home per setting, because two controls reading the same value
// is how one of them ends up showing the wrong tick.
}
/// <summary>One checkable View entry for one registered dock. The label and the dock title are
/// separate arguments because they differ: the dock the menu calls "Feature Tree" is registered
/// as "Features", and SetDockState only answers to the registered name.</summary>
private Option AddDockOption( Menu menu, string label, string icon, string dockTitle )
{
var option = menu.AddOption( label, icon );
option.Checkable = true;
option.Toggled += visible =>
{
if ( _syncingDockChecks )
return;
DockManager.SetDockState( dockTitle, visible );
};
return option;
}
/// <summary>Point the View menu's ticks at the docks that are actually open.
///
/// Called once the layout exists and again after anything here opens a dock. A tick that
/// disagrees with the screen is worse than no tick: clicking a ticked entry for a closed dock
/// "closes" it again and the panel never appears, which reads as a dead menu item.</summary>
private void SyncDockChecks()
{
_syncingDockChecks = true;
try
{
SetDockCheck( _featuresDockOption, "Features" );
SetDockCheck( _materialsDockOption, "Materials" );
SetDockCheck( _rigDockOption, "Rig" );
SetDockCheck( _tutorialDockOption, "Tutorial" );
SetDockCheck( _consoleDockOption, "Console" );
SetDockCheck( _variablesDockOption, "Variables" );
SetDockCheck( _uvDockOption, "UV" );
}
finally
{
_syncingDockChecks = false;
}
}
private void SetDockCheck( Option option, string dockTitle )
{
if ( option is not null )
option.Checked = DockManager.IsDockOpen( dockTitle );
}
// --- default panel view --------------------------------------------------------------------
/// <summary>The cookie the user's saved default panel arrangement lives under. Bump it when the
/// set of docks changes in a way that would make an old saved layout restore wrong, exactly the
/// way StateCookie is bumped for the same reason.</summary>
private const string DefaultPanelCookie = "effigy.panels.default";
/// <summary>Every dock the window can open, in the order the factory layout closes them.</summary>
private static readonly string[] PanelDockTitles =
{
"Features", "Rig", "Materials", "Variables", "Tutorial", "Console"
};
/// <summary>
/// Make the current panel arrangement the default, saved to disk.
///
/// The layout itself is DockManager.State - the same serialised dock tree s&box restores from,
/// positions, splitter proportions and open state all in. Storing it here rather than leaning on
/// s&box's own last-layout memory is what gives the Reset button something fixed to return to:
/// without a saved default, the window only ever knows where it was, never where you meant it
/// to be.
/// </summary>
private void SetDefaultPanelView()
{
if ( string.IsNullOrWhiteSpace( DockManager.State ) )
{
SetPrompt( "There is no panel layout to save yet." );
return;
}
EditorCookie.Set( DefaultPanelCookie, DockManager.State );
SetPrompt( "Default panel view saved - Reset Default Panel View will return here." );
}
/// <summary>
/// Put the panels back to the saved default, or to the factory layout when none has been set.
///
/// The factory case closes every dock and re-runs BuildDefaultLayout, which is the one place the
/// shipped arrangement lives - so "reset" always lands on a known-good layout, whether the user
/// has saved one of their own or not.
/// </summary>
private void ResetDefaultPanelView()
{
var saved = EditorCookie.Get<string>( DefaultPanelCookie, null );
if ( !string.IsNullOrWhiteSpace( saved ) )
{
DockManager.State = saved;
SyncDockChecks();
SetPrompt( "Panel view reset to your saved default." );
return;
}
foreach ( var title in PanelDockTitles )
{
if ( DockManager.IsDockOpen( title ) )
DockManager.SetDockState( title, false );
}
BuildDefaultLayout();
SyncDockChecks();
SetPrompt( "Panel view reset to the default layout." );
}
// --- the stage bar -------------------------------------------------------------------------
/// <summary>Stage names. Constants because the lock rule below has to name one of them, and a
/// typo in a string literal there would silently lock the starter stage instead.</summary>
private const string StageSketch = "Sketch";
private const string StageSolid = "Solid";
private const string StageDetail = "Detail";
private const string StageRepeat = "Repeat";
private const string StageFinish = "Finish";
private void BuildToolbar()
{
// DOCKED ABOVE THE VIEWPORT, not floating on it. The strips this replaced sat on the canvas
// at its top-left, which is exactly where a part's own top-left corner is, and they could
// not be transparent - a widget that declines to paint keeps whatever was in the buffer, so
// the "floating" strip was an opaque band over the model the whole time. A bar of five or
// six NAMED buttons is chrome you read rather than a wall you want off your part, so it
// takes its own band and gives the 3D view back its corner.
_stageBar = new EffigyStageBar( _viewport ) { StageChanged = OnStageChanged };
// ABOVE the stage bar, and the outermost ring of chrome in the window: which part of the
// pipeline you are in is the coarsest question the tool asks, so it is answered furthest
// out. See EffigyWorkspaceBar and EffigyWindow.Workspaces.cs.
_workspaceBar = new EffigyWorkspaceBar( _viewport ) { Switched = SetWorkspace };
// Still on the canvas, and still under the tools: the question it answers - "is this about
// to cut?" - is asked while looking at the MODEL, not at the parameter list.
_resultStrip = new EffigyResultStrip( _viewport.Canvas ) { Changed = OnResultStripChanged };
_viewport.CompleteLayout( _workspaceBar, _stageBar, _resultStrip );
// Model's own chrome, on the canvas; the stage bar hides while it is up. See
// EffigyWindow.ModelChrome.cs.
BuildModelChrome();
// The sculpt number bar keeps its floating spot - it belongs to the stroke you are making,
// not to the tool you picked, and it wants to be near the model rather than up in chrome.
_sculptBar = new EffigySculptBar( _viewport.Canvas ) { Changed = OnSculptBarChanged };
_viewport.AddSculptOverlay( _sculptBar );
BuildMeshEditBar();
// Citizen first, then the grid, both flush-right on the tool row. The citizen is scenery
// in the viewport, so the switch that hides it lives next to the viewport — Settings is
// still the other home, and both write the same bool. The grid still only shows while a
// sketch is open; the citizen switch stays, because a ruler is useful in every mode.
var trailing = new Widget( _stageBar )
{
TranslucentBackground = true,
NoSystemBackground = true,
Layout = Layout.Row(),
FixedHeight = EffigyToolChrome.ButtonHeight,
FixedWidth = EffigySizeReferenceButton.BarWidth,
};
trailing.Layout.Spacing = 6;
_citizenButton = new EffigySizeReferenceButton( trailing, _viewport )
{
Changed = OnSizeReferenceChanged,
};
_gridBar = new EffigySketchGridBar( trailing, _viewport ) { Changed = OnGridBarChanged };
trailing.Layout.Add( _citizenButton );
trailing.Layout.Add( _gridBar );
_stageBar.SetToolRowTrailing( trailing );
_viewport.SketchGridBar = _gridBar;
_viewport.SculptStrokeFinished = NoteSculptEdited;
// The paint bar, in the same floating spot the sculpt bar keeps — it is about the stroke
// you are making, not the tool you picked.
_paintBar = new EffigyPaintBar( _viewport.Canvas )
{
Changed = OnPaintBarChanged,
};
_materialBrushBar = new EffigyMaterialBrushBar( _viewport.Canvas ) { Changed = OnPaintBarChanged };
_viewport.AddPaintOverlay( _materialBrushBar );
_viewport.MaterialDabbed = OnMaterialDabbed;
_viewport.MaterialStrokeStarted = OnMaterialStrokeStarted;
_viewport.AddPaintOverlay( _paintBar );
_viewport.PaintStrokeFinished = OnPaintStrokeFinished;
_testBar = new EffigyTestBar( _viewport.Canvas ) { Scrubbed = OnTestScrubbed };
_viewport.AddTestOverlay( _testBar );
_weightBar = new EffigyWeightBar( _viewport.Canvas ) { Changed = () => _viewport?.Update() };
_viewport.AddWeightOverlay( _weightBar );
_viewport.WeightStrokeFinished = OnWeightStrokeFinished;
_viewport.NoteChanged = OnNoteEdited;
_viewport.NoteTextRequested = PromptNoteText;
// BUILT ONCE, ALL THREE, at startup. The bar paints from these lists and never owns them,
// so a mode change is an assignment rather than a teardown - which is what lets a tool's
// armed state and its chosen variant survive leaving a sketch and coming back to it.
_partStages = BuildPartStages();
_sketchStages = BuildSketchStages();
_sculptStages = BuildSculptStages();
_paintStages = BuildPaintStages();
_sculptHomeStages = BuildSculptHomeStages();
_paintHomeStages = BuildPaintHomeStages();
_rigStages = BuildRigStages();
_meshEditStages = BuildMeshEditStages();
_clothingStages = BuildClothingStages();
// The rig tools read their armed state off the panel, and the panel changes it without
// being asked — Escape closes a chain, clicking another bone disarms an assign. Wired here
// rather than in BuildDocks because the tools these ticks live on have only just been made.
if ( _rigPanel is not null )
_rigPanel.ToolStateChanged = UpdateRigChecks;
ShowPartStages( force: true );
}
/// <summary>
/// The bar moved to another stage, so whatever lives on its buttons has to be pushed onto the
/// ones now showing.
///
/// The state itself never moved - it is on the tool objects, not the buttons - but the row only
/// paints the stage in front of it, and the tutorial highlight has to be re-evaluated because
/// the button it wants may have just arrived on screen or left it.
/// </summary>
private void OnStageChanged()
{
if ( BarMode == EffigyBarMode.Part )
_partStage = _stageBar.SelectedIndex;
else if ( BarMode == EffigyBarMode.Rig )
_rigStage = _stageBar.SelectedIndex;
ApplyToolHighlight();
}
/// <summary>Which part-studio stage was last looked at, so leaving a sketch comes back to where
/// you were rather than to the front of the bar.</summary>
private int _partStage;
/// <summary>
/// Rebuild the three stage tables after a hotload.
///
/// EVERY ACTION ON A STAGE TOOL IS A LAMBDA, and the tables holding them outlive the assembly
/// those lambdas were compiled into. That is not a theoretical worry in this editor: the sketch
/// strip this replaced was built exactly once and never rebuilt, so on every hotload its
/// VariantChosen closures rotted, the hotloader logged "Unable to find matching substitution
/// for a lambda method", and every sketch tool went quietly dead - still highlighting, still
/// checking, calling nothing. The feature strip escaped it only because RefreshToolStrip tore
/// the whole thing down and rebuilt the closures whenever a sketch was finished.
///
/// Stages are never torn down by ordinary use, so there is no refresh to hide behind and the
/// rebuild has to be asked for outright. Nothing is lost by it: every piece of state on a tool
/// is a fact about the VIEWPORT - which sketch tool is armed, whether construction is on, what
/// the sculpt session is doing - so the fresh tables are re-derived from the thing that
/// actually knows, rather than copied off the objects being thrown away.
/// </summary>
[Event( "hotloaded" )]
private static void OnHotloaded() => Current?.RebuildStages();
private void RebuildStages()
{
if ( _stageBar is null )
return;
var stage = _stageBar.SelectedIndex;
// The registries point at the old tools; clearing them here is what stops the rebuilt ones
// being added alongside a set of dead duplicates.
_featureTools.Clear();
_sketchTools.Clear();
_brushTools.Clear();
_partStages = BuildPartStages();
_sketchStages = BuildSketchStages();
_sculptStages = BuildSculptStages();
_paintStages = BuildPaintStages();
_sculptHomeStages = BuildSculptHomeStages();
_paintHomeStages = BuildPaintHomeStages();
_rigStages = BuildRigStages();
_meshEditStages = BuildMeshEditStages();
// Object mode's tools cache themselves; dropping the cache is what makes the next
// ShowObjectModeBar build them against this assembly.
_objectCoreStage = null;
_objectMenus = null;
BindModelDock();
BindModelChrome();
// A spline half-drawn across a hotload keeps its viewport delegates pointing at this assembly.
if ( _viewport?.SplineFeature is { } drawing )
BeginSplineDraw( drawing );
// Same reason as StageChanged below: a lambda compiled into the dead assembly is a rig tool
// that still highlights and calls nothing.
if ( _rigPanel is not null )
_rigPanel.ToolStateChanged = UpdateRigChecks;
if ( _workspaceBar is not null )
_workspaceBar.Switched = SetWorkspace;
// A lambda compiled into the dead assembly is a settle rebuild that never arrives, leaving
// the model stuck in its preview form. Same reason every other delegate is re-assigned here.
if ( _viewport is not null )
_viewport.Ticked = OnTick;
// A hotload taken inside the rig workspace: the mode field survived, a newly-added viewport
// flag did not. See SyncViewportMode.
SyncViewportMode();
// A method group rather than a lambda, so this one migrates on its own - but it costs
// nothing to be certain, and a bar with no StageChanged is a tutorial highlight that
// silently stops following the reader.
_stageBar.StageChanged = OnStageChanged;
switch ( BarMode )
{
case EffigyBarMode.Sketch:
_stageBar.SetFinish( "Finish", FinishSketch );
_stageBar.SetStages( _sketchStages, stage );
UpdateSketchToolChecks( _viewport?.SketchTool ?? SketchToolKind.Select );
if ( _constructionTool is not null )
_constructionTool.Checked = _viewport?.ConstructionMode ?? false;
if ( _inspectorTool is not null )
_inspectorTool.Checked = _viewport?.ProfileInspector ?? true;
_stageBar.Refresh();
break;
case EffigyBarMode.Sculpt:
if ( _viewport is { IsSculpting: true } )
{
_stageBar.Mode = null;
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _sculptStages, stage );
UpdateModelModeSwitch( "Sculpt" );
ShowModelChrome( "Sculpt" );
UpdateSculptChecks();
}
else
{
// The sculpt workspace's landing bar — Subdivide and Sculpt — when no feature
// is open. Brushes arrive with a sculpt, not with the workspace.
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _sculptHomeStages, stage );
}
break;
case EffigyBarMode.Paint:
if ( _viewport is { IsPainting: true } )
{
_stageBar.SetFinish( "Finish", FinishPaint );
_stageBar.SetStages( _paintStages, stage );
}
else
{
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _paintHomeStages, stage );
}
break;
case EffigyBarMode.MeshEdit:
if ( _viewport is { IsMeshEditing: true } )
{
ShowMeshEditBar();
UpdateMeshEditChecks();
}
else
{
ShowSculptHome();
}
break;
case EffigyBarMode.Rig:
// No finish — a rig has no feature to commit to. See EnterRig.
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _rigStages, stage );
UpdateRigChecks();
break;
case EffigyBarMode.Clothing:
// No finish, same argument as Rig: every clothing tool adds a feature, and the
// feature tree already holds it the moment it is added.
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _clothingStages, stage );
break;
default:
_partStage = stage;
ShowPartStages( force: true );
break;
}
}
// --- part stages ---------------------------------------------------------------------------
/// <summary>
/// The creation tools, grouped by the CreateTools table's own Stage column.
///
/// GROUPED FROM THE TABLE rather than listed again here, so there is still exactly one place
/// that says what tools exist. The old strip read the same table and drew all nineteen in a
/// row with a wider gap every four or five; the gaps were the grouping, they were unlabelled,
/// and at 30px between buttons they read as uneven spacing rather than as meaning anything.
/// The stage names are those gaps, said out loud.
/// </summary>
private List<EffigyStage> BuildPartStages()
{
var stages = new List<EffigyStage>();
foreach ( var tool in CreateTools )
{
var stage = stages.FirstOrDefault( s => s.Name == tool.Stage );
if ( stage is null )
{
stage = new EffigyStage { Name = tool.Stage };
stages.Add( stage );
}
// Only the KIND is captured, never the table entry. An enum carries across a hotload
// where a reference into a table built by a dead assembly does not.
var kind = tool.Kind;
var entry = new EffigyStageTool
{
Icon = tool.Icon,
Label = tool.Label,
Tip = tool.Tip,
};
// Variants OR a plain action, never both: a button with variants runs the one on its
// face, so a Clicked sitting behind that would be unreachable rather than harmless.
if ( tool.Choices is { Length: > 0 } )
entry.Variants = ChoiceVariants( tool, kind );
else if ( kind == ToolKind.Paint )
entry.Clicked = AddPaint;
else
entry.Clicked = () => AddFeature( NewFeature( kind, -1 ) );
stage.Add( entry );
_featureTools[kind] = entry;
}
AddNoteTools( stages );
return stages;
}
/// <summary>
/// The grease pencil, on the end of the Sketch stage.
///
/// NOT IN THE CreateTools TABLE, even though it sits among tools that are. Every entry in that
/// table is a FEATURE — it carries a ToolKind and its button calls AddFeature — and a note is
/// deliberately not one of those (see PartStudio.Notes). Giving it a fake ToolKind to get it
/// onto the bar would put annotation one careless switch statement away from the feature tree,
/// which is precisely the coupling the whole design is avoiding. It is added here instead, to
/// the stage the table already built.
///
/// The SKETCH stage because that is where the other thing you do with a pointer and a plane
/// lives, and because it is the one stage that is never locked — a note on an empty studio
/// ("start from the bracket drawing") is a reasonable first thing to want, and every other
/// stage is dimmed until there is a body to act on.
/// </summary>
private void AddNoteTools( List<EffigyStage> stages )
{
if ( stages.FirstOrDefault( s => s.Name == StageSketch ) is not { } sketch )
return;
// One button rather than two. The pen and its colour used to be a checkable button sitting
// next to a second, un-checkable one that existed only to hold the palette; VariantsAreSettings
// is what lets the merged button keep both jobs — a plain click always arms or puts down the
// pen, and the chevron behind it opens the same palette the second button used to be. Built
// from the palette rather than written out again, for the same reason the Primitive menu is
// built from PrimitiveFeature.Shape: a menu naming a colour the kernel has never heard of
// would set an index meaning something else.
_noteTool = new EffigyStageTool
{
Icon = EffigyIcon.NoteTool,
Label = "Note",
Tip = "Grease pencil — scribble notes over the part. Never exported.",
Checkable = true,
VariantsAreSettings = true,
IconColor = SwatchColor( 0 ),
Variants = Enumerable.Range( 0, NotePalette.Count ).Select( i => new EffigyStageVariant
{
Icon = EffigyIcon.NoteTool,
Label = NotePalette.NameAt( i ),
Tip = $"Draw notes in {NotePalette.NameAt( i ).ToLowerInvariant()}",
Chosen = () => SetNoteColor( i ),
} ).ToArray(),
};
_noteTool.Clicked = ToggleNotePen;
_noteEraseTool = new EffigyStageTool
{
Icon = EffigyIcon.NoteEraseTool,
Label = "Erase",
Tip = "Eraser (E) — hold the left button and drag through the notes you want gone",
Checkable = true,
};
_noteEraseTool.Clicked = ToggleNoteEraser;
sketch.Add( _noteTool );
sketch.Add( _noteEraseTool );
}
/// <summary>A palette swatch as the Color the toolbar paints with.</summary>
private static Color SwatchColor( int index )
{
var swatch = NotePalette.At( index );
return new Color( swatch.R, swatch.G, swatch.B );
}
/// <summary>
/// A feature that comes in several shapes - Primitive - as one button with the shapes behind
/// its chevron.
///
/// A CHANGE OF BEHAVIOUR, and a deliberate one. Clicking Primitive used to open the shape menu
/// and nothing else, so adding a second cube was two clicks every time. It now works like every
/// other tool that has variants: the button makes whatever is on its face, and the chevron
/// picks a different shape and leaves it there.
/// </summary>
private EffigyStageVariant[] ChoiceVariants( CreateTool tool, ToolKind kind )
{
var variants = new EffigyStageVariant[tool.Choices.Length];
for ( var i = 0; i < tool.Choices.Length; i++ )
{
var choice = i;
variants[i] = new EffigyStageVariant
{
Icon = tool.Icon,
Label = tool.Choices[choice],
Tip = tool.Tip,
Chosen = () => AddFeature( NewFeature( kind, choice ) ),
};
}
return variants;
}
/// <summary>
/// Put the part-studio stages on the bar, with the lock state recomputed.
///
/// Cheap enough to call whenever anything might have changed: it compares the lock it wants
/// against the lock each stage already has and does nothing at all when they agree, which is
/// the common case.
/// </summary>
private void ShowPartStages( bool force = false )
{
if ( _stageBar is null || _partStages is null )
return;
var reason = StarterLockReason();
var changed = force || BarMode != EffigyBarMode.Part;
// Was everything past the starter stage locked a moment ago? Asked before the loop
// rewrites it, because the answer decides where to land.
var wasLocked = _partStages.Any( s => s.Locked );
foreach ( var stage in _partStages )
{
var locked = stage.Name == StageSketch ? null : reason;
if ( stage.LockedReason == locked )
continue;
stage.LockedReason = locked;
changed = true;
}
if ( !changed )
return;
BarMode = EffigyBarMode.Part;
_stageBar.Mode = null;
_stageBar.SetFinish( null, null );
// JUST UNLOCKED means the first sketch was finished a moment ago, and the thing anybody
// wants next is to pull it into a solid. Landing back on the starter stage would be
// landing on the two tools that have just stopped being the only option.
var land = wasLocked && reason is null
? _partStages.FindIndex( s => s.Name == StageSolid )
: _partStage;
_stageBar.SetStages( _partStages, land );
// Coming back from a sketch or a sculpt, the part tools are on screen again and their
// selection marks were last set for whatever was selected before we left. Nothing else
// will recompute them until the next selection change, which may never come if the user
// finishes a sketch and goes straight for a tool.
MarkToolsTakingSelection();
}
/// <summary>
/// Why everything past the starter stage is unavailable, or null when it is not.
///
/// SOMETHING TO ACT ON, not specifically a sketch. Extrude, Fillet, Shell and the rest all need
/// geometry, and adding one before there is any produces a feature that goes straight to red.
/// The strip this replaced enforced that by HIDING seventeen of nineteen buttons until a sketch
/// had curves in it - which also meant a studio begun with a Primitive, the other tool on the
/// starter stage and a perfectly good way to start a part, sat there with a cube on screen and
/// no fillet, no shell and no mirror to use on it. A body counts too, and the rule is now
/// written on a dimmed tab instead of being enforced by disappearance.
/// </summary>
private string StarterLockReason() =>
HasConfirmedSketch() || (_studio?.Bodies.Count ?? 0) > 0
? null
: "Draw a sketch or add a primitive first — these tools need something to act on";
// --- sketch stages -------------------------------------------------------------------------
/// <summary>
/// The tools from Onshape's sketch row that this kernel can actually build.
///
/// Line, rectangle, circle, arc, polygon and point all map onto SketchLine / SketchArc /
/// SketchCircle. The rest of Onshape's row — dimensions, constraints — has no kernel behind it,
/// so those buttons are absent rather than present and dead.
///
/// SELECT IS ON EVERY STAGE. It is the neutral state every other tool falls back to - Escape
/// lands on it, finishing a shape lands on it - so putting it behind a tab would make the most
/// returned-to tool in sketch mode the only one that costs a stage change. It is one tool
/// object appearing in four lists, not four tools, so its armed state cannot disagree with
/// itself.
/// </summary>
private List<EffigyStage> BuildSketchStages()
{
var select = SketchTool( EffigyIcon.SelectTool, "Select",
"Select - drag a point, or the grip at the middle of a curve; click points and curves to select them, or drag a box on empty space (left to right takes what is inside, right to left takes what it touches); a selected point brings the rest of the selection with it",
SketchToolKind.Select );
var draw = new EffigyStage { Name = "Draw" };
draw.Add( select );
draw.Add( SketchGroup(
new SketchToolVariant( EffigyIcon.LineTool, "Line",
"Line - click start, click end; keeps chaining until Escape", SketchToolKind.Line ),
new SketchToolVariant( EffigyIcon.LineMidpointTool, "Midpoint line",
"Midpoint line - click the middle, then one end; it grows both ways", SketchToolKind.LineMidpoint ) ) );
// The families that have more than one way to place them. Each is ONE button with the
// alternatives behind its chevron, which is how Onshape's sketch row is arranged.
draw.Add( SketchGroup(
new SketchToolVariant( EffigyIcon.RectangleTool, "Rectangle",
"Corner rectangle - click two opposite corners", SketchToolKind.Rectangle ),
new SketchToolVariant( EffigyIcon.RectangleCentreTool, "Centre rectangle",
"Centre rectangle - click the centre, then a corner", SketchToolKind.RectangleCentre ) ) );
draw.Add( SketchGroup(
new SketchToolVariant( EffigyIcon.CircleTool, "Circle",
"Centre circle - click the centre, then a point on the rim", SketchToolKind.Circle ),
new SketchToolVariant( EffigyIcon.CircleThreePointTool, "3 point circle",
"3-point circle - click three points on the rim", SketchToolKind.CircleThreePoint ) ) );
draw.Add( SketchGroup(
new SketchToolVariant( EffigyIcon.ArcTool, "Arc",
"Centre arc - click the centre, the start, then the end direction", SketchToolKind.Arc ),
new SketchToolVariant( EffigyIcon.ArcThreePointTool, "3 point arc",
"3-point arc - click start, end, then a point it passes through", SketchToolKind.ArcThreePoint ) ) );
draw.Add( SketchTool( EffigyIcon.PointTool, "Point", "Point - click to place", SketchToolKind.Point ) );
var shapes = new EffigyStage { Name = "Shapes" };
shapes.Add( select );
shapes.Add( SketchGroup(
new SketchToolVariant( EffigyIcon.PolygonTool, "Polygon",
"Inscribed polygon - click the centre, then a corner", SketchToolKind.Polygon ),
new SketchToolVariant( EffigyIcon.PolygonCircumscribedTool, "Circumscribed polygon",
"Circumscribed polygon - click the centre, then an edge midpoint", SketchToolKind.PolygonCircumscribed ) ) );
shapes.Add( SketchTool( EffigyIcon.SlotTool, "Slot",
"Slot - click both ends of the centre line, then the width", SketchToolKind.Slot ) );
shapes.Add( SketchTool( EffigyIcon.EllipseTool, "Ellipse",
"Ellipse - centre, the long axis, then the bulge", SketchToolKind.Ellipse ) );
shapes.Add( SketchTool( EffigyIcon.SplineTool, "Spline",
"Spline - click points, Enter finishes", SketchToolKind.Spline ) );
// The four that EDIT what is already there get their own stage, because clicking one of
// them on empty space does nothing and the grouping is what says why.
var modify = new EffigyStage { Name = "Modify" };
modify.Add( select );
modify.Add( SketchTool( EffigyIcon.TrimTool, "Trim",
"Trim - click the piece of a curve you want gone", SketchToolKind.Trim ) );
modify.Add( SketchTool( EffigyIcon.ExtendTool, "Extend",
"Extend - click the end of a curve to stretch it", SketchToolKind.Extend ) );
modify.Add( SketchTool( EffigyIcon.SketchFilletTool, "Fillet",
"Fillet - click a corner, then set the radius", SketchToolKind.Fillet ) );
modify.Add( SketchTool( EffigyIcon.OffsetTool, "Offset",
"Offset - click a curve, then which side and how far", SketchToolKind.Offset ) );
modify.Add( SketchTool( EffigyIcon.MirrorTool, "Mirror",
"Mirror - select what you want mirrored with the Select tool, then click the line to "
+ "reflect it across", SketchToolKind.Mirror ) );
// CUT sits with the four edits because that is what it does, but it is worked differently
// from every other tool here: hold the button and drag, and the line you draw cuts what it
// passes through. It is Trim swept rather than clicked - same call underneath - so crossing
// an edge that ends at two corners takes that whole edge, and crossing a lone line takes
// the line.
modify.Add( SketchTool( EffigyIcon.CutTool, "Cut",
"Cut - hold the left button and drag a line through the curves you want gone",
SketchToolKind.Cut ) );
// USE and its neighbours reach OUTSIDE the sketch. Everything on the stages above draws or
// edits what the sketch already contains; these take the outline of the FACE the sketch is
// sitting on and make it geometry the sketch owns. Until one of them is pressed that
// outline is scenery - you can snap to it and you cannot build from it, which is the
// distinction Onshape's Use tool exists to make.
var reference = new EffigyStage { Name = "Reference" };
reference.Add( select );
var use = SketchTool( EffigyIcon.UseTool, "Use",
"Use - click a green edge of the face this sketch is on to copy it into the sketch",
SketchToolKind.Use );
use.IconColor = EffigyToolChrome.ReferenceColor;
reference.Add( use );
// A press rather than a mode: there is nothing to aim at, so arming a tool for it would be
// a step that does nothing but wait for a click anywhere.
reference.Add( new EffigyStageTool
{
Icon = EffigyIcon.UseAllTool,
Label = "Use all",
Tip = "Use all - copy the whole outline of the face this sketch is on into the sketch, so a "
+ "line drawn across it has something to close against",
IconColor = EffigyToolChrome.ReferenceColor,
Clicked = () => _viewport.UseAllReferenceEdges(),
} );
// Construction geometry is a modifier on whatever tool is active, not a tool of its own -
// same as Onshape's toggle. SketchCurve.Construction and ProfileFinder's handling of it
// were already in the kernel with nothing in the UI able to set them.
_constructionTool = new EffigyStageTool
{
Icon = EffigyIcon.ConstructionTool,
Label = "Construction",
Tip = "Construction geometry (Q) - reference lines that never become part of a profile",
Checkable = true,
};
_constructionTool.Clicked = () => _viewport.ConstructionMode = _constructionTool.Checked;
reference.Add( _constructionTool );
_inspectorTool = new EffigyStageTool
{
Icon = EffigyIcon.ProfileInspectorTool,
Label = "Inspector",
Tip = "Profile Inspector - shade closed regions and highlight loose ends",
Checkable = true,
Checked = true,
};
_inspectorTool.Clicked = () => _viewport.ProfileInspector = _inspectorTool.Checked;
reference.Add( _inspectorTool );
return new List<EffigyStage> { draw, shapes, modify, reference };
}
/// <summary>A tool with only one way to place it: one variant, so no chevron appears.</summary>
private EffigyStageTool SketchTool( EffigyIcon icon, string label, string tip, SketchToolKind kind ) =>
SketchGroup( new SketchToolVariant( icon, label, tip, kind ) );
/// <summary>
/// One button for a family of tools. The first variant is what it shows to begin with; the
/// rest sit behind its chevron and take its place once picked.
/// </summary>
private EffigyStageTool SketchGroup( params SketchToolVariant[] variants )
{
var tool = new EffigyStageTool
{
Icon = variants[0].Icon,
Label = variants[0].Label,
Tip = variants[0].Tip,
Checkable = true,
Checked = variants[0].Kind == SketchToolKind.Select,
Variants = new EffigyStageVariant[variants.Length],
};
for ( var i = 0; i < variants.Length; i++ )
{
var variant = variants[i];
tool.Variants[i] = new EffigyStageVariant
{
Icon = variant.Icon,
Label = variant.Label,
Tip = variant.Tip,
Chosen = () =>
{
_viewport.SetSketchTool( variant.Kind );
UpdateSketchToolChecks( variant.Kind );
},
};
// Every variant is registered, not just the one on the face: arming a centre rectangle
// from anywhere else has to be able to find the button that shows it.
_sketchTools.Add( (tool, variant.Kind, i) );
}
return tool;
}
/// <summary>
/// Only one sketch tool can be active, so the rest have to visibly let go.
///
/// Works on the tool DATA rather than on buttons, which is what lets it be right about a tool
/// sitting on a stage that is not currently painted - arm Circle from the C shortcut while the
/// Modify stage is showing and the tick is already correct by the time Reveal brings Draw to
/// the front.
/// </summary>
private void UpdateSketchToolChecks( SketchToolKind active )
{
EffigyStageTool armed = null;
var armedVariant = 0;
foreach ( var (tool, kind, variant) in _sketchTools )
{
if ( kind != active )
continue;
armed = tool;
armedVariant = variant;
}
foreach ( var (tool, _, _) in _sketchTools )
tool.Checked = tool == armed;
// A tool armed from somewhere else - a keyboard shortcut, or Escape dropping back to
// Select - has to appear on the face of its button, or the bar would show one thing while
// the viewport did another.
if ( armed is not null )
armed.Current = armedVariant;
_stageBar?.Refresh();
}
/// <summary>Bring the stage holding the armed tool to the front. Separate from the check
/// update because a click on a button that is already on screen must not make the bar jump,
/// and Reveal is a no-op in exactly that case.</summary>
private void RevealSketchTool( SketchToolKind active )
{
foreach ( var (tool, kind, _) in _sketchTools )
{
if ( kind != active )
continue;
_stageBar?.Reveal( tool );
return;
}
}
// --- sculpt mode ---------------------------------------------------------------------------
private EffigySculptBar _sculptBar;
/// <summary>The feature being sculpted, so finishing knows what to mark dirty.</summary>
private SculptFeature _sculptFeature;
// --- paint mode ---------------------------------------------------------------------------
private EffigyPaintBar _paintBar;
private EffigyMaterialBrushBar _materialBrushBar;
private EffigyWeightBar _weightBar;
private EffigyTestBar _testBar;
/// <summary>The feature being painted, so finishing knows what to mark dirty.</summary>
private PaintFeature _paintFeature;
// The material and texture the CAD preview shows a painted body with, after painting has finished
// and the ordinary merged preview takes over. Cached keyed on the canvas object, so a rebuild that
// reused the feature's canvas — a slider drag on something else — does not re-create a 1024²
// texture.
private PaintCanvas _previewPaintCanvas;
private Texture _previewPaintTexture;
private Material _previewPaintMaterial;
/// <summary>
/// The CAD preview for a mesh carrying a paint atlas: one material with the canvas texture bound,
/// the same path the live paint session uses.
///
/// The texture and material are cached keyed on the canvas object, because the feature's own
/// replay cache means a rebuild that changed neither the topology nor the atlas hands back the
/// same canvas — and re-creating a 1024² texture on every slider drag of an unrelated feature
/// would be the exact waste the dirty rect exists to avoid.
/// </summary>
private Model BuildPaintPreview( PolyMesh mesh )
{
var canvas = mesh.Paint;
if ( canvas is null )
{
_previewPaintCanvas = null;
_previewPaintTexture = null;
_previewPaintMaterial = null;
return null;
}
if ( !ReferenceEquals( _previewPaintCanvas, canvas ) )
{
var res = canvas.Width;
var opaque = new byte[res * res * 4];
canvas.BakeOpaque( opaque, PaintMaterial.DefaultBaseR, PaintMaterial.DefaultBaseG, PaintMaterial.DefaultBaseB );
_previewPaintTexture = Texture.Create( res, res, ImageFormat.RGBA8888 )
.WithData( opaque )
.Finish();
_previewPaintMaterial = Material.Load( "materials/default.vmat" )?.CreateCopy( "effigy_paint" );
_previewPaintMaterial?.Set( "g_tColor", _previewPaintTexture );
_previewPaintCanvas = canvas;
}
return EffigyPreview.Build( mesh, _previewPaintMaterial );
}
/// <summary>
/// The authoring chain for a painted part, run once on compile: canvas → opaque PNG → a .vmat
/// naming the PNG, then the .vmat is bound to the painted body's default slot so the compiled
/// model samples the atlas rather than the missing-material shader. There is no .vtex step — the
/// engine's material compiler does not accept a .vtex as a texture input, so the material names
/// the PNG and the asset system compiles it to a texture on its own.
///
/// A face the user dropped a material on keeps that material — paint takes the slot that had
/// nothing, the same rule the vertex-colour Blend option kept. The slot binding is a real edit
/// to MaterialNames, which is what makes the remap list pick it up; it persists in the document
/// the way any dropped material does.
/// </summary>
private void AuthorPaint( PolyMesh mesh, string name, string folder )
{
// Per painted BODY, not the merged mesh. A merge keeps one atlas, so two painted bodies
// used to write one file and bind it to slot 0 — which is the slot nothing might be on.
// Bind the slots each body's faces actually wear. Isolate a body onto a fresh slot when
// it would share one with another painted body this compile.
var claimed = new HashSet<int>();
foreach ( var body in _studio.Bodies )
{
if ( body.Mesh.Paint is not { } canvas )
continue;
var slots = PaintBind.SlotsToBind( body.Mesh );
var shared = slots.Any( s => claimed.Contains( s ) );
if ( shared || slots.Count == 0 )
{
var fresh = PaintBind.NextFreeSlot( _studio.MaterialNames, claimed.Concat( slots ) );
if ( fresh < 0 )
{
Log.Warning( $"[Effigy] paint on {body.Name}: no free material slot left" );
continue;
}
PaintBind.AssignAllFaces( body.Mesh, fresh );
slots = new List<int> { fresh };
}
foreach ( var slot in slots )
claimed.Add( slot );
var tag = SanitizePaintTag( body.Id );
var opaque = PaintMaterial.OpaqueRgba( canvas );
var pngPath = Path.Combine( folder, $"{name}_{tag}_paint.png" );
var vmatPath = Path.Combine( folder, $"{name}_{tag}_paint.vmat" );
var relPng = $"models/effigy/{name}_{tag}_paint.png";
var relVmat = $"models/effigy/{name}_{tag}_paint.vmat";
PngWriter.WriteFileRgba( pngPath, opaque, canvas.Width, canvas.Height );
File.WriteAllText( vmatPath, PaintMaterial.VmatSource( relPng ) );
foreach ( var slot in slots )
_studio.MaterialNames[slot] = relVmat;
Log.Info( $"[Effigy] authored paint atlas {relVmat} on slot(s) {string.Join( ",", slots )} ({canvas.Width}x{canvas.Height})" );
}
_ = mesh;
}
static string SanitizePaintTag( string id )
{
if ( string.IsNullOrWhiteSpace( id ) )
return "body";
var chars = id.Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray();
return new string( chars );
}
private readonly List<(EffigyStageTool Tool, BrushKind Kind)> _brushTools = new();
private EffigyStageTool _maskTool;
private EffigyStageTool _remeshHomeTool;
private EffigyStageTool _symmetryTool;
/// <summary>
/// The Sculpt workspace's landing bar — the tools you reach for BEFORE a sculpt is open.
///
/// The workspace is about sculpting, and sculpting begins with a cage and a Sculpt feature, so
/// those are the two tools here. The brushes in <see cref="BuildSculptStages"/> only appear once
/// a sculpt feature is actually entered; this is the bar the workspace shows the rest of the time.
/// </summary>
private List<EffigyStage> BuildSculptHomeStages()
{
var stage = new EffigyStage { Name = "Sculpt" };
_subdivideHomeTool = new EffigyStageTool
{
Icon = EffigyIcon.Subdivide,
Label = "Subdivide",
Tip = "Add a Subdivide — Catmull-Clark subdivision",
Clicked = () => AddFeature( NewFeature( ToolKind.Subdivide, -1 ) ),
};
stage.Add( _subdivideHomeTool );
// BESIDE SUBDIVIDE, because it is the same question with the answer the other way round.
// The reason it is on the sculpt bar rather than the CAD strip is the reason Subdivide is:
// triangle budgets are not a CAD concern, and the person who needs one has an imported mesh
// open and is trying to work on it.
_remeshHomeTool = new EffigyStageTool
{
Icon = EffigyIcon.Remesh,
Label = "Remesh",
Tip = "Add a Remesh — reduce a dense part to a triangle budget, keeping its shape",
Clicked = () => AddFeature( NewFeature( ToolKind.Remesh, -1 ) ),
};
stage.Add( _remeshHomeTool );
_sculptHomeTool = new EffigyStageTool
{
Icon = EffigyIcon.Sculpt,
Label = "Sculpt",
Tip = "Add a Sculpt — brush detail onto the cage in levels",
Clicked = () => AddFeature( NewFeature( ToolKind.Sculpt, -1 ) ),
};
stage.Add( _sculptHomeTool );
// Edit first: box-modelling the shape comes before brushing detail onto it.
return new List<EffigyStage> { BuildMeshEditHomeStage(), stage };
}
private List<EffigyStage> BuildSculptStages()
{
var brush = new EffigyStage { Name = "Brush" };
brush.Add( BrushTool( EffigyIcon.SculptDraw, "Draw", "Draw — push the surface out along its normal", BrushKind.Draw ) );
brush.Add( BrushTool( EffigyIcon.SculptSmooth, "Smooth", "Smooth — pull a region towards its own neighbours", BrushKind.Smooth ) );
brush.Add( BrushTool( EffigyIcon.SculptInflate, "Inflate", "Inflate — push out in every direction at once", BrushKind.Inflate ) );
brush.Add( BrushTool( EffigyIcon.SculptGrab, "Grab", "Grab — drag the surface sideways", BrushKind.Grab ) );
brush.Add( BrushTool( EffigyIcon.SculptFlatten, "Flatten", "Flatten — cut a region back towards a plane", BrushKind.Flatten ) );
brush.Add( BrushTool( EffigyIcon.SculptPinch, "Pinch", "Pinch — gather the surface towards the stroke", BrushKind.Pinch ) );
// The two that change what a stroke DOES rather than which stroke it is. Their own stage
// because they compose with all six brushes above - they are not a seventh brush.
var stroke = new EffigyStage { Name = "Stroke" };
_maskTool = new EffigyStageTool
{
Icon = EffigyIcon.SculptMask,
Label = "Mask",
Tip = "Mask (M) — paint the part you want held still",
Checkable = true,
};
_maskTool.Clicked = ToggleSculptMasking;
_symmetryTool = new EffigyStageTool
{
Icon = EffigyIcon.Mirror,
Label = "Symmetry",
Tip = "Symmetry (X) — mirror every stroke across X",
Checkable = true,
};
_symmetryTool.Clicked = ToggleSculptSymmetry;
stroke.Add( _maskTool );
stroke.Add( _symmetryTool );
var levels = new EffigyStage { Name = "Levels" };
levels.Add( new EffigyStageTool
{
Icon = EffigyIcon.SculptLevelDown,
Label = "Coarser",
Tip = "Show one level coarser",
Clicked = () => StepSculptLevel( -1 ),
} );
levels.Add( new EffigyStageTool
{
Icon = EffigyIcon.SculptLevelUp,
Label = "Finer",
Tip = "Show — or add — one level finer",
Clicked = () => StepSculptLevel( 1 ),
} );
levels.Add( new EffigyStageTool
{
Icon = EffigyIcon.SculptBake,
Label = "Bake",
Tip = "Bake a normal map from this sculpt onto the cage",
Clicked = BakeSculpt,
} );
return new List<EffigyStage> { brush, stroke, levels };
}
private EffigyStageTool BrushTool( EffigyIcon icon, string label, string tip, BrushKind kind )
{
var tool = new EffigyStageTool
{
Icon = icon,
Label = label,
Tip = tip,
Checkable = true,
Clicked = () => SetSculptBrush( kind ),
};
_brushTools.Add( (tool, kind) );
return tool;
}
/// <summary>
/// Open a sculpt feature for brushing.
///
/// Rolls the model back to just after this feature so the cage is what you see rather than
/// whatever is stacked on top of it. EditFeature used to do that first, but Edit now calls
/// this, so the rollback lives here — same as it always did, without the round trip.
/// </summary>
private void EnterSculpt( SculptFeature feature )
{
if ( feature is null || _viewport is null )
return;
// Already in this sculpt: a second Edit must not rebuild the session and drop a stroke.
if ( BarMode == EffigyBarMode.Sculpt
&& ReferenceEquals( _sculptFeature, feature )
&& _viewport.IsSculpting )
return;
// Everything else that owns a left-click, shut down in one call — this used to be a
// hand-kept list of the other modes here and a different partial list in EnterPaint. See
// LeaveCurrentWorkspace in EffigyWindow.Workspaces.cs.
LeaveCurrentWorkspace();
// The cage does not exist until the features above this have run, and rolling to just
// after this one is also what puts the thing being sculpted on screen. Skip the rebuild
// when EditFeature already moved the bar here.
var index = _studio.Features.IndexOf( feature );
if ( index >= 0 && _studio.RollbackIndex != index + 1 )
{
_rollbackBeforeEdit ??= _studio.RollbackIndex;
_studio.RollbackIndex = index + 1;
RebuildStudio();
}
if ( feature.Sculpt is null )
{
// The feature errored, so there is nothing to sculpt on. Its own diagnostic says why far
// better than anything this could invent. The dialog stays open so a missing body can
// still be picked — that pick is what produces the cage.
SetPrompt( feature.Error ?? "This sculpt has no cage yet — the feature below it did not build." );
return;
}
_sculptFeature = feature;
// What the workspace switcher comes back to. Set only once the door refusals above are
// past, so a sculpt that would not open is not the one it remembers.
_lastSculptFeature = feature;
// The bar becomes the sculpt bar, and says so: brushes and levels behind the tabs, SCULPT
// and the way out at the right. The dialog closes too — a sculpt is not edited through a
// parameter list, so leaving one open would be two controls claiming the same feature.
BarMode = EffigyBarMode.Sculpt;
_stageBar.Mode = null;
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _sculptStages );
// The same Object | Edit | Sculpt | Retopo switch the rest of Model has, with Sculpt lit.
// Picking Object is what Finish used to do. See EffigyWindow.ObjectMode.cs.
UpdateModelModeSwitch( "Sculpt" );
ShowModelChrome( "Sculpt" );
_dialog?.Close();
var session = new SculptSession( feature.Sculpt );
session.Radius = session.SuggestedRadius;
_viewport.BeginSculpt( session );
_sculptBar.Bind( session );
_viewport.RefreshSculptPreview();
UpdateSculptChecks();
SetPrompt( "Sculpt: drag on the model. X mirrors, M masks, the level buttons add detail." );
}
private void FinishSculpt()
{
if ( _viewport is null || !_viewport.IsSculpting )
return;
_viewport.EndSculpt();
_sculptBar.Bind( null );
var feature = _sculptFeature;
_sculptFeature = null;
// Back to the Sculpt workspace's landing bar, not CAD — Subdivide and Sculpt are the tools
// you reach for next. Leaving the workspace entirely is the CAD pill's job.
ShowSculptHome();
// THE ONLY FULL REBUILD IN SCULPT MODE, and that is the point. Every stroke marks the model
// changed and refreshes the viewport straight from the session, because rebuilding the whole
// feature tree per stroke would be both slow and wrong to look at - the tree builds the TOP
// level while the viewport may be showing a coarser one. The tree catches up here.
if ( feature is not null )
_studio.MarkDirty( feature );
RestoreRollbackAfterEdit();
RebuildStudio();
}
private void SetSculptBrush( BrushKind kind )
{
if ( _viewport?.SculptSession is not { } session )
return;
// Picking a brush leaves masking, or the click would arm a tool that then does not run.
session.Masking = false;
session.Brush = kind;
UpdateSculptChecks();
_sculptBar?.Refresh();
}
// --- paint mode ---------------------------------------------------------------------------
/// <summary>
/// The Paint workspace's landing bar — the tools you reach for BEFORE a paint is open.
///
/// UV Project unwraps the mesh so colour has somewhere to live, and Paint is the brush itself.
/// The brush in <see cref="BuildPaintStages"/> only appears once a paint feature is entered.
/// </summary>
private List<EffigyStage> BuildPaintHomeStages()
{
var stage = new EffigyStage { Name = "Paint" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.UVProject,
Label = "UV Project",
Tip = "Add a UV Project — re-project UVs (box or planar)",
Clicked = () => AddFeature( NewFeature( ToolKind.UVProject, -1 ) ),
} );
_paintHomeTool = new EffigyStageTool
{
Icon = EffigyIcon.Paint,
Label = "Paint",
Tip = "Add a Paint — brush colour straight onto the model",
Clicked = AddPaint,
};
stage.Add( _paintHomeTool );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.FaceMaterial,
Label = "Material",
Tip = "Material brush — drag to lay the Materials browser's selection onto faces",
Clicked = EnterMaterialBrush,
} );
return new List<EffigyStage> { stage };
}
/// <summary>
/// The paint stage set. One stage with one always-armed brush, for now: the colour, radius and
/// strength live on the floating paint bar, and an eraser or a fill is a later stroke tool, not
/// a first-slice button that would have nothing behind it.
/// </summary>
private List<EffigyStage> BuildPaintStages()
{
var brush = new EffigyStage { Name = "Brush" };
brush.Add( new EffigyStageTool
{
Icon = EffigyIcon.PaintBrush,
Label = "Brush",
Tip = "Paint — drag on the model to lay colour down",
Checkable = true,
Checked = true,
} );
return new List<EffigyStage> { brush };
}
/// <summary>
/// Add a Paint feature and step straight into painting it. Paint is not edited through a
/// parameter dialog — its one input is "which body", which the viewport selection already names
/// — so unlike every other tool there is no dialog to open on the way in.
/// </summary>
private void AddPaint()
{
if ( _viewport is null )
return;
LeaveCurrentWorkspace();
var feature = new PaintFeature();
RecordUndo();
ApplyIdleGeometrySelection( feature );
InsertAtRollback( feature );
RebuildStudio();
EnterPaint( feature );
}
/// <summary>
/// Open a Paint feature for brushing.
///
/// Rolls the model back to just after this feature, the same guarantee EnterSculpt makes, then
/// builds the session on the body the feature targets. A body whose UVs cannot carry paint is a
/// refusal at the door rather than an enter-and-discover-later: the paint would scramble, and the
/// fix (a UV Project in Unwrap mode above this feature) is a tree edit, not a brush setting.
/// </summary>
private void EnterPaint( PaintFeature feature )
{
if ( feature is null || _viewport is null )
return;
// Already painting this feature: a second Edit must not rebuild the session and drop a stroke.
if ( BarMode == EffigyBarMode.Paint
&& ReferenceEquals( _paintFeature, feature )
&& _viewport.IsPainting )
return;
LeaveCurrentWorkspace();
// The body does not exist until the features above this have run, and rolling to just after
// this one is also what puts the thing being painted on screen.
var index = _studio.Features.IndexOf( feature );
if ( index >= 0 && _studio.RollbackIndex != index + 1 )
{
_rollbackBeforeEdit ??= _studio.RollbackIndex;
_studio.RollbackIndex = index + 1;
RebuildStudio();
}
// Paint paints ONE body at a time — one stroke list, one atlas. Anything else is a door
// refusal.
var targets = _studio.Bodies.Where( b => feature.Bodies.Matches( b ) ).ToList();
if ( targets.Count != 1 )
{
SetPrompt( targets.Count == 0
? "Paint needs a body to paint on — add a primitive or extrude a sketch first."
: "Paint paints one body at a time — pick one in the Parts list, then press Paint again." );
return;
}
// AUTO-UNWRAP, NOT A GATE. Texel paint needs UVs where every face owns its own texels, but the
// user must not have to know that. A bare box ships six overlapping islands, so entering paint
// on one inserts a UV Project (Unwrap) above this feature and carries on. It is an ordinary
// feature in the history, so it appears in the tree, participates in rollback, and is undone
// by one Ctrl+Z — which is why the prompt says so rather than changing the tree silently.
var unwrapped = false;
if ( !NormalBake.Measure( targets[0].Mesh ).CanBake )
{
RecordUndo();
var uv = new UVProjectFeature();
uv.Mode.Index = Array.IndexOf( uv.Mode.Options, "Unwrap" );
_studio.Insert( index, uv );
RebuildStudio();
targets = _studio.Bodies.Where( b => feature.Bodies.Matches( b ) ).ToList();
unwrapped = targets.Count == 1;
}
_paintFeature = feature;
_lastPaintFeature = feature;
BarMode = EffigyBarMode.Paint;
_stageBar.Mode = "PAINT";
_stageBar.SetFinish( "Finish", FinishPaint );
_stageBar.SetStages( _paintStages );
_dialog?.Close();
// The session replays whatever strokes already exist, so re-entering a painted feature shows
// the paint as it was left, not a blank surface.
var session = new PaintSession( targets[0].Mesh, feature.Resolution.Value, feature.Strokes );
session.Radius = session.SuggestedRadius;
_viewport.BeginPaint( session );
_paintBar.Bind( session, feature );
_viewport.RefreshPaintPreview();
// Say what the auto-unwrap did, and what it costs. An unwrap is 0..1 and does not repeat, so a
// part whose material was sized to tile will sit differently — said rather than silently done.
if ( unwrapped )
{
var tiling = _studio.MaterialScales.Count > 0
? " Its materials were sized to tile, and an unwrap does not tile, so they will sit differently."
: "";
SetPrompt( $"Paint: drag on the model, hold Ctrl to erase. Added a UV Project so the paint "
+ $"has its own texels (one Ctrl+Z removes it).{tiling}" );
}
else
{
SetPrompt( "Paint: drag on the model, hold Ctrl to erase. Colour, size and strength are "
+ "on the bar below." );
}
}
/// <summary>
/// Step into the material brush.
///
/// NO FEATURE IS CREATED, unlike Paint. A material dab is the same history edit dropping a
/// material makes — FaceMaterialEdit assignments onto a slot — so the brush is a way of making
/// those in bulk rather than a new kind of thing in the tree. Which is also why there is nothing
/// to re-enter: leaving and coming back picks up wherever the document got to.
/// </summary>
private void EnterMaterialBrush()
{
if ( _viewport is null || _studio is null )
return;
LeaveCurrentWorkspace();
var bodies = _studio.Bodies.Where( b => b.Visible && b.Mesh is not null ).ToList();
if ( bodies.Count == 0 )
{
SetPrompt( "The material brush needs a body to paint on — add a primitive or extrude a sketch first." );
return;
}
BarMode = EffigyBarMode.Paint;
_stageBar.Mode = "MATERIAL";
_stageBar.SetFinish( "Finish", FinishMaterialBrush );
_stageBar.SetStages( BuildMaterialBrushStages() );
_dialog?.Close();
var session = new MaterialBrushSession( bodies.Select( b => (b.Id, b.Mesh) ) );
session.Radius = session.SuggestedRadius;
_viewport.BeginMaterialBrush( session );
_materialBrushBar.Bind( session );
_materialBrushBar.SetMaterial( _materialsPanel?.SelectedMaterial );
_viewport.MaterialBrushLoaded = !string.IsNullOrWhiteSpace( _materialsPanel?.SelectedMaterial );
SetPrompt( string.IsNullOrWhiteSpace( _materialsPanel?.SelectedMaterial )
? "Material brush: pick a material in the Materials browser, then drag on the model."
: "Material brush: drag on the model. The material comes from the Materials browser." );
}
private void FinishMaterialBrush()
{
if ( _viewport is null || !_viewport.IsMaterialBrushing )
return;
LeaveMaterialBrush();
ShowPaintHome();
}
/// <summary>
/// Disarm without deciding where to go next.
///
/// Split from <see cref="FinishMaterialBrush"/> because the two callers want different things:
/// pressing Finish means "back to the Paint home", while LeaveCurrentWorkspace is on its way
/// somewhere else entirely and showing the paint stages on the way would overwrite the bar the
/// workspace it is entering is about to set.
/// </summary>
/// <summary>The browser's selection moved. Only the bar cares — the dab reads the panel directly,
/// so there is no copy of the material here to keep in step.</summary>
private void OnBrushMaterialChanged()
{
if ( _viewport is not { IsMaterialBrushing: true } )
return;
_materialBrushBar?.SetMaterial( _materialsPanel?.SelectedMaterial );
_viewport.MaterialBrushLoaded = !string.IsNullOrWhiteSpace( _materialsPanel?.SelectedMaterial );
}
/// <summary>
/// A stroke began: record undo, and say so if the brush has nothing loaded.
///
/// The prompt is here rather than in the dab because a dab fires every frame - the same reason
/// undo is recorded here - so warning there would rewrite the status bar continuously while
/// the button is held. Once per gesture is what a person can read.
/// </summary>
private void OnMaterialStrokeStarted()
{
if ( string.IsNullOrWhiteSpace( _materialsPanel?.SelectedMaterial ) )
{
SetPrompt( "No material chosen — pick one in the Materials browser and the ring turns "
+ "amber. Nothing was painted." );
return;
}
RecordUndo();
}
private void LeaveMaterialBrush()
{
_viewport?.EndMaterialBrush();
_materialBrushBar?.Bind( null );
}
/// <summary>The material brush's stage set: one always-armed brush, the same shape the colour
/// brush's own stage takes.</summary>
private List<EffigyStage> BuildMaterialBrushStages()
{
var brush = new EffigyStage { Name = "Brush" };
brush.Add( new EffigyStageTool
{
Icon = EffigyIcon.FaceMaterial,
Label = "Material",
Tip = "Material brush — drag on the model to lay the selected material down",
Checkable = true,
Checked = true,
} );
return new List<EffigyStage> { brush };
}
/// <summary>
/// One dab: put the browser's material on every face the ring covered.
///
/// THE BODY IS RE-RESOLVED BY ID rather than captured, because RebuildStudio below remakes every
/// body — the object the brush started with is stale by the second dab. The MESH the session
/// raycasts is deliberately not re-pointed: a material assignment changes which slot a face
/// carries and never its geometry, so the BVH built at the door stays true for the whole stroke,
/// and rebuilding it per dab would cost the drag its frame rate for nothing.
///
/// Faces already on the slot report no change, so a held brush that is not moving does not put
/// an undo step on the stack every frame — see MaterialDrop.Brush.
/// </summary>
private void OnMaterialDabbed( IReadOnlyList<int> faces )
{
var material = _materialsPanel?.SelectedMaterial;
if ( string.IsNullOrWhiteSpace( material ) || faces is null || faces.Count == 0 )
return;
var bodyId = _viewport?.MaterialBrush?.HoverBodyId;
var body = _studio?.Bodies.FirstOrDefault( b => b.Id == bodyId )
?? _studio?.Bodies.FirstOrDefault();
if ( body is null )
return;
var fresh = MaterialDrop.SlotCarrying( _studio, material ) < 0;
if ( MaterialDrop.Brush( _studio, body, faces, material, out var slot, out var released ) <= 0 )
return;
// Same rule the drop follows: only a slot this gesture INVENTED gets a guessed world size,
// so re-brushing a material never rewrites a number somebody typed.
if ( fresh && slot > 0 )
MaterialScale.SetScale( _studio, slot, EffigyMaterialSize.For( material ) );
RebuildStudio();
SetPrompt( released.Count > 0
? $"{MaterialFileName( material )} → slot {slot}; slot{(released.Count == 1 ? "" : "s")} "
+ $"{string.Join( ", ", released )} freed. Ctrl+Z puts it back."
: $"{MaterialFileName( material )} → slot {slot}. Ctrl+Z puts it back." );
}
private void FinishPaint()
{
if ( _viewport is null || !_viewport.IsPainting )
return;
_viewport.EndPaint();
_paintBar.Bind( null );
var feature = _paintFeature;
_paintFeature = null;
// Back to the Paint workspace's landing bar, not CAD — UV Project and Paint are the tools
// you reach for next. Leaving the workspace entirely is the CAD pill's job.
ShowPaintHome();
// The one full rebuild in paint mode: strokes were appended per stroke, so this replays them
// onto the feature's cached canvas and catches the tree up.
if ( feature is not null )
_studio.MarkDirty( feature );
RestoreRollbackAfterEdit();
RebuildStudio();
}
/// <summary>A stroke landed. The document is now unsaved and the paint bar's readouts may have
/// moved, but the feature tree deliberately does NOT rebuild — see FinishPaint.</summary>
private void OnWeightStrokeFinished()
{
if ( _viewport?.WeightSession is not null )
_studio.WeightPaint = _viewport.WeightSession.Layer;
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
_viewport?.RefreshWeightRamp();
_weightBar?.Refresh( _rigPanel?.SelectedBoneName );
}
private void OnPaintStrokeFinished( PaintStroke stroke )
{
// An undo point per stroke, taken BEFORE the stroke joins the list: the snapshot captures the
// pre-stroke list, so Ctrl+Z pops back one stroke the way it pops one sketch line. The dab
// colours never enter the document — they are the session's own array — so the feature's list
// is the whole of what a paint undo has to restore.
RecordUndo();
if ( _paintFeature is not null )
_paintFeature.AddStroke( stroke );
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
_paintBar?.Refresh();
}
private void OnPaintSettingsChanged() => _paintBar?.Refresh();
private void OnPaintBarChanged() => _viewport?.Update();
// --- grease pencil -------------------------------------------------------------------------
private EffigyStageTool _noteTool, _noteEraseTool;
/// <summary>
/// Arm or put down the pen.
///
/// NOT AN EffigyBarMode. Sketch and sculpt each take the whole bar over because they replace
/// what every button on it means; the pen adds one thing you can do with the pointer and takes
/// nothing away, so it stays on the Part bar as an armed tool. That also means notes can be
/// scribbled between two feature edits without leaving and re-entering a mode, which is when
/// people actually write them.
/// </summary>
private void ToggleNotePen()
{
if ( _viewport is null )
return;
if ( _viewport.IsNoting )
{
_viewport.EndNotes();
UpdateNoteChecks();
return;
}
// Sketching owns the click while it is open, so the two cannot both be armed. Sculpting
// cannot be running here at all - it holds a different bar.
if ( _viewport.IsSketching )
FinishSketch();
var session = new NoteSession( _studio.Notes )
{
Color = _noteTool?.Current ?? 0,
Pivot = _studio.Origin,
};
session.SetBodies( _studio.Bodies );
session.ScaleTo( NotePartSize() );
// Point the viewport at the studio's list HERE rather than waiting for the next rebuild.
// RefreshNotes is the other half of this and runs from RebuildStudio, which is fine for a
// document swap and useless for the case that actually matters: arm the pen on a studio
// nobody has touched since it opened, draw, and let go. Nothing rebuilds on a scribble - a
// note is not in the history - so without this line the note the user just drew would not
// be painted until they happened to edit a feature.
_viewport.Notes = _studio.Notes;
_viewport.PartSize = NotePartSize();
_viewport.BeginNotes( session );
UpdateNoteChecks();
}
private void ToggleNoteEraser()
{
if ( _viewport is null )
return;
// Reaching for the eraser with the pen down means you want to erase, not nothing. Arming
// the pen first is the reading that leaves the button doing something.
if ( !_viewport.IsNoting )
ToggleNotePen();
if ( _viewport.IsNoting )
_viewport.NoteErasing = !_viewport.NoteErasing;
UpdateNoteChecks();
}
private void SetNoteColor( int index )
{
// Picking a colour is also how you reach for the pen, the same way picking a primitive
// shape makes one rather than only remembering the choice.
if ( _viewport is not null && !_viewport.IsNoting )
ToggleNotePen();
if ( _viewport?.NoteSession is { } session )
{
session.Color = index;
// A colour is a decision about the next stroke, never about the one that is running.
_viewport.NoteErasing = false;
}
UpdateNoteChecks();
}
/// <summary>Put the bar's ticks back in step with the viewport, which the E and H shortcuts can
/// change from under it. Same job UpdateSculptChecks does.</summary>
private void UpdateNoteChecks()
{
var noting = _viewport?.IsNoting == true;
if ( _noteTool is not null )
_noteTool.Checked = noting;
if ( _noteEraseTool is not null )
_noteEraseTool.Checked = noting && _viewport.NoteErasing;
if ( _noteTool is not null && _viewport?.NoteSession is { } session )
{
_noteTool.Current = session.Color;
_noteTool.IconColor = SwatchColor( session.Color );
}
_stageBar?.Refresh();
_viewport?.Update();
}
/// <summary>
/// A stroke landed or a note was erased.
///
/// Dirties the document and NOTHING ELSE - no rebuild, no feature tree refresh. A note is not
/// in the history, so there is nothing to re-evaluate, and rebuilding on every scribble would
/// re-run the whole model to change a line the model has never heard of.
/// </summary>
private void OnNoteEdited()
{
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
_viewport?.Update();
}
/// <summary>
/// Ask for the words that go with a note, and hang them on it.
///
/// Through the session rather than by assigning Note.Text, so the caption lands on the same
/// undo stack as the stroke it belongs to - a typo you cannot take back is worse than no
/// caption.
/// </summary>
private void PromptNoteText( Note note )
{
if ( note is null || _viewport?.NoteSession is not { } session )
return;
// The same one-field popup every tree in this tool renames with - see
// EffigyRigPanel.BeginRename. A note's caption is a rename in everything but name, and
// inventing a second way to type one short string would be a second thing to get wrong.
var menu = new Menu( _viewport );
var edit = new LineEdit( note.Text ?? "", menu ) { FixedWidth = 260, PlaceholderText = "What does this one say?" };
edit.ReturnPressed += () =>
{
var text = edit.Text?.Trim();
menu.Close();
// Trimmed to null rather than kept as an empty string: a caption of "" would still draw
// its leader line up to a label with nothing in it, which reads as a bug rather than as
// a note somebody cleared.
if ( session.SetText( note, string.IsNullOrWhiteSpace( text ) ? null : text ) )
OnNoteEdited();
};
menu.AddWidget( edit );
menu.OpenAtCursor();
edit.Focus();
edit.SelectAll();
}
/// <summary>
/// Point the viewport and the live session at the studio's current notes and bodies.
///
/// Called from RebuildStudio, which is the one place every document change funnels through -
/// an edit, an undo, a New, an Open. The session holds the studio's list BY REFERENCE, so most
/// rebuilds need nothing done here; the case that does is a document swap, where the old
/// session is still writing into the list belonging to a studio nobody can see any more.
/// </summary>
private void RefreshNotes()
{
if ( _viewport is null )
return;
_viewport.Notes = _studio.Notes;
if ( _viewport.NoteSession is not { } session )
return;
if ( !ReferenceEquals( session.Notes, _studio.Notes ) )
{
// A new document. Re-arm rather than carrying the old session across, which would also
// carry an undo stack that can paste the previous model's scribbles into this one.
_viewport.EndNotes();
ToggleNotePen();
return;
}
// The bodies a stroke lands on have just been rebuilt. Without this a note drawn after an
// extrude sinks to where the surface used to be.
session.SetBodies( _studio.Bodies );
session.Pivot = _studio.Origin;
// And the part may be a different size than it was when the pen was armed - an extrude on an
// empty studio takes it from nothing to something - so the distances that scale with it are
// re-derived rather than left at what the first guess said.
var size = NotePartSize();
session.ScaleTo( size );
_viewport.PartSize = size;
}
/// <summary>
/// How big the part is, for everything about a note that has to scale with it.
///
/// ToMesh rather than ToVisibleMesh: hiding a body is about what you want to look at, and a note
/// suddenly changing how finely it samples because a body was hidden would be a surprise with no
/// cause the user could see. Zero on an empty studio, which ScaleTo reads as "assume the default
/// primitive".
/// </summary>
private float NotePartSize()
{
if ( _studio is null || _studio.Bodies.Count == 0 )
return 1f;
var diagonal = _studio.ToMesh().BoundsDiagonal;
return diagonal > 1e-6f ? diagonal : 1f;
}
private void ToggleSculptMasking()
{
if ( _viewport?.SculptSession is not { } session )
return;
session.Masking = !session.Masking;
UpdateSculptChecks();
_sculptBar?.Refresh();
}
private void ToggleSculptSymmetry()
{
if ( _viewport?.SculptSession is not { } session )
return;
// The strip button toggles X — the default plane — on and off. Y and Z are available to a
// future axis picker now that the session carries a MirrorAxis rather than a bool.
session.Mirror = session.Mirror == MirrorAxis.None ? MirrorAxis.X : MirrorAxis.None;
UpdateSculptChecks();
_sculptBar?.Refresh();
}
/// <summary>Put the strip's ticks back in step with the session, which the X and M shortcuts can
/// change from under it.</summary>
private void UpdateSculptChecks()
{
var session = _viewport?.SculptSession;
foreach ( var (tool, kind) in _brushTools )
tool.Checked = session is not null && !session.Masking && session.Brush == kind;
if ( _maskTool is not null )
_maskTool.Checked = session?.Masking ?? false;
if ( _symmetryTool is not null )
_symmetryTool.Checked = session is not null && session.Mirror != MirrorAxis.None;
_stageBar?.Refresh();
RefreshModelChrome();
}
/// <summary>
/// Move the working level, adding one when asked for finer than exists.
///
/// ADDING RATHER THAN REFUSING at the top is the point of the button: somebody who has reached
/// the finest level and presses "finer" wants the next one, not a message saying there is not
/// one. Going below zero is different - level 0 is the cage itself and there is genuinely
/// nothing under it.
/// </summary>
private void StepSculptLevel( int delta )
{
if ( _viewport?.SculptSession is not { } session )
return;
var sculpt = session.Sculpt;
var target = session.Level + delta;
if ( target < 0 )
{
SetPrompt( "Level 0 is the cage itself — there is nothing coarser than it." );
return;
}
// Stepping below the top REMOVES the finest level when it is empty of detail, rather than
// leaving a level nobody is using on the model for ever. Only when it is empty: throwing away
// somebody's sculpt because they wanted a coarser view would be unforgivable, and the undo
// stack is what makes even the empty case safe.
if ( delta < 0 && session.Level == sculpt.TopLevel && !sculpt.HasDetail( sculpt.TopLevel ) )
{
session.RemoveTopLevel();
SetPrompt( $"Dropped the empty level {sculpt.TopLevel + 1}. Ctrl+Z puts it back." );
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
NoteSculptEdited();
return;
}
if ( target > sculpt.TopLevel )
{
var (vertices, faces) = sculpt.Cost( target );
RecordUndo();
sculpt.AddLevel();
SetPrompt( $"Level {target}: {vertices:N0} vertices, {faces:N0} faces." );
}
session.Level = target;
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
NoteSculptEdited();
}
/// <summary>
/// Bake the sculpt down onto the cage's UVs and write it out as a PNG.
///
/// The UVs are checked BEFORE anything is written. A bake over overlapping UVs does not fail: it
/// produces a plausible map that is wrong wherever two faces shared a texel, and box projection -
/// this tool's own default - overlaps by construction. Naming that is worth more than a file.
/// </summary>
/// <summary>
/// The two normal-map conventions, and the size.
///
/// THESE EXIST AS CONTROLS BECAUSE NOBODY KNOWS THE ANSWER YET. Which way s&box wants the green
/// channel, and which end of the image v = 0 belongs at, are the two things the suite explicitly
/// cannot judge and the sitting is meant to settle. A bake button that could only write one of
/// the four combinations would make that sitting impossible to finish - you would find out the
/// map was wrong and have no way to write the right one.
///
/// They live in Edit > Settings now, under "Normal map bake", rather than as three verbs on the
/// Edit menu. A toggle whose whole state was a one-line prompt that had already scrolled away is
/// a control you cannot read; the settings window shows the switch position and the size at once,
/// and remembers them between sessions the way every other setting there does.
///
/// Defaults are OpenGL-style green and no vertical flip, which is what the sample in
/// Effigy.Tests/out was written with, so the two can be compared directly.
/// </summary>
private bool _bakeFlipGreen;
private bool _bakeFlipV;
private int _bakeSize = 1024;
private void BakeSculpt()
{
if ( _viewport?.SculptSession is not { } session )
return;
var sculpt = session.Sculpt;
var cage = sculpt.Cage;
var coverage = NormalBake.Measure( cage );
if ( !coverage.CanBake )
{
SetPrompt( $"Cannot bake: {coverage.Problem}" );
return;
}
var fd = new FileDialog( null )
{
Title = "Bake normal map to...",
DefaultSuffix = ".png",
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SelectFile( $"{_sculptFeature?.Name ?? "sculpt"}_normal.png" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( "PNG image (*.png)" );
if ( !fd.Execute() )
return;
try
{
var options = new BakeOptions { FlipGreen = _bakeFlipGreen };
var map = NormalBake.Bake( cage, sculpt.Evaluate( sculpt.TopLevel ), _bakeSize, _bakeSize, options );
PngWriter.WriteFile( fd.SelectedFile, map, _bakeFlipV );
// The convention is named in the message on purpose. Two files that differ only in the
// sign of one channel are indistinguishable once they are on disk, and the whole point of
// the sitting is to work out which one is right.
var convention = $"{(_bakeFlipGreen ? "DirectX" : "OpenGL")} green, v = 0 at the "
+ $"{(_bakeFlipV ? "bottom" : "top")}";
SetPrompt( $"Baked {map.Width}×{map.Height} to {fd.SelectedFile} — {map.FilledCount:N0} texels hit, "
+ convention + "." );
Log.Info( $"[Effigy] baked normal map to {fd.SelectedFile} ({convention})" );
}
catch ( Exception e )
{
// Writing a file is the one place failing quietly is unforgivable, same as Save.
Log.Error( $"[Effigy] could not bake to {fd.SelectedFile}: {e.Message}" );
SetPrompt( $"Bake failed: {e.Message}" );
}
}
/// <summary>
/// Step the sculpt's own undo stack and put the viewport back in step with it.
///
/// A stroke is one entry, which is what a user means by undo - see SculptSession.
/// </summary>
private void StepSculptHistory( bool redo )
{
if ( _viewport?.SculptSession is not { } session )
return;
if ( !(redo ? session.Redo() : session.Undo()) )
{
SetPrompt( redo ? "Nothing to redo in this sculpt." : "Nothing to undo in this sculpt." );
return;
}
_viewport.RefreshSculptPreview();
NoteSculptEdited();
}
/// <summary>
/// The mask actions that are not a brush stroke: invert, clear, erase, and hide what is held.
///
/// IN AN EDIT > SCULPT MASK SUBMENU RATHER THAN ON THE STRIP, deliberately. The strip is
/// hand-painted glyphs and five more of them is real design work for actions nobody reaches for
/// mid-stroke. The menu takes named Material icons, which this window already uses everywhere. A
/// submenu rather than five flat entries because the whole group only does anything while a
/// Sculpt feature is open, and a menu most sessions never need should not be the first thing Edit
/// shows.
///
/// They are added unconditionally and refuse when there is no sculpt open, rather than the menu
/// being rebuilt per state - a menu that changes shape depending on the mode is a menu whose
/// items move under the cursor.
/// </summary>
private bool SculptingOrSaySo( out SculptSession session )
{
session = _viewport?.SculptSession;
if ( session is null )
SetPrompt( "That is a sculpting action — open a Sculpt feature first." );
return session is not null;
}
private void InvertSculptMask()
{
if ( !SculptingOrSaySo( out var session ) )
return;
session.InvertMask();
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( $"Mask inverted — {session.MaskFor( session.Level ).ProtectedFraction:P0} held." );
}
private void ProtectAllSculpt()
{
if ( !SculptingOrSaySo( out var session ) )
return;
// The other end of Clear, and the start of "mask everything but this": protect the lot, then
// invert, then paint free the part you actually want to work on.
session.ProtectAll();
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( "Everything is masked - invert, or paint to release the part you want to work on." );
}
/// <summary>Face sets by topology: everything but the connected piece the cursor was last over
/// is held, so an ear or a hand can be sculpted without the rest catching the brush.</summary>
private void MaskAllButLinked()
{
if ( !SculptingOrSaySo( out var session ) )
return;
if ( _viewport?.SculptCursorHit is not { } hit )
{
SetPrompt( "Point at the piece you want to keep, then choose this again — it masks everything but the part under the cursor." );
return;
}
session.ProtectAllButLinked( hit.Point, hit.FaceIndex );
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( $"Everything but that piece is held — {session.MaskFor( session.Level ).ProtectedFraction:P0} masked. Invert to swap." );
}
private void GrowSculptMask( bool shrink )
{
if ( !SculptingOrSaySo( out var session ) )
return;
session.GrowMask( shrink );
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( $"Mask {(shrink ? "shrunk" : "grown")} one ring — {session.MaskFor( session.Level ).ProtectedFraction:P0} held." );
}
private void ClearSculptMask()
{
if ( !SculptingOrSaySo( out var session ) )
return;
session.ClearMask();
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( "Mask cleared — nothing is held." );
}
private void ToggleSculptMaskErase()
{
if ( !SculptingOrSaySo( out var session ) )
return;
session.Erasing = !session.Erasing;
session.Masking = true;
UpdateSculptChecks();
_sculptBar?.Refresh();
SetPrompt( session.Erasing ? "Mask brush: erasing." : "Mask brush: painting." );
}
private void ToggleHideMasked()
{
if ( !SculptingOrSaySo( out var session ) )
return;
// A VIEW, like the level, and it reaches the model exactly as far as that one does: nowhere.
// Hiding half a head to reach inside it must not export a head with half of it missing.
session.HideMasked = !session.HideMasked;
_viewport.RefreshSculptPreview();
_sculptBar?.Refresh();
SetPrompt( session.HideMasked
? "Masked geometry hidden — the model still builds whole."
: "Showing all geometry." );
}
/// <summary>The radius or strength box was typed in. The viewport only needs to know so the
/// brush ring is drawn at the new size.</summary>
private void OnSculptBarChanged() => _viewport?.Update();
/// <summary>Put the strip's ticks and the bar's readout back in step with the session after a
/// sculpt shortcut changed a brush setting — the radius keys, X and M.</summary>
private void OnSculptSettingsChanged()
{
UpdateSculptChecks();
_sculptBar?.Refresh();
}
/// <summary>A stroke landed. The document is now unsaved and the bar's readouts have moved, but
/// the feature tree deliberately does NOT rebuild - see FinishSculpt.</summary>
/// <summary>
/// The rig changed, so the document is unsaved.
///
/// The same two lines every other unsaved edit uses - there is no shared helper, as the pivot's
/// own comment already notes. The viewport repaint is here too because a rig edit goes through
/// nothing else that would ask for one: RebuildStudio is deliberately not called (no geometry
/// moved) and that is the usual route to a redraw.
/// </summary>
private void NoteRigEdited()
{
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
_viewport?.Update();
_partsPanel?.Refresh();
RefreshTutorial();
}
private void NoteSculptEdited()
{
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
_sculptBar?.Refresh();
}
// --- sketch mode -------------------------------------------------------------------------
/// <summary>
/// Enter sketch mode on a Sketch feature: show the sketch toolbar, point the camera straight
/// at the plane, and start on the Line tool.
///
/// The rebuild is needed for SketchFeature.Plane — the Sketch object's actual plane is only
/// assigned when the feature executes — but the strip swap is UI and must happen first so the
/// toolbar change is instant. BeginSketch uses the rebuilt plane, so it comes after.
/// </summary>
private void EnterSketch( SketchFeature feature )
{
if ( feature is null || _viewport is null )
return;
// Already in this sketch: a second Edit (tree click, dialog Open, the Sketch tool) must
// not reset the tool or drop a half-drawn curve.
if ( BarMode == EffigyBarMode.Sketch
&& ReferenceEquals( _viewport.ActiveSketch, feature.Sketch ) )
return;
if ( _viewport.IsSculpting )
FinishSculpt();
// The bar's stages become the SKETCH stages, and the mode goes on the bar next to the
// control that leaves it. Do this BEFORE the rebuild so the swap is instant instead of
// waiting for the (potentially slow) PartStudio rebuild to finish.
//
// The feature's own name is the mode label rather than a bare "SKETCH": a document with
// four sketches in it makes "which one am I in" a real question, and the feature tree is
// the only other place that answers it.
BarMode = EffigyBarMode.Sketch;
_stageBar.Mode = feature?.Name?.ToUpperInvariant() ?? "SKETCH";
_stageBar.SetFinish( "Finish", FinishSketch );
_stageBar.SetStages( _sketchStages );
// Sketching and the bone tool both drive left-clicks in the viewport; only one may own
// them. The bone tool refuses to arm on top of an open sketch (see SetBoneToolActive), so
// the only direction this needs covering is the other one - entering a sketch while the
// bone tool happened to be armed.
_rigPanel?.CancelBoneTool();
// THE SKETCH BEING EDITED HAS TO BE ONE THAT RUNS. Sketch.Plane is only assigned when
// SketchFeature.Execute does it, so a sketch sitting at or below the bar is still on the
// default XY plane however carefully its face was picked - and everything downstream of the
// plane is then wrong in a way that looks like a drawing bug rather than a rollback one.
//
// EnterSculpt has made exactly this guarantee for sculpts since it was written, for the
// same reason: the cage does not exist until the features above it have run. This is that
// line, for the other mode that edits something a feature has to build first.
var index = _studio.Features.IndexOf( feature );
if ( index >= 0 && _studio.EffectiveCount <= index )
{
_rollbackBeforeEdit ??= _studio.RollbackIndex;
_studio.RollbackIndex = index + 1;
}
RebuildStudio();
_viewport.BeginSketch( feature.Sketch );
// AFTER BeginSketch, which clears whatever the last sketch was sitting on. The plane the
// outline is expressed in is the one the rebuild just assigned, so this cannot move above
// RebuildStudio either.
RefreshSketchReference( feature );
_viewport.ConstructionMode = false;
if ( _constructionTool is not null )
_constructionTool.Checked = false;
UpdateSketchToolChecks( _viewport.SketchTool );
RevealSketchTool( _viewport.SketchTool );
}
private void FinishSketch()
{
if ( !_viewport.IsSketching )
return;
_viewport.EndSketch();
// The part stages come back with their locks recomputed, so the stages this sketch just
// unlocked are already open rather than opening a beat later.
ShowPartStages( force: true );
UpdateSketchToolChecks( SketchToolKind.Select );
SetPrompt( "" );
RebuildStudio();
}
/// <summary>A curve was drawn. Rebuilding here is what makes an extrude above the sketch update
/// as you draw its profile.</summary>
private void OnSketchEdited()
{
// The curve just drawn lives inside a SketchFeature's Sketch object, and PartStudio caches
// a CLONE of that sketch after the feature runs (Snapshot.Of). Without marking it dirty the
// clone is what every downstream feature keeps reading, so an extrude above the sketch never
// sees the profile just closed.
if ( ActiveSketchFeature() is { } sketchFeature )
_studio.MarkDirty( sketchFeature );
RebuildStudio();
_dialog?.Rebuild();
}
/// <summary>
/// Give the sketcher the outline of the face the open sketch sits on, so it can be seen and
/// snapped to. Null for a sketch on Top/Front/Right, which has nothing underneath it.
///
/// REBUILT FROM THE MODEL EVERY TIME rather than cached on the feature. The face moves - that
/// is the whole point of attaching a sketch to one - and an outline held over from before the
/// move is not a stale drawing, it is a set of snap targets sitting where the face used to be.
/// Wrong in the one way that looks exactly like right.
/// </summary>
private void RefreshSketchReference( SketchFeature feature )
{
if ( _viewport is null )
return;
var reference = feature?.Face is { } face
? SketchReference.FromFace( _studio.Bodies, face, feature.Sketch.Plane )
: null;
// The other half of the BeginSketch probe line. That one says where the sketch plane ended
// up; this says whether the face it was supposed to come from was found at all, and where
// that face is - so "grid in the wrong place" resolves to either "the reference is gone" or
// "the reference is fine and the plane still went elsewhere" in one repro.
if ( EffigyViewport.ProbeSketch && feature?.Face is { } probed )
{
var found = FacePlane.TryResolveFace( _studio.Bodies, probed, out var body, out var index );
Log.Info( $"[effigy-probe] SketchReference body={probed.BodyId} resolved={found} "
+ $"to={body?.Id}#{index} refPoint={probed.Point} refNormal={probed.Normal} "
+ $"faceCentroid={(found ? body.Mesh.FaceCentroid( body.Mesh.Faces[index] ).ToString() : "-")} "
+ $"outlinePoints={reference?.Points.Count ?? 0} outlineEdges={reference?.Edges.Count ?? 0} "
+ $"error={feature.Diagnostic?.Problem}" );
// WHOSE SketchFeature THIS IS. The plane above came back as the global XY, which is
// what Sketch.Plane holds until SketchFeature.Execute overwrites it - so either the
// feature never executed, or the object being read is not the one that did. Those want
// opposite fixes, and only the tree can tell them apart.
var inTree = _studio.Features.IndexOf( feature );
var live = inTree >= 0 ? _studio.Features[inTree] as SketchFeature : null;
Log.Info( $"[effigy-probe] feature index={inTree} of {_studio.Features.Count} "
+ $"rollback={_studio.RollbackIndex} effective={_studio.EffectiveCount} "
+ $"suppressed={feature.Suppressed} sameObject={ReferenceEquals( live, feature )} "
+ $"sameSketch={ReferenceEquals( live?.Sketch, feature.Sketch )} "
+ $"livePlaneOrigin={live?.Sketch?.Plane?.Origin.ToString() ?? "-"} "
+ $"faceSet={feature.Face.HasValue}" );
// WHERE THE OUTLINE CAME FROM, when the counts above are not what the geometry says
// they should be. The same body and face fed to the same kernel outside the editor
// gives four points and four edges; in here it gives two, so the disagreement is in
// the mesh rather than in the rule, and this prints the mesh.
if ( found )
{
var mesh = body.Mesh;
var faceCorners = mesh.Faces[index].Count;
var surface = FaceSurface.FromFace( mesh, index );
var corners = string.Join( " ", mesh.Faces[index].Indices
.Select( i => $"{i}:{mesh.Positions[i]}" ) );
Log.Info( $"[effigy-probe] mesh faces={mesh.Faces.Count} verts={mesh.Positions.Count} "
+ $"diag={mesh.BoundsDiagonal:F4} faceCorners={faceCorners} "
+ $"surfaceFaces={surface.Faces.Count} surfaceBoundary={surface.Boundary.Count}" );
Log.Info( $"[effigy-probe] corners {corners}" );
if ( feature.Sketch?.Plane is { } sp )
{
var flat = string.Join( " ", mesh.Faces[index].Indices
.Select( i => sp.ToPlane( mesh.Positions[i] ).ToString() ) );
Log.Info( $"[effigy-probe] planeOrigin={sp.Origin} x={sp.XAxis} y={sp.YAxis} "
+ $"inPlane {flat}" );
}
}
}
_viewport.SetSketchReference( reference );
}
/// <summary>The feature that owns the sketch currently being drawn on, by identity.</summary>
private SketchFeature ActiveSketchFeature()
{
if ( _viewport?.ActiveSketch is not { } active )
return null;
return _studio.Features
.OfType<SketchFeature>()
.FirstOrDefault( f => ReferenceEquals( f.Sketch, active ) );
}
/// <summary>
/// A parameter on the open feature changed.
///
/// MARKING IT DIRTY IS THE ENTIRE POINT OF THIS METHOD. PartStudio caches the body list after
/// each feature and only re-runs from the first dirty one — and Rebuild() ends by setting
/// _dirtyFrom to the feature count, so a rebuild with nothing marked reuses the whole cache and
/// re-executes NOTHING.
///
/// This was wired straight to RebuildStudio, so every edit made through a feature dialog was
/// silently thrown away: the sketch plane dropdown (which is why a sketch stayed on XY however
/// many times you picked Front or Right), an extrude distance, subdivide levels, every checkbox.
/// Picking highlighted beautifully and then changed nothing.
/// </summary>
/// <summary>
/// How long a full rebuild has to take before edits start previewing, in seconds. Below this
/// the slider already keeps up and there is nothing to trade away.
/// </summary>
private const float PreviewThreshold = 0.05f;
/// <summary>How long after the last edit the real rebuild runs. Long enough that a slider being
/// dragged does not keep triggering it, short enough that letting go feels like it finished
/// rather than like it is loading.</summary>
private const float SettleDelay = 0.18f;
/// <summary>How long the last full rebuild took. What decides whether previewing is worth it at
/// all - a box with two features rebuilds in a millisecond and should never preview.</summary>
private float _rebuildCost;
/// <summary>When the settle rebuild is due, or 0 for none.</summary>
private float _settleAt;
private void OnFeatureEdited()
{
if ( _dialog?.Feature is { } feature )
_studio.MarkDirty( feature );
// LIVE EDITING, ADAPTIVELY.
//
// Every edit has always rebuilt immediately, which is right and is why the numbers feel
// connected to the model. It stopped being right once a feature could be expensive: a
// garment slider re-runs the fit, the collision, forty steps of cloth simulation and a
// solidify on every pixel of the drag, and the result is a number that moves and a model
// that lurches after it.
//
// MEASURED RATHER THAN GUESSED. There is no list of expensive features here, and there
// should not be - it would be wrong the day somebody adds one, and wrong in the other
// direction for a simple garment on a coarse body. The last rebuild's own cost decides.
if ( _rebuildCost > PreviewThreshold )
{
_studio.PreviewQuality = true;
_settleAt = RealTime.Now + SettleDelay;
}
// A drag reports an edit every frame, but only geometry the drag is driving changes — the
// tree, the parts list and every panel are exactly as they were. Drags get the light rebuild
// below and the full RebuildStudio runs once when the button comes up (see the drag-end
// handlers wired in BuildDocks).
if ( _viewport?.IsDraggingBody == true
|| _viewport?.IsDraggingFace == true
|| _viewport?.IsDraggingPlaneOffset == true )
{
RebuildPreviewLight();
return;
}
// The dropdown and the strip are two views of one ChoiceParam, so an edit through either
// has to refresh the other or they disagree about what is armed - which is the exact
// confusion the strip exists to end.
_resultStrip?.Bind( _dialog?.Feature, SketchHostBodyId );
RebuildStudio();
}
/// <summary>
/// The per-frame rebuild for any drag — Transform, face, plane offset: re-run the history so
/// downstream geometry stays correct, update the preview in place, and keep the dialog's numbers
/// counting — but skip the feature tree, parts list, notes and tutorial, none of which can change
/// while a handle is held. The full <see cref="RebuildStudio"/> runs once when the button comes up.
///
/// WHEN A TRANSFORM IS THE LAST FEATURE the history does not need to re-run at all: nothing
/// downstream reads the moved body, so the whole rebuild is two deep clones of the mesh and a
/// re-merge, every frame. That is what made dragging a dense import choppy. The fast path below
/// skips it and transforms the grabbed mesh's positions directly (see <see cref="TransformDrag"/>).
/// </summary>
private void RebuildPreviewLight()
{
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
if ( _transformDrag is { } drag && _dialog?.Feature is TransformFeature transform && TryDragTransform( drag, transform ) )
{
_dialog?.RefreshValues();
return;
}
_studio.Rebuild();
RefreshPreview();
_viewport?.SetDisplayBodies( _studio.Bodies );
_dialog?.RefreshValues();
}
/// <summary>
/// The Transform handle was grabbed. Split the visible bodies into the ones the feature moves and
/// the ones it leaves, and hand them to the viewport as two models: the static ones stay in the
/// main preview, the moving ones become a drag model the GPU can transform each frame. Refused
/// (stays null) when the Transform is not the last feature — downstream geometry genuinely
/// changes as the body moves then — or when the preview is on the paint/checker/section path,
/// both of which fall back to the ordinary rebuild.
/// </summary>
private void OnBodyDragBegan()
{
_transformDrag = null;
if ( _dialog?.Feature is not TransformFeature transform )
return;
if ( _studio.Features.IndexOf( transform ) != _studio.EffectiveCount - 1 )
return;
if ( PreviewIsSpecial() )
return;
var visible = _studio.Bodies.Where( b => b.Visible ).ToList();
if ( visible.Count == 0 )
return;
var moved = visible.Where( b => transform.Bodies.Matches( b ) ).ToList();
var statics = visible.Where( b => !transform.Bodies.Matches( b ) ).ToList();
var moveAll = statics.Count == 0;
if ( !moveAll )
{
_viewport?.SetModel( BuildBodiesPreview( statics ), frameCamera: false );
_viewport?.BeginDragModel( BuildBodiesPreview( moved ) );
}
_transformDrag = new TransformDrag
{
MoveAll = moveAll,
BaseXform = TransformOf( transform ),
};
}
/// <summary>The Transform handle's button came up. The drag frames moved models around on the
/// GPU; this is the one full pass that puts the real geometry at the final position, restores the
/// real preview, and re-arms the normal (non-drag) rebuild path.</summary>
private void OnBodyDragEnded()
{
_transformDrag = null;
_viewport?.EndDragModel();
_viewport?.SetModelTransform( new Transform( Vector3.Zero, Rotation.Identity, 1f ) );
RebuildStudio();
}
/// <summary>A face or plane-offset handle's button came up. The drag frames kept everything
/// light; this is the one full pass that refreshes the tree, panels, notes and tutorial against
/// the final geometry.</summary>
private void OnFaceDragEnded() => RebuildStudio();
/// <summary>The full translate/rotate/scale a Transform carries, in the same composition order
/// <see cref="TransformFeature.Execute"/> applies it.</summary>
private static Xform TransformOf( TransformFeature transform ) =>
Xform.Translate( transform.Translate.Value )
* Xform.Rotate( transform.RotationAxis.Value, transform.RotationAngle.Value * MathF.PI / 180f )
* Xform.Scale( transform.Scale.Value );
/// <summary>
/// Move the drag preview to the feature's current transform, as the incremental transform from
/// the grabbed one. Returns false when it cannot (a singular transform), falling back to the
/// ordinary rebuild.
/// </summary>
private bool TryDragTransform( TransformDrag drag, TransformFeature transform )
{
var current = TransformOf( transform );
// A zero-scaled transform has no inverse; the dialog clamps the nub away from it, but a
// typed value can still be singular, and the ordinary rebuild path owns that error.
if ( MathF.Abs( current.Determinant ) < 1e-12f || MathF.Abs( drag.BaseXform.Determinant ) < 1e-12f )
return false;
var incremental = ToSandboxTransform( current * drag.BaseXform.Inverse );
if ( drag.MoveAll )
_viewport?.SetModelTransform( incremental );
else
_viewport?.SetDragModelTransform( incremental );
return true;
}
/// <summary>A preview model from a set of bodies, merged and material-resolved the way the
/// ordinary preview is. Null for no bodies.</summary>
private Model BuildBodiesPreview( List<Body> bodies )
{
if ( bodies is null || bodies.Count == 0 )
return null;
var merged = new PolyMesh();
foreach ( var body in bodies )
MeshTransform.Append( merged, body.Mesh );
return EffigyPreview.Build( merged, SlotMaterial );
}
/// <summary>Whether the preview is on a path the drag fast-path cannot express — paint, the UV
/// checker, or the section cut all rebuild the model every frame by their nature.</summary>
private bool PreviewIsSpecial() =>
_showUVChecker
|| (_viewport is { SectionEnabled: true })
|| _studio.Bodies.Any( b => b.Visible && b.Mesh.HasPaint );
/// <summary>An <see cref="Xform"/> as the engine's position/rotation/uniform-scale transform.
/// Only valid for a rigid-plus-uniform-scale Xform — which is exactly what a Transform drag
/// produces, however the feature was originally typed.</summary>
private static Transform ToSandboxTransform( Xform x )
{
var position = new Vector3( x.Origin.x, x.Origin.y, x.Origin.z );
var scale = x.X.Length;
return new Transform( position, RotationOfBasis( x.X / scale, x.Y / scale, x.Z / scale ), scale );
}
/// <summary>The rotation of an orthonormal basis, via axis-angle so no Euler convention is in
/// play. The 180-degree corner — where the axis vanishes from the skew part — recovers the axis
/// from the largest column of the basis plus identity.</summary>
private static Rotation RotationOfBasis( Vec3 rx, Vec3 ry, Vec3 rz )
{
var trace = rx.x + ry.y + rz.z;
var cos = Math.Clamp( (trace - 1f) * 0.5f, -1f, 1f );
var angle = MathF.Acos( cos );
var ax = ry.z - rz.y;
var ay = rz.x - rx.z;
var az = rx.y - ry.x;
var sin = MathF.Sqrt( ax * ax + ay * ay + az * az ) * 0.5f;
if ( sin < 1e-5f )
{
if ( cos > 0f )
return Rotation.Identity;
var col0 = new Vec3( rx.x + 1f, rx.y, rx.z );
var col1 = new Vec3( ry.x, ry.y + 1f, ry.z );
var col2 = new Vec3( rz.x, rz.y, rz.z + 1f );
var axis = col0;
if ( col1.LengthSquared > axis.LengthSquared ) axis = col1;
if ( col2.LengthSquared > axis.LengthSquared ) axis = col2;
if ( axis.LengthSquared < 1e-10f )
return Rotation.Identity;
return Rotation.FromAxis( new Vector3( axis.x, axis.y, axis.z ).Normal, 180f );
}
var a = new Vector3( ax, ay, az ) / (2f * sin);
return Rotation.FromAxis( a.Normal, angle.RadianToDegree() );
}
/// <summary>
/// The grabbed state a Transform drag redraws from. The drag is a GPU transform — either the
/// whole preview model (<see cref="MoveAll"/>) or the moving bodies' own drag model — so nothing
/// here carries geometry. Only the transform the feature held at grab time is needed, to turn
/// the feature's current absolute transform into the increment to apply.
/// </summary>
private sealed class TransformDrag
{
public bool MoveAll;
public Xform BaseXform;
}
/// <summary>
/// A click on the ADD/REMOVE strip. The parameter is already set by the time this runs; what
/// is left is everything a dropdown change would have done.
///
/// The dialog rebuild is not optional even though the dropdown is gone: Result decides which
/// parameters a feature declares in some cases, and a dialog still showing rows for the mode it
/// was in before is the same disagreement in a different place.
/// </summary>
private void OnResultStripChanged()
{
OnFeatureEdited();
_dialog?.Rebuild();
}
/// <summary>
/// Which body a sketch was drawn on, or null for one on a global plane. This is what Auto
/// reads, so it is what the strip's Auto hint has to read too.
///
/// Straight off the features rather than through the kernel's own resolution, because that needs
/// a FeatureContext which only exists mid-rebuild - see EffigyResultStrip.ResolveAuto. Which
/// means the chain it walks has to be kept in step with SketchFeature.Execute BY HAND, and the
/// day it was not, the strip said "new body" while the rebuild quietly added to a part.
/// </summary>
private string SketchHostBodyId( string sketchId )
{
if ( _studio.Features.OfType<SketchFeature>().FirstOrDefault( f => f.Id == sketchId ) is not { } sketch )
return null;
// A sketch on a datum plane inherits whatever that plane came off, following a chain of
// planes to whichever one named a face. Bounded by the feature count rather than trusted to
// terminate: a document can be hand-edited, and a strip that hangs is worse than one that
// gives up.
if ( !string.IsNullOrEmpty( sketch.PlaneFeatureId ) )
{
var planeId = sketch.PlaneFeatureId;
for ( var step = 0; step < _studio.Features.Count; step++ )
{
if ( _studio.Features.OfType<PlaneFeature>().FirstOrDefault( f => f.Id == planeId ) is not { } plane )
return null;
if ( !string.IsNullOrEmpty( plane.BasePlaneId ) )
{
planeId = plane.BasePlaneId;
continue;
}
return plane.Face?.BodyId;
}
return null;
}
return sketch.Face?.BodyId;
}
/// <summary>The left half of the status bar — what the active tool wants next.</summary>
private void SetPrompt( string prompt )
{
if ( _promptLabel.IsValid() )
_promptLabel.Text = prompt;
}
// --- which creation tools are on the strip -------------------------------------------------
/// <summary>
/// Which feature a strip button makes. An ENUM RATHER THAN A Func<Feature>.
///
/// The table below is static, and static state survives a hotload while the assembly under it
/// does not. A stored lambda therefore comes back pointing into the old assembly, which the
/// hotloader cannot substitute — clicking a button threw "Unable to find matching substitution
/// for a lambda method" and every tool was dead until the editor restarted. An enum value is an
/// int and migrates without any of that; the switch that turns it into a feature is ordinary
/// code, recompiled with everything else. Same reason no System.Type is held here either.
/// </summary>
private enum ToolKind
{
Sketch, Plane, Primitive, Import, Extrude, Revolve, Sweep, Loft, Chamfer, Fillet, Shell, Subdivide,
Remesh,
Draft, Hole, Sculpt, Mirror, LinearPattern, CircularPattern, Transform, UVProject, FaceMaterial,
MoveFace, Paint, Boolean,
Garment, Fur, Trim, Fabric,
Spline, CurveDeform, Part, Profile,
}
/// <summary>Build one, and apply the variant chosen from its dropdown where it has one.</summary>
private static Feature NewFeature( ToolKind kind, int choice ) => kind switch
{
ToolKind.Sketch => new SketchFeature(),
ToolKind.Plane => new PlaneFeature(),
ToolKind.Primitive => NewPrimitive( choice ),
ToolKind.Import => new ImportFeature(),
ToolKind.Extrude => AwaitingPick( new ExtrudeFeature() ),
ToolKind.Revolve => AwaitingPick( NewRevolve() ),
ToolKind.Sweep => new SweepFeature(),
ToolKind.Loft => new LoftFeature(),
ToolKind.Spline => new SplineFeature(),
ToolKind.CurveDeform => new CurveDeformFeature(),
ToolKind.Part => new PartFeature(),
ToolKind.Profile => new ProfileFeature(),
ToolKind.Chamfer => new ChamferFeature(),
ToolKind.Fillet => new FilletFeature(),
ToolKind.Shell => new ShellFeature(),
ToolKind.Subdivide => new SubdivideFeature(),
ToolKind.Remesh => new RemeshFeature(),
ToolKind.Draft => new DraftFeature(),
ToolKind.Hole => new HoleFeature(),
ToolKind.Sculpt => new SculptFeature(),
ToolKind.Mirror => new MirrorFeature(),
ToolKind.LinearPattern => new LinearPatternFeature(),
ToolKind.CircularPattern => new CircularPatternFeature(),
ToolKind.Transform => new TransformFeature(),
ToolKind.UVProject => new UVProjectFeature(),
ToolKind.FaceMaterial => new FaceMaterialFeature(),
ToolKind.MoveFace => new MoveFaceFeature(),
ToolKind.Paint => new PaintFeature(),
ToolKind.Boolean => NewBoolean( choice ),
ToolKind.Garment => new GarmentFeature(),
ToolKind.Fur => new FurFeature(),
ToolKind.Trim => new TrimFeature(),
ToolKind.Fabric => new FabricFeature(),
_ => throw new ArgumentOutOfRangeException( nameof( kind ), kind, "no feature for this tool" )
};
/// <summary>
/// A feature the toolbar just made waits to be pointed at a sketch instead of helping itself to
/// the most recent one.
///
/// Clicking Extrude used to put a solid on screen before you had said anything: the kernel reads
/// an unset reference as "the last sketch", so the default distance was applied to whatever was
/// nearest and the part jumped up a unit under the cursor. Handy once, startling every other
/// time, and it hid the question the dialog was asking.
///
/// ONLY EXTRUDE AND REVOLVE. A Sweep's path and a Loft's sections are DESIGNED around unset
/// references - drawing the profile and the path in either order is the point, and their
/// tooltips promise it - so making those ask first would take away the thing the defaults are
/// for.
/// </summary>
private static T AwaitingPick<T>( T feature ) where T : SketchConsumingFeature
{
feature.SketchFeatureId = SketchConsumingFeature.AwaitingPick;
return feature;
}
/// <summary>
/// A revolve that works on the first press.
///
/// The kernel's default axis is the typed one, and it has to stay that way so documents saved
/// before the Axis dropdown existed rebuild exactly as they were - see RevolveFeature.AxisMode.
/// A revolve created HERE has no such history, so it gets the mode a person actually wants:
/// spun about its own left edge, like a lathe profile.
/// </summary>
private static RevolveFeature NewRevolve()
{
var feature = new RevolveFeature();
feature.AxisMode.Index = RevolveFeature.AxisProfileLeftEdge;
return feature;
}
/// <summary>Build a Boolean already set to the operation picked from the button's dropdown.
/// Same shape as NewPrimitive: the variant is a starting value on the feature, not a separate
/// kind of feature, so it stays editable in the dialog afterwards.</summary>
private static BooleanFeature NewBoolean( int operation )
{
var feature = new BooleanFeature();
if ( operation >= 0 )
feature.Operation.Index = operation;
return feature;
}
private static PrimitiveFeature NewPrimitive( int shape )
{
var feature = new PrimitiveFeature();
if ( shape >= 0 )
feature.Shape.Index = shape;
return feature;
}
/// <summary>One button on the feature strip. Held as data rather than written straight into the
/// layout so the strip can be rebuilt with a subset of them.</summary>
private sealed class CreateTool
{
public EffigyIcon Icon;
public string Tip;
public ToolKind Kind;
/// <summary>
/// A material-symbol name for this tool where it appears in a Qt MENU rather than on the
/// strip — the right-click face menu, which cannot draw an EffigyIcon because those are
/// hand-painted glyphs and a Menu takes a name.
///
/// Beside the label rather than in a switch somewhere, so a tool that starts accepting a
/// face brings its icon with it. Null is fine: the menu falls back rather than breaking.
/// </summary>
public string MenuIcon;
/// <summary>
/// Which stage tab this tool sits behind.
///
/// THE COLUMN THAT REPLACED GapBefore AND Starter. A bool saying "put a wider gap before
/// this one" grouped the tools without naming the groups, and a bool saying "show this one
/// from the start" hid the rest rather than explaining them. A stage name does both jobs
/// out loud: it is the group's label on the tab, and it is what the lock rule tests.
/// </summary>
public string Stage;
/// <summary>Text beside the glyph. On every tool now — showing one stage at a time is what
/// bought the room, and the names are the whole reason to do it.</summary>
public string Label;
/// <summary>
/// Variants behind this button, or null for one that just does its thing.
///
/// Where they exist the button opens a menu instead of adding anything, and the index
/// chosen goes to <see cref="NewFeature"/>. Primitive is the case this was built for: six
/// shapes that are one feature with one parameter set differently, which is a list rather
/// than six buttons.
/// </summary>
public string[] Choices;
}
/// <summary>The shapes behind the Primitive button. Taken from PrimitiveFeature.Shape rather
/// than written out again, so the menu cannot drift from the parameter it sets — a menu naming
/// a shape the feature has never heard of would set an index that means something else.
/// </summary>
private static string[] PrimitiveShapes => new PrimitiveFeature().Shape.Options;
/// <summary>Read off the feature rather than typed again here, for the reason the Primitive
/// menu is: a dropdown naming an operation the kernel has never heard of is a bug nothing
/// catches until someone picks it.</summary>
private static string[] BooleanOps => new BooleanFeature().Operation.Options;
/// <summary>
/// The strip's tools, BUILT FRESH ON EVERY READ rather than held in a static field.
///
/// Nothing here is expensive — it runs once per toolbar refresh, which happens when a sketch is
/// finished — and a property cannot carry objects from a dead assembly across a hotload the way
/// a static field does. Between this and ToolKind replacing the factory delegates, there is no
/// state left here for a reload to invalidate.
/// </summary>
private static CreateTool[] CreateTools => new CreateTool[]
{
// --- Sketch: the two tools that can start a part from nothing ---------------------------
new() { Icon = EffigyIcon.Sketch, Label = "Sketch", Stage = StageSketch, MenuIcon = "edit",
Tip = "Add a Sketch feature — draw lines/arcs on a plane",
Kind = ToolKind.Sketch },
// BESIDE SKETCH, because it is the tool you reach for when the answer to "which plane?" is
// none of the three on offer. It builds nothing on its own, which is why it is not on Solid
// with the tools that do — a plane is somewhere to put a sketch, and Sketch is where the
// person who needs one is already standing.
new() { Icon = EffigyIcon.Plane, Label = "Plane", Stage = StageSketch, MenuIcon = "layers",
Tip = "Add a Plane — offset or angled off a global plane, a face, or another plane",
Kind = ToolKind.Plane },
new() { Icon = EffigyIcon.Primitive, Label = "Primitive", Stage = StageSketch,
Tip = "Add a Primitive — pick a shape",
Kind = ToolKind.Primitive, Choices = PrimitiveShapes },
// BESIDE PRIMITIVE because both start a part from nothing. This one is a mesh that already
// exists — a sculpt, a scan — and the kernel has no other way to make a body out of one.
new() { Icon = EffigyIcon.Primitive, Label = "Import", Stage = StageSketch, MenuIcon = "file_open",
Tip = "Add an Import — load a Wavefront OBJ as a body",
Kind = ToolKind.Import },
// --- Solid: profiles become bodies ------------------------------------------------------
new() { Icon = EffigyIcon.Extrude, Label = "Extrude", Stage = StageSolid, MenuIcon = "arrow_upward",
Tip = "Add an Extrude — pull a sketch profile, or a face of a part, into a solid",
Kind = ToolKind.Extrude },
new() { Icon = EffigyIcon.Revolve, Label = "Revolve", Stage = StageSolid,
Tip = "Add a Revolve — sweep a sketch profile around an axis",
Kind = ToolKind.Revolve },
// Neither of these needs its selector filled in to do something: an empty
// SweepFeature.PathSketchId means "the sketch before the profile's", and a LoftFeature with
// fewer than two Sections lofts every sketch there is. Both are the order a person draws
// them in, so the tooltips say so rather than sending them to a dialog first.
new() { Icon = EffigyIcon.Sweep, Label = "Sweep", Stage = StageSolid,
Tip = "Add a Sweep — run a sketch profile along a path sketch",
Kind = ToolKind.Sweep },
new() { Icon = EffigyIcon.Loft, Label = "Loft", Stage = StageSolid,
Tip = "Add a Loft — skin a surface between two or more sketches",
Kind = ToolKind.Loft },
new() { Icon = EffigyIcon.SplineTool, Label = "Spline", Stage = StageSolid, MenuIcon = "gesture",
Tip = "Add a Spline — click points in the viewport for a curve, and a tube along it. A Sweep or a Bend can follow it too",
Kind = ToolKind.Spline },
new() { Icon = EffigyIcon.Primitive, Label = "Part", Stage = StageSketch, MenuIcon = "settings",
Tip = "Add a Part from the library — cog, bolt, rivet, hinge, pipe elbow, strap, buckle — with a few dials",
Kind = ToolKind.Part },
new() { Icon = EffigyIcon.Loft, Label = "Profile", Stage = StageSketch, MenuIcon = "accessibility_new",
Tip = "Add a Profile body — a front outline and a side outline, stacked into a body. A torso is eight numbers",
Kind = ToolKind.Profile },
// LAST ON SOLID, because it is the tool that turns several solids back into one and there
// have to be several first. Not on Repeat with Mirror and the patterns: those COPY bodies
// and this CONSUMES them, which is the opposite direction and a bad neighbour.
new() { Icon = EffigyIcon.Boolean, Label = "Boolean", Stage = StageSolid, MenuIcon = "join_full",
Tip = "Add a Boolean — union, subtract or intersect bodies",
Kind = ToolKind.Boolean, Choices = BooleanOps },
// --- Detail: refine a body that already exists ------------------------------------------
// Fillet before Chamfer, which is the order Onshape puts them in and the order people reach
// for them: rounding an edge is the common case and chamfering it is the deliberate one.
new() { Icon = EffigyIcon.Fillet, Label = "Fillet", Stage = StageDetail, MenuIcon = "rounded_corner",
Tip = "Add a Fillet — round sharp edges to a radius",
Kind = ToolKind.Fillet },
new() { Icon = EffigyIcon.Chamfer, Label = "Chamfer", Stage = StageDetail, MenuIcon = "details",
Tip = "Add a Chamfer — cut sharp edges back by a distance",
Kind = ToolKind.Chamfer },
new() { Icon = EffigyIcon.Shell, Label = "Shell", Stage = StageDetail, MenuIcon = "crop_free",
Tip = "Add a Shell — hollow to a wall thickness",
Kind = ToolKind.Shell },
// WITH THE DETAIL TOOLS because it acts on a solid that already exists, which is what that
// stage means. It is not next to Extrude even though the two overlap: Extrude BUILDS from a
// face and this MOVES one, and a wall that is in the wrong place is a thing you fix late.
new() { Icon = EffigyIcon.MoveFace, Label = "Move Face", Stage = StageDetail, MenuIcon = "open_with",
Tip = "Add a Move Face — push, pull or slide picked faces of a part",
Kind = ToolKind.MoveFace },
// Both act on picked faces of a solid that already exists, which is what puts them with
// Shell rather than with Extrude.
new() { Icon = EffigyIcon.Draft, Label = "Draft", Stage = StageDetail, MenuIcon = "signal_cellular_null",
Tip = "Add a Draft — taper picked faces so the part leaves a mould",
Kind = ToolKind.Draft },
new() { Icon = EffigyIcon.Hole, Label = "Hole", Stage = StageDetail, MenuIcon = "radio_button_unchecked",
Tip = "Add a Hole — drill, counterbore or countersink into a face",
Kind = ToolKind.Hole },
// --- Repeat: copy and move whole bodies -------------------------------------------------
new() { Icon = EffigyIcon.Mirror, Label = "Mirror", Stage = StageRepeat,
Tip = "Add a Mirror — reflect bodies across a plane",
Kind = ToolKind.Mirror },
new() { Icon = EffigyIcon.LinearPattern, Label = "Linear", Stage = StageRepeat,
Tip = "Add a Linear Pattern — copy bodies along a direction",
Kind = ToolKind.LinearPattern },
new() { Icon = EffigyIcon.CircularPattern, Label = "Circular", Stage = StageRepeat,
Tip = "Add a Circular Pattern — copy bodies around an axis",
Kind = ToolKind.CircularPattern },
new() { Icon = EffigyIcon.Transform, Label = "Transform", Stage = StageRepeat,
Tip = "Add a Transform — move, rotate or scale bodies",
Kind = ToolKind.Transform },
new() { Icon = EffigyIcon.Drape, Label = "Bend", Stage = StageRepeat, MenuIcon = "gesture",
Tip = "Add a Bend along curve — a body's length follows the spline above it",
Kind = ToolKind.CurveDeform },
// --- Finish: what is left on the CAD bar after the other workspaces took theirs ---------
// Subdivide and Sculpt live in the Sculpt workspace, and UV Project and Paint in the Paint
// workspace. Face Material is the one finish-line tool that stays here: it is a material-slot
// assignment, a thing CAD owns, not a sculpt or a paint.
new() { Icon = EffigyIcon.FaceMaterial, Label = "Face Material", Stage = StageFinish, MenuIcon = "palette",
Tip = "Add a Face Material — put picked faces on a material slot",
Kind = ToolKind.FaceMaterial },
};
// --- what a tool will take from the selection ----------------------------------------------
/// <summary>
/// What the tool behind a strip button will take from the geometry already picked.
///
/// ASKED OF THE FEATURE, never written down here. "Which tools consume a face" used to live in
/// three places that could not see each other — the switch in Feature.ApplyGeometrySelection,
/// the sentence under the viewport, and the pick-mode flags each dialog arms — so the hint could
/// name a tool that ignored the face, and a tool that wanted one could be missing from the list
/// with nothing to notice. Feature.Accepts is the one declaration now, and everything below
/// reads it rather than repeating it.
///
/// A FRESH FEATURE PER CALL, no cache. Accepts is a constant expression on every feature, and
/// the alternative — a static map from ToolKind — is exactly the shape of state that comes back
/// from a hotload pointing into a dead assembly (see ToolKind's own note). This is called when
/// the selection changes and when a menu opens, never from a paint pass.
/// </summary>
private static GeometryKind AcceptedBy( ToolKind kind ) => NewFeature( kind, -1 ).Accepts;
/// <summary>The strip's tools that will use a pick of this kind, in the order they sit on the
/// bar so the sentence reads left to right the way the buttons do.</summary>
private static List<CreateTool> ToolsAccepting( GeometryKind kind ) =>
CreateTools.Where( t => AcceptedBy( t.Kind ).HasFlag( kind ) ).ToList();
/// <summary>Those tools, named. Empty when nothing consumes this kind yet.</summary>
private static string ToolsNamed( GeometryKind kind )
{
var names = ToolsAccepting( kind ).Select( t => t.Label ).ToList();
return names.Count switch
{
0 => "",
1 => names[0],
_ => $"{string.Join( ", ", names.Take( names.Count - 1 ) )} and {names[^1]}",
};
}
/// <summary>
/// A sketch with something drawn in it exists, so the rest of the tools have something to bite
/// on.
///
/// Curves rather than merely the feature: clicking Sketch adds the feature to the tree straight
/// away, before a plane is even chosen, so its presence alone would unlock the bar while there
/// was still nothing to extrude.
/// </summary>
private bool HasConfirmedSketch() =>
_studio is not null
&& _studio.Features.OfType<SketchFeature>().Any( f => f.Sketch is { Curves.Count: > 0 } );
/// <summary>
/// The feature the toolbar made a moment ago that this click would only make a second copy of,
/// or null.
///
/// PENDING is the dialog still being open on it as a NEW feature: it has not been ticked, and
/// its cross would delete it again. UNTOUCHED is nobody having answered anything on it yet -
/// nothing drawn in a sketch, no number typed into an extrude. Both halves matter: without the
/// first, clicking Extrude after committing one would reopen the committed extrude instead of
/// starting the next; without the second, there would be no way to add two of anything in a row
/// without ticking in between.
/// </summary>
private Feature PendingDuplicate( Feature candidate )
{
if ( _dialog is not { IsOpen: true, IsNew: true, IsUntouched: true } )
return null;
var pending = _dialog.Feature;
if ( pending is null || pending.GetType() != candidate.GetType() )
return null;
// A variant picked out of a menu is a DIFFERENT thing to make even though it is the same
// feature class, and reusing the pending cube would silently swallow the sphere just chosen.
// Sketch is exempt: its plane is answered inside the dialog, so a candidate built fresh with
// the default plane says nothing about what the pending one is set to.
if ( pending is not SketchFeature && !SameParameters( pending, candidate ) )
return null;
return pending;
}
/// <summary>
/// Whether two features of the same type are set up identically.
///
/// Written out by parameter type rather than through some general value accessor because IParam
/// deliberately has none - the parameters ARE the storage in this kernel (see Feature.cs), which
/// is the same reason the dialog's snapshot is a switch like this one. Exact float comparison is
/// correct here: both sides are constructor defaults, not the result of arithmetic.
/// </summary>
private static bool SameParameters( Feature a, Feature b )
{
var left = a.Parameters;
var right = b.Parameters;
if ( left.Count != right.Count )
return false;
for ( var i = 0; i < left.Count; i++ )
{
var same = (left[i], right[i]) switch
{
(FloatParam x, FloatParam y) => x.Value == y.Value,
(IntParam x, IntParam y) => x.Value == y.Value,
(BoolParam x, BoolParam y) => x.Value == y.Value,
(Vec3Param x, Vec3Param y) => x.Value.Equals( y.Value ),
(ChoiceParam x, ChoiceParam y) => x.Index == y.Index,
(StringParam x, StringParam y) => x.Value == y.Value,
// A parameter kind nobody here knows about: treat it as a difference, so an unknown
// setting can never be quietly thrown away by reusing a feature that does not match.
_ => false,
};
if ( !same )
return false;
}
return true;
}
/// <summary>
/// Append a feature and leave it selected with its dialog open — Onshape's behaviour, and the
/// reason the buttons feel like they did something. A freshly added Extrude with no sketch
/// above it WILL show an error; that is correct, and the parameter panel is where you fix it.
/// </summary>
private void AddFeature( Feature feature )
{
// IMPORT ASKS FOR THE FILE FIRST. An empty Source is a refusal, and adding the feature
// then making you browse from the dialog is a dead button until you notice the red row.
// Cancel the picker and nothing is added, same as Subdivide with nothing selected.
if ( feature is ImportFeature import
&& string.IsNullOrWhiteSpace( import.Source.Value )
&& !import.HasPendingMesh )
{
if ( !TryPickImportFile( out var path ) )
return;
import.BindSource( path );
if ( string.IsNullOrWhiteSpace( import.Name ) )
import.Name = Path.GetFileNameWithoutExtension( path );
}
// Pressing the same button again while the last one is still sitting there unanswered and
// unconfirmed goes BACK TO THAT ONE rather than stacking another copy into the tree.
// Impatience with a picker - clicking once more because nothing appeared to happen - produced
// a row of identical dead features that all had to be deleted by hand. Nothing is added here,
// so there is no undo step to record either.
if ( PendingDuplicate( feature ) is { } pending )
{
_featureTree?.Select( pending );
// Some features open with nothing left to ask for - a Fillet arrives with its radius
// already typed in - and a click that neither adds anything nor lights anything up reads as
// a broken button. Say what happened instead.
if ( !_dialog.ReassertPending() )
SetPrompt( $"{pending.Name ?? pending.TypeName} is already open above - finish it with the "
+ "tick, or cancel it, before adding another." );
return;
}
// SUBDIVIDE AND REMESH ASK WHICH PART FIRST. An empty Bodies list means "the whole studio"
// to either feature, so a click with nothing selected quietly quadrupled the triangle count
// of every part in the document under a Subdivide — or decimated every part of it under a
// Remesh, which is the same trap pointed the other way: the parts you were not looking at,
// on a cage you were about to sculpt, are the ones you can least afford to lose to a target
// typed for one.
//
// The whole-body form is not removed, because it is the one that actually smooths — or the
// one that reduces a whole import at once — and is usually what you want: this only refuses
// to GUESS which body. Click a part in the Parts list and you get that part entire; pick
// faces in the viewport and you get those, since a face selection already names its own body
// (and ApplyGeometrySelection copies that body's id onto the Bodies param).
if ( feature is SubdivideFeature or RemeshFeature
&& _viewport is not null
&& _viewport.IdleFaces.Count == 0
&& _viewport.IdleBodyIds.Count == 0 )
{
if ( feature is RemeshFeature )
{
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." );
}
else
{
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." );
}
// SAID TWICE, ON PURPOSE. The prompt lives in the status bar at the very bottom of the
// window, under whatever docks are open, and a refusal delivered there is a refusal
// nobody sees: pressing Remesh on a dense import and having the button do nothing at all
// is exactly what this looked like from the outside. The console line is the copy that
// survives not looking at the right strip of pixels.
Log.Info( $"[Effigy] {feature.TypeName} needs a part selected - nothing was added." );
return;
}
RecordUndo();
// A face or part already selected is the input, the way Onshape consumes the current
// selection instead of making you pick it again inside the dialog that just opened.
ApplyIdleGeometrySelection( feature );
// A new feature goes AT THE ROLLBACK BAR, not at the end of the tree - same as Onshape.
// Appending would drop it below the bar, where it does not get evaluated: you would add an
// Extrude while rolled back, watch nothing happen, and have no way to tell why. The bar
// moves down past it so the thing you just added is the last one running.
//
// THE BAR AT EXACTLY Features.Count IS THE CASE THAT WAS MISSING, and it is the ordinary
// one rather than an edge case: finishing an edit on the LAST feature leaves the bar at
// index+1, which is Features.Count - a finite number that means "everything runs" today and
// "everything except the next thing you add" a moment later. The old test was `<`, so that
// bar took the append branch and never moved, the new feature landed exactly ON the bar,
// and EffectiveCount excluded it. It was added to the tree, drawn in the tree, and never
// executed.
//
// For a sketch on a face that is invisible until you look closely: SketchFeature.Execute is
// what derives the plane from the face, so a sketch that never ran keeps the default XY
// plane. The face outline then gets projected onto XY, which throws away Z, folds the
// face's four corners onto two, and draws one green line lying flat through the middle of
// the model. Every part of that is downstream of this comparison.
InsertAtRollback( feature );
RebuildStudio();
_featureTree?.Select( feature );
// Select() above already opened the dialog through the tree's selection callback, but as
// an edit. Reopening marks it as new, which is what makes Cancel delete it rather than
// leaving a half-configured feature behind.
_dialog?.Open( feature, isNew: true );
}
// --- docks (viewport, feature tree, parameter panel) -----------------------------------
private void BuildDocks()
{
_viewport = new EffigyViewport( this );
// The drag handles' drag end: where the light per-frame path hands back to the full rebuild
// (tree, panels, notes, tutorial) exactly once. Body, face and plane-offset drags all get
// the same treatment.
_viewport.BodyDragEnded = OnBodyDragEnded;
_viewport.FaceDragEnded = OnFaceDragEnded;
_viewport.PlaneOffsetDragEnded = OnFaceDragEnded;
_featureTree = new EffigyFeatureTreePanel( this, _studio )
{
FeatureSelected = OnFeatureSelected,
StudioChanged = OnStudioChanged,
VisibilityToggled = OnTreeVisibilityToggled,
CommandRequested = OnFeatureCommand,
RenameCommitted = OnFeatureRenamed,
};
_dialog = new EffigyFeatureDialog( this, _viewport )
{
VariableResolve = name => VariableResolver.Bind( _studio.Variables )( name ),
Edited = OnFeatureEdited,
BodyDragBegan = OnBodyDragBegan,
Renamed = () => _featureTree?.Rebuild(),
Accepted = OnDialogAccepted,
Cancelled = OnDialogCancelled,
SketchRequested = EnterSketch,
SculptRequested = EnterSculpt,
SketchNameLookup = id => _studio.Features.OfType<SketchFeature>().FirstOrDefault( f => f.Id == id )?.Name,
PickableBodiesLookup = () => _studio.Bodies,
BodyNameLookup = id => _studio.Bodies.FirstOrDefault( b => b.Id == id )?.Name,
OpenedForFeature = f =>
{
UpdatePickTargets( f );
_resultStrip?.Bind( f, SketchHostBodyId );
// A spline is drawn, not typed: its dialog opening is what arms the viewport.
if ( f is SplineFeature spline )
BeginSplineDraw( spline );
},
MaterialLookup = SlotMaterial,
MaterialChanged = SetSlotMaterial,
};
_partsPanel = new EffigyPartsPanel( this, _studio )
{
VisibilityToggled = OnPartVisibilityToggled,
CommandRequested = OnPartCommand,
DeleteRequested = DeleteParts,
JoinRequested = JoinParts,
RenameCommitted = OnPartRenamed,
SelectionChanged = OnPartTreeSelectionChanged,
BoneAssigned = OnBodyBoneAssigned,
SelectedBoneName = () => _rigPanel?.SelectedBoneName,
};
// The Materials dock is the material BROWSER - a grid of the project's materials you drag
// onto faces - not the column of slot rows it used to be. It edits nothing itself: a drag is
// reported by the VIEWPORT, which is where it lands, and the two clicks come back here.
_materialsPanel = new EffigyMaterialsPanel( this, _studio )
{
MaterialChanged = SetSlotMaterial,
MaterialActivated = SetBaseMaterial,
ScaleChanged = SetMaterialScale,
// So an armed brush picks up a new material the moment it is clicked in the browser,
// rather than needing to be left and re-entered to notice.
SelectedMaterialChanged = OnBrushMaterialChanged,
};
_rigPanel = new EffigyRigPanel( this, _studio, _viewport );
// Close the Rig dock and the bones go with it. The panel is the rig's presence in the tool;
// the viewport asks it, every frame, rather than being told — a dock can be closed from its
// own X, from the View menu or by a layout reset, and only one of those routes runs code
// here.
_viewport.RigVisible = () => _rigPanel?.Visible ?? false;
_variablesPanel = new EffigyVariablesPanel( this, _studio )
{
Changing = RecordUndo,
Changed = OnVariablesChanged,
};
// Built here with the rest so the dock's CreateAction has something to hand back. The
// engine's console widget goes inside it - see EffigyConsolePanel for why that takes any
// code at all.
_consolePanel = new EffigyConsolePanel( this );
_tutorial = new EffigyTutorial();
_uvPanel = new EffigyUVPanel( this )
{
RunOp = RunMeshOp,
Changed = OnMeshEditChanged,
};
_tutorialPanel = new EffigyTutorialPanel( this )
{
Tutorial = _tutorial,
RevealPanel = RevealDock,
HighlightTool = HighlightTool,
SwitchWorkspace = SetWorkspace,
// Only the playermodel lesson offers this, and only on its start screen. The panel raises
// it instead of building the sample itself because replacing the document is the window's
// business - there is a confirm-discard and an undo step to get right.
LoadExample = LoadPlayermodelExample,
// Restart and Dismiss both change what the strip should be showing, and the panel
// itself has no idea a toolbar exists. Re-evaluating here also means a Restart drops
// straight back to whichever step the document already satisfies, rather than
// insisting on step one of work that is already done.
Changed = RefreshTutorial,
};
// Dialog ABOVE the tree in one column, which is where Onshape puts it. It was a separate
// right-hand dock at first and that was the single biggest reason the tool did not read as
// Onshape: the thing you are editing and the history you are editing it in belong in the
// same column, and the viewport gets everything else.
_leftPanel = new Widget( this ) { Layout = Layout.Column() };
_leftPanel.Name = "Features";
_leftPanel.WindowTitle = "Features";
_leftPanel.SetWindowIcon( "account_tree" );
_leftPanel.Layout.Add( _dialog );
_leftPanel.Layout.Add( _featureTree, 1 );
// Parts BELOW the feature tree, the way Onshape stacks them: the recipe on top, what it
// actually built underneath.
_leftPanel.Layout.Add( _partsPanel );
// Edit mode's own column — scene, mesh check, history — shown in place of the tree and the
// parts while a mesh is being edited. See EffigyMeshEditPanel.
_meshPanel = new EffigyMeshEditPanel( this );
BindModelDock();
_leftPanel.Layout.Add( _meshPanel, 1 );
_viewport.SketchEdited = OnSketchEdited;
// The origin is the model's pivot, so moving it changes the exported result and has to be
// recorded. Dead until now for want of anything downstream that cared.
_viewport.OriginMoved = OnOriginMoved;
_viewport.LightingChanged = OnLightingChanged;
// APPLYING OR DELETING A CONSTRAINT MOVES THE SKETCH, so it is a sketch edit and has to
// reach the same place every other one does. This event had no subscriber at all: the solver
// ran, the points moved on screen, and nothing was ever marked dirty - so an extrude above
// the sketch went on standing on the profile from before the constraint. Exactly the fault
// the point drag had, one event over.
_viewport.SketchConstraintApplied = OnSketchEdited;
_viewport.IdleSelectionChanged = OnIdleSelectionChanged;
_viewport.FaceContextMenuRequested = OpenFaceMenu;
_viewport.MaterialDropped = OnMaterialDropped;
_viewport.SketchConstraintMenuRequested = OpenSketchConstraintMenu;
// Re-deform the display mesh every drag frame while posing.
_viewport.PoseChanged = OnPoseChanged;
// Fired BEFORE the viewport changes a sketch, which is the only moment a useful "before"
// exists to snapshot.
_viewport.SketchEditing = RecordUndo;
_viewport.SketchPromptChanged = SetPrompt;
// Same "before" moment, for the rig: a bone placed, deleted, renamed, or mirrored.
_rigPanel.RigChanging = RecordUndo;
// The pose preview's two buttons. The viewport owns the pose (bind snapshot + weights), the
// window owns the mesh and the Model, so both flow through here.
_rigPanel.PoseToggled = OnPoseToggled;
_rigPanel.PoseReset = OnPoseReset;
// AND THE "AFTER", which nothing had ever subscribed to. RigChanged has been declared and
// raised from five places since the panel was written, with no listener on the other end,
// so a rig edit never marked the document unsaved. That was invisible while the rig lived
// only in this window - there was nothing to save it INTO, so nothing to lose - and became
// a way to lose work the moment the rig went into the .effigy file: place bones, close the
// window, and it closes clean without asking, because as far as the title bar was concerned
// nothing had happened.
_rigPanel.RigChanged = NoteRigEdited;
_centralDock = DockManager.SetCentralWidget( _viewport );
DockManager.RegisterDock( new() { Title = "Features", Icon = "account_tree", Area = DockArea.Left, CreateAction = () => _leftPanel } );
DockManager.RegisterDock( new() { Title = "Rig", Icon = "account_tree", Area = DockArea.Right, CreateAction = () => _rigPanel } );
// Right, tabbed behind the Rig, because both are things you do to a part that is already
// modelled and neither is worth permanent screen room while you are still modelling it.
DockManager.RegisterDock( new() { Title = "Materials", Icon = "palette", Area = DockArea.Right, CreateAction = () => _materialsPanel } );
DockManager.RegisterDock( new() { Title = "Variables", Icon = "tag", Area = DockArea.Left, CreateAction = () => _variablesPanel } );
// Right, with the Materials it serves: the UV square is where a texture gets its layout.
DockManager.RegisterDock( new() { Title = "UV", Icon = "texture", Area = DockArea.Right, CreateAction = () => _uvPanel } );
// Bottom, full width, and NOT tabbed behind anything. A tutorial that shares a tab strip
// is a tutorial you lose the moment you look at the thing it told you to look at — which
// is every step. Along the bottom it stays readable while both side docks are in use.
DockManager.RegisterDock( new() { Title = "Tutorial", Icon = "school", Area = DockArea.Bottom, CreateAction = () => _tutorialPanel } );
// Bottom as well, and tabbing with the Tutorial is fine here in a way it is not for the
// Rig: the tutorial's whole objection to sharing a tab strip is that you lose it the moment
// you look at what it told you to look at, and nobody follows the tutorial and reads the
// console at the same time. Along the bottom is also where every editor puts a console, and
// full width is what a stack trace needs.
DockManager.RegisterDock( new() { Title = "Console", Icon = "terminal", Area = DockArea.Bottom, CreateAction = () => _consolePanel } );
// Bumped from Effigy1: the Parameters dock is gone and the tree moved into a shared column
// with the dialog. A restored Effigy1 layout would reinstate the old two-dock arrangement
// and BuildDefaultLayout would never run again.
// Bumped from Effigy2: restored Effigy2 layouts came back degenerate - the Features dock a
// sliver and stray chrome floating over the viewport - so anyone with one saved never got
// a usable window. A fresh cookie forces the known-good default layout.
// Bumped from Effigy3: the Materials dock is new, and a restored Effigy3 layout knows
// nothing about it - the panel would exist, be wired up, and never appear on screen.
// Bumped from Effigy4: the Tutorial dock is new, and the same applies — worse here,
// because the one person it is for is the one person guaranteed to have no saved layout
// only if they have never opened Effigy before, which is not who upgrades.
// Bumped from Effigy5: the Materials dock is now the material browser rather than a column
// of slot rows, and it wants room to show a grid. A restored Effigy5 layout would give the
// new panel the width the old list was sized for and it would come back one cell wide.
// Bumped from Effigy6: the default layout opens the feature tree and nothing else. Which
// docks are open lives in the saved layout, so without a new cookie everyone who has
// already opened Effigy keeps starting with the Rig and Materials columns forever.
// Bumped from Effigy7: the Console dock is new. A restored Effigy7 layout knows nothing
// about it, so the panel would exist, be registered, and have nowhere on screen to go -
// the same failure the Materials and Tutorial docks each hit when they arrived.
StateCookie = "Effigy10";
}
/// <summary>The Parts list clicked a row. That is a whole-part selection — faces drop, the
/// viewport lights the solid, and the next tool that acts on bodies uses this part.</summary>
private bool _syncingSelection;
private void OnPartTreeSelectionChanged( IReadOnlyList<string> bodyIds )
{
if ( _syncingSelection || _viewport is null )
return;
_syncingSelection = true;
_viewport.SelectBodies( bodyIds );
_syncingSelection = false;
DescribeGeometrySelection();
UpdateRigChecks();
}
/// <summary>A face (or empty space) was clicked in the viewport. Keep the Parts list on the
/// same bodies so the row highlight and the 3D highlight cannot disagree.</summary>
private void OnIdleSelectionChanged()
{
if ( _syncingSelection || _partsPanel is null )
return;
_syncingSelection = true;
_partsPanel.Select( _viewport.IdleBodyIds );
_syncingSelection = false;
DescribeGeometrySelection();
UpdateRigChecks();
// Object mode's tools and its Scene list both follow the selection.
if ( InObjectMode )
{
UpdateObjectChecks();
RefreshMeshEditPanel();
}
}
/// <summary>
/// The sentence under the viewport that says what the thing you just clicked is good for.
///
/// GENERATED FROM Feature.Accepts, not typed. It used to read "Face of {name} selected — Sketch,
/// Draft, Hole, Face Material and Fillet will use it", which was true on the day it was written
/// and became a lie the moment anything else learned to take a face — Subdivide did, and the
/// line was edited by hand to keep up. A hint that has to be maintained alongside the tools it
/// describes is a hint that will eventually be wrong, quietly, in the one place a person looks
/// to find out what they can do next.
/// </summary>
private void DescribeGeometrySelection()
{
if ( _viewport is null )
return;
MarkToolsTakingSelection();
if ( _viewport.FacePickMode || _viewport.BodyPickMode || _viewport.PlanePickMode || _viewport.SketchPickMode )
return;
if ( _viewport.IdleSketchFeatureId is not null )
{
var name = _studio.Features.OfType<SketchFeature>()
.FirstOrDefault( f => f.Id == _viewport.IdleSketchFeatureId )?.Name ?? "Sketch";
var n = _viewport.IdleRegionSeeds.Count;
SetPrompt( n <= 1
? $"{name} selected{Users( GeometryKind.SketchRegion, one: true )}"
: $"{name}: {n} faces selected{Users( GeometryKind.SketchRegion, one: false )}" );
return;
}
var faces = _viewport.IdleFaces.Count;
var edgeCount = _viewport.IdleEdges.Count;
var bodies = _viewport.IdleBodyIds.Count;
if ( edgeCount == 1 )
{
SetPrompt( $"1 edge selected{Users( GeometryKind.Edge, one: true )}" );
return;
}
if ( edgeCount > 1 )
{
SetPrompt( $"{edgeCount} edges selected{Users( GeometryKind.Edge, one: false )}" );
return;
}
if ( faces == 1 )
{
var id = _viewport.IdleFaces[0].BodyId;
var name = _studio.Bodies.FirstOrDefault( b => b.Id == id )?.Name ?? "part";
SetPrompt( $"Face of {name} selected{Users( GeometryKind.Face, one: true )}" );
return;
}
if ( faces > 1 )
{
SetPrompt( $"{faces} faces selected{Users( GeometryKind.Face, one: false )}" );
return;
}
if ( bodies == 1 )
{
var name = _studio.Bodies.FirstOrDefault( b => b.Id == _viewport.IdleBodyIds[0] )?.Name ?? "part";
SetPrompt( $"{name} selected" );
return;
}
if ( bodies > 1 )
{
SetPrompt( $"{bodies} parts selected" );
return;
}
SetPrompt( "" );
}
/// <summary>
/// The " — Draft, Hole and Fillet will use it." half of the hint, or nothing at all when no tool
/// takes this kind of pick yet.
///
/// SAYING NOTHING IS A REAL ANSWER. A selection nothing consumes should report itself and stop,
/// rather than promising a list that turns out to be empty.
/// </summary>
private static string Users( GeometryKind kind, bool one )
{
var names = ToolsNamed( kind );
return names.Length == 0 ? "" : $" — {names} will use {(one ? "it" : "them")}.";
}
/// <summary>
/// What kind of geometry is selected right now, in the vocabulary Accepts speaks.
///
/// THE PRIORITY MIRRORS DescribeGeometrySelection'S, deliberately, because the sentence under
/// the viewport and the marks on the bar are two readings of one fact and they must not
/// disagree about which one it is. Sketch region beats edges beats faces beats bodies, and the
/// first branch that matches wins in both places.
///
/// A FACE SELECTION ALSO NAMES ITS BODY, so it reports Face | Body. This is not a convenience:
/// ApplyGeometrySelection really does hand a body-only tool the part a picked face belongs to,
/// and AcceptsTests asserts that asymmetry rather than working around it. Reporting Face alone
/// here would leave Shell and Subdivide unmarked on a click that they would in fact consume.
///
/// NOTHING WHILE A DIALOG IS PICKING. A tool with a pick mode armed owns the clicks, and the
/// marks would be describing a selection that is on its way into a box rather than one sitting
/// there waiting for a tool.
/// </summary>
private GeometryKind SelectedGeometry()
{
if ( _viewport is null )
return GeometryKind.None;
if ( _viewport.FacePickMode || _viewport.BodyPickMode || _viewport.PlanePickMode || _viewport.SketchPickMode )
return GeometryKind.None;
if ( _viewport.IdleSketchFeatureId is not null )
return GeometryKind.SketchRegion;
if ( _viewport.IdleEdges.Count > 0 )
return GeometryKind.Edge;
if ( _viewport.IdleFaces.Count > 0 )
return GeometryKind.Face | GeometryKind.Body;
if ( _viewport.IdleBodyIds.Count > 0 )
return GeometryKind.Body;
return GeometryKind.None;
}
/// <summary>
/// Light the bar's tools that will use what is selected.
///
/// The third consumer of Accepts, and the last one to be built: the type switch that DOES the
/// consuming, the sentence under the viewport that names the tools, and now the buttons
/// themselves. All three ask the same declaration, so the bar cannot mark a tool the sentence
/// leaves out.
///
/// ASKED ONCE PER SELECTION CHANGE, NEVER PER PAINT. AcceptedBy builds a fresh feature to read
/// a constant off it — cheap, but not free, and not something to do sixty times a second for
/// every button on screen. The answer is stored on the tool and the bar just draws it.
///
/// PART MODE ONLY. The sketch stages have their own vocabulary — points and curves, not faces
/// and bodies — and none of their tools declares Accepts, so marking there would mean marking
/// nothing, every time, forever.
/// </summary>
private void MarkToolsTakingSelection()
{
if ( _featureTools.Count == 0 )
return;
var selected = BarMode == EffigyBarMode.Part ? SelectedGeometry() : GeometryKind.None;
var changed = false;
foreach ( var (kind, tool) in _featureTools )
{
// Any overlap at all, not an exact match: a tool that takes faces OR bodies is worth
// marking when either is what you have.
var takes = selected != GeometryKind.None && (AcceptedBy( kind ) & selected) != GeometryKind.None;
if ( tool.Takes == takes )
continue;
tool.Takes = takes;
changed = true;
}
// Only when something actually moved. This runs on every selection change, and a selection
// change is also a viewport repaint — refreshing the bar unconditionally would put a widget
// update behind every click that lands on nothing.
if ( changed )
_stageBar?.Refresh();
}
/// <summary>
/// Put a new feature at the rollback bar. Split out of AddFeature so the pull gizmo places its
/// Move Face the same way the toolbar places everything else — see AddFeature for why the
/// comparison below is what it is.
/// </summary>
private void InsertAtRollback( Feature feature )
{
var at = Math.Min( _studio.RollbackIndex, _studio.Features.Count );
if ( at < _studio.Features.Count )
_studio.Insert( at, feature );
else
_studio.Add( feature );
// Only when the bar is a real position. int.MaxValue already means "evaluate everything"
// and must stay that way, or every add would pin it to a number that stops meaning "the
// end" the next time something is appended.
if ( _studio.RollbackIndex < _studio.Features.Count )
_studio.RollbackIndex = at + 1;
}
/// <summary>Copy the current viewport selection onto a feature the toolbar just made.</summary>
private void ApplyIdleGeometrySelection( Feature feature )
{
if ( _viewport is null || feature is null )
return;
if ( !_viewport.HasIdleSelection )
return;
feature.ApplyGeometrySelection( _viewport.IdleFaces, _viewport.IdleBodyIds, _studio.Bodies,
_viewport.IdleEdges, _viewport.IdleSketchFeatureId, _viewport.IdleRegionSeeds );
}
/// <summary>Hide or show one body, from the Parts list's eye or its Hide menu item.
///
/// Per body, not per feature: hiding one copy of a pattern must not hide the rest. No
/// MarkDirty — this is drawing, not geometry, and PartStudio reapplies HiddenBodyIds at the
/// end of every rebuild including a cached one.</summary>
private void OnPartVisibilityToggled( string bodyId )
{
if ( string.IsNullOrEmpty( bodyId ) )
return;
RecordUndo();
if ( !_studio.HiddenBodyIds.Remove( bodyId ) )
_studio.HiddenBodyIds.Add( bodyId );
RebuildStudio();
}
private void OnPartCommand( string bodyId, EffigyPartCommand command )
{
if ( string.IsNullOrEmpty( bodyId ) )
return;
switch ( command )
{
case EffigyPartCommand.Rename:
_partsPanel?.BeginRename( bodyId );
break;
case EffigyPartCommand.ToggleVisibility:
OnPartVisibilityToggled( bodyId );
break;
case EffigyPartCommand.Edit:
if ( FeatureForBody( bodyId ) is { } feature )
EditFeature( feature );
break;
case EffigyPartCommand.Isolate:
RecordUndo();
_studio.HiddenBodyIds.Clear();
foreach ( var body in _studio.Bodies )
{
if ( body.Id != bodyId )
_studio.HiddenBodyIds.Add( body.Id );
}
RebuildStudio();
break;
case EffigyPartCommand.ShowAll:
RecordUndo();
_studio.HiddenBodyIds.Clear();
RebuildStudio();
break;
case EffigyPartCommand.MakeBone:
if ( _studio.Bodies.FirstOrDefault( b => b.Id == bodyId ) is { } part )
MakeBonesFromBodies( new[] { part } );
break;
case EffigyPartCommand.CollisionAuto:
case EffigyPartCommand.CollisionBox:
case EffigyPartCommand.CollisionHull:
case EffigyPartCommand.CollisionNone:
RecordUndo();
if ( command == EffigyPartCommand.CollisionAuto )
_studio.BodyCollision.Remove( bodyId );
else
_studio.BodyCollision[bodyId] = command switch { EffigyPartCommand.CollisionBox => "box", EffigyPartCommand.CollisionHull => "hull", _ => "none" };
var collisions = _studio.BodyCollision.Count;
Log.Info( collisions == 0
? "[Effigy] collision is automatic again for every part"
: $"[Effigy] collision set on {collisions} part(s); the model's collision is now built per part. File > Collision Report shows it." );
RebuildStudio();
break;
}
}
/// <summary>
/// Delete a set of parts in one undo step. This is the Parts list's Delete, and the single
/// body it hands over when there is no multi-select.
///
/// An import's pieces are marked removed rather than taken out of the file; every other part
/// IS its feature, so deleting it removes the whole feature (and every other part that feature
/// made) exactly the way the feature tree does. Batched so selecting several parts and
/// deleting them is one Ctrl+Z rather than one per part.
/// </summary>
private void DeleteParts( IReadOnlyList<string> bodyIds ) => DeleteParts( bodyIds, recordUndo: true );
/// <summary>
/// Join several parts into one — Blender's Ctrl+J. The meshes are appended into one Import
/// kept beside the document, and the parts they came from are deleted, all as one undo step.
/// Materials come through by slot name; skin weights and paint do not, so join before rigging
/// and painting, which is when joining is wanted anyway.
/// </summary>
private void JoinParts( IReadOnlyList<string> bodyIds )
{
if ( _studio is null || bodyIds is null || bodyIds.Count < 2 )
return;
var joined = new PolyMesh();
var names = new List<string>();
foreach ( var id in bodyIds )
{
var body = _studio.Bodies.FirstOrDefault( b => b.Id == id );
if ( body?.Mesh is not { FaceCount: > 0 } )
continue;
MeshTransform.Append( joined, body.Mesh );
names.Add( body.Name );
}
if ( names.Count < 2 )
{
SetPrompt( "Join needs two or more parts with faces." );
return;
}
var import = new ImportFeature { Name = $"Joined {names[0]}" };
import.LoadMesh( System.Text.Encoding.UTF8.GetBytes( ObjWriter.Write( joined, "joined", materialName: _studio.NameForSlot ) ) );
RecordUndo();
DeleteParts( bodyIds, recordUndo: false );
InsertAtRollback( import );
RebuildStudio();
_featureTree?.Select( import );
SetPrompt( $"Joined {names.Count} parts into one: {string.Join( ", ", names )}. Merge by distance in Edit mode welds the seams if they touch." );
}
private void DeleteParts( IReadOnlyList<string> bodyIds, bool recordUndo )
{
if ( _studio is null || bodyIds is null || bodyIds.Count == 0 )
return;
var features = new HashSet<Feature>();
var piecesByImport = new Dictionary<ImportFeature, List<string>>();
foreach ( var id in bodyIds )
{
if ( string.IsNullOrEmpty( id ) || FeatureForBody( id ) is not { } feature )
continue;
if ( feature is ImportFeature import && import.PieceIndexFor( id ) >= 0 )
{
if ( !piecesByImport.TryGetValue( import, out var list ) )
piecesByImport[import] = list = new List<string>();
if ( !list.Contains( id ) )
list.Add( id );
}
else
{
features.Add( feature );
}
}
// Deleting every remaining piece of an import IS deleting the import, and must go through
// the same whole-feature removal as the feature tree — otherwise an import left with no
// pieces would sit in the tree as an empty row. The single-part delete already had this
// rule for its last piece; batch removal extends it to "all of them at once".
foreach ( var (import, ids) in piecesByImport )
{
if ( ids.Count >= import.RemainingPieceCount() )
features.Add( import );
}
if ( features.Count == 0 && piecesByImport.Count == 0 )
return;
if ( recordUndo )
RecordUndo();
foreach ( var feature in features )
{
if ( _dialog?.Feature == feature )
{
_dialog.Close();
RestoreRollbackAfterEdit();
}
_studio.Remove( feature );
}
foreach ( var (import, ids) in piecesByImport )
{
if ( features.Contains( import ) )
continue;
foreach ( var id in ids )
import.RemovePiece( id );
_studio.MarkDirty( import );
}
RebuildStudio();
}
private void OnPartRenamed( string bodyId, string name )
{
if ( string.IsNullOrEmpty( bodyId ) )
return;
RecordUndo();
var trimmed = string.IsNullOrWhiteSpace( name ) ? null : name.Trim();
if ( trimmed is null )
_studio.BodyNames.Remove( bodyId );
else
_studio.BodyNames[bodyId] = trimmed;
RebuildStudio();
}
/// <summary>Assign a body to a bone (or clear it) from the Parts list. The same binding the rig
/// panel makes from the bone's side, keyed on the same map, so both stay in agreement. Undo is
/// recorded here because the panel only reports the click — the window owns the studio.</summary>
private void OnBodyBoneAssigned( string bodyId, string boneName )
{
if ( string.IsNullOrEmpty( bodyId ) || _studio is null )
return;
// A bone name that does not resolve is refused rather than written — writing it would only
// throw at export time, and the menu only offers bones that exist, so this is a guard
// against drift rather than a user-facing path.
if ( !string.IsNullOrEmpty( boneName ) && _studio.Rig.IndexOf( boneName ) < 0 )
return;
RecordUndo();
if ( string.IsNullOrEmpty( boneName ) )
_studio.BodyBoneMap.Remove( bodyId );
else
_studio.BodyBoneMap[bodyId] = boneName;
NoteRigEdited();
_rigPanel?.RefreshBodyAssignments();
_partsPanel?.Refresh();
}
/// <summary>The Rig bar's Bone from Part — same as the Parts-list menu, pointed at whatever
/// is currently selected rather than at one right-clicked row.</summary>
private void MakeBonesFromSelectedParts()
{
if ( _studio is null || _viewport is null )
return;
var ids = _viewport.IdleBodyIds;
var bodies = _studio.Bodies.Where( b => ids.Contains( b.Id ) ).ToList();
if ( bodies.Count == 0 )
{
SetPrompt( "Select a part first — in the Parts list or the viewport." );
return;
}
MakeBonesFromBodies( bodies );
}
/// <summary>
/// Derive a bone for each of <paramref name="bodies"/> and pin the body to it. The Parts list
/// arrives here with one body; the feature tree arrives with every body one feature produced.
///
/// PARENTED TO THE SELECTED BONE WHEN THERE IS ONE, and that is more than a convenience. The
/// selected bone's tail is handed over as the anchor, which is the only thing that can tell a
/// derived bone which of its two ends is the root — measuring a finger says where it lies, never
/// which end is the knuckle. With a palm bone selected, a row of fingers therefore comes out
/// pointing away from the palm together; with nothing selected they come out canonically
/// oriented and some will want turning around by hand.
///
/// A BODY WITH NO AXIS IS SKIPPED, NOT REFUSED. Asking for eight bones and finding that one of
/// the eight is a sphere should still leave seven bones and a note about the eighth, so nothing
/// is written until every body has been measured and the batch is known to be worth an undo
/// step.
/// </summary>
private void MakeBonesFromBodies( IReadOnlyList<Body> bodies )
{
if ( _studio is null || bodies is null || bodies.Count == 0 )
return;
var rig = _studio.Rig;
var parent = _rigPanel is { HasSelectedBone: true } panel
? rig.IndexOf( panel.SelectedBoneName )
: -1;
Vec3? anchor = parent >= 0 ? rig.TailWorld( parent ) : null;
var derived = new List<(Body Body, Vec3 Head, Vec3 Tail, Vec3 Up)>();
var skipped = new List<string>();
foreach ( var body in bodies )
{
if ( BoneFromBody.TryDerive( body.Mesh, out var head, out var tail, out var up, anchor ) )
derived.Add( (body, head, tail, up) );
else
skipped.Add( body.Name );
}
if ( derived.Count == 0 )
{
// No undo step, because nothing happened. Said out loud rather than silently, since a
// menu item that appears to do nothing is indistinguishable from a broken one.
Log.Warning( $"[Effigy] nothing to measure a bone along in {string.Join( ", ", skipped )}" );
return;
}
RecordUndo();
string lastName = null;
foreach ( var (body, head, tail, up) in derived )
{
// Named after the part, which is named after the feature that made it unless somebody
// renamed the part — so the bone carries whatever the modeller last called this thing,
// which is the whole reason the feature tree is a way in to this at all.
var name = rig.UniqueName( BoneNameFor( body.Name ) );
rig.AddBoneFromPoints( name, parent, head, tail, up );
_studio.BodyBoneMap[body.Id] = name;
lastName = name;
}
NoteRigEdited();
_rigPanel?.Refresh();
_partsPanel?.Refresh();
if ( lastName is not null )
_rigPanel?.SelectBoneNamed( lastName );
if ( skipped.Count > 0 )
{
Log.Warning( $"[Effigy] {derived.Count} bone(s) made; "
+ $"no axis to measure along in {string.Join( ", ", skipped )}" );
}
else
{
Log.Info( $"[Effigy] {derived.Count} bone(s) made from the part's shape" );
}
}
/// <summary>
/// A part's name as a bone name.
///
/// Only whitespace is touched, and only because bone names travel: they are written into DMX,
/// SMD and the compiled model, read back by animation code, and typed into console commands
/// like the rig probe's. "index finger" survives all of those quoted and breaks the moment
/// something is not. Everything else about the name is left exactly as the modeller typed it —
/// a bone that does not match the part it came from would defeat the point of naming it after
/// the part.
/// </summary>
private static string BoneNameFor( string bodyName )
{
if ( string.IsNullOrWhiteSpace( bodyName ) )
return "bone";
var parts = bodyName.Split( (char[])null, StringSplitOptions.RemoveEmptyEntries );
return string.Join( "_", parts );
}
// --- pose preview -----------------------------------------------------------------------
/// <summary>The Pose button. Arms the preview against a bind-pose snapshot and the export's own
/// weights, so what the preview deforms is what the compiled model would ship. Toggling off is a
/// reset: the pose is a scratchpad, never a saved state.</summary>
private void OnPoseToggled()
{
if ( _viewport is null || _rigPanel is not { HasBones: true } rig )
return;
if ( _viewport.PosePreviewActive )
{
// Leaving pose mode restores the bind pose — a rig you can bend and cannot un-bend is
// worse than one that does not bend, and the toggle is the obvious way out.
_viewport.ResetPose();
_viewport.DisarmPosePreview();
_rigPanel.RefreshPoseState();
_viewport.Update();
RefreshPreview();
RefreshTutorial();
return;
}
// Same weights, same bind as CompileVmdl: BindBodies for the body pins, then SmoothWeights
// so joints bend rather than crease. Deforming with anything else would show a preview that
// disagrees with the export.
var (mesh, ranges) = _studio.ToMeshWithBodies();
var weights = SkinBinder.BindBodies( mesh, ranges, rig.BodyBoneMap, rig.Skeleton );
weights = SkinBinder.SmoothWeights( mesh, weights );
if ( _studio.WeightPaint is { Count: > 0 } )
_studio.WeightPaint.Apply( mesh, weights, rig.Skeleton, out _ );
if ( _viewport.ArmPosePreview( mesh, weights, rig.Skeleton ) )
{
_rigPanel.RefreshPoseState();
RefreshPosePreview();
}
RefreshTutorial();
}
private void OnPoseReset()
{
_viewport?.ResetPose();
_viewport?.Update();
RefreshPosePreview();
}
private void OnPoseChanged() => RefreshPosePreview();
/// <summary>Deform the display mesh by the current pose and put it on screen. Falls back to the
/// ordinary (bind) preview when the preview is off, or when the skeleton changed shape under it
/// (a bone placed or deleted) — re-arming is a fresh Pose press rather than a guess.</summary>
private void RefreshPosePreview()
{
if ( _viewport is null )
return;
var deformed = _viewport.PosePreviewActive ? _viewport.PoseMesh() : null;
if ( deformed is null )
{
RefreshPreview();
return;
}
_viewport.SetModel( BuildPreview( deformed ), frameCamera: false );
}
private Feature FeatureForBody( string bodyId )
{
var featureId = _studio.Bodies.FirstOrDefault( b => b.Id == bodyId )?.FeatureId;
return featureId is null ? null : _studio.Features.FirstOrDefault( f => f.Id == featureId );
}
private void OnTreeVisibilityToggled( string key, bool visible )
{
if ( _viewport is null )
return;
switch ( key )
{
case "origin": _viewport.OriginVisible = visible; break;
case "top": _viewport.TopPlaneVisible = visible; break;
case "front": _viewport.FrontPlaneVisible = visible; break;
case "right": _viewport.RightPlaneVisible = visible; break;
default:
// A datum plane's flag is read out of the tree by UpdateDatumPlanes rather than
// pushed as a property, because the viewport is handed the whole list every rebuild
// anyway and a second copy of "is this one hidden" would be one too many.
if ( key.StartsWith( "plane:" ) )
{
UpdateDatumPlanes( _dialog?.Feature );
break;
}
var sketch = _studio.Features.OfType<SketchFeature>()
.FirstOrDefault( x => $"sketch:{x.Id}" == key );
_viewport.SetSketchVisibility( sketch?.Sketch, visible );
break;
}
}
/// <summary>The window a fresh Effigy opens as: the feature tree on the left, the viewport
/// taking everything else, and nothing else on screen.
///
/// THE RIG AND MATERIALS DOCKS ARE REGISTERED BUT DELIBERATELY NOT OPENED. Both are for work
/// that comes after there is a shape to do it to, and a window that starts with three panels
/// open spends its first minute being closed rather than used. Each is one click away in View,
/// which is the whole reason every dock now has a line in that menu.</summary>
protected override void BuildDefaultLayout()
{
var featuresDock = DockManager.OpenDock( "Features", DockArea.Left, _centralDock );
DockManager.SetSplitterProportions( featuresDock, 0.26f, 0.74f );
DockManager.RaiseDock( "Features" );
}
// --- status bar -------------------------------------------------------------------------
private void BuildStatusBar()
{
_statusWidget = new StatusBar( this );
_statusWidget.AddWidgetLeft( new Editor.Label( "Effigy" ) { FixedWidth = 52 }, 0 );
_promptLabel = new Editor.Label( "" );
_statusWidget.AddWidgetLeft( _promptLabel, 1 );
_statusInfoLabel = new Editor.Label( "" );
_statusWidget.AddWidgetRight( _statusInfoLabel, 0 );
_viewport.ModelInfoChanged = info =>
{
if ( _statusInfoLabel.IsValid() )
_statusInfoLabel.Text = info;
};
StatusBar = _statusWidget;
}
// --- feature actions --------------------------------------------------------------------
/// <summary>
/// Selection in the tree opens that feature's dialog. A sketch that already has a plane
/// goes straight into sketch mode from that Open — selecting it is editing it. A sculpt
/// that already has a cage does the same.
///
/// A null selection deliberately does nothing. Every rebuild clears and refills the tree,
/// which momentarily reports "nothing selected" - closing the dialog on that would slam it
/// shut on the first tick of every slider drag, since dragging rebuilds.
/// </summary>
private void OnFeatureSelected( Feature feature )
{
if ( feature is null )
return;
if ( _viewport.IsSketching && feature != _dialog?.Feature )
FinishSketch();
// Sculpt closes its dialog on entry, so "still this feature" is the live sculpt rather
// than the dialog. Clicking the same row must not Finish and re-Open.
if ( _viewport.IsSculpting && feature != _sculptFeature )
FinishSculpt();
if ( _viewport.IsSculpting && feature == _sculptFeature )
return;
// Paint is the same shape: no dialog, so "still this feature" is the live paint session.
if ( _viewport.IsPainting && feature != _paintFeature )
FinishPaint();
if ( _viewport.IsPainting && feature == _paintFeature )
return;
// Selecting a paint feature IS painting it — there is no parameter dialog to stop at.
if ( feature is PaintFeature paint )
{
HighlightFeatureInViewport( feature );
EnterPaint( paint );
return;
}
// Same reasoning as FinishSketch above, for the bone tool: opening a dialog that may set
// SketchPickMode (Extrude/Revolve) or arm a body/plane picker of its own would otherwise
// collide with it exactly the way an open sketch would. Cheap to cancel outright — all
// that's lost is an empty pending-chain state, not a feature mid-edit.
if ( _viewport.BoneToolActive && feature != _dialog?.Feature )
_rigPanel?.CancelBoneTool();
if ( _dialog is null || (_dialog.IsOpen && _dialog.Feature == feature) )
return;
HighlightFeatureInViewport( feature );
_dialog.Open( feature, isNew: false );
}
/// <summary>Clicking a feature in the tree lights what it made: a sketch's shaded face, or
/// the parts a Primitive/Extrude/Fillet produced. Same idea as clicking a row in Parts.
/// </summary>
private void HighlightFeatureInViewport( Feature feature )
{
if ( _viewport is null || feature is null )
return;
_syncingSelection = true;
if ( feature is SketchFeature )
{
_viewport.SelectIdleSketch( feature.Id, null );
}
else
{
var ids = new List<string>();
foreach ( var body in _studio.Bodies )
{
if ( body.FeatureId == feature.Id )
ids.Add( body.Id );
}
if ( ids.Count > 0 )
_viewport.SelectBodies( ids );
}
_syncingSelection = false;
_partsPanel?.Select( _viewport.IdleBodyIds );
DescribeGeometrySelection();
}
private void OnDialogAccepted( Feature feature )
{
_resultStrip?.Bind( null, SketchHostBodyId );
// The "already open above" line belongs to a feature that is no longer pending - see
// AddFeature. Leaving it up would have it telling you to finish something you just did.
SetPrompt( "" );
if ( _viewport.IsSketching )
FinishSketch();
if ( _viewport.IsSculpting )
FinishSculpt();
RestoreRollbackAfterEdit();
RebuildStudio();
}
/// <summary>Cancel on a feature that the toolbar had just created removes it outright - the
/// feature only ever existed to be configured, so an abandoned dialog should leave the tree as
/// it was. Cancelling an edit has already had its parameters restored by the dialog.</summary>
private void OnDialogCancelled( Feature feature, bool wasNew )
{
_resultStrip?.Bind( null, SketchHostBodyId );
SetPrompt( "" );
if ( wasNew )
_studio.Remove( feature );
if ( _viewport.IsSketching )
FinishSketch();
if ( _viewport.IsSculpting )
FinishSculpt();
RestoreRollbackAfterEdit();
RebuildStudio();
}
private void OnStudioChanged()
{
RebuildStudio();
}
/// <summary>
/// Where the studio lives on disk, and whether it has been changed since it got there.
///
/// EVERY EDIT GOES THROUGH RebuildStudio, which is why the dirty flag is set there rather than
/// at each of the thirty-odd call sites. Marking at the funnel cannot be forgotten by whoever
/// adds the thirty-first; marking at the sites is a promise nobody keeps for long. Load, save
/// and new all rebuild too, so each of those clears the flag afterwards.
/// </summary>
private string _documentPath;
private bool _dirty;
private void MarkClean()
{
_dirty = false;
UpdateTitle();
}
private void UpdateTitle() =>
Title = $"Effigy - {(_documentPath is null ? "untitled" : Path.GetFileName( _documentPath ))}{(_dirty ? "*" : "")}";
/// <summary>
/// Once a frame, from the viewport's canvas. Turns a preview back into the real thing once the
/// edits stop.
///
/// THE SETTLE IS WHAT MAKES THE PREVIEW SAFE. A cheap rebuild is a lie about the model - no
/// drape, no thickness, no weights - and the only thing that makes telling it acceptable is
/// that it is corrected within a fifth of a second, without being asked, every time. So this
/// runs off a clock rather than off a mouse-up: a drag that ends outside the window, a slider
/// released on a disabled control, a value typed instead of dragged and a hot reload taken
/// mid-drag all stop producing edits, and that is the only condition this waits for.
/// </summary>
private void OnTick()
{
if ( _settleAt <= 0f || RealTime.Now < _settleAt )
return;
_settleAt = 0f;
if ( !_studio.PreviewQuality )
return;
_studio.PreviewQuality = false;
// Every feature that took the cheap path cached a cheap result, so asking for a rebuild
// without dirtying them would hand those snapshots straight back. Only the feature being
// edited can have previewed - it is the only one marked dirty per edit - but its
// DOWNSTREAM neighbours consumed its preview output, so the rebuild has to start there.
if ( _dialog?.Feature is { } feature )
_studio.MarkDirty( feature );
RebuildStudio();
}
private void RebuildStudio()
{
// A test pose bends the document's rig in place; rebuilding on it would cut the garment
// to the pose. See TestRelax.
TestRelax( false );
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
// Timed so OnFeatureEdited can decide whether previewing is worth it - see PreviewThreshold.
// Only a FULL rebuild is measured: a preview is cheap by construction, and letting one set
// the bar would turn previewing off after the first time it worked.
var started = RealTime.Now;
var report = _studio.Rebuild();
if ( !_studio.PreviewQuality )
_rebuildCost = RealTime.Now - started;
SyncFurMaterials();
SyncFabricMaterials();
_featureTree?.Rebuild();
_partsPanel?.Refresh();
RefreshMeshEditPanel();
UpdateObjectChecks();
_materialsPanel?.Refresh();
_variablesPanel?.Rebuild();
_rigPanel?.RefreshBodyNames();
// Covers every other way the lock can change — undo back past the first sketch, deleting
// it, opening a saved studio. Cheap: it returns immediately unless a stage's lock is
// actually wrong.
if ( BarMode == EffigyBarMode.Part )
ShowPartStages();
// Show whatever DID build, errors or not. A broken feature halfway down the tree should
// leave the part above it on screen — going blank hides the very geometry you need to
// look at to work out what the failing feature is missing.
RefreshPreview();
// The preview model is one flat grey, so a material slot is invisible in it. The viewport
// tints the faces that carry one instead, and needs the bodies to do it - the mesh handed to
// EffigyPreview above has already been flattened into one and lost which body it came from.
_viewport?.SetDisplayBodies( _studio.Bodies );
// Notes are not in the history and nothing above rebuilt them, but the surfaces they land
// on have just moved and a New or an Open has swapped the list out from under the pen.
RefreshNotes();
// Rebuild() above discarded every tree node, taking the highlight with it. The feature
// being edited has to stay visibly selected or the tree and the dialog disagree about
// what you are working on.
if ( _dialog?.Feature is { } editing )
_featureTree?.Select( editing );
UpdateDisplaySketches();
// The face an open sketch is drawn on has just been rebuilt, so its outline is a rebuild
// out of date. This is what makes editing the block underneath while its sketch is open
// work rather than quietly snap to where that block used to be — and it is why the outline
// lives here rather than being handed over once on entry.
if ( _viewport?.IsSketching == true && ActiveSketchFeature() is { } openSketch )
RefreshSketchReference( openSketch );
// Feature.Error and Feature.Warning are only meaningful once the studio has tried to run
// the feature, so the dialog's state is refreshed here rather than when it was opened.
_dialog?.RefreshState();
// AFTER the rebuild, never before. Every check the tutorial makes is about what the tree
// produced — a body with volume, a fillet that did not error — and all of those are stale
// or absent until Rebuild has run. Asking first would tick step one off on the rebuild
// after the reader finished it, which reads as the tutorial lagging a move behind.
RefreshTutorial();
if ( report.HasErrors )
Log.Warning( $"[Effigy] rebuild: {string.Join( "; ", report.Errors.Select( e => e.Message ) )}" );
}
/// <summary>The engine's dev checker, worn as a whole-part material when the UV checker overlay
/// is on. The number grid says scale and orientation, and any stretch or seam in the UVs reads
/// straight off the surface.</summary>
private const string CheckerMaterialPath = "materials/dev/dev_measuregeneric01.vmat";
/// <summary>Whether the part is drawn with the checker instead of its own materials. A view
/// setting, so it is remembered in a cookie and never marks the document dirty.</summary>
private bool _showUVChecker;
/// <summary>
/// Rebuild the on-screen model from the studio as it already stands, without re-running the
/// features.
///
/// The checker overlay toggles only the material the preview wears, so it needs this rather than
/// a full <see cref="RebuildStudio"/> — a view setting must not re-execute the history or mark
/// the document dirty.
/// </summary>
private void RefreshPreview() => RefreshPreview( VisibleMeshForPreview() );
/// <summary>
/// The mesh the preview builds from. One visible body is the common case for a fresh import, and
/// it is handed over directly — the preview only reads, and <see cref="PartStudio.ToVisibleMesh"/>
/// would otherwise clone the whole dense mesh (positions plus a per-face index and UV array) for
/// nothing. Several bodies still merge.
/// </summary>
private PolyMesh VisibleMeshForPreview()
{
PolyMesh single = null;
var count = 0;
foreach ( var body in _studio.Bodies )
{
if ( !body.Visible )
continue;
count++;
if ( count == 1 )
{
single = body.Mesh;
}
else
{
return _studio.ToVisibleMesh();
}
}
return count == 1 ? single : _studio.ToVisibleMesh();
}
private void RefreshPreview( PolyMesh visible )
{
var special = _showUVChecker || (_viewport is { SectionEnabled: true }) || visible.HasPaint;
// Plain material-slot path: keep a LivePreview and rewrite its vertex buffers when only
// positions change — the Transform handle, or typing into its fields. Rebuilding the Model
// and its GameObject every frame is what made dragging a dense part sluggish.
if ( !special )
{
if ( _livePreview is null
|| _viewport?.CurrentModel != _livePreview.Model
|| !_livePreview.TryUpdate( visible, SlotMaterial, MeshNormals.DefaultSmoothingAngleDegrees ) )
{
_livePreview = EffigyPreview.LivePreview.Build( visible, SlotMaterial,
MeshNormals.DefaultSmoothingAngleDegrees );
var preview = _livePreview.Model;
_viewport?.SetModel( preview, frameCamera: preview is not null && !_hasPreview );
_hasPreview = preview is not null;
}
return;
}
// Paint, checker and section cut are rare paths and rebuild fully each time.
_livePreview = null;
var specialPreview = BuildPreview( visible );
// Frame only when geometry first appears. Every later rebuild leaves the camera alone,
// because rebuilds also happen on every parameter tick and the view must hold still
// while you drag.
_viewport?.SetModel( specialPreview, frameCamera: specialPreview is not null && !_hasPreview );
_hasPreview = specialPreview is not null;
}
/// <summary>
/// The preview for a mesh: each face's slot resolves to the material dropped on it, so the part
/// wears the real vmats rather than one flat placeholder. Unbound slots come back null and fall
/// back. A painted body carries its atlas, which needs the paint-preview path — one material
/// with the canvas texture bound — rather than the per-slot bucketing. The checker overlay
/// overrides all of that, because a diagnostic is the thing you asked to see, not the part.
/// </summary>
private Model BuildPreview( PolyMesh visible )
{
if ( _showUVChecker )
{
var checker = Material.Load( CheckerMaterialPath );
if ( checker is not null )
return EffigyPreview.Build( visible, checker );
}
if ( _viewport is { SectionEnabled: true } )
visible = MeshClip.Keep( visible, _viewport.SectionOrigin, _viewport.SectionNormal );
if ( visible.HasPaint )
return BuildPaintPreview( visible );
return EffigyPreview.Build( visible, slot => _studio.MaterialNames.TryGetValue( slot, out var name ) ? name : null );
}
private void OnVariablesChanged()
{
_dirty = true;
UpdateTitle();
RebuildStudio();
_variablesPanel?.Rebuild();
}
/// <summary>
/// View ▸ Reference Images ▸ Add: pick a picture and stand it in the viewport facing the named
/// view. The file has to live under the project's Assets folder, because that is what the
/// engine's texture loader can see; one picked from anywhere else is copied in.
/// </summary>
private void AddReferenceImage( string viewName )
{
var fd = new FileDialog( null )
{
Title = $"Add {viewName} reference image",
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SetFindFile();
fd.SetNameFilter( "Images (*.png *.jpg *.jpeg *.tga)" );
if ( !fd.Execute() || string.IsNullOrWhiteSpace( fd.SelectedFile ) )
return;
string relative;
try
{
var root = EffigyAssetFolder.AssetsRoot();
var full = System.IO.Path.GetFullPath( fd.SelectedFile );
if ( root is not null && full.StartsWith( root, StringComparison.OrdinalIgnoreCase ) )
{
relative = full[root.Length..].TrimStart( '\\', '/' ).Replace( '\\', '/' );
}
else
{
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy/reference" );
System.IO.Directory.CreateDirectory( folder );
var target = System.IO.Path.Combine( folder, System.IO.Path.GetFileName( full ) );
System.IO.File.Copy( full, target, true );
EffigyAssetFolder.Register( folder );
relative = $"models/effigy/reference/{System.IO.Path.GetFileName( full )}";
Log.Info( $"[Effigy] copied the picture into the project as {relative}, where the engine can load it" );
}
}
catch ( Exception e )
{
Log.Error( $"[Effigy] could not use that picture: {e.Message}" );
return;
}
RecordUndo();
// Sized to the size reference when it is up, else to the model, else a person.
var height = _viewport?.SizeReferenceHeight > 0f ? _viewport.SizeReferenceHeight
: _studio.Bodies.Count > 0 ? MathF.Max( _studio.ToMesh().BoundsDiagonal * 0.6f, 1f ) : 72f;
_studio.ReferenceImages.Add( new ReferenceImage { Path = relative, View = viewName, Height = height, Depth = height * 0.5f } );
SyncReferenceImages();
BuildMenuBar();
_viewport?.SetStandardView( viewName switch { "side" => EffigyViewport.StandardView.Right, "top" => EffigyViewport.StandardView.Top, _ => EffigyViewport.StandardView.Front } );
Log.Info( $"[Effigy] {viewName} reference image added. View ▸ Reference Images adjusts it; it is saved with the document." );
}
/// <summary>Change one picture (or remove it, with a null change), as one undo step.</summary>
private void EditReferenceImage( ReferenceImage image, Action<ReferenceImage> change )
{
RecordUndo();
if ( change is null )
_studio.ReferenceImages.Remove( image );
else
change( image );
SyncReferenceImages();
BuildMenuBar();
}
/// <summary>Push the document's pictures into the viewport. Called after any change to the
/// list and whenever the document is swapped or restored.</summary>
private void SyncReferenceImages()
{
_viewport?.SetReferenceImages( _studio?.ReferenceImages );
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
}
private void ToggleSectionView()
{
if ( _viewport is null )
return;
_viewport.SectionEnabled = !_viewport.SectionEnabled;
RebuildStudio();
SetPrompt( _viewport.SectionEnabled
? "Section view on — looking through the origin along +X. Toggle again to restore."
: "Section view off." );
}
// --- tutorial ------------------------------------------------------------------------------
/// <summary>Open the tutorial dock and put it back at step one. The Help menu's whole
/// contents, and the reason that menu exists.</summary>
private void StartTutorial()
{
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
SyncDockChecks();
_tutorial?.Restart( EffigyLesson.House );
RefreshTutorial();
}
private void StartRigTutorial()
{
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
SyncDockChecks();
_tutorial?.Restart( EffigyLesson.Rigging );
RefreshTutorial();
}
private void StartHandTutorial()
{
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
SyncDockChecks();
_tutorial?.Restart( EffigyLesson.Hand );
RefreshTutorial();
}
private void StartPlayermodelTutorial()
{
DockManager.SetDockState( "Tutorial", true );
DockManager.RaiseDock( "Tutorial" );
SyncDockChecks();
// OFFER, NOT RESTART. This lesson's start screen is where you choose between the example
// robot and your own model, and Restart sets Active - which makes the panel draw step one
// and skip that screen entirely. See EffigyTutorial.Offer.
_tutorial?.Offer( EffigyLesson.Playermodel );
RefreshTutorial();
}
/// <summary>Open a dock and bring it to the front, for the panel's "show me the X panel"
/// button. Opening without raising is not enough — a dock tabbed behind another comes back
/// visible and still hidden, which looks exactly like the button doing nothing.</summary>
private void RevealDock( string title )
{
if ( string.IsNullOrEmpty( title ) )
return;
DockManager.SetDockState( title, true );
DockManager.RaiseDock( title );
SyncDockChecks();
}
/// <summary>Re-read the document, advance the tutorial past anything already done, and repaint
/// the panel. Called from RebuildStudio, so a step ticks off on the same rebuild that
/// satisfied it rather than on whatever the reader happens to do next.</summary>
private void RefreshTutorial()
{
if ( _tutorial is null )
return;
_tutorial.Evaluate( new EffigyTutorialState( _studio, CurrentWorkspace,
_viewport is { PosePreviewActive: true } ) );
// Rebuild unconditionally rather than only when Evaluate moved. The panel also renders
// Active, the step's own pointer affordance and the highlight, and those change on a
// Restart or a Dismiss that moved nothing at all.
_tutorialPanel?.Rebuild();
}
/// <summary>Told by the panel which tool the current step wants, or null for none. Stores the
/// target and re-applies; never stores a button.</summary>
private void HighlightTool( EffigyToolTarget? target )
{
_highlightedTool = target;
ApplyToolHighlight();
}
/// <summary>
/// Push the current highlight onto the tools, and bring the one it wants into view.
///
/// Clears every tool before setting one, rather than tracking which was lit last: the cost of
/// being certain is one pass over nineteen objects, and a stale ring on a button nobody can
/// turn off is the failure it buys out of.
///
/// REVEALING IS THE HALF THE STRIP COULD NOT DO. A tutorial that says "press Extrude" used to
/// be pointing at a button that was either on screen or hidden by the starter set, with
/// nothing in between; now the tool always exists, and lighting it also opens the stage it
/// lives on so the reader is looking at it.
/// </summary>
private void ApplyToolHighlight()
{
if ( _boneTool is not null )
_boneTool.Attention = _highlightedTool == EffigyToolTarget.AddBone;
if ( _boneFromPartTool is not null )
_boneFromPartTool.Attention = _highlightedTool == EffigyToolTarget.BoneFromPart;
if ( _highlightedTool == EffigyToolTarget.AddBone && _boneTool is not null )
{
foreach ( var (_, tool) in _featureTools )
tool.Attention = false;
_stageBar?.Reveal( _boneTool );
_stageBar?.Refresh();
return;
}
if ( _highlightedTool == EffigyToolTarget.BoneFromPart && _boneFromPartTool is not null )
{
foreach ( var (_, tool) in _featureTools )
tool.Attention = false;
_stageBar?.Reveal( _boneFromPartTool );
_stageBar?.Refresh();
return;
}
if ( _subdivideHomeTool is not null )
_subdivideHomeTool.Attention = _highlightedTool == EffigyToolTarget.Subdivide;
if ( _sculptHomeTool is not null )
_sculptHomeTool.Attention = _highlightedTool == EffigyToolTarget.Sculpt;
if ( _paintHomeTool is not null )
_paintHomeTool.Attention = _highlightedTool == EffigyToolTarget.Paint;
if ( _highlightedTool == EffigyToolTarget.Subdivide && _subdivideHomeTool is not null )
{
foreach ( var (_, tool) in _featureTools )
tool.Attention = false;
_stageBar?.Reveal( _subdivideHomeTool );
_stageBar?.Refresh();
return;
}
if ( _highlightedTool == EffigyToolTarget.Sculpt && _sculptHomeTool is not null )
{
foreach ( var (_, tool) in _featureTools )
tool.Attention = false;
_stageBar?.Reveal( _sculptHomeTool );
_stageBar?.Refresh();
return;
}
if ( _highlightedTool == EffigyToolTarget.Paint && _paintHomeTool is not null )
{
foreach ( var (_, tool) in _featureTools )
tool.Attention = false;
_stageBar?.Reveal( _paintHomeTool );
_stageBar?.Refresh();
return;
}
if ( _featureTools.Count == 0 || BarMode != EffigyBarMode.Part )
return;
var wanted = _highlightedTool is { } target ? ToolKindFor( target ) : null;
EffigyStageTool lit = null;
foreach ( var (kind, tool) in _featureTools )
{
tool.Attention = wanted == kind;
if ( tool.Attention )
lit = tool;
}
// Only when it is not already in front of the reader — Reveal is a no-op on the current
// stage, so this cannot fight somebody who has just navigated somewhere themselves.
if ( lit is not null )
_stageBar?.Reveal( lit );
_stageBar?.Refresh();
}
/// <summary>
/// The tutorial's vocabulary of tools, mapped onto the strip's.
///
/// The one place the two enums meet, and the reason they are two enums: the strip's list is
/// free to grow without the tutorial silently claiming to teach whatever was added, and the
/// tutorial's list is free to name a tool the strip has not got yet — which returns null here
/// and lights nothing, rather than throwing in a paint path.
/// </summary>
private static ToolKind? ToolKindFor( EffigyToolTarget target ) => target switch
{
EffigyToolTarget.Primitive => ToolKind.Primitive,
EffigyToolTarget.Hole => ToolKind.Hole,
_ => null,
};
/// <summary>Push all committed sketches from the feature tree into the viewport so they
/// remain visible after leaving sketch mode, and push the subset a feature being edited is
/// allowed to pick — only sketches standing before it in the history, since a feature cannot
/// consume a sketch that has not run yet.</summary>
private void UpdateDisplaySketches() => UpdatePickTargets( _dialog?.Feature );
/// <summary>Turn the material-slot tint on and off. OFF by default, because the preview now
/// renders the real vmat bound to each slot and a tint over the top of that is a lie about what
/// the part is made of. Turn it on to ask the other question - which SLOT a face is on, which two
/// slots sharing one material or an unbound slot cannot be read off the rendered colour.</summary>
private void ToggleMaterialShading()
{
if ( _viewport is null )
return;
_viewport.ShadeMaterialSlots = !_viewport.ShadeMaterialSlots;
}
/// <summary>Whether the rules holding a sketch together are drawn on it. On by default — a
/// constraint you cannot see is a constraint you fight, and until now there were none to see
/// because there was no way to add one.</summary>
private void ToggleConstraintMarks()
{
if ( _viewport is null )
return;
_viewport.ShowConstraintMarks = !_viewport.ShowConstraintMarks;
}
/// <summary>Rebuild both sketch lists against the feature a dialog is open on. Called by the
/// dialog the moment it opens, because the pick list and the auto-arm decision are only
/// correct relative to THAT feature.</summary>
private void UpdatePickTargets( Feature editing )
{
if ( _viewport is null )
return;
var sketchFeatures = _studio.Features.OfType<SketchFeature>().ToList();
_viewport.SetDisplaySketches( sketchFeatures.Select( f => f.Sketch ) );
UpdateSketchVisibility( sketchFeatures, editing );
var cutoff = editing is null ? int.MaxValue : _studio.Features.IndexOf( editing );
if ( cutoff < 0 )
cutoff = int.MaxValue;
_viewport.SetPickableSketches( _studio.Features.Take( cutoff )
.OfType<SketchFeature>()
.Select( f => new EffigyViewport.PickableSketch( f.Id, f.Name ?? f.TypeName, f.Sketch ) ) );
UpdateDatumPlanes( editing );
}
/// <summary>
/// Hand the viewport the datum planes the last rebuild published, with the names, hide flags and
/// tree positions it needs to draw and offer them.
///
/// TAKEN FROM THE REBUILD, NOT FROM THE FEATURES. PartStudio.Planes is what the tree actually
/// produced; the features carry the parameters that produced it, and during a parameter drag
/// those are ahead of the model. Drawing from them would put the plane where the number says
/// while everything built on it is still where the rebuild left it.
///
/// EVERY PLANE IS DRAWN AND ONLY SOME ARE PICKABLE. A feature can only be built on a plane
/// standing before it in the history, so the ones at or below it are not answers — but they are
/// still part of the model, and the one you are editing is the one you most need to see. The
/// sketch list a few lines up cuts at the same point and does not need the distinction, because
/// nothing is ever editing a sketch's own dialog and looking at that sketch in space at once.
/// </summary>
private void UpdateDatumPlanes( Feature editing )
{
var cutoff = editing is null ? int.MaxValue : _studio.Features.IndexOf( editing );
if ( cutoff < 0 )
cutoff = int.MaxValue;
// Only while a plane's own dialog is open: the axes are the answer to that dialog's Tilt
// about dropdown, and a permanent pair of crosshairs on every plane would be clutter.
_viewport.PlaneAxesShownOn = (editing as PlaneFeature)?.Id;
_viewport.SetDatumPlanes( _studio.Features
.Select( ( f, index ) => (Feature: f as PlaneFeature, Index: index) )
.Where( entry => entry.Feature is not null && _studio.Planes.ContainsKey( entry.Feature.Id ) )
.Select( entry => new EffigyViewport.PickablePlane(
entry.Feature.Id,
entry.Feature.Name ?? entry.Feature.TypeName,
_studio.Planes[entry.Feature.Id],
_featureTree?.IsVisible( PlaneVisibilityKey( entry.Feature ) ) ?? true,
entry.Index < cutoff,
// Which way this plane's offset handle pulls. Worked out here rather than in the
// viewport because it needs the feature's Angle and Hinge, and the viewport is handed
// the results of a rebuild rather than the parameters a drag is busy changing.
entry.Feature.OffsetAxis( _studio.Planes[entry.Feature.Id] ) ) ) );
}
/// <summary>The tree's hide flag for a datum plane. One spelling of the key, because the tree
/// writes it and the viewport reads it and a second spelling would silently hide nothing.</summary>
internal static string PlaneVisibilityKey( Feature plane ) => $"plane:{plane.Id}";
/// <summary>
/// Hide the sketches that have already been turned into geometry, keeping the eye in the
/// feature tree authoritative wherever it has been clicked.
///
/// The one sketch that is always shown regardless is the one the open dialog is building
/// from: you cannot pick a region of a sketch that is not on screen, and while a feature is
/// being edited its input is the thing you are looking at.
/// </summary>
private void UpdateSketchVisibility( List<SketchFeature> sketchFeatures, Feature editing )
{
var editingId = editing is SketchConsumingFeature consumer
? _studio.ResolveSketchFeatureId( consumer )
: null;
foreach ( var feature in sketchFeatures )
{
var visible = _featureTree?.IsVisible( $"sketch:{feature.Id}" ) ?? true;
_viewport.SetSketchVisibility( feature.Sketch, visible || feature.Id == editingId );
}
}
private void NewStudio() => ConfirmDiscard( () =>
{
RecordUndo();
_studio = new PartStudio();
_featureTree?.SetStudio( _studio );
_partsPanel?.SetStudio( _studio );
_materialsPanel?.SetStudio( _studio );
_variablesPanel?.Bind( _studio );
_rigPanel?.SetStudio( _studio );
_dialog?.Close();
_viewport?.ClearReferenceImages();
BuildMenuBar();
// The handle has to show the pivot the document carries, or opening a file would leave the
// marker at zero while the export used the saved value. SetOrigin raises OriginMoved and so
// dirties the document; both callers MarkClean() below, after this.
SyncOriginFromStudio();
RebuildStudio();
_documentPath = null;
MarkClean();
} );
/// <summary>
/// Replace the document with the sample humanoid, for the playermodel tutorial's "Use the
/// example robot" button.
///
/// NewStudio with one line changed, and for the same reasons: the discard confirmation so an
/// unsaved model is not thrown away by a button on a tutorial panel, the undo step so the
/// replacement is reversible, and the panel resets so nothing is still pointing at a feature
/// that no longer exists.
/// </summary>
private void LoadPlayermodelExample() => ConfirmDiscard( () =>
{
RecordUndo();
_studio = HumanoidSample.Build();
_featureTree?.SetStudio( _studio );
_partsPanel?.SetStudio( _studio );
_materialsPanel?.SetStudio( _studio );
_variablesPanel?.Bind( _studio );
_rigPanel?.SetStudio( _studio );
_dialog?.Close();
SyncOriginFromStudio();
RebuildStudio();
// NO document path. The sample is a starting point, not a file somebody opened, and leaving
// the previous path in place would let Save overwrite whatever was open before.
_documentPath = null;
MarkClean();
_viewport?.FrameCamera();
Log.Info( $"[Effigy] loaded the example humanoid - {_studio.Bodies.Count} parts, "
+ "each named after the bone it becomes" );
// AND ONLY NOW does the lesson begin. ConfirmDiscard above shows a popup and returns
// rather than blocking, so this is the first point at which the robot is definitely on
// screen - and pressing Cancel in that popup lands here never, which is right: the reader
// backed out, so they should still be looking at the start screen and their own model.
_tutorial?.Restart( EffigyLesson.Playermodel );
RefreshTutorial();
} );
private void DeleteSelectedFeature()
{
if ( _featureTree?.SelectedFeature is { } feature )
{
RecordUndo();
_studio.Remove( feature );
_dialog?.Close();
RebuildStudio();
}
}
private void MoveFeatureUp()
{
if ( _featureTree?.SelectedFeature is not { } feature )
return;
var idx = _studio.Features.IndexOf( feature );
if ( idx > 0 )
{
RecordUndo();
_studio.Move( idx, idx - 1 );
RebuildStudio();
}
}
private void MoveFeatureDown()
{
if ( _featureTree?.SelectedFeature is not { } feature )
return;
var idx = _studio.Features.IndexOf( feature );
if ( idx < _studio.Features.Count - 1 )
{
RecordUndo();
_studio.Move( idx, idx + 1 );
RebuildStudio();
}
}
/// <summary>
/// Where the feature tree's context menu ends up. The panel raises intent; everything that
/// needs the studio, the dialog or the undo stack happens here.
/// </summary>
private void OnFeatureCommand( Feature feature, EffigyFeatureCommand command )
{
if ( feature is null )
return;
var index = _studio.Features.IndexOf( feature );
switch ( command )
{
case EffigyFeatureCommand.Edit:
EditFeature( feature );
break;
case EffigyFeatureCommand.Sculpt:
if ( feature is SculptFeature )
EditFeature( feature );
break;
case EffigyFeatureCommand.Rename:
_featureTree?.BeginRename( feature );
break;
case EffigyFeatureCommand.ToggleSuppress:
RecordUndo();
feature.Suppressed = !feature.Suppressed;
_studio.MarkDirty( feature );
RebuildStudio();
break;
case EffigyFeatureCommand.Delete:
RecordUndo();
if ( _dialog?.Feature == feature )
{
_dialog.Close();
RestoreRollbackAfterEdit();
}
_studio.Remove( feature );
RebuildStudio();
break;
case EffigyFeatureCommand.MoveUp when index > 0:
RecordUndo();
_studio.Move( index, index - 1 );
RebuildStudio();
break;
case EffigyFeatureCommand.MoveDown when index >= 0 && index < _studio.Features.Count - 1:
RecordUndo();
_studio.Move( index, index + 1 );
RebuildStudio();
break;
// An explicit move of the bar STICKS. Forgetting the pre-edit position is the point:
// otherwise closing a dialog that happened to be open would put the bar back and undo
// the move the user just made by hand.
case EffigyFeatureCommand.RollbackTo when index >= 0:
RecordUndo();
_rollbackBeforeEdit = null;
SetRollback( index );
break;
case EffigyFeatureCommand.RollForward:
RecordUndo();
_rollbackBeforeEdit = null;
SetRollback( int.MaxValue );
break;
// Bodies in the order the rebuild appended them, which is the order the feature made
// them in — so a pattern's copies become bones numbered the way the pattern laid them
// out rather than in some order of the rig's own.
case EffigyFeatureCommand.MakeBones:
MakeBonesFromBodies( _studio.Bodies.Where( b => b.FeatureId == feature.Id ).ToList() );
break;
}
}
private void OnFeatureRenamed( Feature feature, string name )
{
if ( feature is null )
return;
RecordUndo();
// Blank means "no name of your own", which is what a feature starts with - the tree falls
// back to the type name. Storing "" instead would print an empty row.
feature.Name = string.IsNullOrWhiteSpace( name ) ? null : name.Trim();
_featureTree?.Rebuild();
_partsPanel?.Refresh();
if ( _dialog?.Feature == feature )
_dialog.Open( feature, isNew: false );
}
/// <summary>Move the rollback bar and rebuild. RollbackIndex is the index of the first feature
/// NOT evaluated, so int.MaxValue means "everything runs".</summary>
private void SetRollback( int index )
{
_studio.RollbackIndex = index;
RebuildStudio();
}
/// <summary>
/// Onshape's edit: roll the model back to how it looked WHEN THIS FEATURE RAN, and open its
/// parameters. Editing an extrude with six features stacked on top of it is otherwise done
/// blind - you cannot see the thing you are changing.
///
/// The previous bar position is remembered and put back when the dialog closes, so an edit
/// does not silently leave half the model switched off. An explicit "Roll back to before
/// this" from the menu is the one that sticks.
/// </summary>
private void EditFeature( Feature feature )
{
var index = _studio.Features.IndexOf( feature );
if ( index < 0 )
return;
_rollbackBeforeEdit ??= _studio.RollbackIndex;
_studio.RollbackIndex = index + 1;
RebuildStudio();
_featureTree?.Select( feature );
// Sculpt closes its dialog on entry. Opening it here would put the body picker back
// up after EnterSculpt had just dismissed it. Select() may already have Opened (and
// entered) via OnFeatureSelected; EnterSculpt is a no-op when that sculpt is active.
if ( feature is SculptFeature sculpt )
{
EnterSculpt( sculpt );
return;
}
if ( feature is MeshEditFeature meshEdit )
{
EnterMeshEdit( meshEdit );
return;
}
// Paint is the same shape as sculpt: editing it means painting it, not parking on a dialog.
if ( feature is PaintFeature paint )
{
EnterPaint( paint );
return;
}
_dialog?.Open( feature, isNew: false );
// A sketch's Edit is entering the sketch, not parking on a dialog that asks you to
// confirm you meant it. Open() also requests this when the plane is already chosen;
// EnterSketch is a no-op when that sketch is already active.
if ( feature is SketchFeature sketch )
EnterSketch( sketch );
}
/// <summary>Where the rollback bar was before an Edit temporarily moved it. Null when no edit
/// has moved it.</summary>
private int? _rollbackBeforeEdit;
/// <summary>Put the bar back after an edit finishes, whichever way it finished.</summary>
private void RestoreRollbackAfterEdit()
{
if ( _rollbackBeforeEdit is not { } previous )
return;
_rollbackBeforeEdit = null;
_studio.RollbackIndex = previous;
}
private void ToggleSuppressFeature()
{
if ( _featureTree?.SelectedFeature is { } feature )
{
RecordUndo();
feature.Suppressed = !feature.Suppressed;
// Without this the rebuild restores everything above the first dirty feature from the
// cache, so the feature you just suppressed is re-used exactly as it was and nothing
// on screen changes.
_studio.MarkDirty( feature );
RebuildStudio();
}
}
// --- export / compile (reusing EffigyTool's proven logic) -------------------------------
[Shortcut( "editor.save", "CTRL+S", ShortcutType.Window )]
private void Save()
{
// A studio that has never been saved has nowhere to go, so Save becomes Save As the first
// time. Silently doing nothing here is the shape of the bug the rig tool had.
if ( _documentPath is null )
{
SaveAs();
return;
}
WriteDocument( _documentPath );
}
private void SaveAs()
{
var fd = new FileDialog( null )
{
Title = "Save Part Studio As...",
DefaultSuffix = StudioDocument.Extension,
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SelectFile( _documentPath ?? $"untitled{StudioDocument.Extension}" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( $"Effigy Part Studio (*{StudioDocument.Extension})" );
if ( !fd.Execute() )
return;
WriteDocument( fd.SelectedFile );
}
private void WriteDocument( string path )
{
// A rig bent by a clothing test pose must not be what gets written. See TestRelax.
TestRelax( false );
try
{
StudioDocument.WriteFile( _studio, path );
}
catch ( Exception e )
{
// Saving is the one operation where failing quietly is unforgivable: the whole point of
// pressing it is to be able to close the window.
Log.Error( $"[Effigy] could not save to {path}: {e.Message}" );
return;
}
// THE DELTAS ARE NOT IN THE DOCUMENT. StudioDocument saves a feature's public fields, and a
// sculpt's state is megabytes of per-vertex deltas that deliberately do not go into a text
// format - see SculptFeature. Without this the .effigy file saves perfectly and the sculpt is
// gone, which is the worst shape a save bug can have: it looks like it worked.
try
{
var blobs = SculptSidecar.Save( _studio, path );
if ( blobs > 0 )
Log.Info( $"[Effigy] wrote {blobs} sculpt blob(s) beside {path}" );
}
catch ( Exception e )
{
// The document itself is already on disk, so this is not fatal - but it must be loud. A
// sculpt that quietly did not save is the thing this whole side-car exists to avoid.
Log.Error( $"[Effigy] saved {path} but could NOT write its sculpt data: {e.Message}" );
}
try
{
var meshes = ImportSidecar.Save( _studio, path );
if ( meshes > 0 )
Log.Info( $"[Effigy] wrote {meshes} imported mesh(es) beside {path}" );
}
catch ( Exception e )
{
Log.Error( $"[Effigy] saved {path} but could NOT write its imported mesh: {e.Message}" );
}
try
{
var edits = MeshEditSidecar.Save( _studio, path );
if ( edits > 0 )
Log.Info( $"[Effigy] wrote {edits} mesh edit(s) beside {path}" );
}
catch ( Exception e )
{
Log.Error( $"[Effigy] saved {path} but could NOT write its mesh edits: {e.Message}" );
}
_documentPath = path;
MarkClean();
Log.Info( $"[Effigy] saved {path}" );
}
/// <summary>Pick a Wavefront OBJ to import. False if the dialog was cancelled.</summary>
private static bool TryPickImportFile( out string path )
{
path = null;
var fd = new FileDialog( null )
{
Title = "Import mesh...",
DefaultSuffix = ".obj",
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SetFindFile();
fd.SetNameFilter( "Wavefront OBJ (*.obj)" );
if ( !fd.Execute() )
return false;
path = fd.SelectedFile;
return !string.IsNullOrWhiteSpace( path );
}
/// <summary>Import a mesh as a body into the current studio, same as the Import tool button.</summary>
private void ImportObj() => AddFeature( new ImportFeature() );
/// <summary>
/// File ▸ Body from Bones: the block-out a character starts from, built on the rig. A cube per
/// joint, a tube per bone, smoothed twice — see <see cref="SkinBlockout"/>. It arrives as an
/// Import with the mesh kept beside the document, so it is an ordinary part from here on:
/// edit it, sculpt it, rig it to the same bones.
/// </summary>
private void BodyFromBones()
{
if ( _rigPanel is not { HasBones: true } rig )
{
SetPrompt( "Body from Bones needs a rig. Switch to Rig and draw the bones first - or Make a bone from a part." );
Log.Info( "[Effigy] Body from Bones: there are no bones yet" );
return;
}
PolyMesh body;
try
{
body = SkinBlockout.Build( rig.Skeleton );
}
catch ( InvalidOperationException e )
{
SetPrompt( e.Message );
Log.Warning( $"[Effigy] Body from Bones: {e.Message}" );
return;
}
var import = new ImportFeature { Name = "Blockout" };
import.LoadMesh( System.Text.Encoding.UTF8.GetBytes( ObjWriter.Write( body, "blockout" ) ) );
RecordUndo();
InsertAtRollback( import );
RebuildStudio();
_featureTree?.Select( import );
SetPrompt( $"Blockout: {body.FaceCount:N0} quads over {rig.Skeleton.Count} bones. Edit it, sculpt it, and rig it to the same bones." );
}
private void Open()
{
// The unsaved work belongs to the studio being replaced, so the question comes first.
ConfirmDiscard( () =>
{
var fd = new FileDialog( null )
{
Title = "Open Part Studio",
DefaultSuffix = StudioDocument.Extension,
Directory = Project.Current?.GetAssetsPath() ?? "",
};
fd.SetFindFile();
// No SetModeOpen call: SetModeSave is the only one of the pair with proven usage in this
// repo, and an unproven method name is a COMPILE error that takes the whole editor
// assembly down rather than failing at the one dialog. Not calling it leaves the dialog
// in its default mode, which at worst is a cosmetic wrinkle on an open dialog.
fd.SetNameFilter( $"Effigy Part Studio (*{StudioDocument.Extension})" );
if ( fd.Execute() )
LoadDocument( fd.SelectedFile );
} );
}
private void LoadDocument( string path )
{
PartStudio loaded;
try
{
loaded = StudioDocument.ReadFile( path );
}
catch ( Exception e )
{
// StudioDocument's errors name the line and what was wrong with it, so they are worth
// passing through rather than replacing with "could not open".
Log.Error( $"[Effigy] could not open {path}: {e.Message}" );
return;
}
// BEFORE the rebuild, because that is when the deltas are consumed: SculptSidecar hands each
// feature its bytes, and the feature turns them into a sculpt on the first rebuild, once the
// cage it belongs to has been built by the features above it.
try
{
SculptSidecar.Load( loaded, path );
}
catch ( Exception e )
{
Log.Error( $"[Effigy] opened {path} but could not read its sculpt data: {e.Message}" );
}
try
{
ImportSidecar.Load( loaded, path );
}
catch ( Exception e )
{
Log.Error( $"[Effigy] opened {path} but could not read its imported mesh: {e.Message}" );
}
try
{
MeshEditSidecar.Load( loaded, path );
}
catch ( Exception e )
{
Log.Error( $"[Effigy] opened {path} but could not read its mesh edits: {e.Message}" );
}
_studio = loaded;
_featureTree?.SetStudio( _studio );
_partsPanel?.SetStudio( _studio );
_materialsPanel?.SetStudio( _studio );
_variablesPanel?.Bind( _studio );
_rigPanel?.SetStudio( _studio );
_dialog?.Close();
_viewport?.SetReferenceImages( _studio.ReferenceImages );
BuildMenuBar();
// The handle has to show the pivot the document carries, or opening a file would leave the
// marker at zero while the export used the saved value. SetOrigin raises OriginMoved and so
// dirties the document; both callers MarkClean() below, after this.
SyncOriginFromStudio();
// History belongs to the document that was open. Carrying it across a load would let Ctrl+Z
// paste the previous model's features into this one.
_undoStack.Clear();
_redoStack.Clear();
RebuildStudio();
_documentPath = path;
MarkClean();
// Deliberately AFTER the rebuild: a file that opens with a broken feature is exactly the
// file you opened it to fix, and it should be on screen rather than refused.
Log.Info( $"[Effigy] opened {path}" );
}
/// <summary>
/// Ask before throwing away unsaved work, then run <paramref name="proceed"/>.
///
/// Cancel does nothing at all, which is the point of it: the studio is left exactly as it was.
/// Modelled on the rig tool's, down to the button order — the same question should not be asked
/// two different ways in one editor.
/// </summary>
private void ConfirmDiscard( Action proceed )
{
if ( !_dirty )
{
proceed();
return;
}
var name = _documentPath is null ? "untitled" : Path.GetFileName( _documentPath );
var confirm = new PopupWindow( "Unsaved Changes",
$"\"{name}\" has unsaved changes. Would you like to save now?", "Cancel",
new Dictionary<string, Action>
{
{ "Don\'t Save", proceed },
{ "Save", () => { Save(); proceed(); } }
} );
confirm.Show();
}
/// <summary>
/// Closing with unsaved work asks first.
///
/// Returning false CANCELS the close, and the window is closed again from inside the popup once
/// the question is answered — Don't Save clears the flag first so the second Close sails past
/// this check rather than asking again forever.
/// </summary>
protected override bool OnClose()
{
if ( !_dirty )
return true;
var name = _documentPath is null ? "untitled" : Path.GetFileName( _documentPath );
var confirm = new PopupWindow( "Unsaved Changes",
$"\"{name}\" has unsaved changes. Would you like to save now?", "Cancel",
new Dictionary<string, Action>
{
{ "Don\'t Save", () => { _dirty = false; Close(); } },
{ "Save", () => { Save(); Close(); } }
} );
confirm.Show();
return false;
}
/// <summary>
/// The PhysicsShapeList the export should carry, or an empty string for none.
///
/// THE SHAPES USED TO GO NOWHERE. They were computed, correct and tested, and the .vmdl carried
/// no collision at all, because writing one meant guessing at ModelDoc's KV3 and a guessed node
/// fails as a model that will not load rather than as a model without physics. That is settled
/// now: every key VmdlPhysics writes was put into a probe .vmdl, compiled, and read back off the
/// compiled model's own physics bounds. See that file for what each probe answered.
///
/// A RIGGED PART USED TO FALL BACK TO THE RENDER MESH, and that was wrong in exactly the way it
/// was trying to avoid. The reasoning was that CollisionBuilder's shapes have no bone to hang
/// off, so writing them against the root "would put a static collision hull on an animating
/// character - the wrong kind of wrong: it looks right until something moves". True. But
/// PhysicsMeshFromRender writes `parent_bone = ""` too. It is the same static hull on the same
/// animating character, arrived at by a different route: pose a bone and the mesh swings away
/// from a collision box lying where the model used to be.
///
/// SO IT WRITES THE BONE NOW. The map from a body to the bone that drives it is what the rig
/// panel exists to let somebody decide, and it is right there in BodyBoneMap - the same map the
/// weights are bound through, so collision and mesh agree by construction rather than by
/// coincidence. A body nobody assigned goes to the bone nearest its centre, which is where
/// SkinBinder was going to weight it anyway.
///
/// ONE HULL PER BODY, not the history decomposition. Bones are assigned to bodies, and a
/// decomposition's boxes have no body to be assigned through - see CollisionBuilder.HullsPerBody.
/// A rigged part therefore gets the coarser representation on purpose, and the rig is what pays
/// for it: a hull per body that MOVES beats an exact union of boxes that does not.
/// </summary>
private string BuildPhysics( bool rigged )
{
if ( _studio is null )
return "";
if ( rigged )
return BuildRiggedPhysics();
try
{
var report = CollisionBuilder.Build( _studio );
ApplyPivot( report.Shapes );
var node = VmdlPhysics.ShapeList( report.Shapes );
if ( node.Length == 0 )
return VmdlPhysics.MeshFromRender();
Log.Info( $"[Effigy] collision into the .vmdl: {report}" );
return node;
}
catch ( Exception e )
{
// A collision build failing must not take the export with it. The model without physics
// is still a model; the exception on the way to one is not worth losing it over.
Log.Warning( $"[Effigy] collision could not be built ({e.Message}) - falling back to the render mesh" );
return VmdlPhysics.MeshFromRender();
}
}
/// <summary>
/// A rigged part's PhysicsShapeList: one hull per body, each naming the bone that drives it.
///
/// THE PIVOT IS APPLIED HERE AND THE SKELETON IS NOT PIVOTED YET, and those two facts have to
/// stay in step. Hull vertices are MODEL space even on a bone-parented shape (VmdlPhysics has
/// the probe that says so), so they belong in the same coordinates as the .dmx - which is the
/// pivoted mesh. CompileVmdl shifts mesh and skeleton by the same offset, so a hull shifted with
/// the mesh lands correctly against the shifted bone; a hull left unshifted would not.
///
/// FALLING BACK TO THE RENDER MESH IS STILL THE ANSWER WHEN THERE IS NOTHING BETTER - a part
/// whose bodies produced no hull at all, or a rig that has lost its skeleton between the caller's
/// check and this one. It is static collision, which is wrong for an animated model, but a model
/// with wrong collision beats a model with none while somebody works out why.
/// </summary>
private string BuildRiggedPhysics()
{
if ( _rigPanel is not { HasBones: true } rig )
return VmdlPhysics.MeshFromRender();
try
{
var skeleton = rig.Skeleton;
var map = rig.BodyBoneMap;
var shapes = CollisionBuilder.HullsPerBody( _studio );
foreach ( var shape in shapes )
{
// An assignment the rig panel made, when there is one. A name it no longer holds -
// a bone deleted after the assignment was made - is treated as no assignment rather
// than written through, because parent_bone naming a bone the skeleton does not have
// is a compile error and a silent fallback is not.
if ( shape.BodyId is not null && map.TryGetValue( shape.BodyId, out var named )
&& skeleton.IndexOf( named ) >= 0 )
{
shape.Bone = named;
continue;
}
shape.Bone = skeleton.Bones[SkinBinder.NearestBone( Centre( shape ), skeleton )].Name;
}
ApplyPivot( shapes );
var node = VmdlPhysics.ShapeList( shapes );
if ( node.Length == 0 )
return VmdlPhysics.MeshFromRender();
Log.Info( $"[Effigy] collision into the .vmdl: {shapes.Count} hull(s) on "
+ $"{shapes.Select( s => s.Bone ).Distinct().Count()} bone(s)" );
return node;
}
catch ( Exception e )
{
Log.Warning( $"[Effigy] bone-parented collision could not be built ({e.Message}) "
+ "- falling back to the render mesh" );
return VmdlPhysics.MeshFromRender();
}
}
/// <summary>
/// The middle of a shape, for asking which bone it is nearest.
///
/// The average of a hull's points rather than the centre of its bounds: it costs the same and it
/// leans toward where the geometry actually is, which is the better question to ask of a limb
/// that tapers. Non-hulls carry their centre in Position already.
/// </summary>
private static Vec3 Centre( CollisionShape shape )
{
if ( shape.Points is not { Count: > 0 } points )
return shape.Position;
var sum = Vec3.Zero;
foreach ( var p in points )
sum += p;
return sum / points.Count;
}
/// <summary>
/// What this part's physics representation is, listed where a person can read it.
///
/// Still worth having now that the shapes reach the .vmdl: this is where you find out WHY a part
/// came out as one hull per body instead of as the boxes it was drawn from - CollisionReport
/// names the feature that spoiled the decomposition, and nothing in the compiled model does.
///
/// A RIGGED PART IS ASKED THE QUESTION IT WILL ACTUALLY BE EXPORTED UNDER. Its collision is one
/// hull per body on the bone that drives it, whatever the history says, so reporting the
/// decomposition here would describe boxes the .vmdl is never going to carry - and the point of
/// this button is to answer "what collision am I about to ship".
/// </summary>
private void ReportCollision()
{
if ( _studio is null )
return;
if ( _rigPanel is { HasBones: true } )
{
var hulls = CollisionBuilder.HullsPerBody( _studio );
Log.Info( $"[Effigy] collision: {hulls.Count} hull(s), one per body, bone-parented" );
foreach ( var hull in hulls )
Log.Info( $"[Effigy] {hull} for body {hull.BodyId}" );
SetPrompt( $"Collision: {hulls.Count} hull(s), one per body and parented to the bone "
+ "that drives it — see the console." );
return;
}
var report = CollisionBuilder.Build( _studio );
Log.Info( $"[Effigy] collision: {report}" );
foreach ( var shape in report.Shapes )
Log.Info( $"[Effigy] {shape} at ({shape.Position.x:0.##}, {shape.Position.y:0.##}, {shape.Position.z:0.##})" );
SetPrompt( report.FromHistory
? $"Collision: {report.Shapes.Count} shape(s) read straight from the history — see the console."
: $"Collision: {report.Shapes.Count} hull(s) — {report.Reason}. See the console." );
}
// --- the pivot -----------------------------------------------------------------------------
/// <summary>
/// The offset that moves the model's origin to (0,0,0), which is what every writer applies on
/// the way out. See PartStudio.Origin for what the pivot IS; this is only the arithmetic.
///
/// EffigyViewport.ToWorldDir is the identity, so the viewport's Vector3 and the kernel's Vec3
/// are the same three numbers and no axis mapping belongs here. If that ever stops being true,
/// this is the conversion that has to learn about it.
/// </summary>
private Vec3 PivotOffset => _studio is null ? default : -_studio.Origin;
/// <summary>Whether the pivot has been moved off zero at all. The untouched case — which is
/// most documents — then does no work and cannot walk vertices through a float add that was
/// only ever going to add nothing.</summary>
private bool HasPivot => PivotOffset.Length > 1e-6f;
/// <summary>
/// Shift a mesh onto the pivot.
///
/// Safe to mutate in place: ToMesh and ToMeshWithBodies merge the bodies into a FRESH PolyMesh
/// every call, so this never touches geometry the studio is still holding. Handing it a body's
/// own mesh would move the model itself, one export at a time.
/// </summary>
private void ApplyPivot( PolyMesh mesh )
{
if ( mesh is not null && HasPivot )
MeshTransform.Apply( mesh, Xform.Translate( PivotOffset ) );
}
/// <summary>
/// The skeleton shifted onto the pivot, as a COPY — the rig panel is still holding the original
/// and exporting a model must not move the user's bones.
///
/// ONLY THE ROOTS MOVE. Every other bone's Local is relative to its parent, so shifting a root
/// carries its whole chain; shifting the children too would move them once per level of depth.
/// </summary>
private Skeleton PivotedSkeleton( Skeleton skeleton )
{
if ( skeleton is null || !HasPivot )
return skeleton;
var copy = skeleton.Clone();
var shift = Xform.Translate( PivotOffset );
foreach ( var bone in copy.Bones )
{
if ( bone.Parent < 0 )
bone.Local = shift * bone.Local;
}
return copy;
}
/// <summary>Shift built collision onto the pivot, so the hulls stay where the mesh went. Without
/// this the render mesh moves and the physics stays behind, which reads in game as a model you
/// walk through and a wall where nothing is.</summary>
private void ApplyPivot( List<CollisionShape> shapes )
{
if ( shapes is null || !HasPivot )
return;
var offset = PivotOffset;
foreach ( var shape in shapes )
{
shape.Position += offset;
if ( shape.Points is null )
continue;
for ( var i = 0; i < shape.Points.Count; i++ )
shape.Points[i] += offset;
}
}
/// <summary>
/// The origin handle was dragged, or set from a number field.
///
/// This event had no subscriber at all until the origin became the pivot, which is why the
/// handle used to move a marker and nothing else. It does NOT rebuild: the kernel builds in its
/// own coordinates and the viewport draws in the same ones, so the pivot changes only what the
/// writers subtract. It does dirty the DOCUMENT, because a pivot that is not saved is not a
/// pivot.
/// </summary>
/// <summary>Push the document's pivot onto the origin handle. The other direction of
/// <see cref="OnOriginMoved"/>, for load and for New.</summary>
private void SyncOriginFromStudio()
{
if ( _studio is null || _viewport is null )
return;
var o = _studio.Origin;
_viewport.SetOrigin( new Vector3( o.x, o.y, o.z ) );
}
private void OnOriginMoved()
{
if ( _studio is null || _viewport is null )
return;
var o = _viewport.OriginPosition;
_studio.Origin = new Vec3( o.x, o.y, o.z );
// Same two lines every other unsaved edit uses; there is no shared helper to call.
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
}
/// <summary>
/// Say what is about to happen before the blocking part of it.
///
/// Export opens with a full synchronous rebuild, and a rebuild of a dense subdivide or a sculpt
/// can take long enough for the tools stall monitor to fire. When that happened there was
/// nothing in the log between "opened" and the stall, so the hang was indistinguishable from a
/// hang anywhere else in the editor — and if the process goes away before the rebuild returns,
/// the completion line that would have named the culprit is never written. One line before the
/// call is the difference between guessing and knowing.
/// </summary>
private RebuildReport RebuildForExport( string what )
{
Log.Info( $"[Effigy] rebuilding before {what} — {_studio.Features.Count} features" );
return _studio.Rebuild();
}
/// <summary>
/// The clips queued for the next compile.
///
/// Held on the window rather than in the document, because they are an EXPORT setting and the
/// .effigy document is the parametric model. That is a real trade and worth naming: the list
/// does not survive closing the tool, and a clip added yesterday has to be added again. Putting
/// it in the document means a saved model referencing a .riganim that references a model, which
/// is a loop the loader would have to break; that is a bigger decision than this one and is not
/// made here.
/// </summary>
private readonly List<EffigyAnimExport.ClipSource> _animClips = new();
private EffigyAnimClipsWindow _animClipsWindow;
private void OpenAnimClips()
{
// One dialog. Reopening brings the existing one forward rather than stacking a second list
// over the first, both editing the same clips.
if ( _animClipsWindow.IsValid() )
{
_animClipsWindow.Focus();
return;
}
// No dirty mark. The clip list is not saved into the .effigy (see _animClips), so putting a
// * in the title would promise a save that does not carry it.
_animClipsWindow = new EffigyAnimClipsWindow( this, _animClips );
_animClipsWindow.Show();
}
/// <summary>
/// The stem every exported file takes: the document's own name.
///
/// EVERYTHING USED TO BE CALLED "export". models/effigy/export.vmdl, export.obj, export.dmx,
/// export.smd - one set of names for every part studio in the project, so compiling the
/// spatula overwrote the grill, and whatever had already been placed in a scene changed shape
/// under you without a word. The document has a name, the user chose it, and it is the obvious
/// thing to name its model after.
///
/// AN UNSAVED DOCUMENT HAS NO NAME TO TAKE, so this asks for one through the same save dialog
/// Save As uses. Inventing "untitled" instead would only move the collision to a different
/// spelling and hide it again. Cancelling returns null and the caller exports nothing, which
/// is what cancelling a dialog should do.
/// </summary>
private string ExportBaseName()
{
if ( _documentPath is not null )
return SanitiseAssetName( Path.GetFileNameWithoutExtension( _documentPath ) );
var fd = new FileDialog( null )
{
Title = "Name the exported model",
DefaultSuffix = ".vmdl",
Directory = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" ),
};
fd.SelectFile( "untitled.vmdl" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( "Model (*.vmdl)" );
if ( !fd.Execute() )
return null;
return SanitiseAssetName( Path.GetFileNameWithoutExtension( fd.SelectedFile ) );
}
/// <summary>
/// Fold a document name into something safe to sit in an asset path: lowercase, and nothing
/// outside a-z, 0-9, underscore and hyphen. A studio saved as "Flat Top v2" therefore compiles
/// to flat_top_v2.vmdl rather than to a path the asset system has to guess at.
/// </summary>
private static string SanitiseAssetName( string raw )
{
if ( string.IsNullOrWhiteSpace( raw ) )
return "export";
var sb = new System.Text.StringBuilder( raw.Length );
foreach ( var c in raw.ToLowerInvariant() )
sb.Append( (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'
? c : '_' );
var name = sb.ToString().Trim( '_' );
// Everything stripped - a name made entirely of punctuation. "export" is a poor name but a
// working one, and better than an empty filename.
return name.Length == 0 ? "export" : name;
}
private void ExportObj()
{
var report = RebuildForExport( "OBJ export" );
if ( report.HasErrors || _studio.Bodies.Count == 0 )
{
Log.Warning( "[Effigy] cannot export — studio has errors or no bodies" );
return;
}
var name = ExportBaseName();
if ( name is null )
return;
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" );
Directory.CreateDirectory( folder );
var objPath = Path.Combine( folder, $"{name}.obj" );
// Slot names go through so the file names its materials the way the user did, rather than
// material_0..63. NameForSlot falls back to the numbers for anything unnamed. Vertex colours
// ride on the mesh and are written by the exporter alongside the positions.
var mesh = _studio.ToMesh();
ApplyPivot( mesh );
ObjWriter.WriteFile( mesh, objPath, name,
materialName: _studio.NameForSlot );
Log.Info( $"[Effigy] exported {objPath}" );
}
/// <summary>The skinned model the last CompileVmdl produced, as an asset path, or null when it
/// did not get that far. Publish as clothing reads it; nothing else should.</summary>
private string _lastSkinnedVmdl;
/// <summary>File ▸ Export with LODs: a static compile also writes decimated copies and a
/// distance switch. Off by default — a character or a one-off prop does not need them.</summary>
private bool _exportLods;
private void CompileVmdl()
{
_lastSkinnedVmdl = null;
var report = RebuildForExport( "vmdl compile" );
if ( report.HasErrors || _studio.Bodies.Count == 0 )
{
Log.Warning( "[Effigy] cannot compile — studio has errors or no bodies" );
return;
}
var name = ExportBaseName();
if ( name is null )
return;
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" );
Directory.CreateDirectory( folder );
// RIGGED PATH: bones exist in the rig panel, so export DMX (which carries the skeleton
// and per-vertex weights) instead of a weightless OBJ.
if ( _rigPanel is { HasBones: true } rig )
{
var (mesh, ranges) = _studio.ToMeshWithBodies();
var skeleton = rig.Skeleton;
// BindBodies assigns each body's vertices to the bone it was assigned to in the rig
// panel. Unassigned bodies fall back to nearest-bone rigid weighting. SmoothWeights
// then diffuses across mesh adjacency so joints bend rather than crease.
var weights = SkinBinder.BindBodies( mesh, ranges, rig.BodyBoneMap, skeleton );
weights = SkinBinder.SmoothWeights( mesh, weights );
if ( _studio.WeightPaint is { Count: > 0 } )
_studio.WeightPaint.Apply( mesh, weights, skeleton, out _ );
mesh.Skin = weights;
// AFTER binding, and both together. The weights come from distances between vertices and
// bones, so shifting either side before the bind would rig the model to where the bones
// used to be. Shifting both afterwards moves the bind pose and leaves the weights - which
// are indices and scalars, not positions - saying exactly what they said.
ApplyPivot( mesh );
skeleton = PivotedSkeleton( skeleton );
// Paint rides on the mesh as an atlas now; author it (canvas → PNG → vmat) and bind it to
// the painted body's slot before the remap list is written, so the compiled model samples
// the atlas rather than the missing-material shader.
AuthorPaint( mesh, name, folder );
// DMX, not SMD. ModelDoc's loader takes FBX, DMX, OBJ and VOX and nothing else (see
// DmxWriter for the exact string it prints), so DMX is the only supported format that
// carries a skeleton and per-vertex weights. The .smd is still written alongside it
// because every DCC reads one and it costs nothing to keep.
var smdPath = Path.Combine( folder, $"{name}.smd" );
SmdWriter.WriteFile( mesh, smdPath, skeleton, materialName: _studio.NameForSlot );
var dmxPath = Path.Combine( folder, $"{name}.dmx" );
DmxWriter.WriteFile( mesh, dmxPath, skeleton, materialName: _studio.NameForSlot,
modelName: name );
Log.Info( $"[Effigy] wrote {dmxPath} - {skeleton.Count} bones, {mesh.VertexCount} vertices" );
// The clips, written beside the mesh and BEFORE the folder is registered, so the asset
// system sees the .dmx files at the same moment it sees the .vmdl that names them.
//
// The skeleton passed here is the PIVOTED one, the same one the mesh was written
// against. A clip sampled against the unpivoted rig would pose the model correctly
// relative to bones that had since moved, which is a whole-model offset that only
// appears once the clip plays.
var clips = EffigyAnimExport.WriteClips( _animClips, folder, "models/effigy", skeleton );
var vmdlPath = Path.Combine( folder, $"{name}.vmdl" );
File.WriteAllText( vmdlPath, BuildSkinnedVmdl( $"models/effigy/{name}.dmx", skeleton,
BuildPhysics( rigged: true ), VmdlMaterials.GroupList( _studio, mesh ),
VmdlAnimation.AnimationList( clips.ToArray() ) ) );
var result = EffigyAssetFolder.Register( folder );
Log.Info( $"[Effigy] wrote {vmdlPath} - {result.Registered} registered" );
var asset = AssetSystem.FindByPath( $"models/effigy/{name}.vmdl" );
if ( asset is null )
{
Log.Warning( $"[Effigy] {name}.vmdl was written but the asset system couldn't find it" );
return;
}
asset.Compile( true );
if ( asset.IsCompileFailed )
{
Log.Warning( $"[Effigy] {name}.vmdl compile FAILED - the compiler's own output above "
+ "says why. The .dmx and .smd are both on disk either way." );
return;
}
_lastSkinnedVmdl = $"models/effigy/{name}.vmdl";
Log.Info( $"[Effigy] {name}.vmdl compiled - {skeleton.Count} bone(s), loading into viewport" );
_viewport?.SetModel( Model.Load( $"models/effigy/{name}.vmdl" ) );
return;
}
// STATIC PATH: no bones — export a weightless OBJ.
var staticObjPath = Path.Combine( folder, $"{name}.obj" );
var staticMesh = _studio.ToMesh();
ApplyPivot( staticMesh );
AuthorPaint( staticMesh, name, folder );
ObjWriter.WriteFile( staticMesh, staticObjPath, name,
materialName: _studio.NameForSlot );
var staticVmdlPath = Path.Combine( folder, $"{name}.vmdl" );
var staticPhysics = BuildPhysics( rigged: false );
var staticMaterials = VmdlMaterials.GroupList( _studio, staticMesh );
if ( _exportLods && Decimate.TriangleCount( staticMesh ) >= 64 )
{
// Half and a quarter of the triangles, switching at six and fifteen times the model's
// size away — far enough that the coarser mesh is a few pixels' difference.
var files = new List<string> { $"models/effigy/{name}.obj" };
var distances = new List<float> { 0f };
var size = MathF.Max( staticMesh.BoundsDiagonal, 1f );
var levels = new[] { (0.5f, 6f), (0.25f, 15f) };
for ( var i = 0; i < levels.Length; i++ )
{
var (ratio, far) = levels[i];
var lod = Decimate.ToRatio( staticMesh, ratio );
var lodPath = Path.Combine( folder, $"{name}_lod{i + 1}.obj" );
ObjWriter.WriteFile( lod, lodPath, $"{name}_lod{i + 1}", materialName: _studio.NameForSlot );
files.Add( $"models/effigy/{name}_lod{i + 1}.obj" );
distances.Add( size * far );
}
File.WriteAllText( staticVmdlPath, VmdlDocument.StaticWithLods( files, distances, staticPhysics, staticMaterials ) );
Log.Info( $"[Effigy] {name}: LODs written at half and a quarter detail" );
}
else
{
File.WriteAllText( staticVmdlPath, BuildVmdl( $"models/effigy/{name}.obj", staticPhysics, staticMaterials ) );
}
var staticResult = EffigyAssetFolder.Register( folder );
Log.Info( $"[Effigy] wrote {staticObjPath} and {staticVmdlPath} — {staticResult.Registered} registered" );
var staticAsset = AssetSystem.FindByPath( $"models/effigy/{name}.vmdl" );
if ( staticAsset is null )
{
Log.Warning( $"[Effigy] {name}.vmdl was written but asset system couldn't find it" );
return;
}
staticAsset.Compile( true );
Log.Info( staticAsset.IsCompileFailed
? $"[Effigy] {name}.vmdl compile FAILED"
: $"[Effigy] {name}.vmdl compiled — loading into viewport" );
if ( !staticAsset.IsCompileFailed )
{
var model = Model.Load( $"models/effigy/{name}.vmdl" );
_viewport?.SetModel( model );
}
}
/// <summary>
/// File → Compile Playermodel. Writes the model that carries citizen's skeleton and names
/// citizen's animation graph, so citizen's animations drive it.
///
/// A SEPARATE MENU ITEM RATHER THAN A FLAG ON Compile .vmdl, because the two produce different
/// things for different purposes and the difference is not a detail. Compile .vmdl writes YOUR
/// skeleton with your bones and your clips - the right answer for a prop, a weapon or a creature
/// with its own animation. This writes citizen's 95 bones fitted into your mesh, which only makes
/// sense for something that is going to be a player.
///
/// THE NAME GETS `_citizen` ON THE END, so the two can sit in the same folder. A playermodel and
/// an ordinary compile of the same document are both legitimate things to want at once.
/// </summary>
private void CompilePlayermodel()
{
var report = RebuildForExport( "playermodel compile" );
if ( report.HasErrors || _studio.Bodies.Count == 0 )
{
Log.Warning( "[Effigy] cannot compile - studio has errors or no bodies" );
return;
}
// WARNED, NOT BLOCKED. Somebody who has modelled a humanoid and not yet rigged it gets a
// model out of this - a rigid statue that slides rather than walks - and being told why is
// more use than being refused. The export says the same thing again from its own side.
if ( _rigPanel is not { HasBones: true } )
Log.Warning( "[Effigy] this model has no bones, so there is nothing for citizen's "
+ "animations to drive. Switch to Rig and make a bone per part first - Help → Start "
+ "Playermodel Tutorial walks through it." );
var name = ExportBaseName();
if ( name is null )
return;
// NO ApplyPivot, unlike CompileVmdl. A playermodel's origin has to be between its feet
// because that is where the engine stands a player; honouring a pivot the modeller set for
// some other export would sink or float the whole character. See EffigyPlayermodelExport.
EffigyPlayermodelExport.Export( _studio, $"{name}_citizen" );
}
/// <summary>
/// File → Make Player. Compile the playermodel, then write a scene with a floor, a light and a
/// player wearing it, so the thing can be walked around in.
///
/// IT DOES NOT OPEN THE SCENE AND IT DOES NOT PRESS PLAY, and both of those are on purpose.
/// Opening a scene replaces whatever is open in the editor, which could be an hour of unsaved
/// work that this menu item has no business discarding; entering play mode on a scene somebody
/// is mid-edit in has cost this project a whole prefab before now. So it writes the file, logs
/// the path, and leaves both decisions to the person who can see their own editor.
/// </summary>
private void MakePlayer()
{
var report = RebuildForExport( "Make Player" );
if ( report.HasErrors || _studio.Bodies.Count == 0 )
{
Log.Warning( "[Effigy] cannot make a player - studio has errors or no bodies" );
return;
}
if ( _rigPanel is not { HasBones: true } )
Log.Warning( "[Effigy] this model has no bones, so the player will be a rigid statue. "
+ "Switch to Rig and make a bone per part first." );
var name = ExportBaseName();
if ( name is null )
return;
var model = EffigyPlayermodelExport.Export( _studio, $"{name}_citizen" );
if ( model is null )
{
// The export has already said what went wrong in its own words. Adding "and therefore no
// scene" is the part it cannot know.
Log.Warning( "[Effigy] the playermodel did not compile, so no scene was written" );
return;
}
var scene = EffigyPlayerScene.Write( model, name );
if ( scene is null )
{
Log.Warning( $"[Effigy] {model} compiled but the scene could not be written - "
+ "Assets/scenes could not be resolved" );
return;
}
Log.Info( $"[Effigy] wrote {scene} - open it from the asset browser and press Play to walk "
+ "your model around. It is not opened for you: that would close whatever scene you "
+ "have open, unsaved changes included." );
}
/// <summary>
/// The .vmdl for a static export. The template, and the measured import_rotation the OBJ
/// importer needs, live in <see cref="VmdlDocument"/> — there were four hand-copied copies of
/// this string and only this one had the correction, so it is one place now.
/// </summary>
static string BuildVmdl( string meshFilename, string physics = "", string materials = "" ) =>
VmdlDocument.Static( meshFilename, physics, materials );
/// <summary>
/// The .vmdl for a rigged export: the .dmx carries the bone hierarchy, bind pose and
/// per-vertex weights, and <paramref name="animations"/> is the AnimationList node — empty
/// means the bind pose alone, which is what this always wrote.
/// </summary>
static string BuildSkinnedVmdl( string meshFilename, Skeleton skeleton, string physics = "",
string materials = "", string animations = null ) =>
VmdlDocument.Skinned( meshFilename, skeleton, physics, materials, animations );
// --- undo / redo -------------------------------------------------------------------------
/// <summary>
/// A point in the studio's history: which features exist, in what order, with what values,
/// and where the rollback bar was.
///
/// The values are the part that was missing. The previous version snapshotted
/// `_studio.Features.Select( f => f ).ToList()` - a shallow copy of the LIST, holding the same
/// Feature objects. Parameters are the storage in this kernel (see Feature.cs: "The parameter
/// object IS the storage"), so undo restored membership and order while silently keeping every
/// number the user had changed since. Ctrl+Z after a parameter edit did nothing at all.
///
/// Values are keyed by parameter object rather than by index, because PrimitiveFeature returns
/// a different Parameters list per shape - indices are not stable across a shape change, and
/// the parameter objects are (they are readonly fields on the feature).
/// </summary>
private sealed class StudioSnapshot
{
public List<Feature> Features;
/// <summary>Each mesh edit's mesh, as bytes. Not a parameter, so invisible to undo without this.</summary>
public Dictionary<MeshEditFeature, byte[]> MeshEdits;
public Dictionary<IParam, object> Values;
/// <summary>
/// A copy of every sketch's geometry, which is NOT a parameter and so was invisible to
/// undo entirely.
///
/// This is what made Ctrl+Z during sketching so strange: the curves you had drawn were not
/// in the snapshot, so undo could neither remove nor restore them. It went back to the
/// last thing that WAS recorded - usually the moment the Sketch feature was added - took
/// the feature out of the tree, and left the lines it owned still drawn on screen.
/// </summary>
public Dictionary<SketchFeature, Sketch> Sketches;
/// <summary>
/// The faces each material assignment holds, which are not parameters either and so were
/// invisible to undo for the same reason sketch geometry was.
///
/// This mattered little while the only way to pick faces was a dialog you could cancel. It
/// matters now that right-clicking a face assigns one: without it, Ctrl+Z after a right-click
/// took away a feature it had just added and left a face added to an existing one exactly
/// where it was.
/// </summary>
public Dictionary<FaceMaterialFeature, List<FaceRef>> FaceSets;
/// <summary>
/// Every paint feature's stroke list, for the same reason Sketches and FaceSets are captured:
/// strokes are a public field, not a parameter, so the parameter sweep would miss them and
/// Ctrl+Z after a brush stroke would do nothing. The list is copied by reference — strokes are
/// immutable once painted — so the snapshot shares the stroke objects and only the list is new.
/// </summary>
public Dictionary<PaintFeature, List<PaintStroke>> PaintStrokes;
/// <summary>Deleted import pieces, for the same reason PaintStrokes is captured: a public
/// field, not a parameter, so the parameter sweep would miss it and Ctrl+Z after deleting a
/// piece would bring the whole import back or none of it.</summary>
public Dictionary<ImportFeature, List<int>> RemovedPieces;
/// <summary>Slot names, renamed from the same menu.</summary>
public Dictionary<int, string> MaterialNames;
/// <summary>Parts-list names, keyed by body id. Not a feature field, so they have to be
/// captured the same way material names are or Ctrl+Z after a rename would keep the new
/// name on the same Feature objects.</summary>
public Dictionary<string, string> BodyNames;
public HashSet<string> HiddenBodyIds;
/// <summary>Feature.Name at this step. The Feature objects themselves are shared across
/// snapshots, so a rename mutated in place would survive undo without this.</summary>
public Dictionary<Feature, string> FeatureNames;
public int RollbackIndex;
/// <summary>A full clone (Skeleton.Clone) rather than a reference — the rig panel mutates
/// its own Skeleton in place, so holding the same instance would make every snapshot equal
/// the current state by the time anyone looked at it again.</summary>
public Skeleton RigSkeleton;
public Dictionary<string, string> BodyBoneMap;
}
private readonly List<StudioSnapshot> _undoStack = new();
private readonly List<StudioSnapshot> _redoStack = new();
private StudioSnapshot Capture()
{
var values = new Dictionary<IParam, object>();
foreach ( var feature in _studio.Features )
{
foreach ( var param in feature.Parameters )
{
if ( ParamValue( param ) is { } value )
values[param] = value;
}
}
var sketches = new Dictionary<SketchFeature, Sketch>();
foreach ( var feature in _studio.Features.OfType<SketchFeature>() )
sketches[feature] = feature.Sketch.Clone();
var faceSets = new Dictionary<FaceMaterialFeature, List<FaceRef>>();
foreach ( var feature in _studio.Features.OfType<FaceMaterialFeature>() )
faceSets[feature] = new List<FaceRef>( feature.Faces );
var paintStrokes = new Dictionary<PaintFeature, List<PaintStroke>>();
foreach ( var feature in _studio.Features.OfType<PaintFeature>() )
paintStrokes[feature] = feature.Strokes is null ? null : new List<PaintStroke>( feature.Strokes );
var removedPieces = new Dictionary<ImportFeature, List<int>>();
foreach ( var feature in _studio.Features.OfType<ImportFeature>() )
removedPieces[feature] = new List<int>( feature.RemovedPieces );
// A mesh edit's state is a mesh, not parameters — held as its side-car bytes, which is the
// one form of it that is already a faithful copy.
var meshEdits = new Dictionary<MeshEditFeature, byte[]>();
foreach ( var feature in _studio.Features.OfType<MeshEditFeature>() )
meshEdits[feature] = feature.SaveMesh();
return new StudioSnapshot
{
MeshEdits = meshEdits,
Features = _studio.Features.ToList(),
Values = values,
Sketches = sketches,
FaceSets = faceSets,
PaintStrokes = paintStrokes,
RemovedPieces = removedPieces,
MaterialNames = new Dictionary<int, string>( _studio.MaterialNames ),
BodyNames = new Dictionary<string, string>( _studio.BodyNames ),
HiddenBodyIds = new HashSet<string>( _studio.HiddenBodyIds ),
FeatureNames = _studio.Features.ToDictionary( f => f, f => f.Name ),
RollbackIndex = _studio.RollbackIndex,
RigSkeleton = _rigPanel?.Skeleton.Clone() ?? new Skeleton(),
BodyBoneMap = _rigPanel is null
? new Dictionary<string, string>()
: new Dictionary<string, string>( _rigPanel.BodyBoneMap ),
};
}
private static object ParamValue( IParam param ) => param switch
{
FloatParam f => f.Value,
IntParam i => i.Value,
BoolParam b => b.Value,
Vec3Param v => v.Value,
ChoiceParam c => c.Index,
_ => null,
};
private void Restore( StudioSnapshot snapshot )
{
_studio.Features = snapshot.Features.ToList();
_studio.RollbackIndex = snapshot.RollbackIndex;
foreach ( var (param, value) in snapshot.Values )
{
switch ( param )
{
case FloatParam f when value is float v: f.Value = v; break;
case IntParam i when value is int v: i.Value = v; break;
case BoolParam b when value is bool v: b.Value = v; break;
case Vec3Param p when value is Vec3 v: p.Value = v; break;
case ChoiceParam c when value is int v: c.Index = v; break;
}
}
// Sketch geometry is put back INTO THE EXISTING Sketch objects rather than swapped for the
// clones. The viewport holds a direct reference to whichever sketch is open, so replacing
// the object would leave it drawing an orphan - which is the other half of the bug this
// fixes.
foreach ( var (feature, sketch) in snapshot.Sketches )
{
feature.Sketch.Points = new List<Vec2>( sketch.Points );
feature.Sketch.Curves = sketch.Curves.Select( c => c.Clone() ).ToList();
feature.Sketch.Constraints = sketch.Constraints
.Select( c => new SketchConstraint( c.Kind, c.CurveId ) ).ToList();
}
// Put back INTO the existing lists, for the same reason sketch geometry is: the dialog's
// selection box holds a direct reference to the feature it is editing.
foreach ( var (feature, faces) in snapshot.FaceSets )
{
feature.Faces.Clear();
feature.Faces.AddRange( faces );
}
foreach ( var (feature, strokes) in snapshot.PaintStrokes )
feature.ReplaceStrokes( strokes );
if ( snapshot.MeshEdits is not null )
{
foreach ( var (feature, bytes) in snapshot.MeshEdits )
{
if ( bytes is null )
feature.Clear();
else
feature.LoadMesh( bytes );
}
}
// An open edit whose feature the restore just removed ends with it, uncommitted.
if ( _viewport?.IsMeshEditing == true
&& (_meshEditFeature is null || !_studio.Features.Contains( _meshEditFeature )) )
{
CloseMeshEdit();
ShowSculptHome();
}
foreach ( var (feature, pieces) in snapshot.RemovedPieces )
{
feature.RemovedPieces.Clear();
feature.RemovedPieces.AddRange( pieces );
}
_studio.MaterialNames.Clear();
foreach ( var (slot, name) in snapshot.MaterialNames )
_studio.MaterialNames[slot] = name;
_studio.BodyNames.Clear();
foreach ( var (id, name) in snapshot.BodyNames )
_studio.BodyNames[id] = name;
_studio.HiddenBodyIds.Clear();
foreach ( var id in snapshot.HiddenBodyIds )
_studio.HiddenBodyIds.Add( id );
foreach ( var (feature, name) in snapshot.FeatureNames )
feature.Name = name;
_rigPanel?.RestoreRig( snapshot.RigSkeleton, snapshot.BodyBoneMap );
_studio.MarkAllDirty();
// The dialog may be open on a feature the restore just removed, and its snapshot of
// "before" is now meaningless either way.
_dialog?.Close();
// If the sketch being drawn on no longer exists, sketch mode has to end with it. Leaving
// it open is what left curves on screen belonging to a feature that had just been undone
// out of the tree.
if ( _viewport?.IsSketching == true && ActiveSketchFeature() is null )
FinishSketch();
if ( _viewport?.IsSculpting == true
&& (_sculptFeature is null || !_studio.Features.Contains( _sculptFeature )) )
FinishSculpt();
// Paint's undo IS the document's — there is no session-internal stack. The restore rewrote the
// feature's stroke list, but the live session holds its own colour array, so it is either ended
// (the feature is gone) or rebuilt from the restored strokes (the feature is still there).
if ( _viewport?.IsPainting == true )
{
if ( _paintFeature is null || !_studio.Features.Contains( _paintFeature ) )
FinishPaint();
else
_viewport.PaintSession.Reload( _paintFeature.Strokes );
}
RebuildStudio();
}
/// <summary>
/// Mark an undo point.
///
/// Granularity is one dialog session, not one keystroke: this is called when a feature is
/// added, when its dialog is opened to edit it, and on the structural commands. Recording per
/// parameter tick would put a hundred steps on the stack for one slider drag.
///
/// SKETCHING IS THE EXCEPTION, and deliberately so: there each committed entity is its own
/// step, because "undo the line I just drew" is what the key means while a sketch is open, and
/// a dialog session there could be fifty lines long.
/// </summary>
private void RecordUndo()
{
var snapshot = Capture();
// A step that changes nothing is a Ctrl+Z that appears broken. Clicks that only advance a
// tool - the first corner of a rectangle, a grabbed point let go where it was - go through
// the same path as clicks that do commit something, so the cheapest place to tell them
// apart is here, by comparing against what is already on top.
if ( _undoStack.Count > 0 && Same( _undoStack[^1], snapshot ) )
return;
_undoStack.Add( snapshot );
_redoStack.Clear();
if ( _undoStack.Count > 100 )
_undoStack.RemoveAt( 0 );
}
/// <summary>Whether two snapshots describe the same model - same features in the same order,
/// same parameter values, same sketch geometry.</summary>
private static bool Same( StudioSnapshot a, StudioSnapshot b )
{
if ( a.RollbackIndex != b.RollbackIndex || a.Features.Count != b.Features.Count )
return false;
for ( var i = 0; i < a.Features.Count; i++ )
{
if ( !ReferenceEquals( a.Features[i], b.Features[i] ) )
return false;
}
if ( a.Values.Count != b.Values.Count )
return false;
foreach ( var (param, value) in a.Values )
{
if ( !b.Values.TryGetValue( param, out var other ) || !Equals( value, other ) )
return false;
}
if ( a.Sketches.Count != b.Sketches.Count )
return false;
foreach ( var (feature, sketch) in a.Sketches )
{
if ( !b.Sketches.TryGetValue( feature, out var other ) || !SameSketch( sketch, other ) )
return false;
}
if ( a.FaceSets.Count != b.FaceSets.Count )
return false;
foreach ( var (feature, faces) in a.FaceSets )
{
// By COUNT, not by comparing references. Two captures of the same face are not equal, so
// a per-element comparison would call every snapshot different and put a step on the undo
// stack for clicks that changed nothing. A count is enough for what this decides: whether
// a face went in or came out.
if ( !b.FaceSets.TryGetValue( feature, out var others ) || faces.Count != others.Count )
return false;
}
if ( a.PaintStrokes.Count != b.PaintStrokes.Count )
return false;
foreach ( var (feature, strokes) in a.PaintStrokes )
{
if ( !b.PaintStrokes.TryGetValue( feature, out var others ) || !SameStrokes( strokes, others ) )
return false;
}
if ( a.RemovedPieces.Count != b.RemovedPieces.Count )
return false;
foreach ( var (feature, pieces) in a.RemovedPieces )
{
if ( !b.RemovedPieces.TryGetValue( feature, out var others )
|| pieces.Count != others.Count
|| !pieces.SequenceEqual( others ) )
return false;
}
if ( a.MaterialNames.Count != b.MaterialNames.Count )
return false;
foreach ( var (slot, name) in a.MaterialNames )
{
if ( !b.MaterialNames.TryGetValue( slot, out var other ) || name != other )
return false;
}
if ( a.BodyNames.Count != b.BodyNames.Count )
return false;
foreach ( var (id, name) in a.BodyNames )
{
if ( !b.BodyNames.TryGetValue( id, out var other ) || name != other )
return false;
}
if ( a.HiddenBodyIds.Count != b.HiddenBodyIds.Count )
return false;
foreach ( var id in a.HiddenBodyIds )
{
if ( !b.HiddenBodyIds.Contains( id ) )
return false;
}
if ( a.FeatureNames.Count != b.FeatureNames.Count )
return false;
foreach ( var (feature, name) in a.FeatureNames )
{
if ( !b.FeatureNames.TryGetValue( feature, out var other ) || name != other )
return false;
}
if ( !SameSkeleton( a.RigSkeleton, b.RigSkeleton ) )
return false;
if ( a.BodyBoneMap.Count != b.BodyBoneMap.Count )
return false;
foreach ( var (body, bone) in a.BodyBoneMap )
{
if ( !b.BodyBoneMap.TryGetValue( body, out var other ) || bone != other )
return false;
}
return true;
}
/// <summary>
/// Whether two bones are equally soft.
///
/// NULL IS A VALUE HERE, not a missing one: a bone with no SoftBone is RIGID, and rigid versus
/// soft-with-default-numbers is the single biggest difference a bone can have. Comparing the
/// four floats without first comparing the nulls would call those two the same thing.
/// </summary>
private static bool SameSoft( SoftBone a, SoftBone b )
{
if ( a is null || b is null )
return a is null && b is null;
// Exact, for the reason SameSkeleton gives below: a stiffness nudged from 60 to 60.5 through
// the inspector was still a deliberate edit, and tuning a wobble is a run of exactly those.
return a.Stiffness.Equals( b.Stiffness )
&& a.Damping.Equals( b.Damping )
&& a.Weight.Equals( b.Weight )
&& a.MaxAngle.Equals( b.MaxAngle );
}
/// <summary>Exact comparison, same reasoning as SameSketch's point-by-point check: a bone
/// nudged by a millionth of a unit through the numeric inspector was still moved on purpose,
/// and a tolerance here would silently swallow a fine adjustment instead of recording it.</summary>
private static bool SameSkeleton( Skeleton a, Skeleton b )
{
if ( a.Count != b.Count )
return false;
for ( var i = 0; i < a.Count; i++ )
{
var ba = a.Bones[i];
var bb = b.Bones[i];
if ( ba.Name != bb.Name || ba.Parent != bb.Parent || ba.Length != bb.Length )
return false;
// SOFTNESS COUNTS AS A DIFFERENCE, and leaving it out cost an undo step per edit.
// RecordUndo drops a snapshot that compares equal to the one already on top - the right
// call for a click that only advanced a tool - so a comparison blind to Soft made every
// softness change look like nothing happened. Ticking Soft and then Ctrl+Z left the bone
// soft, and re-tuning stiffness twice in a row lost the first value entirely.
if ( !SameSoft( ba.Soft, bb.Soft ) )
return false;
if ( !ba.Local.X.Equals( bb.Local.X ) || !ba.Local.Y.Equals( bb.Local.Y )
|| !ba.Local.Z.Equals( bb.Local.Z ) || !ba.Local.Origin.Equals( bb.Local.Origin ) )
return false;
}
return true;
}
private static bool SameSketch( Sketch a, Sketch b )
{
if ( a.Points.Count != b.Points.Count || a.Curves.Count != b.Curves.Count )
return false;
for ( var i = 0; i < a.Points.Count; i++ )
{
// Exact comparison on purpose: a point that moved by a millionth of a unit was still
// moved by the user, and a tolerance here would silently swallow fine adjustments.
if ( a.Points[i].x != b.Points[i].x || a.Points[i].y != b.Points[i].y )
return false;
}
for ( var i = 0; i < a.Curves.Count; i++ )
{
if ( a.Curves[i].Id != b.Curves[i].Id || a.Curves[i].Construction != b.Curves[i].Construction )
return false;
}
return true;
}
/// <summary>
/// Whether two paint-stroke lists describe the same paint, for the "did this change anything"
/// dedupe in <see cref="RecordUndo"/>. Compared by the stroke objects' IDENTITY, not their fields:
/// strokes are immutable once painted, so a capture shares the very objects the feature holds and
/// two captures agree iff they point at the same strokes. Null means "never painted" and is a value
/// here, not a missing one.
/// </summary>
private static bool SameStrokes( List<PaintStroke> a, List<PaintStroke> b )
{
if ( a is null || b is null )
return a is null && b is null;
if ( a.Count != b.Count )
return false;
for ( var i = 0; i < a.Count; i++ )
{
if ( !ReferenceEquals( a[i], b[i] ) )
return false;
}
return true;
}
// ShortcutType.Window, matching RigControlWindow and ShaderGraph's MainWindow. Without the
// attribute the Edit menu's "editor.undo" name resolves to nothing and Ctrl+Z never reaches
// this window - the menu item worked and the key did not.
[Shortcut( "editor.undo", "CTRL+Z", ShortcutType.Window )]
private void Undo()
{
if ( StepMeshEditHistory( redo: false ) )
return;
// SCULPT MODE OWNS UNDO OUTRIGHT while it is open, and does not fall through when its own
// stack is empty. The studio's undo restores a feature list, and a snapshot taken before this
// sculpt feature existed would leave the live session holding a feature the studio no longer
// has. Doing nothing is the honest answer to "there is nothing left to undo in here".
if ( _viewport?.SculptSession is not null )
{
StepSculptHistory( redo: false );
return;
}
// THE PEN FALLS THROUGH WHERE SCULPTING DOES NOT, and the difference is that sculpt mode is
// exclusive and the pen is not. Sculpting owns undo outright because the studio's undo
// restores a feature list its live session may not survive; a note is not in that list at
// all, so once the pen's own stack is empty the next Ctrl+Z is unambiguously about the
// model. Owning it anyway would mean arming the pen quietly disabled undo for everything
// else on the bar.
if ( _viewport?.NoteSession is { CanUndo: true } noting )
{
noting.Undo();
OnNoteEdited();
return;
}
if ( _undoStack.Count == 0 )
return;
_redoStack.Add( Capture() );
var previous = _undoStack[^1];
_undoStack.RemoveAt( _undoStack.Count - 1 );
Restore( previous );
}
// CTRL+Y, which is what this editor's own asset editors bind redo to.
[Shortcut( "editor.redo", "CTRL+Y", ShortcutType.Window )]
private void Redo()
{
if ( StepMeshEditHistory( redo: true ) )
return;
if ( _viewport?.SculptSession is not null )
{
StepSculptHistory( redo: true );
return;
}
// Falls through once the pen's stack is empty - see Undo above for why the pen does not own
// the shortcut the way sculpting does.
if ( _viewport?.NoteSession is { CanRedo: true } noting )
{
noting.Redo();
OnNoteEdited();
return;
}
if ( _redoStack.Count == 0 )
return;
_undoStack.Add( Capture() );
var next = _redoStack[^1];
_redoStack.RemoveAt( _redoStack.Count - 1 );
Restore( next );
}
// --- sketch shortcuts --------------------------------------------------------------------
// Onshape's own sketch keys: N looks square at the sketch plane, L is line, C is circle,
// Q toggles construction geometry. They are documented shortcuts, not invented ones.
[Shortcut( "effigy.view.normal", "N", ShortcutType.Window )]
private void ShortcutViewNormal() => _viewport?.ViewNormalToSketchPlane();
[Shortcut( "effigy.sketch.line", "L", ShortcutType.Window )]
private void ShortcutLineTool() => ArmSketchTool( SketchToolKind.Line );
[Shortcut( "effigy.sketch.circle", "C", ShortcutType.Window )]
private void ShortcutCircleTool() => ArmSketchTool( SketchToolKind.Circle );
[Shortcut( "effigy.sketch.construction", "Q", ShortcutType.Window )]
private void ShortcutConstruction()
{
if ( _viewport?.IsSketching != true || _constructionTool is null )
return;
_constructionTool.Checked = !_constructionTool.Checked;
_viewport.ConstructionMode = _constructionTool.Checked;
// The toggle lives on the Reference stage, which is probably not the one showing — a
// modifier flipped by a shortcut that leaves no mark anywhere on screen is a mode you
// forget you are in.
_stageBar?.Reveal( _constructionTool );
_stageBar?.Refresh();
}
// --- sculpt / paint / note / bone shortcuts ---------------------------------------------
// The viewport keys are registered as shortcuts now rather than read out of OnKeyPress, so they
// appear in Settings > Hotkeys and can be rebound. They are scoped to the viewport (the
// typeof( EffigyViewport ) override) rather than to the window, because several of them are
// digits and brackets that a numeric field would otherwise swallow as typing. Each is guarded to
// its own mode; X is sculpt's mirror AND paint's symmetry, E is the note eraser AND bone rotate,
// and the two sides of each pair can never be live at once, so the guards are what keep them
// apart instead of the old OnKeyPress ordering.
[Shortcut( "effigy.sculpt.mirror", "X", typeof( EffigyViewport ) )]
private void ShortcutSculptMirror()
{
if ( MeshEditKey( 'X' ) )
return;
ToggleSculptSymmetry();
}
[Shortcut( "effigy.sculpt.mask", "M", typeof( EffigyViewport ) )]
private void ShortcutSculptMask()
{
if ( MeshEditKey( 'M' ) )
return;
ToggleSculptMasking();
}
[Shortcut( "effigy.sculpt.brush1", "1", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush1()
{
if ( MeshEditKey( '1' ) )
return;
SelectSculptBrush( 0 );
}
[Shortcut( "effigy.sculpt.brush2", "2", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush2()
{
if ( MeshEditKey( '2' ) )
return;
SelectSculptBrush( 1 );
}
[Shortcut( "effigy.sculpt.brush3", "3", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush3()
{
if ( MeshEditKey( '3' ) )
return;
SelectSculptBrush( 2 );
}
[Shortcut( "effigy.sculpt.brush4", "4", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush4() => SelectSculptBrush( 3 );
[Shortcut( "effigy.sculpt.brush5", "5", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush5() => SelectSculptBrush( 4 );
[Shortcut( "effigy.sculpt.brush6", "6", typeof( EffigyViewport ) )]
private void ShortcutSculptBrush6() => SelectSculptBrush( 5 );
[Shortcut( "effigy.sculpt.radius.decrease", "[", typeof( EffigyViewport ) )]
private void ShortcutSculptRadiusDecrease()
{
if ( MeshEditKey( '[' ) )
return;
ScaleSculptRadius( 0.8f );
}
[Shortcut( "effigy.sculpt.radius.increase", "]", typeof( EffigyViewport ) )]
private void ShortcutSculptRadiusIncrease()
{
if ( MeshEditKey( ']' ) )
return;
ScaleSculptRadius( 1.25f );
}
[Shortcut( "effigy.paint.symmetry", "X", typeof( EffigyViewport ) )]
private void ShortcutPaintSymmetry()
{
if ( _viewport?.PaintSession is not { } session )
return;
// X toggles the default plane on and off; Y and Z are available to a future axis picker.
session.Mirror = session.Mirror == MirrorAxis.None ? MirrorAxis.X : MirrorAxis.None;
OnPaintSettingsChanged();
}
[Shortcut( "effigy.note.erase", "E", typeof( EffigyViewport ) )]
private void ShortcutNoteErase()
{
if ( MeshEditKey( 'E' ) )
return;
if ( _viewport?.NoteSession is null )
return;
_viewport.NoteErasing = !_viewport.NoteErasing;
UpdateNoteChecks();
}
[Shortcut( "effigy.note.hide", "H", typeof( EffigyViewport ) )]
private void ShortcutNoteHide()
{
if ( _viewport?.NoteSession is null )
return;
_viewport.ShowNotes = !_viewport.ShowNotes;
_viewport.Update();
}
// W/E/R drive whichever handle is up — the selected bone's and an open Transform's. Each setter
// no-ops when its handle is not there, so one convention serves both rather than two keymaps.
[Shortcut( "effigy.bone.move", "W", typeof( EffigyViewport ) )]
private void ShortcutBoneMove()
{
if ( MeshEditKey( 'W' ) )
return;
_viewport?.SetBoneDragMode( EffigyViewport.BoneDragMode.Move );
_viewport?.SetBodyDragMode( EffigyViewport.BodyDragMode.Move );
}
[Shortcut( "effigy.bone.rotate", "E", typeof( EffigyViewport ) )]
private void ShortcutBoneRotate()
{
// E is Extrude while editing a mesh — the note eraser shortcut on the same key already
// routes it there, and doing it twice would extrude twice.
if ( _viewport?.IsMeshEditing == true )
return;
_viewport?.SetBoneDragMode( EffigyViewport.BoneDragMode.Rotate );
_viewport?.SetBodyDragMode( EffigyViewport.BodyDragMode.Rotate );
}
[Shortcut( "effigy.bone.scale", "R", typeof( EffigyViewport ) )]
private void ShortcutBoneScale()
{
if ( MeshEditKey( 'R' ) )
return;
_viewport?.SetBoneDragMode( EffigyViewport.BoneDragMode.Scale );
_viewport?.SetBodyDragMode( EffigyViewport.BodyDragMode.Scale );
}
/// <summary>The index is into <see cref="_brushTools"/>, so the keyboard order and the stage
/// bar's order are one list rather than two that can drift apart.</summary>
private void SelectSculptBrush( int index )
{
if ( index < 0 || index >= _brushTools.Count )
return;
SetSculptBrush( _brushTools[index].Kind );
}
private void ScaleSculptRadius( float factor )
{
if ( _viewport?.SculptSession is not { } session )
return;
session.Radius = MathF.Max( session.Radius * factor, 1e-4f );
OnSculptSettingsChanged();
}
/// <summary>A sketch tool key outside sketch mode has nothing to arm, and silently switching a
/// hidden tool would leave the strip disagreeing with the viewport next time it opened.</summary>
private void ArmSketchTool( SketchToolKind kind )
{
if ( _viewport?.IsSketching != true )
return;
_viewport.SetSketchTool( kind );
UpdateSketchToolChecks( kind );
RevealSketchTool( kind );
}
// --- palette / theming ------------------------------------------------------------------
private void SetPalette( int index )
{
_paletteIndex = Math.Clamp( index, 0, EffigyPalette.All.Length - 1 );
_palette = EffigyPalette.All[_paletteIndex];
ApplyPalette();
// No BuildMenuBar() any more. It was here to redraw the View menu's checkmarks, and the
// palette list is a dropdown in Edit > Settings now — the combo already shows what is
// selected, and rebuilding the whole menu bar to update a tick that no longer exists was
// throwing away the Edit and View menus on every palette change.
EditorCookie.Set( PaletteCookie, _paletteIndex );
}
// --- settings ------------------------------------------------------------------------------
/// <summary>Where the two settings persist between sessions. EditorCookie is the engine's own
/// per-editor store — the same one the Boolean tool keeps its mode in.</summary>
private const string PaletteCookie = "Effigy.Palette";
/// <summary>A NEW KEY, not the old Effigy.ShowSketchGrid. That one meant "grid on the sketch
/// plane" and defaulted to on; this one means "grid on every plane" and defaults to off. Reusing
/// the key would have read a value stored against the old meaning and turned every plane's grid
/// on for anyone who had ever opened the settings window.</summary>
private const string PlaneGridCookie = "Effigy.ShowPlaneGrid";
private const string GridSpacingCookie = "Effigy.GridSpacing";
private const string SnapGridCookie = "Effigy.SnapToGrid";
private const string SnapPointsCookie = "Effigy.SnapToPoints";
private const string SnapFaceEdgesCookie = "Effigy.SnapToFaceEdges";
/// <summary>Defaults to off. The stand-in is a whole character in the viewport and most parts
/// are not built at body scale, so it is something you ask for rather than something you have
/// to turn off before you can see what you are making.</summary>
private const string SizeReferenceCookie = "Effigy.ShowSizeReference";
/// <summary>Defaults to on. Modelling wants every face readable; the studio sun is the setting
/// you turn on when you want to judge a material, not the light you sketch under.</summary>
private const string FullBrightCookie = "Effigy.FullBright";
/// <summary>Defaults to off. The checker is a diagnostic, not how the part looks.</summary>
private const string CheckerCookie = "Effigy.ShowUVChecker";
/// <summary>The normal-map bake conventions and size. Defaults match the reference sample in
/// Effigy.Tests/out: OpenGL green, no vertical flip, 1024.</summary>
private const string BakeGreenCookie = "Effigy.BakeDirectXGreen";
private const string BakeFlipVCookie = "Effigy.BakeFlipV";
private const string BakeSizeCookie = "Effigy.BakeSize";
/// <summary>The open settings window, or null. Held so a second Edit > Settings raises the one
/// already open rather than stacking another on top of it.</summary>
private EffigySettingsWindow _settingsWindow;
/// <summary>The grid switch floating over the sketch, or null before the viewport is built.
/// </summary>
private EffigySketchGridBar _gridBar;
/// <summary>The citizen switch at the end of the tool row, or null before the viewport is built.</summary>
private EffigySizeReferenceButton _citizenButton;
/// <summary>
/// The overlay changed the grid. It has already written the viewport; this is the rest of what
/// the settings window's own callback does — remember it, and put the open settings window
/// straight if it happens to be showing the value that just changed.
///
/// Both controls edit ONE value rather than each keeping their own, so the only thing that can
/// go wrong is a stale view, and that is what the refresh below is for.
/// </summary>
private void OnGridBarChanged()
{
if ( !_viewport.IsValid() )
return;
EditorCookie.Set( PlaneGridCookie, _viewport.ShowPlaneGrid );
EditorCookie.Set( GridSpacingCookie, _viewport.GridSpacing );
_settingsWindow?.Sync( CurrentSettings() );
}
private void OnSizeReferenceChanged()
{
if ( !_viewport.IsValid() )
return;
EditorCookie.Set( SizeReferenceCookie, _viewport.ShowSizeReference );
_settingsWindow?.Sync( CurrentSettings() );
}
private void OpenSettings()
{
if ( _settingsWindow.IsValid() )
{
_settingsWindow.Focus();
return;
}
_settingsWindow = new EffigySettingsWindow( this, CurrentSettings(), ApplySettings,
addPointLight: AddViewportLight,
clearLights: ClearViewportLights );
_settingsWindow.Show();
}
private EffigySettingsWindow.Values CurrentSettings() => new()
{
ShowGrid = _viewport?.ShowPlaneGrid ?? false,
GridSpacing = _viewport?.GridSpacing ?? 0f,
SnapToGrid = _viewport?.SnapToGrid ?? true,
SnapToPoints = _viewport?.SnapToPoints ?? true,
SnapToFaceEdges = _viewport?.SnapToFaceEdges ?? true,
PaletteIndex = _paletteIndex,
ShowSizeReference = _viewport?.ShowSizeReference ?? false,
SizeReferenceHeight = _viewport?.SizeReferenceHeight ?? 0f,
FullBright = _viewport?.FullBright ?? true,
PlacedLightCount = _viewport?.PlacedLightCount ?? 0,
ShowUVChecker = _showUVChecker,
BakeDirectXGreen = _bakeFlipGreen,
BakeFlipV = _bakeFlipV,
BakeSize = _bakeSize,
};
/// <summary>Take everything the settings window is showing and make it true, then remember it.
/// Called on every control change rather than behind an OK button — a viewport setting you
/// cannot see take effect is one you have to guess at.</summary>
private EffigySettingsWindow.Values ApplySettings( EffigySettingsWindow.Values values )
{
if ( _viewport.IsValid() )
{
_viewport.ShowPlaneGrid = values.ShowGrid;
_viewport.GridSpacing = values.GridSpacing;
_viewport.SnapToGrid = values.SnapToGrid;
_viewport.SnapToPoints = values.SnapToPoints;
_viewport.SnapToFaceEdges = values.SnapToFaceEdges;
_viewport.ShowSizeReference = values.ShowSizeReference;
_viewport.FullBright = values.FullBright;
// READ BACK, not echoed. The viewport turns the switch off again if the citizen will
// not load, and it is the only thing that knows how tall the one that did load is - so
// what goes back to the settings window is what actually happened, not what was asked
// for. Full bright is the same shape: adding a light turns it off so the lamp is
// visible, and the switch has to follow.
values.ShowSizeReference = _viewport.ShowSizeReference;
values.SizeReferenceHeight = _viewport.SizeReferenceHeight;
values.FullBright = _viewport.FullBright;
values.PlacedLightCount = _viewport.PlacedLightCount;
}
if ( values.PaletteIndex != _paletteIndex )
SetPalette( values.PaletteIndex );
// The checker is a preview-material toggle, not viewport state: it changes what the preview
// is built with, so flipping it rebuilds the model rather than drawing something over it.
if ( _showUVChecker != values.ShowUVChecker )
{
_showUVChecker = values.ShowUVChecker;
if ( _studio is not null )
RefreshPreview();
}
// Not viewport state - the bake reads these fields directly when the Bake button is pressed,
// so applying them is just storing them.
_bakeFlipGreen = values.BakeDirectXGreen;
_bakeFlipV = values.BakeFlipV;
_bakeSize = values.BakeSize;
EditorCookie.Set( BakeGreenCookie, values.BakeDirectXGreen );
EditorCookie.Set( BakeFlipVCookie, values.BakeFlipV );
EditorCookie.Set( BakeSizeCookie, values.BakeSize );
EditorCookie.Set( PlaneGridCookie, values.ShowGrid );
EditorCookie.Set( GridSpacingCookie, values.GridSpacing );
EditorCookie.Set( SnapGridCookie, values.SnapToGrid );
EditorCookie.Set( SnapPointsCookie, values.SnapToPoints );
EditorCookie.Set( SnapFaceEdgesCookie, values.SnapToFaceEdges );
EditorCookie.Set( SizeReferenceCookie, values.ShowSizeReference );
EditorCookie.Set( FullBrightCookie, values.FullBright );
EditorCookie.Set( CheckerCookie, values.ShowUVChecker );
_citizenButton?.Refresh();
return values;
}
/// <summary>Put last session's settings back, before anything is drawn with them.</summary>
private void RestoreSettings()
{
SetPalette( EditorCookie.Get( PaletteCookie, _paletteIndex ) );
_bakeFlipGreen = EditorCookie.Get( BakeGreenCookie, false );
_bakeFlipV = EditorCookie.Get( BakeFlipVCookie, false );
_bakeSize = EditorCookie.Get( BakeSizeCookie, 1024 );
_showUVChecker = EditorCookie.Get( CheckerCookie, false );
if ( !_viewport.IsValid() )
return;
_viewport.ShowPlaneGrid = EditorCookie.Get( PlaneGridCookie, false );
_viewport.GridSpacing = EditorCookie.Get( GridSpacingCookie, 0f );
_viewport.SnapToGrid = EditorCookie.Get( SnapGridCookie, true );
_viewport.SnapToPoints = EditorCookie.Get( SnapPointsCookie, true );
_viewport.SnapToFaceEdges = EditorCookie.Get( SnapFaceEdgesCookie, true );
_viewport.ShowSizeReference = EditorCookie.Get( SizeReferenceCookie, false );
_viewport.FullBright = EditorCookie.Get( FullBrightCookie, true );
_citizenButton?.Refresh();
_gridBar?.Refresh();
}
/// <summary>Drop a point light into the viewport. Full bright turns off so the lamp is
/// visible — a light you cannot see is how this would look like it did nothing.</summary>
private void AddViewportLight()
{
if ( !_viewport.IsValid() )
return;
_viewport.AddPointLight();
SetPrompt( "Drag the bulb to move it. Delete removes it. Full bright is in Settings if you want even light back." );
}
private void ClearViewportLights()
{
if ( !_viewport.IsValid() )
return;
_viewport.ClearLights();
SetPrompt( _viewport.FullBright
? "Lamps cleared."
: "Lamps cleared. The studio sun is still on." );
}
/// <summary>A light was added, removed, or full bright flipped — persist the mode and keep
/// the settings window's switch in agreement if it is open.</summary>
private void OnLightingChanged()
{
if ( !_viewport.IsValid() )
return;
EditorCookie.Set( FullBrightCookie, _viewport.FullBright );
if ( _settingsWindow.IsValid() )
_settingsWindow.Sync( CurrentSettings() );
}
/// <summary>
/// Push the active palette at everything that reads one.
///
/// This set a single property that the camera had already read once in the viewport's
/// constructor, before any palette was applied - so all four palettes rendered identically.
/// See EffigyViewport.BackgroundColor for the other half of that fix.
/// </summary>
private void ApplyPalette()
{
if ( !_viewport.IsValid() )
return;
_viewport.BackgroundColor = _palette.ViewportBg;
// The bar is CHROME and takes the chrome colour. The strips it replaced took the viewport's
// background instead, because they sat on the 3D view and had to disappear into it; the bar
// sits above the view and is meant to be seen.
if ( _stageBar is not null )
_stageBar.ChromeColor = _palette.Chrome;
// Chrome2 rather than Chrome, so the two docked rows are distinguishable without a divider
// doing all the work. The workspace row is the outer one and takes the darker of the pair —
// chrome that contains other chrome should recede behind it, not sit in front.
if ( _workspaceBar is not null )
_workspaceBar.ChromeColor = _palette.Chrome2;
if ( _sculptBar is not null )
_sculptBar.GapColor = _palette.ViewportBg;
// The chrome colour, not the viewport's: this one sits ON the bar, so its gaps have to
// disappear into the bar the way the tool buttons beside it do.
if ( _gridBar is not null )
_gridBar.GapColor = _palette.Chrome;
if ( _citizenButton is not null )
_citizenButton.GapColor = _palette.Chrome;
// Grid lines want the palette's dim text colour: it is picked to sit just above the
// background in every one of these palettes, which is exactly the job.
_viewport.PlaneColor = _palette.TextDim.WithAlpha( 0.55f );
}
// --- constraining a sketch selection --------------------------------------------------------
/// <summary>
/// The constraint menu, on a right-click inside a sketch.
///
/// A MENU RATHER THAN A TOOLBAR, which is not what Onshape does. The reason is what the offers
/// are: they change with every click, so a strip of buttons would have to relabel, enable and
/// disable itself per frame, and every bit of that is widget code this repo cannot compile to
/// check. A menu is built fresh each time it opens, from machinery already proven in the feature
/// tree and the face menu, and it puts the choices where the cursor already is.
///
/// What may be applied is ConstraintTools' answer, not this method's — it knows a point and a
/// line make a point-on-line and two lines do not, and it knows what the sketch already says.
/// </summary>
private void OpenSketchConstraintMenu()
{
if ( _viewport?.ActiveSketch is not { } sketch )
return;
var offers = ConstraintTools.Offers( sketch, _viewport.SketchSelection );
var menu = new Menu( _viewport );
if ( offers.Count == 0 )
{
// SAYING SO IS THE POINT. An empty menu, or no menu at all, reads as a broken right
// button — the user has selected something and is entitled to know why it buys them
// nothing.
menu.AddHeading( "Nothing to constrain from this selection" );
menu.AddOption( "Clear selection", "backspace", () => _viewport.ClearSketchSelection() );
menu.OpenAtCursor();
return;
}
menu.AddHeading( Describe( _viewport.SketchSelection ) );
foreach ( var offer in offers )
{
var it = offer;
var option = menu.AddOption( it.NeedsValue ? $"{it.Label}…" : it.Label, IconFor( it.Kind ),
() =>
{
if ( it.NeedsValue )
AskForDimension( it );
else
ApplyConstraint( it );
} );
option.StatusTip = it.Hint;
}
menu.AddSeparator();
menu.AddOption( "Clear selection", "backspace", () => _viewport.ClearSketchSelection() );
menu.OpenAtCursor();
}
/// <summary>
/// A dimension asks for its number before it is applied, in the one-field popup the feature tree
/// renames with — pre-filled with what the sketch currently measures.
///
/// Pre-filled matters more than it looks. Most dimensions are added to LOCK something where it
/// already is, and an empty box turns that into measuring by hand and typing a rounded version,
/// which moves the geometry by however much the rounding was.
/// </summary>
private void AskForDimension( ConstraintOffer offer )
{
var menu = new Menu( _viewport );
var edit = new LineEdit( Expression.Format( offer.Value ), menu ) { FixedWidth = 140 };
edit.ReturnPressed += () =>
{
menu.Close();
// Through the expression evaluator, the same as every numeric field in the dialog, so a
// dimension can be typed as "25/2" or "3*8" like any other number in this editor.
// The offer's own unit, so an angle typed as "45" reads as degrees and a length as units —
// the same evaluator every numeric field in the dialog goes through.
if ( !Expression.TryEvaluate( edit.Text, string.IsNullOrEmpty( offer.Unit ) ? null : offer.Unit, out var value ) )
{
SetPrompt( $"'{edit.Text}' is not a number" );
return;
}
offer.Value = value;
ApplyConstraint( offer );
};
menu.AddWidget( edit );
menu.OpenAtCursor();
edit.Focus();
edit.SelectAll();
}
/// <summary>Apply, and treat it as an edit of the sketch — an undo step, and a rebuild, because
/// the solve has moved geometry that features downstream are standing on.</summary>
private void ApplyConstraint( ConstraintOffer offer )
{
RecordUndo();
if ( !_viewport.ApplySketchConstraint( offer ) )
return;
OnSketchEdited();
}
static string Describe( SketchSelection selection )
{
var parts = new List<string>();
if ( selection.Points.Count > 0 )
parts.Add( $"{selection.Points.Count} point{(selection.Points.Count == 1 ? "" : "s")}" );
if ( selection.Curves.Count > 0 )
parts.Add( $"{selection.Curves.Count} curve{(selection.Curves.Count == 1 ? "" : "s")}" );
return string.Join( " and ", parts );
}
/// <summary>Classic Material Icons only — the set this editor's other menus draw from.</summary>
static string IconFor( SketchConstraintKind kind ) => kind switch
{
SketchConstraintKind.Horizontal => "horizontal_rule",
SketchConstraintKind.Vertical => "straighten",
SketchConstraintKind.Coincident => "adjust",
SketchConstraintKind.Distance => "straighten",
SketchConstraintKind.EqualLength => "drag_handle",
SketchConstraintKind.Parallel => "menu",
SketchConstraintKind.Perpendicular => "square_foot",
SketchConstraintKind.Angle => "square_foot",
SketchConstraintKind.PointOnLine => "linear_scale",
SketchConstraintKind.Symmetric => "flip",
SketchConstraintKind.Radius => "radio_button_unchecked",
SketchConstraintKind.Diameter => "circle",
SketchConstraintKind.Midpoint => "vertical_align_center",
SketchConstraintKind.Concentric => "adjust",
SketchConstraintKind.Fixed => "lock",
SketchConstraintKind.Tangent => "trip_origin",
SketchConstraintKind.TangentArcs => "trip_origin",
_ => "rule",
};
// --- right-click a face -------------------------------------------------------------------
/// <summary>
/// The right-click menu on a face of the model: what you can DO to this face, then what it is
/// made of.
///
/// THE TOOL LIST IS GENERATED FROM Feature.Accepts, which is the whole point of it. The complaint
/// that started this was pointing at a face, wanting to extrude it, and finding the tool nowhere
/// on offer — so a hand-kept list here would have been the same failure with an extra place to
/// forget. Every tool that says it takes a face appears, seeded with the face under the cursor,
/// and one that learns to take a face later appears without anybody editing this.
///
/// The face is SELECTED first and then the tool is added, rather than the feature being poked
/// directly: AddFeature already copies the idle selection onto whatever it makes, and going
/// through it means the right-click route and the click-then-press-the-button route are the same
/// path rather than two that can drift.
///
/// --- and the material half, which this menu was originally all of ---
///
/// The Face Material feature on the toolbar is how you paint a SET of faces in one go, and it is
/// the wrong shape for the common case: one face, one slot, now. Opening a dialog, arming a
/// selection box, clicking the face, closing the dialog is five actions for a thing you were
/// already pointing at.
///
/// It still goes through the history. Writing the slot straight onto the mesh would work until
/// the next rebuild and then quietly revert — bodies are rebuilt from scratch, which is the whole
/// reason FaceMaterialFeature exists (see FaceMaterialTests: "the reason this is a feature").
/// </summary>
private void OpenFaceMenu( EffigyFaceHit hit )
{
if ( _studio is null || _viewport is null || hit.Body is null )
return;
// In the rig workspace a right-click on a part is about bones, not materials. The Parts
// list already had Make a bone from this part; pointing at the solid itself has to reach
// the same menu or the first-rig path only exists if you know to use the dock.
if ( CurrentWorkspace == EffigyWorkspace.Rig && _partsPanel is not null )
{
_viewport.SelectBodies( new[] { hit.Body.Id } );
_partsPanel.OpenPartMenu( hit.Body );
return;
}
// In Model with nothing open, a right-click is about the body, not its material: the
// Object menu, the way Edit mode's right-click is the menu for the element under the mouse.
if ( InObjectMode )
{
OpenObjectMenu( hit.Body );
return;
}
var menu = new Menu( _viewport );
AddFaceToolOptions( menu, hit );
menu.AddHeading( $"Material — {_studio.NameForSlot( hit.Material )}" );
foreach ( var slot in MenuMaterialSlots() )
{
var value = slot;
// Slot 0 is the default every face starts on and the one the viewport deliberately does
// not tint, so it gets the hollow marker — "no material" rather than "material zero".
var option = menu.AddOption( _studio.NameForSlot( value ),
value == 0 ? "panorama_fish_eye" : "lens",
() => AssignFaceMaterial( hit, value ) );
option.Checkable = true;
option.Checked = hit.Material == value;
}
menu.AddSeparator();
// The picker rather than the row widget the dialog and the Materials panel use: a menu closes
// the moment you click anything in it, and it would take an embedded row — and the modal that
// row had just parented to itself — down with it. Pick is the shared half that survives that.
var choose = menu.AddOption( $"Choose material for {_studio.NameForSlot( hit.Material )}…", "palette",
() => EffigyMaterialSlot.Pick( this, hit.Material, SlotMaterial( hit.Material ), SetSlotMaterial ) );
choose.StatusTip = "Browse for the material this slot exports as";
var rename = menu.AddOption( $"Rename {_studio.NameForSlot( hit.Material )}…", "edit",
() => BeginMaterialSlotRename( hit.Material ) );
rename.StatusTip = "The name every exporter writes for this slot";
AddTextureScaleMenu( menu, hit );
var shade = menu.AddOption( "Shade Material Slots", "palette",
() => _viewport.ShadeMaterialSlots = !_viewport.ShadeMaterialSlots );
shade.Checkable = true;
shade.Checked = _viewport.ShadeMaterialSlots;
menu.OpenAtCursor();
}
/// <summary>Every tool that will take a face, above the material entries because acting on the
/// face is the bigger thing you can do to it.</summary>
private void AddFaceToolOptions( Menu menu, EffigyFaceHit hit )
{
var tools = ToolsAccepting( GeometryKind.Face );
if ( tools.Count == 0 )
return;
menu.AddHeading( $"Face of {hit.Body.Name ?? "part"}" );
foreach ( var tool in tools )
{
// The KIND, captured, never the table entry — same rule as the strip: an enum crosses a
// hotload and a reference into a table built by a dead assembly does not.
var kind = tool.Kind;
var option = menu.AddOption( tool.Label, tool.MenuIcon ?? "build",
() => UseFaceWith( hit, kind ) );
option.StatusTip = tool.Tip;
}
menu.AddSeparator();
}
/// <summary>Point a tool at the face that was right-clicked.</summary>
private void UseFaceWith( EffigyFaceHit hit, ToolKind kind )
{
_viewport?.SelectFace( hit );
AddFeature( NewFeature( kind, -1 ) );
}
/// <summary>
/// The size of the material on the face you right-clicked, as a submenu.
///
/// WHY HERE FIRST. A material that is the wrong size is something you notice by LOOKING at it,
/// and what you do next is right-click the thing that looks wrong. Every other route — a field
/// in a dock, a UV Project feature in the tree — asks you to leave the face and go find the
/// control, which is exactly the walk that made this feel like it did not exist.
///
/// THE VERBS ARE THE MESH EDITOR'S. s&box's FaceTool offers doubling and halving buttons and a
/// Fit with a repeat count, rather than a number field, because texture size is a thing you
/// converge on by eye: you cannot look at a floor and say "38", you can say "bigger" four times.
/// Fit is the one exact answer available without measuring anything, and it is also the only
/// entry that is honest on an extrude SIDE, whose UVs are not in units at all — it measures the
/// face rather than assuming what its numbers mean.
///
/// SLOT 0 IS EXCLUDED. It is every face nobody has painted, so resizing it from one face resizes
/// most of the part — the same reason MaterialDrop refuses to allocate it. The entry is left
/// visible and disabled rather than hidden, so the menu does not change shape depending on where
/// you clicked.
/// </summary>
private void AddTextureScaleMenu( Menu menu, EffigyFaceHit hit )
{
var slot = hit.Material;
var scale = MaterialScale.ScaleFor( _studio, slot );
// Shown on the parent entry, because the current size is the thing you came to find out and
// making you open a submenu to read it is a click for nothing.
var square = MathF.Abs( scale.x - scale.y ) < 0.01f;
var reading = square ? $"{Round( scale.x )}" : $"{Round( scale.x )} × {Round( scale.y )}";
var scaleMenu = menu.AddMenu( $"Texture scale — {reading} u/tile", "aspect_ratio" );
if ( slot <= 0 )
{
// AddOption rather than a note, so the reason is attached to something that looks like
// the thing you were reaching for.
var blocked = scaleMenu.AddOption( "Put this face on a material slot first", "block" );
blocked.Enabled = false;
blocked.StatusTip = "Slot 0 is every unpainted face — resizing it would resize the part";
return;
}
scaleMenu.AddOption( "Bigger (×2)", "zoom_in", () => ScaleFaceMaterial( slot, 2f ) );
scaleMenu.AddOption( "Smaller (÷2)", "zoom_out", () => ScaleFaceMaterial( slot, 0.5f ) );
scaleMenu.AddSeparator();
// One, two and four repeats rather than one alone: fitting a floor to a single repeat is a
// tile the size of the room, which is right for a sign and never right for a tile.
foreach ( var repeats in new[] { 1, 2, 4 } )
{
var count = repeats;
scaleMenu.AddOption( count == 1 ? "Fit to face" : $"Fit — {count} across", "fit_screen",
() => FitFaceMaterial( hit, count ) );
}
scaleMenu.AddSeparator();
// The sizes a game texture is actually authored at, in inches. 48 is on the list because a
// 12-inch tile four to a repeat is the case that started all of this.
foreach ( var preset in new[] { 16f, 32f, 48f, 64f, 128f, 256f } )
{
var value = preset;
var option = scaleMenu.AddOption( $"{Round( value )} u/tile", "straighten",
() => SetMaterialScale( slot, new Vec2( value, value ) ) );
option.Checkable = true;
option.Checked = square && MathF.Abs( scale.x - value ) < 0.01f;
}
scaleMenu.AddSeparator();
var reset = scaleMenu.AddOption( "Reset to 1:1", "restart_alt",
() => SetMaterialScale( slot, MaterialScale.Unscaled ) );
reset.StatusTip = "One repeat per unit — how the model was mapped before anything was set";
// Off when there is nothing to reset, so the menu says whether this slot has been touched
// without anyone having to read the number at the top and know what 1 means.
reset.Enabled = _studio.MaterialScales.ContainsKey( slot );
}
/// <summary>Multiply a slot's size, which is the doubling and halving pair.</summary>
private void ScaleFaceMaterial( int slot, float factor ) =>
SetMaterialScale( slot, MaterialScale.ScaleFor( _studio, slot ) * factor );
/// <summary>
/// Size a slot so its material repeats a set number of times across the face that was clicked.
///
/// The face is looked up on the CURRENT bodies rather than through the FaceRef, because the
/// index came out of a raycast against exactly those bodies moments ago and nothing has rebuilt
/// since. The reference matters when an edit has to survive a rebuild; this one is consumed
/// before the next one.
/// </summary>
private void FitFaceMaterial( EffigyFaceHit hit, int repeats )
{
if ( hit.Body?.Mesh is not { } mesh )
return;
SetMaterialScale( hit.Material,
MaterialScale.Fit( mesh, hit.FaceIndex, MaterialScale.ScaleFor( _studio, hit.Material ), repeats ) );
}
/// <summary>
/// Resize a slot — the one place every scale control lands, exactly as SetSlotMaterial is for
/// naming one.
///
/// A document edit like any other: undo first, rebuild after. The rebuild is what re-divides the
/// UVs, since MaterialScale.Apply runs at the end of one; nothing here touches a mesh directly.
/// </summary>
private void SetMaterialScale( int slot, Vec2 scale )
{
if ( _studio is null )
return;
RecordUndo();
if ( !MaterialScale.SetScale( _studio, slot, scale ) )
return;
RebuildStudio();
var now = MaterialScale.ScaleFor( _studio, slot );
SetPrompt( $"{_studio.NameForSlot( slot )} at {Round( now.x )} × {Round( now.y )} units per tile." );
}
/// <summary>A size as you would say it out loud: 48 rather than 48.000, 0.5 rather than
/// 0.500.</summary>
private static string Round( float value ) =>
value.ToString( MathF.Abs( value - MathF.Round( value ) ) < 0.005f ? "0" : "0.##" );
/// <summary>
/// Which slots the menu offers: zero through seven, plus anything the document already uses.
///
/// Seven is not arbitrary — it is how many colours the viewport tints with, so every slot on the
/// menu is one you can tell apart on screen. The kernel allows 0..63 and nobody picks slot 40 off
/// a list, but a document that arrived with one must not be unreachable, so the slots already in
/// use are added back in however high they are.
/// </summary>
private IEnumerable<int> MenuMaterialSlots()
{
var slots = new SortedSet<int>();
for ( var i = 0; i <= 7; i++ )
slots.Add( i );
foreach ( var slot in FaceMaterialEdit.UsedSlots( _studio ) )
slots.Add( slot );
return slots;
}
/// <summary>Name a slot, in the one-field popup the feature tree renames with.</summary>
private void BeginMaterialSlotRename( int slot )
{
var menu = new Menu( this );
var edit = new LineEdit( _studio.NameForSlot( slot ), menu ) { FixedWidth = 190 };
edit.ReturnPressed += () =>
{
// Closed BEFORE the edit, because SetSlotMaterial rebuilds and this menu is a child of
// the window it is rebuilding.
var name = edit.Text?.Trim();
menu.Close();
SetSlotMaterial( slot, name );
};
menu.AddWidget( edit );
menu.OpenAtCursor();
edit.Focus();
edit.SelectAll();
}
/// <summary>
/// What a slot carries, or null when it is still on its numbered default.
///
/// Not NameForSlot: that answers "what do the exporters write", which is never null, and the
/// controls need "has anybody chosen anything", which is the question with an empty answer.
/// </summary>
private string SlotMaterial( int slot ) =>
_studio is not null && _studio.MaterialNames.TryGetValue( slot, out var name )
&& !string.IsNullOrWhiteSpace( name )
? name
: null;
/// <summary>
/// Give a slot a material — the one place all three controls land.
///
/// It is a document edit like any other: undo first, rebuild after. The rebuild is what repaints
/// every face on the slot, refreshes the Materials panel, and pushes the new value back into a
/// feature dialog that happens to be open on the same slot.
/// </summary>
private void SetSlotMaterial( int slot, string material )
{
if ( _studio is null || slot < 0 )
return;
var name = material?.Trim();
// Clearing it puts the slot back on its numbered default rather than leaving it blank. Every
// exporter has to write SOMETHING per slot, and an empty usemtl is not it. Typing the default
// back in by hand means the same thing as clearing it, and is stored the same way — otherwise
// the slot would read as assigned while exporting exactly what an unassigned one does.
var clearing = string.IsNullOrWhiteSpace( name ) || name == ObjWriter.DefaultMaterialName( slot );
if ( clearing ? !_studio.MaterialNames.ContainsKey( slot ) : SlotMaterial( slot ) == name )
return;
RecordUndo();
if ( clearing )
_studio.MaterialNames.Remove( slot );
else
_studio.MaterialNames[slot] = name;
RebuildStudio();
}
/// <summary>Put one face on one slot. The bookkeeping — which assignment to reuse, what happens
/// to the one the face is leaving, where a new one goes in a rolled-back tree — is
/// FaceMaterialEdit in the kernel, where FaceMenuTests can hold it to account.</summary>
private void AssignFaceMaterial( EffigyFaceHit hit, int slot )
{
if ( _studio is null || hit.Body is null || hit.Material == slot )
return;
RecordUndo();
if ( FaceMaterialEdit.Assign( _studio, hit.Body.Id, hit.FaceIndex, hit.Reference, slot ) )
RebuildStudio();
}
/// <summary>
/// A material was dragged out of the browser and dropped on a face.
///
/// The same shape as AssignFaceMaterial above — undo, edit, rebuild — with one difference that
/// is the whole reason MaterialDrop exists: the drop names a material and no slot, so the edit
/// CHOOSES a slot, and the choice has to be said out loud. Nothing else on screen explains why
/// the face went that particular shade of the slot palette, and the number is what you need if
/// you then want to rename or rebind it from the Materials panel.
/// </summary>
private void OnMaterialDropped( EffigyFaceHit hit, string material )
{
if ( _studio is null || hit.Body is null || string.IsNullOrWhiteSpace( material ) )
return;
RecordUndo();
// The slot the material is about to land on, asked BEFORE the drop, so "was this slot in use
// already" has an answer. A material joining a slot it is already on must keep the size that
// slot was given — the whole point of one-slot-per-material is that the second drop is the
// same material, and re-guessing its size would undo a number somebody typed.
var fresh = MaterialDrop.SlotCarrying( _studio, material ) < 0;
if ( MaterialDrop.Drop( _studio, hit.Body.Id, hit.FaceIndex, hit.Reference, material,
out var slot, out var released ) )
{
// Only a slot this drop INVENTED gets a guessed size. See EffigyMaterialSize for where
// the number comes from and why it is the editor's own rule rather than one of ours.
if ( fresh && slot > 0 )
MaterialScale.SetScale( _studio, slot, EffigyMaterialSize.For( material ) );
RebuildStudio();
// The freed slot is said out loud for the same reason the chosen one is. Changing your
// mind about a face used to leave the material you rejected bound to a slot forever,
// which is invisible here and turns up as an export full of materials the part does not
// wear. Now it is cleaned up — and a cleanup nobody is told about is its own surprise.
SetPrompt( released >= 0
? $"{MaterialFileName( material )} → slot {slot}, and slot {released} was freed. Ctrl+Z puts it back."
: $"{MaterialFileName( material )} → slot {slot}. Ctrl+Z puts it back." );
return;
}
// Nothing happened, which is two different situations and worth telling apart. A drop on a
// face that already wears the material is an ordinary near-miss and needs no alarm; running
// out of slots is a wall you have hit, and saying nothing there looks like the drag failed.
SetPrompt( slot < 0
? $"All {MaterialDrop.HighestSlot} material slots are in use — free one from the Materials panel."
: $"That face is already on slot {slot}." );
}
/// <summary>
/// Double-clicking a material in the browser: bind the part's BASE material, slot 0.
///
/// Slot 0 is every face nobody has painted, so this is "the part is made of this" — usually the
/// largest surface on the model and the first thing you want bound. Dragging cannot do it,
/// deliberately: MaterialDrop never allocates slot 0 because a drop points at ONE face, and
/// giving it the slot the rest of the part is on would paint everything.
/// </summary>
private void SetBaseMaterial( string material )
{
if ( string.IsNullOrWhiteSpace( material ) )
return;
SetSlotMaterial( 0, material );
SetPrompt( $"{MaterialFileName( material )} is the part's base material. Ctrl+Z puts it back." );
}
/// <summary>The last segment of a material path, for a status line that has no room for the
/// rest of it. The same trimming EffigyMaterialSlot's label does, and for the same reason: the
/// folders are what tell two materials apart in a picker, and noise in one line of feedback.
/// </summary>
private static string MaterialFileName( string path )
{
var cut = path.LastIndexOfAny( new[] { '/', '\\' } );
return cut >= 0 && cut < path.Length - 1 ? path[(cut + 1)..] : path;
}
}
// ============================================================================
// The left panel — a flat feature tree matching Onshape's Part Studio layout:
//
// FEATURES (2)
// ├─ Origin
// ├─ Top
// ├─ Front
// ├─ Right
// ├─ Box
// └─ Subdivide
//
// Selecting a feature shows its parameters in the right panel.
// Uses TreeView + TreeNode<T> — the same pattern as RigBonesPanel.
// ============================================================================
/// <summary>
/// The hover-reveal "eye" a tree row uses to toggle visibility — one implementation shared by the
/// Features tree (sketches, origin and planes) and the Parts tree (bodies).
///
/// Before this they were two independent copies of the same idea that had quietly drifted: the
/// Features tree reserved 34px of right margin for its secondary text and never hid it, the Parts
/// tree reserved only 30px and hid its face count on hover instead — two different answers to the
/// same "don't let anything sit under the eye" problem, which is exactly the kind of thing that
/// reads as the eye behaving inconsistently between the two trees even though neither was wrong on
/// its own. One rect, one show/hide rule, one click test, everywhere a row has an eye — and
/// SecondaryTextRightMargin so a row's own text picks a margin that is provably wide enough
/// rather than tracking Width by memory in a second place.
/// </summary>
internal static class TreeEyeIcon
{
/// <summary>Width of the eye's own hit/paint rect, right-aligned to the tree.</summary>
public const float Width = 24f;
/// <summary>Gap kept clear between the eye and its own left edge.</summary>
public const float Padding = 4f;
/// <summary>How far from the row's right edge a row's OTHER text needs to stay clear of,
/// whether or not the eye is actually drawn on this frame — the eye still needs the room the
/// instant the row is hovered, so the margin cannot depend on hover state.</summary>
public const float SecondaryTextRightMargin = Width + Padding + 6f;
public static Rect Rect( TreeView tree, VirtualWidget item ) =>
new( tree.LocalRect.Right - Width - Padding, item.Rect.Top, Width, item.Rect.Height );
/// <summary>Shown on hover always, and whether or not hovered when the row is hidden — a
/// hidden row stays obviously hidden rather than only announcing it while the mouse happens to
/// be there.</summary>
public static bool ShouldShow( VirtualWidget item, bool visible ) => item.Hovered || !visible;
public static void Draw( TreeView tree, VirtualWidget item, bool visible )
{
if ( !ShouldShow( item, visible ) )
return;
Paint.SetPen( visible ? Theme.TextLight : Theme.Text );
Paint.DrawIcon( Rect( tree, item ), visible ? "visibility" : "visibility_off", 16, TextFlag.Center );
}
public static bool WasClicked( TreeView tree, VirtualWidget item, MouseEvent e ) =>
Rect( tree, item ).IsInside( e.LocalPosition );
}
/// <summary>What the feature tree's context menu asked the window to do. The panel knows what was
/// clicked; the window owns the studio, the dialog and the undo stack, so it does the doing.</summary>
internal enum EffigyFeatureCommand
{
Edit,
Rename,
ToggleSuppress,
Delete,
MoveUp,
MoveDown,
RollbackTo,
RollForward,
Sculpt,
MakeBones,
}
/// <summary>What the Parts list's context menu asked the window to do. Same split as
/// <see cref="EffigyFeatureCommand"/>: the panel knows the row, the window owns undo.</summary>
internal enum EffigyPartCommand
{
Rename,
ToggleVisibility,
Edit,
Isolate,
ShowAll,
MakeBone,
CollisionAuto,
CollisionBox,
CollisionHull,
CollisionNone,
}
internal sealed class EffigyFeatureTreePanel : Widget
{
private interface IVisibilityNode
{
bool IsVisible { get; }
string VisibilityKey { get; }
void ToggleVisibility();
}
private sealed class VisibilityTreeView : TreeView
{
public VisibilityTreeView( Widget parent ) : base( parent ) { }
protected override bool OnItemPressed( VirtualWidget item, MouseEvent e )
{
if ( item.Object is IVisibilityNode node && TreeEyeIcon.WasClicked( this, item, e ) )
{
node.ToggleVisibility();
return false;
}
return base.OnItemPressed( item, e );
}
}
private PartStudio _studio;
private TreeView _tree;
private readonly Dictionary<Feature, FeatureNode> _nodes = new();
public Feature SelectedFeature { get; private set; }
public Action<Feature> FeatureSelected { get; set; }
public Action StudioChanged { get; set; }
public Action<string, bool> VisibilityToggled { get; set; }
/// <summary>A context-menu item was picked.</summary>
public Action<Feature, EffigyFeatureCommand> CommandRequested { get; set; }
/// <summary>A rename was typed and confirmed. Separate from CommandRequested because it
/// carries the new text, and because the window has to snapshot for undo BEFORE applying
/// it.</summary>
public Action<Feature, string> RenameCommitted { get; set; }
/// <summary>
/// What a sketch is attached to, shown on its row in the tree.
///
/// THE DIFFERENCE THIS MAKES IS THE WHOLE PARAMETRIC MODEL. A sketch on a face moves when that
/// face moves, so everything built from it follows; a sketch on Top/Front/Right is anchored in
/// world space and never follows anything. Both are legitimate and they look identical once
/// the dialog is closed, which makes "why did that not update?" impossible to answer by
/// looking at the tree. Now the row says which one it is.
///
/// WORDED EXACTLY AS THE PLANE BOX WORDS IT — "Face of Extrude 1", not "on Extrude 1". They are
/// the same fact stated in two places a few pixels apart, and two phrasings of one fact read as
/// two different facts: the box named a face and the row named something else, so the row had
/// to be decoded rather than read. See EffigyFeatureDialog.FaceLabel, which is the other half
/// of this and must keep saying the same thing.
/// </summary>
public string AttachmentLabel( Feature feature )
{
// A datum plane is attached to things in exactly the same three ways a sketch is, and its
// row raises the same question — is this one anchored to a part, or standing in world space?
// One reader rather than two, so a plane and a sketch on it never describe the same
// attachment differently.
var (face, planeId, choice, offset) = feature switch
{
SketchFeature sketch => (sketch.Face, sketch.PlaneFeatureId, sketch.Plane.Value, sketch.PlaneOffset.Value),
PlaneFeature plane => (plane.Face, plane.BasePlaneId, plane.Base.Value, plane.Offset.Value),
_ => (null, null, null, 0f),
};
if ( choice is null )
return "";
var shift = offset == 0f ? "" : $" {offset:+0.##;-0.##}";
if ( !string.IsNullOrEmpty( planeId ) )
{
var on = _studio?.Features.FirstOrDefault( f => f.Id == planeId );
// Same shout as a lost face below: the feature is about to fail, or already has.
return on is null ? "On (missing)" : $"On {on.Name ?? on.TypeName}{shift}";
}
if ( face is not { } attached )
return $"{choice}{shift}";
var body = _studio?.Bodies.FirstOrDefault( b => b.Id == attached.BodyId );
if ( body is null )
return "Face of (missing)";
// The offset applies to a face-attached sketch exactly as it does to a global plane, and
// leaving it off the row here said the sketch was ON a face when it was floating above it.
return $"Face of {body.Name ?? "part"}{shift}";
}
/// <summary>True when the rollback bar sits above this feature, so it is not being evaluated.
/// Painted dimmer, the way Onshape greys out everything below the bar.</summary>
public bool IsRolledPast( Feature feature ) =>
_studio is not null && _studio.Features.IndexOf( feature ) >= _studio.EffectiveCount;
/// <summary>True for the FIRST feature below the bar - the one the bar is drawn above.</summary>
public bool IsFirstRolledPast( Feature feature ) =>
_studio is not null
&& _studio.RollbackIndex < _studio.Features.Count
&& _studio.Features.IndexOf( feature ) == _studio.EffectiveCount;
/// <summary>
/// Rename in place: a one-field popup at the cursor, which is what Menu.AddWidget is for.
/// Opened by double-clicking a feature (TreeNode.OnActivated) or from the context menu.
///
/// The tree paints its rows virtually - there is no per-row widget to turn into a text box -
/// so an editor has to be floated over it either way, and a popup is the one the editor
/// already has machinery for.
/// </summary>
public void BeginRename( Feature feature )
{
if ( feature is null )
return;
var menu = new Menu( this );
var edit = new LineEdit( feature.Name ?? feature.TypeName, menu ) { FixedWidth = 190 };
edit.ReturnPressed += () =>
{
RenameCommitted?.Invoke( feature, edit.Text );
menu.Close();
};
menu.AddWidget( edit );
menu.OpenAtCursor();
edit.Focus();
edit.SelectAll();
}
/// <summary>The right-click menu on a feature. Every entry acts on the feature that was
/// clicked rather than on the selection, so right-clicking one row while another is selected
/// does what it looks like it does.</summary>
public void OpenFeatureMenu( Feature feature )
{
if ( feature is null )
return;
var menu = new Menu( this );
var editLabel = feature switch
{
SketchFeature => "Edit Sketch",
SculptFeature => "Edit Sculpt",
_ => "Edit",
};
menu.AddOption( editLabel, "edit",
() => CommandRequested?.Invoke( feature, EffigyFeatureCommand.Edit ) );
menu.AddOption( "Rename", "text_fields", () => BeginRename( feature ) );
menu.AddSeparator();
menu.AddOption( feature.Suppressed ? "Unsuppress" : "Suppress", "block",
() => CommandRequested?.Invoke( feature, EffigyFeatureCommand.ToggleSuppress ) );
if ( feature is SketchFeature or PlaneFeature )
{
var noun = feature is PlaneFeature ? "plane" : "sketch";
var key = feature is PlaneFeature ? EffigyWindow.PlaneVisibilityKey( feature ) : $"sketch:{feature.Id}";
menu.AddOption( IsVisible( key ) ? $"Hide {noun}" : $"Show {noun}",
IsVisible( key ) ? "visibility_off" : "visibility", () => ToggleVisibility( key ) );
}
menu.AddSeparator();
menu.AddOption( "Move up", "arrow_upward", () => CommandRequested?.Invoke( feature, EffigyFeatureCommand.MoveUp ) );
menu.AddOption( "Move down", "arrow_downward", () => CommandRequested?.Invoke( feature, EffigyFeatureCommand.MoveDown ) );
menu.AddSeparator();
menu.AddOption( "Roll back to before this", "history",
() => CommandRequested?.Invoke( feature, EffigyFeatureCommand.RollbackTo ) );
if ( _studio is not null && _studio.RollbackIndex < _studio.Features.Count )
{
menu.AddOption( "Roll forward to end", "last_page",
() => CommandRequested?.Invoke( feature, EffigyFeatureCommand.RollForward ) );
}
menu.AddSeparator();
// RIGGING, REACHED FROM THE TREE — and note what it does not do. The rig stays out of the
// feature list for the reasons PartStudio.Rig gives, so this creates no feature and stores
// nothing here: it derives a bone for each body the feature PRODUCED, which is the same
// operation the Parts list offers one row at a time. The tree is offered as a way in
// because that is where the modeller typed the name, so it is where they look for the
// thing they named.
var produced = _studio?.Bodies.Count( b => b.FeatureId == feature.Id ) ?? 0;
var makeBones = menu.AddOption(
produced > 1 ? $"Make {produced} bones from this feature" : "Make a bone from this feature",
"straighten",
() => CommandRequested?.Invoke( feature, EffigyFeatureCommand.MakeBones ) );
makeBones.Enabled = produced > 0;
makeBones.StatusTip = produced > 0
? "Adds a bone spanning each part this feature made, and pins the part to it."
: "This feature changed parts that already existed rather than making one of its own, "
+ "so there is nothing here to hang a bone on.";
menu.AddSeparator();
menu.AddOption( "Delete", "delete", () => CommandRequested?.Invoke( feature, EffigyFeatureCommand.Delete ) );
menu.OpenAtCursor();
}
/// <summary>Only keys the user has actually clicked the eye on. Everything else falls through
/// to DefaultVisible, so an automatic decision (a consumed sketch hiding itself) can be
/// overridden by hand and STAY overridden across rebuilds.</summary>
private readonly Dictionary<string, bool> _visibility = new();
public bool IsVisible( string key ) =>
_visibility.TryGetValue( key, out var value ) ? value : DefaultVisible( key );
/// <summary>Everything starts visible except a sketch some later feature has already built
/// from - Onshape hides those the moment they are consumed, and so do we.</summary>
private bool DefaultVisible( string key )
{
if ( _consumedSketchIds is null || !key.StartsWith( "sketch:" ) )
return true;
return !_consumedSketchIds.Contains( key["sketch:".Length..] );
}
/// <summary>Recomputed once per Rebuild rather than per eye paint - the tree repaints
/// constantly and walking the feature list on every row of every frame would be wasteful.</summary>
private HashSet<string> _consumedSketchIds;
private void ToggleVisibility( string key )
{
var visible = !IsVisible( key );
_visibility[key] = visible;
VisibilityToggled?.Invoke( key, visible );
_tree.Update();
}
private void PaintEye( VirtualWidget item, string key ) => TreeEyeIcon.Draw( _tree, item, IsVisible( key ) );
public EffigyFeatureTreePanel( Widget parent, PartStudio studio ) : base( parent )
{
Name = "Features";
WindowTitle = "Features";
SetWindowIcon( "account_tree" );
_studio = studio;
Layout = Layout.Column();
var header = new Widget( this ) { Layout = Layout.Row() };
header.Layout.Margin = new Sandbox.UI.Margin( 8, 4 );
header.Layout.Spacing = 8;
header.Layout.Add( new Editor.Label( "Features" ) { FixedWidth = 80 } );
header.Layout.Add( new Editor.Label( "" ), 1 );
Layout.Add( header );
_tree = new VisibilityTreeView( this );
_tree.OnSelectionChanged = objs =>
{
if ( objs?.FirstOrDefault() is FeatureNode node )
{
SelectedFeature = node.Feature;
FeatureSelected?.Invoke( node.Feature );
}
else
{
SelectedFeature = null;
FeatureSelected?.Invoke( null );
}
};
Layout.Add( _tree, 1 );
Rebuild();
}
public void SetStudio( PartStudio studio )
{
// This used to drop the argument on the floor, so File > New Studio rebuilt the tree
// against the OLD studio and the window kept showing features that were gone.
_studio = studio ?? new PartStudio();
Rebuild();
}
/// <summary>Select a feature by identity. Rebuild throws the nodes away and makes new ones,
/// so a caller holding a Feature cannot select it without this lookup.</summary>
public void Select( Feature feature )
{
if ( feature is null || !_nodes.TryGetValue( feature, out var node ) )
return;
SelectedFeature = feature;
_tree.SelectItem( node );
}
public void Rebuild()
{
_tree.Clear();
_nodes.Clear();
SelectedFeature = null;
_consumedSketchIds = _studio?.ConsumedSketchIds();
// Origin and the three reference planes - always present, at the top of the tree. They used
// to hang under a "Default geometry" folder, which was a row whose only job was to be
// expanded before you could reach the four rows inside it. The four rows sit here now.
foreach ( var node in new DefaultGeometryChildNode[]
{
new( this, "Origin", "adjust", "origin" ),
new( this, "Top (XY)", "crop_landscape", "top" ),
new( this, "Front (XZ)", "crop_landscape", "front" ),
new( this, "Right (YZ)", "crop_landscape", "right" ),
} )
_tree.AddItem( node );
// Feature nodes
foreach ( var feature in _studio.Features )
{
if ( IsHiddenFromTree( feature ) )
continue;
var node = new FeatureNode( this, feature );
_nodes[feature] = node;
_tree.AddItem( node );
if ( feature.Suppressed )
_tree.Close( node );
}
}
/// <summary>
/// Features that do their job without ever needing to be looked at.
///
/// FACE MATERIALS ARE BOOKKEEPING, NOT STEPS. Right-clicking a face and picking a slot creates
/// one of these — one per slot, reused thereafter (FaceMaterialEdit.SlotFeature) — because the
/// assignment has to live in the history or the next rebuild throws it away. That is a storage
/// decision, and it was leaking into the tree as a row per slot: paint four faces four colours
/// and the recipe for the part gained four entries that say nothing about how it was built.
///
/// Hiding the row does not hide the effect — the faces stay painted, undo still steps back
/// through the assignments, and right-clicking the face again is how you change your mind.
/// </summary>
private static bool IsHiddenFromTree( Feature feature ) => feature is FaceMaterialFeature;
// --- tree node types --------------------------------------------------------------------
/// <summary>Origin and the three reference planes, at the top of the feature tree.</summary>
private sealed class DefaultGeometryChildNode : TreeNode<string>
, IVisibilityNode
{
private readonly string _icon;
private readonly EffigyFeatureTreePanel _panel;
public string VisibilityKey { get; }
public bool IsVisible => _panel.IsVisible( VisibilityKey );
public void ToggleVisibility() => _panel.ToggleVisibility( VisibilityKey );
public DefaultGeometryChildNode( EffigyFeatureTreePanel panel, string name, string icon, string key ) : base( name )
{
_panel = panel;
_icon = icon;
VisibilityKey = key;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
Paint.SetPen( Theme.TextLight );
Paint.DrawIcon( item.Rect, _icon, 14, TextFlag.LeftCenter );
Paint.SetPen( Theme.Text );
Paint.DrawText( item.Rect.Shrink( 22, 0, 0, 0 ), Value, TextFlag.LeftCenter );
_panel.PaintEye( item, VisibilityKey );
}
}
/// <summary>A feature in the tree — icon + name + error/suppressed indicator.</summary>
private sealed class FeatureNode : TreeNode<Feature>, IVisibilityNode
{
private readonly EffigyFeatureTreePanel _panel;
/// <summary>Sketches and datum planes are the two features that are DRAWN rather than built,
/// so they are the two that have something to hide. The prefix distinguishes them because
/// the flags share one dictionary and one id could otherwise mean two things.</summary>
public string VisibilityKey => Feature is PlaneFeature
? EffigyWindow.PlaneVisibilityKey( Feature )
: $"sketch:{Feature.Id}";
private bool Hideable => Feature is SketchFeature or PlaneFeature;
public bool IsVisible => Hideable && _panel.IsVisible( VisibilityKey );
public void ToggleVisibility() { if ( Hideable ) _panel.ToggleVisibility( VisibilityKey ); }
public Feature Feature => Value;
public FeatureNode( EffigyFeatureTreePanel panel, Feature feature ) : base( feature ) { _panel = panel; }
/// <summary>The problem line, so a broken feature is readable without opening it. A red
/// icon with no words is the Onshape behaviour this dialog exists to beat.</summary>
public override string GetTooltip()
{
if ( Value.Diagnostic is { } diagnostic && !string.IsNullOrEmpty( diagnostic.Tooltip ) )
return diagnostic.Tooltip.Replace( "\n", "<br/>" );
return Value.Error ?? Value.Warning;
}
/// <summary>Double click renames, which is where every tree in the editor puts it.</summary>
public override void OnActivated() => _panel.BeginRename( Feature );
/// <summary>Right click opens the feature menu. Returning true stops the tree falling back
/// to its own (empty) menu.</summary>
public override bool OnContextMenu()
{
_panel.OpenFeatureMenu( Feature );
return true;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
// Below the rollback bar: this feature is not being evaluated at all, so it is drawn
// as history rather than as part of the model. The bar itself is a line across the top
// of the first such row - the same place Onshape draws it.
var rolled = _panel.IsRolledPast( Value );
if ( _panel.IsFirstRolledPast( Value ) )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Yellow.WithAlpha( 0.75f ) );
Paint.DrawRect( new Rect( item.Rect.Left, item.Rect.Top, item.Rect.Width, 2f ) );
}
// Icon color: blue for active, grey for suppressed, red for error, yellow for warning
if ( Value.Suppressed || rolled )
Paint.SetPen( Theme.TextLight.WithAlpha( 0.5f ) );
else if ( Value.Error is not null )
Paint.SetPen( Theme.Red );
else if ( Value.Warning is not null )
Paint.SetPen( Theme.Yellow );
else
Paint.SetPen( Theme.Blue );
Paint.DrawIcon( item.Rect, "category", 14, TextFlag.LeftCenter );
Paint.SetPen( Value.Suppressed || rolled ? Theme.TextLight : Theme.Text );
var label = $"{Value.Name ?? Value.TypeName}";
if ( Value.Suppressed )
label += " (suppressed)";
Paint.DrawText( item.Rect.Shrink( 22, 0, 0, 0 ), label, TextFlag.LeftCenter );
// Right-aligned, clear of the eye's strip: what this sketch is attached to, and
// therefore whether anything built from it will follow an edit upstream.
if ( Value is SketchFeature or PlaneFeature )
{
Paint.SetPen( Theme.TextLight.WithAlpha( 0.55f ) );
Paint.DrawText( item.Rect.Shrink( 0, 0, TreeEyeIcon.SecondaryTextRightMargin, 0 ),
_panel.AttachmentLabel( Value ), TextFlag.RightCenter );
}
if ( Hideable ) _panel.PaintEye( item, VisibilityKey );
}
}
}
/// <summary>
/// One entry in a sketch tool's dropdown: the same kind of tool, done a different way. A corner
/// rectangle and a centre rectangle are one button in Onshape, not two.
///
/// A BUILD-TIME DESCRIPTION, not something the bar ever sees. BuildSketchStages turns each of
/// these into an EffigyStageVariant carrying a closure, which is what the bar understands; this
/// type exists so the stage table can be written as a list of tools and kinds rather than a list
/// of lambdas, and so registering every variant against its SketchToolKind stays one line.
/// </summary>
internal sealed class SketchToolVariant
{
public readonly EffigyIcon Icon;
public readonly string Label;
public readonly string Tip;
public readonly SketchToolKind Kind;
public SketchToolVariant( EffigyIcon icon, string label, string tip, SketchToolKind kind )
{
Icon = icon;
Label = label;
Tip = tip;
Kind = kind;
}
}
// ============================================================================
// The Parts list — the bodies the feature tree has actually produced, in
// their own list below it. Onshape's Parts panel: the feature tree is the
// RECIPE, this is the RESULT, and the two are not the same thing. Three
// features can make one part and one pattern feature can make eight.
// ============================================================================
internal sealed class EffigyPartsPanel : Widget
{
private PartStudio _studio;
private readonly PartsTreeView _tree;
/// <summary>Body id of the part whose eye was clicked. The window owns the studio and the
/// rebuild, so the panel reports the click rather than acting on it.</summary>
public Action<string> VisibilityToggled { get; set; }
public Action<string, EffigyPartCommand> CommandRequested { get; set; }
/// <summary>A rename was typed and confirmed. Carries the new text, and the window has to
/// snapshot for undo BEFORE applying it.</summary>
public Action<string, string> RenameCommitted { get; set; }
/// <summary>Delete was chosen. Carries every body to remove — the whole selection when the
/// clicked row is part of a multi-select, just that row otherwise — so the window can remove
/// them all in one undo step.</summary>
public Action<IReadOnlyList<string>> DeleteRequested { get; set; }
/// <summary>Join these parts into one. Raised with two or more ids.</summary>
public Action<IReadOnlyList<string>> JoinRequested { get; set; }
/// <summary>The row highlight changed. Body ids of the selected parts, empty for none.</summary>
public Action<IReadOnlyList<string>> SelectionChanged { get; set; }
/// <summary>A bone was assigned to (or removed from) a body from the Parts list. Carries the
/// body id and the bone name — empty means unassign — so the window owns the undo, the write and
/// the refresh, exactly the way the rig panel's own Assign Body does.</summary>
public Action<string, string> BoneAssigned { get; set; }
/// <summary>The bone currently selected in the Rig tree, or null. Used to offer a one-click
/// "Assign to this bone" on the part menu so you do not have to hunt the submenu.</summary>
public Func<string> SelectedBoneName { get; set; }
private readonly List<string> _selectedBodyIds = new();
private readonly Dictionary<string, PartNode> _nodes = new();
private bool _restoringSelection;
public EffigyPartsPanel( Widget parent, PartStudio studio ) : base( parent )
{
Name = "Parts";
WindowTitle = "Parts";
_studio = studio;
Layout = Layout.Column();
var header = new Widget( this ) { Layout = Layout.Row() };
header.Layout.Margin = new Sandbox.UI.Margin( 8, 4 );
header.Layout.Spacing = 8;
header.Layout.Add( new Editor.Label( "Parts" ) { FixedWidth = 80 } );
header.Layout.Add( new Editor.Label( "" ), 1 );
Layout.Add( header );
_tree = new PartsTreeView( this );
_tree.OnSelectionChanged = objs =>
{
if ( _restoringSelection )
return;
var ids = new List<string>();
if ( objs is not null )
{
foreach ( var obj in objs )
{
// BOTH SHAPES ACCEPTED. Whether a TreeView reports the node it holds or the value
// inside it is not worth being wrong about a fourth time: a row that cannot name
// its body is a Parts list that does not select, which is exactly the symptom.
var id = obj switch
{
PartNode node => node.Value?.Id,
Body body => body.Id,
TreeNode<Body> generic => generic.Value?.Id,
_ => null,
};
if ( !string.IsNullOrEmpty( id ) )
ids.Add( id );
}
}
// AN EMPTY REPORT FROM A TREE THAT STILL HAS THE ROWS IS A REBUILD, NOT A DESELECTION.
//
// This is the bug behind "clicking a part in the list highlights nothing in the
// viewport", which outlived several attempts at it. Refresh() rebuilds the rows, and
// TreeView.Clear drops its selection and says so — but it says so on a LATER frame, so
// the _restoringSelection flag above has already closed by the time the notification
// arrives and cannot catch it. With a feature dialog open the studio rebuilds on every
// parameter tick, this panel refreshes with it, and the click was being wiped about once
// a second: the highlight appeared and vanished faster than it read as having appeared.
//
// The panel is the authority on its own selection. Nothing in this list DESELECTS by
// reporting emptiness — a row click reports that row, and clearing the selection comes
// from the viewport (Select, below) or from the parts themselves going away (Refresh,
// which clears _selectedBodyIds outright when there is nothing left to select). So an
// empty report while we still believe in a selection that still exists is put back
// rather than forwarded.
if ( ids.Count == 0 && _selectedBodyIds.Count > 0
&& _selectedBodyIds.TrueForAll( id => _nodes.ContainsKey( id ) ) )
{
RestoreTreeSelection();
return;
}
_selectedBodyIds.Clear();
_selectedBodyIds.AddRange( ids );
SelectionChanged?.Invoke( _selectedBodyIds );
};
Layout.Add( _tree, 1 );
// Tall enough for a few parts without taking the feature tree's room - the tree above it
// is the one that grows.
MinimumHeight = 118f;
Refresh();
}
public void SetStudio( PartStudio studio )
{
_studio = studio ?? new PartStudio();
// A different document's parts can sign identically to this one's — open two saves of the
// same model and they will — so the skip in Refresh has to be told outright that what it is
// looking at is not the same list any more.
_signature = null;
Refresh();
}
/// <summary>
/// Rebuild the rows from the studio's bodies.
///
/// THE WHOLE THING IS FENCED OFF FROM THE SELECTION CALLBACK, and that fence is the fix for the
/// oldest complaint against this panel: clicking a part highlighted nothing in the viewport.
/// TreeView.Clear drops its selection and reports that the way it reports any other selection
/// change — so the first line of this method told the window "nothing is selected now", the
/// window forwarded it to the viewport, and the highlight went out. It survived being reported
/// several times because it only bites while the list is being refreshed OFTEN, which is exactly
/// what happens with a feature dialog open: every parameter tick rebuilds the studio, every
/// rebuild refreshes this panel, and the click you just made is wiped before the next frame
/// draws. With no dialog open the same click looked like it worked.
///
/// _restoringSelection was already the flag for this — it just did not cover the clear.
/// </summary>
public void Refresh()
{
// NOTHING CHANGED, SO NOTHING IS REBUILT. A feature dialog rebuilds the studio on every
// parameter tick and every rebuild lands here, where the answer is almost always the same
// fourteen rows saying the same fourteen things. Tearing the tree down to put it back
// identically costs a row of widgets a tick and, worse, makes the tree announce a lost
// selection each time — see the OnSelectionChanged note above for what that did.
var signature = Signature();
if ( signature == _signature && _nodes.Count > 0 )
return;
_signature = signature;
var restoring = _restoringSelection;
_restoringSelection = true;
try
{
_tree.Clear();
_nodes.Clear();
if ( _studio is null || _studio.Bodies.Count == 0 )
{
_selectedBodyIds.Clear();
_tree.AddItem( new EmptyPartsNode() );
return;
}
_selectedBodyIds.RemoveAll( id => _studio.Bodies.All( b => b.Id != id ) );
foreach ( var body in _studio.Bodies )
{
var node = new PartNode( this, body );
_nodes[body.Id] = node;
_tree.AddItem( node );
}
RestoreTreeSelection();
}
finally
{
_restoringSelection = restoring;
}
}
/// <summary>Select these parts in the list. Used when the viewport picked a face so the row
/// and the solid stay the same selection. Does not re-raise SelectionChanged.</summary>
public void Select( IReadOnlyList<string> bodyIds )
{
_selectedBodyIds.Clear();
if ( bodyIds is not null )
_selectedBodyIds.AddRange( bodyIds.Where( id => !string.IsNullOrEmpty( id ) ) );
RestoreTreeSelection();
}
/// <summary>Everything the rows display, in one string. Anything a row SHOWS belongs in here —
/// the face count moves when a Remesh runs, the eye when a part is hidden, the bone when one is
/// assigned — or the list will sit there showing the old value.</summary>
private string Signature()
{
if ( _studio is null || _studio.Bodies.Count == 0 )
return "";
var builder = new StringBuilder();
foreach ( var body in _studio.Bodies )
{
builder.Append( body.Id ).Append( '/' )
.Append( body.Name?.Length ?? 0 ).Append( '/' ).Append( body.Name ).Append( '/' )
.Append( body.Mesh?.FaceCount ?? 0 ).Append( '/' )
.Append( body.Visible ? '1' : '0' ).Append( '/' )
.Append( BoneFor( body.Id ) ).Append( ';' );
}
return builder.ToString();
}
private string _signature;
/// <summary>Put the row highlight back on whatever is selected, without reporting it as a new
/// selection. Saves and restores the flag rather than clearing it outright, because Refresh
/// calls this from inside its own fence and a bare `false` here would open it early.</summary>
private void RestoreTreeSelection()
{
var restoring = _restoringSelection;
_restoringSelection = true;
try
{
if ( _selectedBodyIds.Count == 0 || !_nodes.TryGetValue( _selectedBodyIds[0], out var node ) )
_tree.ClearPartSelection();
else
_tree.SelectItem( node );
}
finally
{
_restoringSelection = restoring;
}
}
/// <summary>Rename in place: a one-field popup at the cursor, same as the feature tree.</summary>
public void BeginRename( string bodyId )
{
var body = BodyById( bodyId );
if ( body is null )
return;
var menu = new Menu( this );
var edit = new LineEdit( body.Name ?? "Part", menu ) { FixedWidth = 190 };
edit.ReturnPressed += () =>
{
RenameCommitted?.Invoke( bodyId, edit.Text );
menu.Close();
};
menu.AddWidget( edit );
menu.OpenAtCursor();
edit.Focus();
edit.SelectAll();
}
/// <summary>The right-click menu on a part. Every entry acts on the row that was clicked
/// rather than on the selection, so right-clicking one part while another is selected does
/// what it looks like it does.</summary>
public void OpenPartMenu( Body body )
{
if ( body is null )
return;
var menu = new Menu( this );
var bodyId = body.Id;
var visible = body.Visible;
var othersHidden = _studio.HiddenBodyIds.Count > 0;
menu.AddOption( "Rename", "text_fields", () => BeginRename( bodyId ) );
menu.AddOption( "Edit", "edit", () => CommandRequested?.Invoke( bodyId, EffigyPartCommand.Edit ) );
menu.AddSeparator();
menu.AddOption( visible ? "Hide" : "Show",
visible ? "visibility_off" : "visibility",
() => CommandRequested?.Invoke( bodyId, EffigyPartCommand.ToggleVisibility ) );
menu.AddOption( "Show only this", "center_focus_strong",
() => CommandRequested?.Invoke( bodyId, EffigyPartCommand.Isolate ) );
if ( othersHidden )
{
menu.AddOption( "Show all parts", "visibility",
() => CommandRequested?.Invoke( bodyId, EffigyPartCommand.ShowAll ) );
}
menu.AddSeparator();
// Delete removes the whole selection when the clicked row is part of a multi-select — the
// same rule a file manager follows. A single selected row, or a right-click on a row
// outside the selection, deletes just that row.
var deleteSet = _selectedBodyIds.Count > 1 && _selectedBodyIds.Contains( bodyId )
? _selectedBodyIds.ToList()
: new List<string> { bodyId };
if ( deleteSet.Count > 1 )
{
var join = menu.AddOption( $"Join {deleteSet.Count} parts", "merge_type", () => JoinRequested?.Invoke( deleteSet ) );
join.StatusTip = "Makes one part of the selected ones, as a new Import. Ctrl+Z brings them back.";
}
var delete = menu.AddOption(
deleteSet.Count > 1 ? $"Delete {deleteSet.Count} parts" : "Delete", "delete",
() => DeleteRequested?.Invoke( deleteSet ) );
if ( deleteSet.Count > 1 )
{
delete.StatusTip = $"Removes the {deleteSet.Count} selected parts. Ctrl+Z brings them back.";
}
else
{
var feature = _studio.Features.FirstOrDefault( f => f.Id == body.FeatureId );
if ( feature is ImportFeature import && import.CanRemovePiece( bodyId ) )
{
delete.StatusTip = "Removes just this part. Ctrl+Z brings it back.";
}
else
{
var siblings = _studio.Bodies.Count( b => b.FeatureId == body.FeatureId );
if ( siblings > 1 )
delete.StatusTip = "Removes the feature that made this part, and every other part it made.";
}
}
menu.AddSeparator();
// The other half of the binding below. "Assign" attaches this body to a bone somebody has
// already drawn; this measures the body and draws the bone for it. Deliberately NOT behind
// the same "are there bones yet" guard, because an empty rig is exactly when it is the only
// one of the two that can do anything.
var makeBone = menu.AddOption( "Make a bone from this part", "straighten",
() => CommandRequested?.Invoke( bodyId, EffigyPartCommand.MakeBone ) );
makeBone.StatusTip = "Adds a bone spanning the part's longest axis, and pins the part to it.";
// What this part collides as when the model ships. Auto reads the history (a box stays a
// box) or wraps a hull; the rest override it. Setting any part switches the whole model
// to per-part collision, so a box here does not leave the rest reading the history.
_studio.BodyCollision.TryGetValue( bodyId, out var collision );
var collisionMenu = menu.AddMenu( "Collision", "fitness_center" );
void CollisionChoice( string label, string kind, EffigyPartCommand command, string tip )
{
var option = collisionMenu.AddOption( label, null, () => CommandRequested?.Invoke( bodyId, command ) );
option.Checkable = true;
option.Checked = (collision ?? "auto") == kind;
option.StatusTip = tip;
}
CollisionChoice( "Auto", "auto", EffigyPartCommand.CollisionAuto, "Read from the history where it can be, a convex hull otherwise." );
CollisionChoice( "Box", "box", EffigyPartCommand.CollisionBox, "A box round the part - cheapest, and right for walls, crates and floors." );
CollisionChoice( "Hull", "hull", EffigyPartCommand.CollisionHull, "The part's convex hull - follows a rock or a barrel without the cost of the mesh." );
CollisionChoice( "None", "none", EffigyPartCommand.CollisionNone, "No collision: foliage, cables, decoration you should walk through." );
var selectedBone = SelectedBoneName?.Invoke();
if ( !string.IsNullOrEmpty( selectedBone ) )
{
var pin = menu.AddOption( $"Assign to '{selectedBone}'", "link",
() => BoneAssigned?.Invoke( bodyId, selectedBone ) );
pin.StatusTip = "Pin this part to the bone currently selected in the Rig tree.";
}
// The body -> bone direction. The rig panel pins bodies to a bone from the bone's side; this
// is the same binding reachable from the body's side, so a part built of many bodies can be
// rigged without hunting through the bone tree for each one.
if ( _studio.Rig is { Count: > 0 } rig )
{
var assign = menu.AddMenu( "Assign to bone", "account_tree" );
if ( _studio.BodyBoneMap.ContainsKey( bodyId ) )
{
assign.AddOption( "None", "close", () => BoneAssigned?.Invoke( bodyId, "" ) );
assign.AddSeparator();
}
foreach ( var bone in rig.Bones )
{
var name = bone.Name;
assign.AddOption( name, null, () => BoneAssigned?.Invoke( bodyId, name ) );
}
}
menu.OpenAtCursor();
}
private Body BodyById( string bodyId ) =>
_studio?.Bodies.FirstOrDefault( b => b.Id == bodyId );
/// <summary>The bone a body is pinned to, or null — shown right-aligned on the row so the Parts
/// list reads as a rig as well as a result list.</summary>
private string BoneFor( string bodyId ) =>
_studio is not null && _studio.BodyBoneMap.TryGetValue( bodyId, out var bone ) ? bone : null;
private sealed class PartsTreeView : TreeView
{
public PartsTreeView( Widget parent ) : base( parent ) { }
public void ClearPartSelection()
{
foreach ( var item in SelectedItems.ToList() )
SetSelected( item, false, skipEvents: true );
}
protected override bool OnItemPressed( VirtualWidget item, MouseEvent e )
{
if ( item.Object is PartNode node && TreeEyeIcon.WasClicked( this, item, e ) )
{
node.ToggleVisibility();
return false;
}
return base.OnItemPressed( item, e );
}
}
/// <summary>One body: name, face count, and an eye.</summary>
private sealed class PartNode : TreeNode<Body>
{
private readonly EffigyPartsPanel _panel;
public PartNode( EffigyPartsPanel panel, Body body ) : base( body ) { _panel = panel; }
public void ToggleVisibility() => _panel.VisibilityToggled?.Invoke( Value.Id );
/// <summary>Double click renames, which is where every tree in the editor puts it.</summary>
public override void OnActivated() => _panel.BeginRename( Value.Id );
/// <summary>Right click opens the part menu. Returning true stops the tree falling back
/// to its own (empty) menu.</summary>
public override bool OnContextMenu()
{
_panel.OpenPartMenu( Value );
return true;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
var visible = Value.Visible;
Paint.SetPen( visible ? Theme.Green.WithAlpha( 0.8f ) : Theme.TextLight.WithAlpha( 0.5f ) );
Paint.DrawIcon( item.Rect, "view_in_ar", 14, TextFlag.LeftCenter );
Paint.SetPen( visible ? Theme.Text : Theme.TextLight );
Paint.DrawText( item.Rect.Shrink( 22, 0, TreeEyeIcon.SecondaryTextRightMargin, 0 ),
Value.Name ?? "Part", TextFlag.LeftCenter );
// An assigned body shows its bone (blue, to match the rig tree) instead of its face
// count — a rigged part's bone is the more meaningful thing to read at a glance, and it
// is the only place this half of the binding is visible outside the rig panel.
var bone = _panel.BoneFor( Value.Id );
if ( bone is not null )
{
Paint.SetPen( Theme.Blue );
Paint.DrawText( item.Rect.Shrink( 0, 0, TreeEyeIcon.SecondaryTextRightMargin, 0 ),
bone, TextFlag.RightCenter );
}
else
{
Paint.SetPen( Theme.TextLight.WithAlpha( 0.6f ) );
Paint.DrawText( item.Rect.Shrink( 0, 0, TreeEyeIcon.SecondaryTextRightMargin, 0 ),
$"{Value.Mesh?.FaceCount ?? 0}", TextFlag.RightCenter );
}
TreeEyeIcon.Draw( _panel._tree, item, visible );
}
}
/// <summary>Shown instead of an empty list, because an empty panel reads as broken.</summary>
private sealed class EmptyPartsNode : TreeNode<string>
{
public EmptyPartsNode() : base( "No parts yet" ) { }
public override void OnPaint( VirtualWidget item )
{
Paint.SetPen( Theme.TextLight.WithAlpha( 0.6f ) );
Paint.DrawText( item.Rect.Shrink( 8, 0, 0, 0 ), Value, TextFlag.LeftCenter );
}
}
}