Editor/Effigy/Features/Feature.cs
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace Effigy;
/// <summary>One solid in the studio. Onshape calls these parts; a Part Studio holds several.</summary>
public sealed class Body
{
public string Id;
public string Name;
public PolyMesh Mesh;
/// <summary>Whether this body is drawn. Set from the feature that produced it — see
/// Feature.Visible for why this is not the same thing as suppression.</summary>
public bool Visible = true;
/// <summary>Id of the Feature that produced this body, recorded by the rebuild alongside
/// Visible. Bodies are re-made from scratch every rebuild and have no identity a UI can hold
/// onto, so a Parts list needs this to get from a row back to the thing that owns it.</summary>
public string FeatureId;
public Body( string id, string name, PolyMesh mesh )
{
Id = id;
Name = name;
Mesh = mesh;
}
public Body Clone() => new( Id, Name, Mesh.Clone() ) { Visible = Visible, FeatureId = FeatureId };
}
// --- parameters -------------------------------------------------------------------------------
/// <summary>
/// Feature parameters describe themselves, so one generic panel can render any feature's dialog
/// rather than each feature needing hand-written UI.
///
/// This is copied from Onshape deliberately. Every feature dialog there has the same shape, and
/// that uniformity is most of why the tool feels coherent with a hundred features in it. Getting
/// it for free is worth the small indirection here.
///
/// The parameter object IS the storage — the feature reads `_length.Value` — so there is no
/// separate copy to keep in sync.
/// </summary>
public interface IParam
{
string Label { get; }
}
public sealed class FloatParam : IParam
{
public string Label { get; }
public float Value;
public float Min, Max;
public string Unit;
public FloatParam( string label, float value, float min = float.MinValue, float max = float.MaxValue, string unit = null )
{
Label = label;
Value = value;
Min = min;
Max = max;
Unit = unit;
}
public float Clamped => Math.Clamp( Value, Min, Max );
}
public sealed class IntParam : IParam
{
public string Label { get; }
public int Value;
public int Min, Max;
/// <summary>
/// Whether dragging this number through its range means anything.
///
/// A slider is for a MAGNITUDE — segment counts, subdivision levels, numbers where the values
/// either side of the one you have are the neighbouring answers. A material slot is an
/// IDENTIFIER that happens to be stored as a number: slot 7 is not "more" than slot 6, sweeping
/// through 40 of them on the way says nothing, and the drag bar was the widest control in the
/// Extrude dialog doing the least. Those get a field and no slider.
///
/// The bounds test in the dialog cannot tell the two apart — 0..63 looks exactly as draggable
/// as 0..6 — so the parameter says which it is.
/// </summary>
public bool Slider = true;
public IntParam( string label, int value, int min = int.MinValue, int max = int.MaxValue )
{
Label = label;
Value = value;
Min = min;
Max = max;
}
public int Clamped => Math.Clamp( Value, Min, Max );
}
public sealed class BoolParam : IParam
{
public string Label { get; }
public bool Value;
public BoolParam( string label, bool value )
{
Label = label;
Value = value;
}
}
public sealed class Vec3Param : IParam
{
public string Label { get; }
public Vec3 Value;
public Vec3Param( string label, Vec3 value )
{
Label = label;
Value = value;
}
}
public sealed class ChoiceParam : IParam
{
public string Label { get; }
public string[] Options;
public int Index;
public ChoiceParam( string label, string[] options, int index = 0 )
{
Label = label;
Options = options;
Index = index;
}
public string Value => Options[Math.Clamp( Index, 0, Options.Length - 1 )];
}
/// <summary>Which bodies a feature acts on. Empty means every body, which is what Onshape's
/// "all" behaves like and is the sane default for a studio holding one part.</summary>
public sealed class BodySelectionParam : IParam
{
public string Label { get; }
public List<string> BodyIds = new();
public BodySelectionParam( string label )
{
Label = label;
}
public bool Matches( Body b ) => BodyIds.Count == 0 || BodyIds.Contains( b.Id );
}
// --- features ---------------------------------------------------------------------------------
/// <summary>The state a feature reads and writes as it runs.</summary>
public sealed class FeatureContext
{
public List<Body> Bodies = new();
/// <summary>Sketches published by SketchFeature, keyed by that feature's id. Extrude and
/// Revolve look themselves up here rather than holding a reference, so editing the sketch
/// upstream and rebuilding feeds the new geometry through without any wiring to keep in sync.</summary>
public Dictionary<string, Sketch> Sketches = new();
/// <summary>
/// For a sketch drawn on a face, the id of the body that face belongs to. Keyed by sketch
/// feature id, same as Sketches.
///
/// This is what lets an extrude know it is growing OUT OF something rather than into thin air.
/// A Sketch is pure geometry and has no business knowing about bodies, and the consuming
/// feature never sees the SketchFeature itself — only what it published here — so the
/// attachment travels the same way the sketch does.
/// </summary>
public Dictionary<string, string> SketchHostBodies = new();
int _nextId = 1;
string _featureId;
int _featureBodies;
/// <summary>
/// A body id belonging to the feature currently running: its feature id, plus a counter of the
/// bodies that feature has made this run.
///
/// IDS USED TO BE A SINGLE RUNNING COUNTER - body1, body2, body3 in creation order across the
/// whole rebuild - and that is the topological naming problem all over again, one level up from
/// the faces FaceRef was written to protect. Add a feature ANYWHERE upstream that produces a
/// body and every id after it shifts by one, so a sketch attached to "body1" silently lands on
/// whatever happens to be body1 now. Not an error, not a warning: a boss quietly reattaches
/// itself to an unrelated block.
///
/// Feature ids are assigned once at creation and never reused, so a body named after the
/// feature that made it cannot be renumbered by anything happening elsewhere in the tree.
/// </summary>
public string NewBodyId() =>
_featureId is null ? $"body{_nextId++}" : $"{_featureId}b{_featureBodies++}";
/// <summary>Called by Feature.Run before Execute, so bodies are named after their maker. The
/// per-feature counter restarts, which is what makes a feature that produces three bodies -
/// a pattern, say - name them the same three ids on every rebuild.</summary>
public void BeginFeature( string featureId )
{
_featureId = featureId;
_featureBodies = 0;
}
/// <summary>Vestigial: only the fallback numbering uses it, for a context nothing has called
/// BeginFeature on.</summary>
public void SeedIdCounter( int next ) => _nextId = Math.Max( _nextId, next );
}
/// <summary>
/// One step in the history. Features run in order, each seeing what the ones before it produced.
///
/// THE ORDERED TREE IS THE WHOLE POINT. It is what separates a parametric modeller from a stack of
/// bakes: roll back to before feature 3, change a number, roll forward and everything downstream
/// rebuilds against the new value. Every design decision here bends toward keeping that true —
/// features hold parameters rather than results, and nothing caches geometry inside a feature.
/// </summary>
public abstract class Feature
{
public string Id = Guid.NewGuid().ToString( "N" )[..8];
public string Name;
/// <summary>
/// Suppressed features do not run at all — their effect is removed from the model.
/// </summary>
public bool Suppressed;
/// <summary>
/// Whether the geometry this feature produced is DRAWN. Deliberately not the same thing as
/// Suppressed, and conflating the two would be wrong: suppression takes a feature's effect out
/// of the model, hiding only stops you looking at it. A hidden body is still there, still
/// exported, and everything downstream still builds on it.
///
/// Solvespace keeps the same two flags separately on a group for the same reason.
/// </summary>
public bool Visible = true;
/// <summary>Set by Run when Execute threw. A failed feature does not stop the rebuild — the
/// tree carries on with the bodies as they were, which is what lets you fix an upstream
/// mistake without every later feature also reporting failure.</summary>
public string Error { get; internal set; }
/// <summary>
/// Something the feature worked around rather than failed on — it built, but not from
/// everything it was given.
///
/// Distinct from Error because the two need opposite handling. An error means there is no
/// geometry and the tree below is standing on nothing; a warning means there IS geometry and
/// you should look at it. Collapsing the second into the first is why one stray branch
/// anywhere in a sketch used to fail every extrude that read it.
/// </summary>
public string Warning { get; internal set; }
/// <summary>
/// Structured form of Error or Warning. Null when the feature ran clean.
///
/// Error and Warning stay as strings because PartStudio, RebuildReport, the feature tree and
/// several tests already read them. They are set alongside this: Error = Diagnostic.Problem
/// on a failure, Warning = Diagnostic.Problem on a warning.
/// </summary>
public FeatureDiagnostic Diagnostic { get; internal set; }
public abstract string TypeName { get; }
public abstract IReadOnlyList<IParam> Parameters { get; }
/// <summary>
/// The subset of <see cref="Parameters"/> that belongs behind the dialog's Advanced disclosure
/// — folded away by default, and still every bit as much a parameter as the ones above it.
///
/// It is a SUBSET RATHER THAN A SECOND LIST. Everything generic — the snapshot Cancel restores
/// from, the document writer, the diagnostics that point at a parameter by label — walks
/// Parameters, and a parameter that only appeared here would be invisible to all of it. So this
/// says nothing about what a feature HAS; it only says which of them are answered once and then
/// left alone. Extrude is the case that asked for it: taper, a second distance and a material
/// slot are three rows of nothing-to-do-here sitting under the distance that is the whole point
/// of the feature.
///
/// Held by REFERENCE, matched by reference. Naming the parameters by label would tie which rows
/// fold up to the words on screen.
/// </summary>
public virtual IReadOnlyList<IParam> AdvancedParameters => Array.Empty<IParam>();
/// <summary>
/// Whether this feature's cached result is out of date even though nobody called MarkDirty.
///
/// The convention everywhere else is that whoever edits a feature marks it dirty, and for a
/// dialog full of numbers that is one call in one place. It does not hold for a feature whose
/// state is a live object somebody else is mutating — SculptFeature's levels are changed by a
/// brush, hundreds of times a stroke, nowhere near the code that owns the studio. Relying on
/// that caller to remember is how "every parameter edit is a silent no-op" happened once
/// already, and it looked like three unrelated UI faults for a day.
///
/// So a feature that owns mutable state outside its parameters answers this instead, and the
/// rebuild asks. Must be cheap: it is called for every reusable feature on every rebuild.
/// </summary>
public virtual bool IsStale => false;
protected abstract void Execute( FeatureContext ctx );
internal void Run( FeatureContext ctx )
{
Error = null;
Warning = null;
Diagnostic = null;
// Bodies made from here on belong to this feature and are named after it. Set before the
// Suppressed check is pointless; set here so every path into Execute is covered.
ctx.BeginFeature( Id );
if ( Suppressed )
return;
try
{
Execute( ctx );
}
catch ( FeatureException e )
{
ApplyDiagnostic( e.Diagnostic );
}
catch ( Exception e )
{
ApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Error, e.Message ) );
}
}
/// <summary>Refuse to proceed. The three arguments are the three things the dialog shows.</summary>
[DoesNotReturn]
protected static void Fail( string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, remedies: remedies ) );
/// <summary>Refuse, and name the control the dialog should highlight.</summary>
[DoesNotReturn]
protected static void FailOn( string parameterLabel, string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, remedies: remedies ) );
/// <summary>Refuse, highlight the control, and offer a button that writes <paramref name="suggested"/>
/// into it.</summary>
[DoesNotReturn]
protected static void FailOn( string parameterLabel, float suggested, string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, suggested, remedies ) );
/// <summary>The feature built, but not from what was asked. Geometry stays; the dialog goes yellow.</summary>
protected void Warn( string problem, string cause, params string[] remedies ) =>
ApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Warning, problem, cause, remedies: remedies ) );
/// <summary>
/// After a cut, put every piece the cut severed into the part list. Returns how many bodies
/// were added, so the caller can say so.
///
/// A boolean is allowed to sever a part and has no obligation to mention it — "one mesh" is a
/// fine answer to "subtract this". Whether that mesh is one SOLID is the part list's question,
/// and this is where it gets asked. See MeshSplit for why connectivity is by shared vertex and
/// why the order the pieces come back in is a promise rather than an implementation detail.
///
/// THE ORIGINAL BODY KEEPS ITS ID, and keeps the largest piece. Everything built on this part is
/// holding that id — a sketch drawn on one of its faces, a later feature's body selection — and
/// a cut must not invalidate them. The offcuts are new bodies named after the feature that made
/// them, which is the same rule every other body in the studio follows.
/// </summary>
protected int SeparatePieces( FeatureContext ctx, Body body )
{
if ( body?.Mesh is null || MeshSplit.PieceCount( body.Mesh ) < 2 )
return 0;
var pieces = MeshSplit.ConnectedPieces( body.Mesh );
body.Mesh = pieces[0];
// Straight after the body they came off, so the parts list reads in the order someone would
// expect rather than collecting offcuts at the bottom. IndexOf can legitimately miss - a
// feature is allowed to hand us a body it has not published - and appending is right then.
var at = ctx.Bodies.IndexOf( body );
for ( var i = 1; i < pieces.Count; i++ )
{
var piece = new Body( ctx.NewBodyId(), $"{body.Name} ({i + 1})", pieces[i] )
{
Visible = body.Visible,
FeatureId = Id,
};
if ( at < 0 )
ctx.Bodies.Add( piece );
else
ctx.Bodies.Insert( at + i, piece );
}
return pieces.Count - 1;
}
/// <summary>
/// The warning a severing cut earns. Never an error: the geometry is right, and the part list
/// having grown is something to look at rather than something to fix.
/// </summary>
protected void WarnSeparated( int added, string bodyName )
{
if ( added <= 0 )
return;
Warn(
added == 1
? $"This cut separated '{bodyName}' into two parts"
: $"This cut separated '{bodyName}' into {added + 1} parts",
$"The tool went all the way through, so what was one solid is now {added + 1} that touch nowhere. "
+ "The largest keeps the original part's name and id; the rest are new parts below it.",
"Reduce the depth if the cut was meant to be a pocket",
"Nothing to fix if separating the part was the intent" );
}
/// <summary>Names in a sentence: "'A'", "'A' and 'B'", "'A', 'B' and 'C'".</summary>
protected static string Listed( IReadOnlyList<string> names )
{
if ( names is null || names.Count == 0 )
return "nothing";
var quoted = new List<string>( names.Count );
foreach ( var name in names )
quoted.Add( $"'{name}'" );
if ( quoted.Count == 1 )
return quoted[0];
return string.Join( ", ", quoted.GetRange( 0, quoted.Count - 1 ) ) + " and " + quoted[^1];
}
protected void ApplyDiagnostic( FeatureDiagnostic diagnostic )
{
if ( diagnostic is null )
return;
Diagnostic = diagnostic;
if ( diagnostic.Severity == DiagnosticSeverity.Error )
Error = diagnostic.Problem;
else
Warning = diagnostic.Problem;
}
/// <summary>The bodies this feature acts on, or a refusal if there are none. A feature that
/// did nothing is never a success.</summary>
protected List<Body> RequireBodies( FeatureContext ctx, BodySelectionParam selection )
{
var bodies = new List<Body>();
foreach ( var b in ctx.Bodies )
{
if ( selection.Matches( b ) )
bodies.Add( b );
}
if ( bodies.Count > 0 )
return bodies;
if ( ctx.Bodies.Count == 0 )
{
Fail(
"This studio has no bodies yet",
"There is nothing to act on — the feature list has not produced a solid.",
"Add a Primitive, or extrude a sketch first" );
}
Fail(
"No matching body is selected",
$"The studio has {ctx.Bodies.Count} body/bodies but none match this feature's selection.",
"Clear the body selection to act on every body",
"Pick a body that is still in the studio" );
return bodies;
}
/// <summary>Assign blended meshes, or refuse, only after every body has been tried — so a
/// failure leaves the studio as it was, which is Feature.Run's contract.</summary>
protected void CommitBlend( List<(Body Body, BlendReport Report)> reports, string sizeLabel )
{
foreach ( var ( _, report ) in reports )
{
if ( report.Failure is null )
continue;
report.Failure.ParameterLabel ??= sizeLabel;
if ( report.SuggestedSize > 0f )
report.Failure.SuggestedValue ??= FloorThousandths( report.SuggestedSize );
throw new FeatureException( report.Failure );
}
foreach ( var ( body, report ) in reports )
body.Mesh = report.Mesh;
var warnings = new List<FeatureDiagnostic>();
foreach ( var ( _, report ) in reports )
warnings.AddRange( report.Warnings );
if ( warnings.Count == 0 )
return;
if ( warnings.Count == 1 )
{
ApplyDiagnostic( warnings[0] );
return;
}
Warn(
warnings[0].Problem,
string.Join( " ", warnings.Select( w => w.Cause ).Where( c => !string.IsNullOrEmpty( c ) ) ),
warnings.SelectMany( w => w.Remedies ).Distinct().ToArray() );
}
/// <summary>A number the user can type that is never larger than the true fit, so suggesting
/// it cannot immediately fail again to rounding.</summary>
protected static float FloorThousandths( float value ) => MathF.Floor( value * 1000f ) / 1000f;
public override string ToString() => $"{TypeName} '{Name ?? Id}'{(Suppressed ? " (suppressed)" : "")}";
}