Editor/EffigyEditor/EffigyWindow.cs
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 any Effigy-related asset (if/when one exists).
// ============================================================================
[EditorApp( "Effigy", "editor/effigy_icon.png", "Parametric modelling, subdivision, and rig-ready mesh export" )]
public sealed class EffigyWindow : DockWindow
{
// --- 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;
private EffigyFeatureTreePanel _featureTree;
private EffigyFeatureDialog _dialog;
private EffigyPartsPanel _partsPanel;
private EffigyMaterialsPanel _materialsPanel;
private EffigyRigPanel _rigPanel;
private Widget _leftPanel;
// --- 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;
/// <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;
/// <summary>Which mode's stages the bar is showing. The cheapest evidence in the editor about
/// whether entering a sketch actually happened — see DiagnosticStripState.</summary>
private EffigyBarMode _barMode = EffigyBarMode.Part;
/// <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;
/// <summary>
/// Tutorial latches — things that happened and left no trace in the document to check.
///
/// Rolling back and rolling forward again leaves a studio byte-identical to one that was
/// never rolled back, and a bake writes a PNG and is over. Neither can be read off the
/// document afterwards, so the only honest check is to remember having seen it. Session-
/// scoped by intent: they are about what the reader has DONE, not about what the file is.
/// </summary>
private bool _sawRollback;
private bool _sawRollforward;
private bool _sawBake;
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.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( "Collision Report", "fitness_center", ReportCollision );
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();
edit.AddOption( "Delete Feature", "delete", DeleteSelectedFeature );
edit.AddOption( "Move Feature Up", "arrow_upward", MoveFeatureUp );
edit.AddOption( "Move Feature Down", "arrow_downward", MoveFeatureDown );
edit.AddSeparator();
edit.AddOption( "Toggle Suppress", "visibility", ToggleSuppressFeature );
edit.AddSeparator();
edit.AddOption( "Normal Map: OpenGL / DirectX Green", "invert_colors", ToggleBakeGreen );
edit.AddOption( "Normal Map: Flip V", "swap_vert", ToggleBakeFlipV );
edit.AddOption( "Normal Map: Cycle Size", "photo_size_select_large", CycleBakeSize );
edit.AddSeparator();
edit.AddOption( "Invert Sculpt Mask", "flip", InvertSculptMask );
edit.AddOption( "Clear Sculpt Mask", "layers_clear", ClearSculptMask );
edit.AddOption( "Mask All Sculpt Geometry", "select_all", ProtectAllSculpt );
edit.AddOption( "Sculpt Mask: Paint / Erase", "brush", ToggleSculptMaskErase );
edit.AddOption( "Hide / Show Masked 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 Lamp Tutorial", "school", StartTutorial );
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 );
// "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" );
// 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" );
}
finally
{
_syncingDockChecks = false;
}
}
private void SetDockCheck( Option option, string dockTitle )
{
if ( option is not null )
option.Checked = DockManager.IsDockOpen( dockTitle );
}
// --- 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 };
// 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( _stageBar, _resultStrip );
// 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 );
_viewport.SculptStrokeFinished = NoteSculptEdited;
_viewport.SculptSettingsChanged = OnSculptSettingsChanged;
// 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();
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;
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();
// 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:
_stageBar.SetFinish( "Finish", FinishSculpt );
_stageBar.SetStages( _sculptStages, stage );
UpdateSculptChecks();
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
entry.Clicked = () => AddFeature( NewFeature( kind, -1 ) );
stage.Add( entry );
_featureTools[kind] = entry;
}
return stages;
}
/// <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 );
}
/// <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, and 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 ) );
// 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;
private readonly List<(EffigyStageTool Tool, BrushKind Kind)> _brushTools = new();
private EffigyStageTool _maskTool;
private EffigyStageTool _symmetryTool;
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;
if ( _viewport.IsSketching )
FinishSketch();
// 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;
// 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 = "SCULPT";
_stageBar.SetFinish( "Finish", FinishSculpt );
_stageBar.SetStages( _sculptStages );
_dialog?.Close();
_rigPanel?.CancelBoneTool();
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 );
ShowPartStages( force: true );
var feature = _sculptFeature;
_sculptFeature = null;
SetPrompt( "" );
// 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();
}
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;
session.MirrorX = !session.MirrorX;
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?.MirrorX ?? false;
_stageBar?.Refresh();
}
/// <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.
///
/// 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 ToggleBakeGreen()
{
_bakeFlipGreen = !_bakeFlipGreen;
SetPrompt( $"Normal map green channel: {(_bakeFlipGreen ? "DirectX (-Y)" : "OpenGL (+Y)")}." );
}
private void ToggleBakeFlipV()
{
_bakeFlipV = !_bakeFlipV;
SetPrompt( $"Normal map rows: v = 0 at the {(_bakeFlipV ? "bottom" : "top")} of the image." );
}
private void CycleBakeSize()
{
_bakeSize = _bakeSize >= 4096 ? 256 : _bakeSize * 2;
SetPrompt( $"Normal map size: {_bakeSize}x{_bakeSize}." );
}
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})" );
// Latched only after the write succeeded. A bake that threw on the way to disk has
// not happened, however far through it got, and ticking the step off for it would
// send the reader to the rig with nothing baked.
_sawBake = true;
RefreshTutorial();
}
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 THE EDIT MENU RATHER THAN ON THE STRIP, deliberately. The strip is hand-painted glyphs and
/// four 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.
///
/// 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." );
}
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>The viewport changed a brush setting itself - the X and M shortcuts - so the strip's
/// ticks and the bar's readout have to catch up with it.</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>
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();
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;
_viewport.SetSketchReference( feature?.Face is { } face
? SketchReference.FromFace( _studio.Bodies, face, feature.Sketch.Plane )
: null );
}
/// <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>
private void OnFeatureEdited()
{
if ( _dialog?.Feature is { } feature )
_studio.MarkDirty( feature );
// 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>
/// 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 SketchFeature.Face rather than through the kernel's own resolution, because
/// that needs a FeatureContext which only exists mid-rebuild - see EffigyResultStrip.ResolveAuto.
/// </summary>
private string SketchHostBodyId( string sketchId ) =>
_studio.Features.OfType<SketchFeature>().FirstOrDefault( f => f.Id == sketchId )?.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, Primitive, Extrude, Revolve, Sweep, Loft, Chamfer, Fillet, Shell, Subdivide,
Draft, Hole, Sculpt, Mirror, LinearPattern, CircularPattern, Transform, UVProject, FaceMaterial,
}
/// <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.Primitive => NewPrimitive( choice ),
ToolKind.Extrude => AwaitingPick( new ExtrudeFeature() ),
ToolKind.Revolve => AwaitingPick( NewRevolve() ),
ToolKind.Sweep => new SweepFeature(),
ToolKind.Loft => new LoftFeature(),
ToolKind.Chamfer => new ChamferFeature(),
ToolKind.Fillet => new FilletFeature(),
ToolKind.Shell => new ShellFeature(),
ToolKind.Subdivide => new SubdivideFeature(),
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(),
_ => 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;
}
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>
/// 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>
/// 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,
Tip = "Add a Sketch feature — draw lines/arcs on a plane",
Kind = ToolKind.Sketch },
new() { Icon = EffigyIcon.Primitive, Label = "Primitive", Stage = StageSketch,
Tip = "Add a Primitive — pick a shape",
Kind = ToolKind.Primitive, Choices = PrimitiveShapes },
// --- Solid: profiles become bodies ------------------------------------------------------
new() { Icon = EffigyIcon.Extrude, Label = "Extrude", Stage = StageSolid,
Tip = "Add an Extrude — pull a sketch profile 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 },
// --- 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,
Tip = "Add a Fillet — round sharp edges to a radius",
Kind = ToolKind.Fillet },
new() { Icon = EffigyIcon.Chamfer, Label = "Chamfer", Stage = StageDetail,
Tip = "Add a Chamfer — cut sharp edges back by a distance",
Kind = ToolKind.Chamfer },
new() { Icon = EffigyIcon.Shell, Label = "Shell", Stage = StageDetail,
Tip = "Add a Shell — hollow to a wall thickness",
Kind = ToolKind.Shell },
// 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,
Tip = "Add a Draft — taper picked faces so the part leaves a mould",
Kind = ToolKind.Draft },
new() { Icon = EffigyIcon.Hole, Label = "Hole", Stage = StageDetail,
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 },
// --- Finish: the cage and the skin it carries downstream --------------------------------
// This is the stage the README's pipeline names: CAD is done, and what is left is getting
// the mesh ready for a sculpt, a bake and a rig.
new() { Icon = EffigyIcon.Subdivide, Label = "Subdivide", Stage = StageFinish,
Tip = "Add a Subdivide — Catmull-Clark subdivision",
Kind = ToolKind.Subdivide },
// Next to Subdivide because it REPLACES it on a part you mean to sculpt: the levels are the
// subdivision, and a Subdivide underneath would hand the sculpt a dense mesh as its cage.
new() { Icon = EffigyIcon.Sculpt, Label = "Sculpt", Stage = StageFinish,
Tip = "Add a Sculpt — brush detail onto the cage in levels",
Kind = ToolKind.Sculpt },
new() { Icon = EffigyIcon.UVProject, Label = "UV Project", Stage = StageFinish,
Tip = "Add a UV Project — re-project UVs (box or planar)",
Kind = ToolKind.UVProject },
new() { Icon = EffigyIcon.FaceMaterial, Label = "Face Material", Stage = StageFinish,
Tip = "Add a Face Material — put picked faces on a material slot",
Kind = ToolKind.FaceMaterial },
};
/// <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,
// 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 )
{
// 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;
}
RecordUndo();
// 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.
if ( _studio.RollbackIndex < _studio.Features.Count )
{
_studio.Insert( _studio.RollbackIndex, feature );
_studio.RollbackIndex++;
}
else
{
_studio.Add( 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 );
_featureTree = new EffigyFeatureTreePanel( this, _studio )
{
FeatureSelected = OnFeatureSelected,
StudioChanged = OnStudioChanged,
VisibilityToggled = OnTreeVisibilityToggled,
CommandRequested = OnFeatureCommand,
RenameCommitted = OnFeatureRenamed,
};
_dialog = new EffigyFeatureDialog( this, _viewport )
{
Edited = OnFeatureEdited,
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 );
},
MaterialLookup = SlotMaterial,
MaterialChanged = SetSlotMaterial,
};
_partsPanel = new EffigyPartsPanel( this, _studio )
{
VisibilityToggled = OnPartVisibilityToggled,
CommandRequested = OnPartCommand,
RenameCommitted = OnPartRenamed,
};
// 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,
};
_rigPanel = new EffigyRigPanel( this, _studio, _viewport );
_tutorial = new EffigyTutorial();
_tutorialPanel = new EffigyTutorialPanel( this )
{
Tutorial = _tutorial,
RevealPanel = RevealDock,
HighlightTool = HighlightTool,
// 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 );
_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;
// 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.FaceContextMenuRequested = OpenFaceMaterialMenu;
_viewport.MaterialDropped = OnMaterialDropped;
_viewport.SketchConstraintMenuRequested = OpenSketchConstraintMenu;
// 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;
// And the "after", which the tutorial needs: rig edits do not go through RebuildStudio,
// so without this the bone step would sit unticked until something unrelated forced a
// rebuild — the tutorial appearing not to notice four bones is worse than no check.
_rigPanel.RigChanged = RefreshTutorial;
_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 } );
// 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 } );
// 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.
StateCookie = "Effigy7";
}
/// <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.Delete:
if ( FeatureForBody( bodyId ) is { } toDelete )
OnFeatureCommand( toDelete, EffigyFeatureCommand.Delete );
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;
}
}
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();
}
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:
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;
// 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;
_dialog.Open( feature, isNew: false );
}
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 ? "*" : "")}";
private void RebuildStudio()
{
if ( !_dirty )
{
_dirty = true;
UpdateTitle();
}
var report = _studio.Rebuild();
_featureTree?.Rebuild();
_partsPanel?.Refresh();
_materialsPanel?.Refresh();
_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.
// Preview shows only what is visible; export below deliberately still takes everything.
// Each face's slot resolves to the material dropped on it, so the preview wears the real
// vmats rather than one flat placeholder. Unbound slots come back null and fall back.
var preview = EffigyPreview.Build( _studio.ToVisibleMesh(),
slot => _studio.MaterialNames.TryGetValue( slot, out var name ) ? name : null );
// 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( preview, frameCamera: preview is not null && !_hasPreview );
_hasPreview = preview is not null;
// 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 );
// 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 ) )}" );
}
// --- 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();
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;
// The rollback latch, sampled here because RebuildStudio runs on every drag of the bar.
// Both halves are needed and in order: seeing a rolled-back studio is not the step, and
// neither is seeing a rolled-forward one — the step is having been under and come back.
if ( _studio is not null )
{
if ( _studio.RollbackIndex < _studio.Features.Count )
_sawRollback = true;
else if ( _sawRollback )
_sawRollforward = true;
}
var state = new EffigyTutorialState(
_studio,
_rigPanel?.Skeleton,
_rigPanel?.BodyBoneMap,
_sawRollback && _sawRollforward,
_sawBake );
_tutorial.Evaluate( state );
// 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 ( _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.Sketch => ToolKind.Sketch,
EffigyToolTarget.Extrude => ToolKind.Extrude,
EffigyToolTarget.Revolve => ToolKind.Revolve,
EffigyToolTarget.Fillet => ToolKind.Fillet,
EffigyToolTarget.Shell => ToolKind.Shell,
EffigyToolTarget.Subdivide => ToolKind.Subdivide,
EffigyToolTarget.UVProject => ToolKind.UVProject,
EffigyToolTarget.Sculpt => ToolKind.Sculpt,
_ => 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 ) ) );
}
/// <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 );
_rigPanel?.SetStudio( _studio );
_dialog?.Close();
// 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();
} );
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;
}
}
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;
}
_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 )
{
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}" );
}
_documentPath = path;
MarkClean();
Log.Info( $"[Effigy] saved {path}" );
}
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}" );
}
_studio = loaded;
_featureTree?.SetStudio( _studio );
_partsPanel?.SetStudio( _studio );
_materialsPanel?.SetStudio( _studio );
_rigPanel?.SetStudio( _studio );
_dialog?.Close();
// 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 FALLS BACK TO THE RENDER MESH, and that is the one judgement call here. Every
/// shape CollisionBuilder produces is in MODEL space, with no bone to hang off - a shape list on
/// a skinned model wants parent_bone set per shape, and the mapping from a body to the bone that
/// drives it is exactly the thing the rig panel exists to let somebody decide. Writing them all
/// against the root would put a static collision hull on an animating character, which is the
/// wrong kind of wrong: it looks right until something moves. PhysicsMeshFromRender is honest,
/// costs nothing, and is what every hand-authored model in this project already uses.
/// </summary>
private string BuildPhysics( bool rigged )
{
if ( _studio is null )
return "";
if ( rigged )
return VmdlPhysics.MeshFromRender();
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>
/// 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.
/// </summary>
private void ReportCollision()
{
if ( _studio is null )
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();
}
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 folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" );
Directory.CreateDirectory( folder );
var objPath = Path.Combine( folder, "export.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.
var mesh = _studio.ToMesh();
ApplyPivot( mesh );
ObjWriter.WriteFile( mesh, objPath, "effigy_export",
materialName: _studio.NameForSlot );
Log.Info( $"[Effigy] exported {objPath}" );
}
private void CompileVmdl()
{
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 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 );
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 );
// 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, "export.smd" );
SmdWriter.WriteFile( mesh, smdPath, skeleton, materialName: _studio.NameForSlot );
var dmxPath = Path.Combine( folder, "export.dmx" );
DmxWriter.WriteFile( mesh, dmxPath, skeleton, materialName: _studio.NameForSlot,
modelName: "effigy_export" );
Log.Info( $"[Effigy] wrote {dmxPath} - {skeleton.Count} bones, {mesh.VertexCount} vertices" );
var vmdlPath = Path.Combine( folder, "export.vmdl" );
File.WriteAllText( vmdlPath, BuildSkinnedVmdl( "models/effigy/export.dmx", skeleton,
BuildPhysics( rigged: true ), VmdlMaterials.GroupList( _studio, mesh ) ) );
var result = EffigyAssetFolder.Register( folder );
Log.Info( $"[Effigy] wrote {vmdlPath} - {result.Registered} registered" );
var asset = AssetSystem.FindByPath( "models/effigy/export.vmdl" );
if ( asset is null )
{
Log.Warning( "[Effigy] export.vmdl was written but the asset system couldn't find it" );
return;
}
asset.Compile( true );
if ( asset.IsCompileFailed )
{
Log.Warning( "[Effigy] export.vmdl compile FAILED - the compiler's own output above "
+ "says why. The .dmx and .smd are both on disk either way." );
return;
}
Log.Info( $"[Effigy] export.vmdl compiled - {skeleton.Count} bone(s), loading into viewport" );
_viewport?.SetModel( Model.Load( "models/effigy/export.vmdl" ) );
return;
}
// STATIC PATH: no bones — export a weightless OBJ.
var staticObjPath = Path.Combine( folder, "export.obj" );
var staticMesh = _studio.ToMesh();
ApplyPivot( staticMesh );
ObjWriter.WriteFile( staticMesh, staticObjPath, "effigy_export",
materialName: _studio.NameForSlot );
var staticVmdlPath = Path.Combine( folder, "export.vmdl" );
File.WriteAllText( staticVmdlPath, BuildVmdl( "models/effigy/export.obj",
BuildPhysics( rigged: false ), VmdlMaterials.GroupList( _studio, staticMesh ) ) );
var staticResult = EffigyAssetFolder.Register( folder );
Log.Info( $"[Effigy] wrote {staticObjPath} and {staticVmdlPath} — {staticResult.Registered} registered" );
var staticAsset = AssetSystem.FindByPath( "models/effigy/export.vmdl" );
if ( staticAsset is null )
{
Log.Warning( "[Effigy] export.vmdl was written but asset system couldn't find it" );
return;
}
staticAsset.Compile( true );
Log.Info( staticAsset.IsCompileFailed
? "[Effigy] export.vmdl compile FAILED"
: "[Effigy] export.vmdl compiled — loading into viewport" );
if ( !staticAsset.IsCompileFailed )
{
var model = Model.Load( "models/effigy/export.vmdl" );
_viewport?.SetModel( model );
}
}
/// <summary>
/// Same one-node RenderMeshFile shape as EffigyTool.BuildVmdl, plus whatever PhysicsShapeList
/// VmdlPhysics built and the MaterialGroupList VmdlMaterials built.
///
/// MATERIALS USED TO GO NOWHERE, the same way collision did. The mesh writers named each slot
/// and the .vmdl had no MaterialGroupList, so ModelDoc filled one in with
/// use_global_default = true and materials/default.vmat — a part that rendered in the viewport
/// with the materials that were dropped on it compiled as a blank grey prop. The node is always
/// present: an omitted list is what gets replaced, an empty one with the global default off
/// leaves the mesh names in place.
///
/// THE -90 PITCH AND -90 YAW ARE NOT DECORATION. ModelDoc's OBJ importer does not land the mesh
/// in the coordinates the file gives it. It reads the file as Y-up (the OBJ convention) and then
/// turns it another quarter turn, so the whole thing arrives cyclically permuted:
///
/// engine.x = obj.z engine.y = obj.x engine.z = obj.y
///
/// The kernel is Z-up - its sketch planes are named "Top (XY)", "Front (XZ)", "Right (YZ)" - so
/// this is TWO errors stacked, and only one of them used to be corrected here. A bare -90 yaw
/// undoes the extra turn and leaves the Y-up reading in place, landing the mesh at
/// (obj.x, -obj.z, obj.y): a part drawn lying flat comes out standing on its side. [-90, -90, 0]
/// is the full inverse of the permutation above and puts the mesh back in the coordinates the
/// file was written in.
///
/// MEASURED. A two-box part whose OBJ bounds are 155 x 159 x 84 compiled to 84 x 155 x 159 at
/// rotation zero - the permutation, read straight off the numbers - and to 155 x 159 x 84 at
/// [-90, -90, 0], with the bar still pointing along +x and the raised lip still on top, so this
/// is the identity and not some other transform that happens to share its bounds.
///
/// The old measurement was not wrong, it was too narrow: it unioned a bar along x = 0..10 with a
/// matching PhysicsShapeBox and checked only that ONE axis came back 10 wide. A -90 yaw does
/// hold x still, which is why it passed while y and z stayed swapped.
///
/// This matters most for collision. The shapes BuildPhysics emits come from CollisionBuilder
/// over the studio, i.e. in kernel coordinates, and import_rotation does not touch them - so the
/// mesh has to arrive in kernel coordinates too, or the collision sits at an angle to the model
/// it belongs to.
///
/// The DMX path does not get this and must not: it is only the OBJ importer that turns the mesh,
/// and the rigged export uses PhysicsMeshFromRender anyway, so its physics follows its mesh
/// wherever the importer puts it.
/// </summary>
static string BuildVmdl( string meshFilename, string physics = "", string materials = "" ) =>
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n" +
"{\n" +
"\trootNode = \n" +
"\t{\n" +
"\t\t_class = \"RootNode\"\n" +
"\t\tchildren = \n" +
"\t\t[\n" +
materials +
"\t\t\t{\n" +
"\t\t\t\t_class = \"RenderMeshList\"\n" +
"\t\t\t\tchildren = \n" +
"\t\t\t\t[\n" +
"\t\t\t\t\t{\n" +
"\t\t\t\t\t\t_class = \"RenderMeshFile\"\n" +
"\t\t\t\t\t\tname = \"Body_LOD0\"\n" +
"\t\t\t\t\t\tchildren = \n" +
"\t\t\t\t\t\t[\n" +
"\t\t\t\t\t\t]\n" +
$"\t\t\t\t\t\tfilename = \"{meshFilename}\"\n" +
"\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n" +
"\t\t\t\t\t\timport_rotation = [ -90.0, -90.0, 0.0 ]\n" +
"\t\t\t\t\t\timport_scale = 1.0\n" +
"\t\t\t\t\t\talign_origin_x_type = \"None\"\n" +
"\t\t\t\t\t\talign_origin_y_type = \"None\"\n" +
"\t\t\t\t\t\talign_origin_z_type = \"None\"\n" +
"\t\t\t\t\t\tparent_bone = \"\"\n" +
"\t\t\t\t\t},\n" +
"\t\t\t\t]\n" +
"\t\t\t},\n" +
physics +
"\t\t]\n" +
"\t\tmodel_archetype = \"\"\n" +
"\t\tprimary_associated_entity = \"\"\n" +
"\t\tanim_graph_name = \"\"\n" +
"\t\tbase_model_name = \"\"\n" +
"\t}\n" +
"}\n";
/// <summary>
/// A skinned .vmdl: the RenderMeshFile points at an SMD (which carries the bone hierarchy,
/// bind pose, and per-vertex weights). ModelDoc imports the skeleton from the SMD and bakes
/// everything into the compiled model.
/// </summary>
static string BuildSkinnedVmdl( string meshFilename, Skeleton skeleton, string physics = "",
string materials = "" ) =>
"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n" +
"{\n" +
"\trootNode = \n" +
"\t{\n" +
"\t\t_class = \"RootNode\"\n" +
"\t\tchildren = \n" +
"\t\t[\n" +
materials +
"\t\t\t{\n" +
"\t\t\t\t_class = \"RenderMeshList\"\n" +
"\t\t\t\tchildren = \n" +
"\t\t\t\t[\n" +
"\t\t\t\t\t{\n" +
"\t\t\t\t\t\t_class = \"RenderMeshFile\"\n" +
"\t\t\t\t\t\tname = \"Body_LOD0\"\n" +
"\t\t\t\t\t\tchildren = \n" +
"\t\t\t\t\t\t[\n" +
"\t\t\t\t\t\t]\n" +
$"\t\t\t\t\t\tfilename = \"{meshFilename}\"\n" +
"\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n" +
"\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n" +
"\t\t\t\t\t\timport_scale = 1.0\n" +
"\t\t\t\t\t\talign_origin_x_type = \"None\"\n" +
"\t\t\t\t\t\talign_origin_y_type = \"None\"\n" +
"\t\t\t\t\t\talign_origin_z_type = \"None\"\n" +
"\t\t\t\t\t\tparent_bone = \"\"\n" +
"\t\t\t\t\t},\n" +
"\t\t\t\t]\n" +
"\t\t\t},\n" +
VmdlAnimation.BoneMarkupList( skeleton ) +
// THE BIND POSE, which a non-static model is documented as needing or morph targets and IK
// data break quietly. It was absent until the node's real shape could be read off a shipping
// file rather than guessed - see VmdlAnimation.
VmdlAnimation.BindPoseList() +
physics +
"\t\t]\n" +
"\t\tmodel_archetype = \"\"\n" +
"\t\tprimary_associated_entity = \"\"\n" +
"\t\tanim_graph_name = \"\"\n" +
"\t\tbase_model_name = \"\"\n" +
"\t}\n" +
"}\n";
// --- 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;
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>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 );
return new StudioSnapshot
{
Features = _studio.Features.ToList(),
Values = values,
Sketches = sketches,
FaceSets = faceSets,
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 );
}
_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();
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.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>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;
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;
}
// 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()
{
// 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;
}
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 ( _viewport?.SculptSession is not null )
{
StepSculptHistory( redo: true );
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();
}
/// <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>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;
private void OpenSettings()
{
if ( _settingsWindow.IsValid() )
{
_settingsWindow.Focus();
return;
}
_settingsWindow = new EffigySettingsWindow( this, CurrentSettings(), ApplySettings );
_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,
};
/// <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;
// 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.
values.ShowSizeReference = _viewport.ShowSizeReference;
values.SizeReferenceHeight = _viewport.SizeReferenceHeight;
}
if ( values.PaletteIndex != _paletteIndex )
SetPalette( values.PaletteIndex );
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 );
return values;
}
/// <summary>Put last session's settings back, before anything is drawn with them.</summary>
private void RestoreSettings()
{
SetPalette( EditorCookie.Get( PaletteCookie, _paletteIndex ) );
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 );
}
/// <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;
if ( _sculptBar is not null )
_sculptBar.GapColor = _palette.ViewportBg;
// 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 material menu on a face of the model.
///
/// 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 OpenFaceMaterialMenu( EffigyFaceHit hit )
{
if ( _studio is null || _viewport is null || hit.Body is null )
return;
var menu = new Menu( _viewport );
menu.AddHeading( $"Face — {_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>
/// 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,
}
/// <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,
Delete,
Isolate,
ShowAll,
}
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( SketchFeature sketch )
{
if ( sketch is null )
return "";
if ( sketch.Face is not { } face )
{
var offset = sketch.PlaneOffset.Value;
return offset == 0f ? sketch.Plane.Value : $"{sketch.Plane.Value} {offset:+0.##;-0.##}";
}
var body = _studio?.Bodies.FirstOrDefault( b => b.Id == face.BodyId );
// A face reference that resolves to nothing is the one case worth shouting about: the
// sketch is about to fail, or already has.
if ( body is null )
return "Face of (missing)";
var raised = sketch.PlaneOffset.Value;
// 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 raised == 0f
? $"Face of {body.Name ?? "part"}"
: $"Face of {body.Name ?? "part"} {raised:+0.##;-0.##}";
}
/// <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 )
{
var key = $"sketch:{feature.Id}";
menu.AddOption( IsVisible( key ) ? "Hide sketch" : "Show sketch",
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();
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;
public string VisibilityKey => $"sketch:{Feature.Id}";
public bool IsVisible => Feature is SketchFeature && _panel.IsVisible( VisibilityKey );
public void ToggleVisibility() { if ( Feature is SketchFeature ) _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 attached )
{
Paint.SetPen( Theme.TextLight.WithAlpha( 0.55f ) );
Paint.DrawText( item.Rect.Shrink( 0, 0, TreeEyeIcon.SecondaryTextRightMargin, 0 ),
_panel.AttachmentLabel( attached ), TextFlag.RightCenter );
}
if ( Value is SketchFeature ) _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; }
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 );
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();
Refresh();
}
public void Refresh()
{
_tree.Clear();
if ( _studio is null || _studio.Bodies.Count == 0 )
{
_tree.AddItem( new EmptyPartsNode() );
return;
}
foreach ( var body in _studio.Bodies )
_tree.AddItem( new PartNode( this, body ) );
}
/// <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();
var delete = menu.AddOption( "Delete", "delete",
() => CommandRequested?.Invoke( bodyId, EffigyPartCommand.Delete ) );
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.OpenAtCursor();
}
private Body BodyById( string bodyId ) =>
_studio?.Bodies.FirstOrDefault( b => b.Id == bodyId );
private sealed class PartsTreeView : TreeView
{
public PartsTreeView( Widget parent ) : base( parent ) { }
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 );
// Always drawn, same as the Features tree's attachment label — the shared margin
// already keeps it clear of the eye, so there is no need to make it vanish and
// reappear on hover the way this row used to.
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 );
}
}
}