Editor/EffigyEditor/EffigyTutorial.cs
using Editor;
using Effigy;
using System;
using System.Collections.Generic;
using System.Linq;
// Effigy.Skeleton, not Sandbox.Skeleton - the same aliasing every other file in this folder does.
using Skeleton = Effigy.Skeleton;
namespace Marionette.EditorTools;
/// <summary>
/// A feature-strip button a tutorial step can point at.
///
/// ITS OWN ENUM RATHER THAN EffigyWindow.ToolKind, which is private and says in its own comment
/// that it exists to survive hotloads. This lists only the handful of tools the tutorial actually
/// names, so adding a feature to the strip never silently adds a thing the tutorial claims to
/// teach, and the window keeps one switch mapping these onto its own kinds.
/// </summary>
internal enum EffigyToolTarget
{
Sketch,
Extrude,
Revolve,
Fillet,
Shell,
Subdivide,
UVProject,
Sculpt,
}
/// <summary>
/// Everything a tutorial step is allowed to look at when deciding whether it has been done.
///
/// A struct passed in rather than the window handed over, because the difference decides what
/// kind of check a step can write. Given the window a step could call RebuildStudio, open a
/// dialog, or read a field that only exists on Tuesdays - and IsDone runs on every rebuild, so
/// any of that would be a live hazard. Given this, the worst a step can do is ask a question.
///
/// The two booleans are latches the window sets, not things that can be read off the document.
/// Rolling back and rolling forward again leaves a studio identical to one that was never rolled
/// back at all, and a bake writes a file and is over. Neither leaves a trace to inspect, so the
/// only honest way to check them is for the window to remember it saw them happen.
/// </summary>
internal readonly struct EffigyTutorialState
{
public readonly PartStudio Studio;
public readonly Skeleton Skeleton;
public readonly IReadOnlyDictionary<string, string> BodyBoneMap;
/// <summary>The rollback bar has been dragged up above at least one feature and then put
/// back. See the struct summary for why this is a latch.</summary>
public readonly bool RolledBackAndForward;
/// <summary>A normal map has been baked and written this session.</summary>
public readonly bool Baked;
public EffigyTutorialState( PartStudio studio, Skeleton skeleton,
IReadOnlyDictionary<string, string> bodyBoneMap, bool rolledBackAndForward, bool baked )
{
Studio = studio;
Skeleton = skeleton;
BodyBoneMap = bodyBoneMap;
RolledBackAndForward = rolledBackAndForward;
Baked = baked;
}
// --- the vocabulary the steps below are written in ---------------------------------------
//
// Every one of these is a question about the SHAPE of the document rather than its numbers.
// That is deliberate and it is the lesson RigTutorial paid for three times: a check that
// tests a proxy ticks off when the proxy is true, which is not the same day as when the
// reader did the thing. "A closed solid exists" is checkable and true; "you drew a 40x20
// rectangle" is neither the point nor something anyone should have to hit.
/// <summary>Features of one kind that actually ran. A feature carrying an Error produced no
/// geometry, so counting it as done would wave the reader past a step they have not
/// completed - and onto one that builds on geometry that is not there.</summary>
public IEnumerable<T> Clean<T>() where T : Feature =>
Studio?.Features.OfType<T>().Where( f => f.Error is null && !f.Suppressed )
?? Enumerable.Empty<T>();
public bool HasClean<T>() where T : Feature => Clean<T>().Any();
/// <summary>Bodies that enclose something. A body with no volume is a surface, a sliver, or
/// the wreckage of a boolean that went wrong, and none of those are "you made a solid".</summary>
public int SolidCount =>
Studio?.Bodies.Count( b => MathF.Abs( b.Mesh.SignedVolume() ) > 1e-4f ) ?? 0;
}
/// <summary>
/// The lamp tutorial: nine steps from an empty studio to a rigged model Marionette can pose.
///
/// A DESK LAMP AND NOT A CHARACTER, which is the choice the rest of this file follows from. The
/// lamp is the only subject that earns every stage honestly - a revolved base is a real lathe
/// profile rather than a demonstration of revolve, a shelled shade is the textbook case for
/// shell, and four rigid segments are what a lamp actually is, so binding bodies to bones is the
/// right rig rather than a stand-in for the weight painting that has no editor yet.
///
/// The shape of this class is RigTutorial's, deliberately, down to the auto-advance latch - see
/// Evaluate. What is new is Points: a step can name something on screen for the panel to
/// highlight, because "click Extrude" is a sentence that still leaves you hunting a strip of
/// twenty glyphs.
/// </summary>
internal sealed class EffigyTutorial
{
/// <summary>
/// What a step wants the reader to look at.
///
/// A CLOSED SET, NOT A COORDINATE. A step can only point at something the tool is able to
/// find on its own, so a step that points nowhere is visible while it is being written
/// rather than as an arrow into empty space at runtime. It also means moving, resizing or
/// rebuilding the strip cannot leave a highlight stranded: nothing here is a position.
/// </summary>
public enum PointAt
{
None,
/// <summary>A button on the feature strip, named by the feature it makes. Resolved to a
/// live button by the window on every refresh - never held - because RefreshToolStrip
/// rebuilds the strip as the document changes and a kept reference goes stale the moment
/// the first sketch exists.</summary>
Tool,
/// <summary>A dock, by the title it was registered under. The panel offers to open and
/// raise it, which is the honest answer to "where is that".</summary>
Panel,
/// <summary>Somewhere in the menu bar. NOTHING CAN BE HIGHLIGHTED HERE and the step text
/// has to carry the whole path itself: a Menu is built fresh every time it opens and does
/// not exist in between, so there is no widget to point at. Kept as its own case rather
/// than as None so the limit is stated where a step author will read it.</summary>
Menu,
}
/// <summary>A drawn glyph per step, painted rather than shipped as art - same reasoning as
/// RigTutorial.StepArt, and the same reason EffigyIcons exists at all.</summary>
public enum StepArt
{
Sketch,
Solid,
Blend,
Rollback,
Unwrap,
Sculpt,
Bone,
Export,
}
public sealed class Step
{
public string Instruction { get; init; }
/// <summary>What you DO, one per bullet, scannable without reading a sentence.</summary>
public string[] Bullets { get; init; }
/// <summary>The why, in a line or two. The part that means you still know what you are
/// doing after the tutorial is over.</summary>
public string Detail { get; init; }
public StepArt Art { get; init; }
public PointAt Points { get; init; }
/// <summary>Which tool the strip should highlight, when Points is Tool.</summary>
public EffigyToolTarget Tool { get; init; }
/// <summary>Which dock to offer, when Points is Panel.</summary>
public string Panel { get; init; }
/// <summary>True once the reader has actually done this.</summary>
public Func<EffigyTutorialState, bool> IsDone { get; init; }
}
private readonly List<Step> _steps;
public EffigyTutorial()
{
_steps = new List<Step>
{
// ---------------------------------------------------------------------------------
// PHASE 1 - GET A SOLID
//
// Every minute spent here is a minute the reader has not yet seen anything happen,
// so the first step is a circle and a number and there is a solid on screen. The
// parametric argument is true and it can wait; nobody stays for an argument.
// ---------------------------------------------------------------------------------
new()
{
Instruction = "Everything starts on a sketch",
Bullets = new[]
{
"Click Sketch, and pick the Top plane",
"Draw a circle roughly 6 units across",
"Finish the sketch, then click Extrude and give it 1.5",
},
Detail = "That is the lamp's base. A sketch is a plane with curves on it and nothing more - "
+ "it is the extrude underneath that turns it into something solid.",
Art = StepArt.Sketch,
Points = PointAt.Tool,
Tool = EffigyToolTarget.Sketch,
// A clean extrude AND a body with volume. Either alone lies: an extrude that
// errored is still an ExtrudeFeature sitting in the tree, and a body can exist
// with no volume at all if the profile never closed.
IsDone = s => s.HasClean<ExtrudeFeature>() && s.SolidCount >= 1
},
new()
{
Instruction = "Soften the rim, and meet a refusal",
Bullets = new[]
{
"Click Fillet and pick the base's top edge",
"Try a radius of 4 first",
"Then bring it down to 0.3",
},
Detail = "4 is deliberately too big and the tool will say so - naming the largest radius that "
+ "actually fits on this model. Features here refuse with numbers rather than failing "
+ "silently, and that is worth seeing once on purpose before it happens by accident.",
Art = StepArt.Blend,
Points = PointAt.Tool,
Tool = EffigyToolTarget.Fillet,
IsDone = s => s.HasClean<FilletFeature>()
},
new()
{
Instruction = "Now the two arms",
Bullets = new[]
{
"Sketch a long thin rectangle on the Front plane",
"Extrude it",
"Do it once more, for the upper arm",
},
Detail = "They do not have to meet neatly. The bones you place later are what makes the lamp "
+ "articulate, and they do not care whether the parts touch.",
Art = StepArt.Solid,
Points = PointAt.Tool,
Tool = EffigyToolTarget.Extrude,
IsDone = s => s.SolidCount >= 3
},
new()
{
Instruction = "The shade is a revolve, then a shell",
Bullets = new[]
{
"Sketch the shade's outline on the Front plane - a slanted line and a flat top",
"Click Revolve",
"Then Shell it, picking the open bottom face",
},
Detail = "Revolve spins the profile about its own left edge, like a lathe. Shell hollows the "
+ "result and leaves whichever face you picked open, which on a lampshade is the whole point.",
Art = StepArt.Solid,
Points = PointAt.Tool,
Tool = EffigyToolTarget.Revolve,
IsDone = s => s.HasClean<RevolveFeature>() && s.HasClean<ShellFeature>()
},
// ---------------------------------------------------------------------------------
// PHASE 2 - CAGE AND SURFACE
//
// The part that justifies the tool existing. If a reader only ever does phase 1 they
// have used a worse Onshape; everything that makes this worth building is here.
// ---------------------------------------------------------------------------------
new()
{
Instruction = "Subdivide, then get back underneath it",
Bullets = new[]
{
"Click Subdivide and pick the base",
"Drag the rollback bar in the feature tree up above it",
"Change the base's radius, then drag the bar back down",
},
Detail = "Rolling back switches off everything below the bar, so you are editing the low-poly "
+ "cage rather than the smoothed result. That cage is what carries the UVs, what receives "
+ "the sculpt and what gets skinned - subdivision is a feature in the tree precisely so "
+ "you can always get under it.",
Art = StepArt.Rollback,
Points = PointAt.Panel,
Panel = "Features",
IsDone = s => s.HasClean<SubdivideFeature>() && s.RolledBackAndForward
},
new()
{
Instruction = "Lay out UVs - Unwrap, not Box",
Bullets = new[]
{
"Click UV Project and pick the base",
"Set Mode to Unwrap",
},
Detail = "Box and Planar tile on purpose, which means two faces can land on the same texels. "
+ "A texture survives that; the normal map in the next step does not - it bakes without "
+ "complaining and comes out wrong wherever they overlap. Unwrap is the only mode a bake "
+ "can use, and it is not the default.",
Art = StepArt.Unwrap,
Points = PointAt.Tool,
Tool = EffigyToolTarget.UVProject,
// The MODE, not just the feature. A UV Project left on Box is the exact mistake
// this step exists to prevent, and ticking it off would be worse than no check.
IsDone = s => s.Clean<UVProjectFeature>().Any( f => f.Mode.Value == "Unwrap" )
},
new()
{
Instruction = "Sculpt the base, then bake it down",
Bullets = new[]
{
"Click Sculpt and pick the base",
"Knock a few dents into it with the brush",
"Bake the normal map",
},
Detail = "The base is cast metal, not milled - a few soft dents stop it reading as CAD output. "
+ "The bake moves that detail onto the low-poly cage as a normal map, so the model you "
+ "ship stays cheap and still looks like it was cast.",
Art = StepArt.Sculpt,
Points = PointAt.Tool,
Tool = EffigyToolTarget.Sculpt,
IsDone = s => s.Baked
&& s.Clean<SculptFeature>().Any( f => f.Sculpt is { Revision: > 0 } )
},
// ---------------------------------------------------------------------------------
// PHASE 3 - RIG AND OUT
// ---------------------------------------------------------------------------------
new()
{
Instruction = "Four bones, one per part",
Bullets = new[]
{
"Open the Rig panel and click Add Bone",
"Click up the lamp: base, lower arm, upper arm, shade",
"Then select each bone and use Assign Body to pin its part to it",
},
Detail = "Assigning a body binds all of it rigidly to one bone, which is exactly right here - "
+ "a lamp's parts really are rigid and it is the joints between them that move. Anything "
+ "left unassigned falls back to nearest-bone weighting, which is a guess.",
Art = StepArt.Bone,
Points = PointAt.Panel,
Panel = "Rig",
// Bones AND every solid accounted for. Four bones with nothing bound to them is a
// skeleton floating inside an unrigged model, and it looks identical in the
// viewport to one that works.
IsDone = s => s.Skeleton is { Count: >= 4 }
&& s.BodyBoneMap is { Count: >= 4 }
},
new()
{
Instruction = "Compile it, and take it to Marionette",
Bullets = new[]
{
"File → Compile .vmdl",
"Open Tools → Marionette and load the lamp",
"Pose the arms and key them",
},
Detail = "That is the whole pipeline in one model: a parametric cage, sculpted detail baked "
+ "onto it, and a skeleton - which is the point at which this tool's job ends and the "
+ "animation editor's begins.",
Art = StepArt.Export,
// The one place nothing can be highlighted - a Menu does not exist between
// openings. Hence the full path spelled out in the bullet.
Points = PointAt.Menu,
// The last step is not a checkbox. Same as RigTutorial's "find the timing":
// finishing is something the reader decides, not something a predicate notices.
IsDone = _ => false
},
};
}
/// <summary>
/// Whether the tutorial dock opens itself when Effigy starts.
///
/// EditorCookie, so it survives restarts and belongs to the person rather than the document -
/// which panels you want to see is not a property of the lamp you happen to have open. Its
/// own key, separate from Rig Control's: dismissing one tutorial is not a statement about
/// the other.
///
/// DEFAULTS TO FALSE. Effigy opens with the feature tree and the viewport and nothing else,
/// and a tutorial that shows up uninvited is an extra panel to close like any other. It is in
/// Help > Start Lamp Tutorial and in View > Tutorial, both one click.
/// </summary>
public static bool OpenOnStartup
{
get => EditorCookie.Get( "effigy.tutorial.openonstartup", false );
set => EditorCookie.Set( "effigy.tutorial.openonstartup", value );
}
/// <summary>Starts inactive so the panel shows its start screen first. Nobody should be
/// dropped into step one of something they never asked for.</summary>
public bool Active { get; private set; }
public int CurrentIndex { get; private set; }
public int StepCount => _steps.Count;
public Step CurrentStep => Active && CurrentIndex < _steps.Count ? _steps[CurrentIndex] : null;
public Step StepAt( int index ) => index >= 0 && index < _steps.Count ? _steps[index] : null;
/// <summary>The furthest step reached, so stepping back does not immediately snap forward
/// again. See Evaluate.</summary>
private int _furthest;
public void Restart()
{
Active = true;
CurrentIndex = 0;
_furthest = 0;
}
public void Dismiss() => Active = false;
public bool CanGoBack => Active && CurrentIndex > 0;
public bool CanGoForward => Active && CurrentIndex < _steps.Count;
public void Back()
{
if ( !CanGoBack )
return;
CurrentIndex--;
}
/// <summary>Skip forward without having done the step. Some are worth reading and not
/// following, and a tutorial that can only be advanced by obeying it is a cage.</summary>
public void Forward()
{
if ( !CanGoForward )
return;
CurrentIndex++;
_furthest = Math.Max( _furthest, CurrentIndex );
}
/// <summary>
/// Advance past every step already satisfied, and say whether anything moved.
///
/// DOES NOT FIGHT A MANUAL REWIND. These conditions stay true once satisfied - a filleted
/// edge is still filleted afterwards - so stepping back would re-satisfy the step just left
/// and snap forward again on the same rebuild, making the Back button look broken. While the
/// reader is behind the furthest point they reached, auto-advance stops entirely and picks up
/// once they are back at the front. Straight from RigTutorial, where it was a real bug.
///
/// Loops rather than stepping once, so someone who does three things before looking down is
/// not left three steps behind.
/// </summary>
public bool Evaluate( in EffigyTutorialState state )
{
if ( !Active )
return false;
if ( CurrentIndex < _furthest )
return false;
var moved = false;
while ( CurrentIndex < _steps.Count && _steps[CurrentIndex].IsDone( state ) )
{
CurrentIndex++;
moved = true;
}
_furthest = Math.Max( _furthest, CurrentIndex );
return moved;
}
}