Effigy.Tests/EditorFlowTests.cs
using System;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// The editor's workflows, replayed headlessly against the real kernel.
///
/// WHY THIS EXISTS. The editor cannot be compiled outside s&box, so its logic used to be verified
/// by reading it — and reading it missed a bug that made every parameter edit a no-op, which in
/// turn looked like "sketches only work on the Top plane" and "the extrude controls do nothing".
/// Everything the editor does to the studio is ordinary kernel calls in a particular ORDER, and
/// that order is exactly what was wrong. This replays those sequences so the next one is caught
/// here rather than by hand at the far end of a compile.
///
/// These are not UI tests. They assert nothing about widgets. They assert that the sequence of
/// kernel calls an editor action performs produces the geometry the user was promised.
/// </summary>
public static class EditorFlowTests
{
public static void Run()
{
Report.Section( "editor flow: sketch on a plane, then extrude it" );
TestSketchThenExtrudeOnEveryPlane();
Report.Section( "editor flow: changing a plane after the fact moves the solid" );
TestChangingPlaneMovesTheSolid();
Report.Section( "editor flow: undo restores parameter VALUES, not just the feature list" );
TestUndoRestoresValues();
Report.Section( "editor flow: the rollback bar" );
TestRollback();
Report.Section( "editor flow: the feature tree as a parametric tree" );
TestTreeOperations();
Report.Section( "editor flow: a messy sketch degrades instead of blocking" );
TestMessySketchStillBuilds();
Report.Section( "editor flow: sculpting a feature that is not the last one" );
TestSculptModeSurvivesTheRollbackDance();
Report.Section( "editor flow: a revolve that works on the first press" );
TestTheTypedAxisStillRefusesAndSaysSo();
TestAnEdgeAxisBuildsTheProfileAsDrawn();
TestOldDocumentsKeepTheAxisTheyWereSavedWith();
Report.Section( "editor flow: a revolve spun about a line drawn in the sketch" );
TestALineOfTheSketchIsAnAxis();
TestTheAxisFollowsTheLineWhenTheLineMoves();
TestALostAxisLineIsAnErrorRatherThanASilentFallback();
TestTheChosenAxisLineSurvivesASaveAndLoad();
Report.Section( "editor flow: a new extrude waits to be pointed at a sketch" );
TestANewExtrudeBuildsNothingUntilItIsPicked();
TestPickingTheSketchBuildsTheSolid();
TestAnUnsetReferenceStillMeansTheMostRecentSketch();
Report.Section( "editor flow: a picked face or part seeds the next tool" );
TestAPickedFaceSeedsASketchAndADraft();
TestAPickedPartSeedsAFillet();
TestAPickedFaceOpensAShell();
TestAPickedFaceSeedsAFilletWithItsEdges();
TestAPickedEdgeSeedsAFillet();
TestAnEdgeRefSurvivesATallerBox();
TestAPickedSketchSeedsAnExtrude();
TestAPickedSketchRegionSeedsAnExtrude();
}
/// <summary>A sketch with a square in it and an extrude below it, in whatever state of
/// pickedness the caller wants the extrude to be in.</summary>
static (PartStudio Studio, SketchFeature Sketch, ExtrudeFeature Extrude) SquareAndExtrude( string sketchId )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0f, 0f ), new Vec2( 2f, 2f ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.SketchFeatureId = sketchId;
studio.Rebuild();
return (studio, sketch, extrude);
}
/// <summary>
/// THE POINT OF THE CHANGE. Pressing Extrude used to put a solid on screen before anyone had
/// said which profile they meant - the kernel reads an unset reference as "the most recent
/// sketch", so the default distance was applied to whatever was nearest. A feature the toolbar
/// has just made now arrives awaiting its pick and builds nothing until it gets one.
/// </summary>
static void TestANewExtrudeBuildsNothingUntilItIsPicked()
{
var (studio, _, extrude) = SquareAndExtrude( SketchConsumingFeature.AwaitingPick );
Report.Check( "a brand new extrude builds nothing", studio.Bodies.Count == 0,
$"{studio.Bodies.Count} bodies" );
Report.Check( "and does not do it silently", extrude.Error is not null, "no diagnostic" );
Report.Check( "the way out is to point at a sketch",
extrude.Diagnostic?.Remedies.Exists( r => r.Contains( "Click a sketch" ) ) == true,
extrude.Diagnostic is null ? "no diagnostic" : string.Join( "; ", extrude.Diagnostic.Remedies ) );
// Nothing has consumed the sketch, which is what keeps it on screen and pickable - the only
// way to answer the question the extrude is asking.
Report.Check( "and it has consumed nothing", studio.ConsumedSketchIds().Count == 0 );
var reloaded = StudioDocument.Read( StudioDocument.Write( studio ) )
.Features.OfType<ExtrudeFeature>().Single();
Report.Check( "a document saved mid-pick comes back still waiting", reloaded.IsAwaitingPick,
reloaded.SketchFeatureId );
}
/// <summary>What the viewport pick does: write the sketch's id into the reference and rebuild.</summary>
static void TestPickingTheSketchBuildsTheSolid()
{
var (studio, sketch, extrude) = SquareAndExtrude( SketchConsumingFeature.AwaitingPick );
extrude.SketchFeatureId = sketch.Id;
studio.MarkDirty( extrude );
studio.Rebuild();
Report.Check( "picking the sketch builds it", extrude.Error is null, extrude.Error ?? "" );
Report.Check( "into one solid", studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount > 0,
$"{studio.Bodies.Count} bodies" );
Report.Check( "and the sketch now counts as consumed",
studio.ConsumedSketchIds().Contains( sketch.Id ) );
}
/// <summary>
/// The empty reference still means "the most recent sketch", and it has to: it is what every
/// extrude built in code does, and what every document saved before the awaiting-pick state
/// existed comes back as. Waiting is a state the EDITOR puts a new feature into, not a new
/// meaning for unset.
/// </summary>
static void TestAnUnsetReferenceStillMeansTheMostRecentSketch()
{
var (studio, sketch, extrude) = SquareAndExtrude( "" );
Report.Check( "an unset reference still builds from the last sketch", extrude.Error is null,
extrude.Error ?? "" );
Report.Check( "and produces a solid", studio.Bodies.Count == 1, $"{studio.Bodies.Count} bodies" );
Report.Check( "which the studio reports as consuming that sketch",
studio.ResolveSketchFeatureId( extrude ) == sketch.Id );
}
/// <summary>A sketch with a rectangle straddling the origin - what somebody actually draws.</summary>
static (PartStudio Studio, RevolveFeature Revolve) Lathe( int axisMode )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
// Straddling BOTH axes on purpose. The typed default runs along X through the origin, so a
// profile only has to cross y = 0 to defeat it - which a rectangle drawn around the origin
// does, and which is what people draw.
sketch.Sketch.AddRectangle( new Vec2( -1f, -1f ), new Vec2( 2f, 3f ) );
var revolve = studio.Add( new RevolveFeature() );
revolve.AxisMode.Index = axisMode;
studio.Rebuild();
return (studio, revolve);
}
/// <summary>
/// The typed axis is still the kernel's default, and it still refuses a profile drawn across it.
///
/// That refusal is CORRECT - each half sweeps the same solid - and it is not the thing that was
/// wrong. What was wrong is that it was the only thing a fresh Revolve could do.
/// </summary>
static void TestTheTypedAxisStillRefusesAndSaysSo()
{
var (_, revolve) = Lathe( RevolveFeature.AxisCustom );
Report.Check( "a profile drawn across the typed axis is still refused", revolve.Error is not null,
"it built" );
Report.Check( "and the refusal offers the dropdown as the way out",
revolve.Diagnostic is not null
&& revolve.Diagnostic.Remedies.Exists( r => r.Contains( "Axis" ) || r.Contains( "axis" ) ),
revolve.Diagnostic is null ? "no diagnostic" : string.Join( "; ", revolve.Diagnostic.Remedies ) );
}
/// <summary>
/// THE POINT OF THE CHANGE. The same sketch, on the mode the editor creates a revolve with,
/// builds - because an axis tangent to the profile is one the profile cannot straddle.
/// </summary>
static void TestAnEdgeAxisBuildsTheProfileAsDrawn()
{
var (studio, revolve) = Lathe( RevolveFeature.AxisProfileLeftEdge );
Report.Check( "the same sketch spun about its own left edge builds", revolve.Error is null,
revolve.Error ?? "" );
Report.Check( "and produces a solid", studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount > 0,
$"{studio.Bodies.Count} bodies" );
// A ring, by Pappus: the rectangle spans x -1..2 and y -1..3, so it is 3 by 4 with its
// centroid at x = 0.5, which is 1.5 from the left edge it is being spun about.
var volume = MathF.Abs( studio.Bodies[0].Mesh.SignedVolume() );
var pappus = 12f * 2f * MathF.PI * 1.5f;
Report.Check( "with the volume Pappus predicts, to within the faceting",
MathF.Abs( volume - pappus ) < pappus * 0.02f,
$"{volume:0.###}, expected about {pappus:0.###}" );
// Every edge mode has to be legal on the same profile - that is what "tangent" buys.
for ( var mode = RevolveFeature.AxisProfileLeftEdge; mode <= 4; mode++ )
{
var (_, each) = Lathe( mode );
Report.Check( $"axis mode {mode} builds too", each.Error is null, each.Error ?? "" );
}
}
/// <summary>
/// A lathe as somebody actually draws one: a profile off to one side, and a construction line
/// down the middle to spin it about.
///
/// The centreline is CONSTRUCTION on purpose — that is what the blue dashed line in a lathe
/// sketch is, and the profile finder throws it away, so a picker that only offered real edges
/// would not offer the one line the drawing exists around.
/// </summary>
static (PartStudio Studio, SketchFeature Sketch, SketchLine Axis, RevolveFeature Revolve) LatheOnALine()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 1f, 0f ), new Vec2( 3f, 4f ) );
// Off the sketch Y axis on purpose. A centreline at x = 0 produces the same solid as
// "Sketch Y axis", so a revolve that never took this branch would still pass on volume.
var axis = sketch.Sketch.AddLine( new Vec2( -1f, -1f ), new Vec2( -1f, 5f ) );
axis.Construction = true;
var revolve = studio.Add( new RevolveFeature() );
revolve.AxisMode.Index = RevolveFeature.AxisSketchLine;
revolve.AxisLineId = axis.Id;
studio.Rebuild();
return (studio, sketch, axis, revolve);
}
/// <summary>
/// The point of the mode: the axis is a line you drew, not four numbers in sketch coordinates
/// that nobody can produce from memory.
/// </summary>
static void TestALineOfTheSketchIsAnAxis()
{
var (studio, _, _, revolve) = LatheOnALine();
Report.Check( "a revolve about a line of the sketch builds", revolve.Error is null,
revolve.Error ?? "" );
Report.Check( "and produces a solid", studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount > 0,
$"{studio.Bodies.Count} bodies" );
// Pappus: a 2 by 4 rectangle whose centroid sits 3 from the line at x = -1.
var volume = MathF.Abs( studio.Bodies[0].Mesh.SignedVolume() );
var pappus = 8f * 2f * MathF.PI * 3f;
Report.Check( "with the volume Pappus predicts, to within the faceting",
MathF.Abs( volume - pappus ) < pappus * 0.02f,
$"{volume:0.###}, expected about {pappus:0.###}" );
// A tube, not a disc. The sketch is on Top (XY), so the centreline at sketch x = -1 is
// the world line x = -1, z = 0. Distance from that axis is sqrt((x+1)² + z²).
Report.Check( "and a bore where the centreline is",
studio.Bodies[0].Mesh.Positions.All( v =>
MathF.Sqrt( (v.x + 1f) * (v.x + 1f) + v.z * v.z ) > 1.5f ),
"a vertex sits on the axis" );
}
/// <summary>
/// WHY THE CURVE ID IS STORED AND NOT THE COORDINATES. Move the line and the axis moves with
/// it, the same way a sketch's own geometry follows its points. Stored endpoints would have
/// left the axis behind, and the part would have kept a bore that no longer matched the drawing.
/// </summary>
static void TestTheAxisFollowsTheLineWhenTheLineMoves()
{
var (studio, sketch, axis, revolve) = LatheOnALine();
var before = MathF.Abs( studio.Bodies[0].Mesh.SignedVolume() );
// Slide the centreline one unit left, so the centroid is now 4 from it rather than 3.
sketch.Sketch.Points[axis.Start] = new Vec2( -2f, -1f );
sketch.Sketch.Points[axis.End] = new Vec2( -2f, 5f );
studio.MarkDirty( sketch );
studio.Rebuild();
Report.Check( "it still builds after the line moved", revolve.Error is null, revolve.Error ?? "" );
var after = MathF.Abs( studio.Bodies[0].Mesh.SignedVolume() );
var pappus = 8f * 2f * MathF.PI * 4f;
Report.Check( "and the solid grew, because the axis went with the line",
after > before && MathF.Abs( after - pappus ) < pappus * 0.02f,
$"{before:0.###} then {after:0.###}, expected about {pappus:0.###}" );
}
/// <summary>
/// Deleting the line is an ERROR, not a quiet return to the typed axis. Falling back would
/// spin the part about the sketch origin and hand back a shape that looks built.
/// </summary>
static void TestALostAxisLineIsAnErrorRatherThanASilentFallback()
{
var (studio, sketch, axis, revolve) = LatheOnALine();
sketch.Sketch.Curves.RemoveAll( c => c.Id == axis.Id );
studio.MarkDirty( sketch );
studio.Rebuild();
Report.Check( "a deleted axis line fails the revolve", revolve.Error is not null, "it built" );
Report.Check( "and the refusal names the box that fixes it",
revolve.Diagnostic?.ParameterLabel == RevolveFeature.AxisLineLabel,
revolve.Diagnostic?.ParameterLabel ?? "no label" );
Report.Check( "leaving no solid behind", studio.Bodies.Count == 0, $"{studio.Bodies.Count} bodies" );
// Nothing picked at all is the same refusal, which is what a revolve reads as the moment
// the mode is chosen and before a line has been.
revolve.AxisLineId = "";
studio.MarkDirty( revolve );
studio.Rebuild();
Report.Check( "and so does the mode with nothing picked yet", revolve.Error is not null, "it built" );
}
static void TestTheChosenAxisLineSurvivesASaveAndLoad()
{
var (studio, _, axis, _) = LatheOnALine();
var reloaded = StudioDocument.Read( StudioDocument.Write( studio ) );
var revolve = reloaded.Features.OfType<RevolveFeature>().Single();
Report.Check( "the axis mode round-trips",
revolve.AxisMode.Index == RevolveFeature.AxisSketchLine, $"index {revolve.AxisMode.Index}" );
Report.Check( "and so does the line it names", revolve.AxisLineId == axis.Id,
$"'{revolve.AxisLineId}' rather than '{axis.Id}'" );
reloaded.Rebuild();
Report.Check( "and it rebuilds to the same solid",
revolve.Error is null && reloaded.Bodies.Count == 1, revolve.Error ?? $"{reloaded.Bodies.Count} bodies" );
}
/// <summary>
/// A ChoiceParam serialises its INDEX, and a document saved before this dropdown existed has no
/// line for it — so it loads on index 0. If index 0 were an edge mode, every revolve in every
/// saved file would quietly move to a different axis on the next open, and the model would come
/// back a different shape with nothing to say it had changed.
/// </summary>
static void TestOldDocumentsKeepTheAxisTheyWereSavedWith()
{
Report.Check( "index 0 is the typed axis", RevolveFeature.AxisCustom == 0 );
// A document from before the parameter existed: no AxisMode line at all.
var document = "effigy 1\n"
+ "feature SketchFeature\n\tid sk\nend\n"
+ "feature RevolveFeature\n\tid rv\n\tparam AxisPoint 2 0 0\n"
+ "\tparam AxisDirection 0 1 0\n\tparam Angle 360\nend\n";
var loaded = StudioDocument.Read( document );
var revolve = loaded.Features.OfType<RevolveFeature>().Single();
Report.Check( "an old document loads on the typed axis",
revolve.AxisMode.Index == RevolveFeature.AxisCustom, $"index {revolve.AxisMode.Index}" );
Report.Check( "and keeps the axis it was saved with",
MathF.Abs( revolve.AxisPoint.Value.x - 2f ) < 1e-4f
&& MathF.Abs( revolve.AxisDirection.Value.y - 1f ) < 1e-4f,
$"{revolve.AxisPoint.Value.x}, {revolve.AxisDirection.Value.y}" );
}
/// <summary>
/// What EffigyWindow actually does around a sculpt, replayed without any widgets.
///
/// ENTERING SCULPT MODE MOVES THE ROLLBACK BAR. EditFeature rolls to just after the feature so
/// the cage is what you see rather than whatever is stacked on top of it, and finishing puts the
/// bar back. That is three pieces of state changing around a fourth - the deltas - and the order
/// is exactly the kind of thing that reads as correct and is not: roll back, sculpt, roll
/// forward, and the feature above has to come back carrying the sculpt underneath it.
///
/// This is the sequence, not the widgets. If it passes, what is left in the editor is a strip of
/// buttons calling these methods in this order.
/// </summary>
static void TestSculptModeSurvivesTheRollbackDance()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = 2f;
box.SizeY.Value = 2f;
box.SizeZ.Value = 2f;
var sculpt = studio.Add( new SculptFeature() );
// Something ABOVE the sculpt, so the rollback actually hides one feature and putting the bar
// back has something to restore.
var mirror = studio.Add( new MirrorFeature() );
studio.Rebuild();
var bodiesWithMirror = studio.Bodies.Count;
Report.Check( "the fixture builds with the mirror on top", bodiesWithMirror > 1,
$"{bodiesWithMirror} bodies" );
// --- EnterSculpt: roll to just after the sculpt, and rebuild ------------------------------
var restoreTo = studio.RollbackIndex;
studio.RollbackIndex = studio.Features.IndexOf( sculpt ) + 1;
studio.Rebuild();
Report.Check( "rolling to the sculpt hides the feature above it", studio.Bodies.Count < bodiesWithMirror,
$"{studio.Bodies.Count} bodies" );
Report.Check( "and the sculpt has a cage to work on", sculpt.Sculpt is not null );
// --- the session, exactly as the viewport drives it ---------------------------------------
var session = new SculptSession( sculpt.Sculpt );
session.Radius = session.SuggestedRadius;
session.Sculpt.AddLevel();
session.Level = session.Sculpt.TopLevel;
var started = session.BeginStroke( new Vec3( 0, 0, 5 ), new Vec3( 0, 0, -1 ) );
session.MoveTo( new Vec3( 0.3f, 0, 5 ), new Vec3( 0, 0, -1 ) );
var edit = session.EndStroke();
Report.Check( "a stroke lands on the cage from above", started && edit is not null,
started ? "nothing committed" : "the press missed" );
// --- FinishSculpt: mark dirty, put the bar back, rebuild ----------------------------------
studio.MarkDirty( sculpt );
studio.RollbackIndex = restoreTo;
var report = studio.Rebuild();
Report.Check( "the model builds again with the bar back", !report.HasErrors, report.ToString() );
Report.Check( "the feature above the sculpt is back", studio.Bodies.Count == bodiesWithMirror,
$"{studio.Bodies.Count} bodies, expected {bodiesWithMirror}" );
Report.Check( "and the sculpt survived the round trip", sculpt.Sculpt.HasDetail( sculpt.Sculpt.TopLevel ) );
// The mirror copies the sculpted body, so the sculpt has to be in what it copied - the check
// that the bar going back did not quietly rebuild the cage from scratch underneath it.
var vertices = 0;
foreach ( var body in studio.Bodies )
vertices += body.Mesh.VertexCount;
Report.Check( "the sculpted level is what everything above it was built from",
vertices > bodiesWithMirror * sculpt.Sculpt.Cage.VertexCount,
$"{vertices} vertices across {studio.Bodies.Count} bodies" );
}
/// <summary>
/// A stray line left in a sketch used to fail EVERY feature that read it, with no way forward
/// but to hunt the stray down - and no indication of where it was beyond a point index.
///
/// Now the good regions build and the feature carries a warning saying what it skipped. The
/// opposite mistake matters too: it must NOT silently ignore the stray, because extruding one
/// arbitrary sub-loop and looking like it worked is worse than refusing.
/// </summary>
static void TestMessySketchStillBuilds()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
// A clean rectangle...
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
// ...plus a stray line spliced onto one of its corners. Branching is handled now, so this is
// no longer about the corner joining three curves — it is that the line's far end is loose,
// so the line encloses nothing and gets pruned. What must not happen is it being pruned
// SILENTLY.
var corner = sketch.Sketch.Points
.Select( ( p, i ) => (p, i) )
.First( t => MathF.Abs( t.p.x ) < 1e-6f && MathF.Abs( t.p.y ) < 1e-6f ).i;
var stray = sketch.Sketch.AddPoint( new Vec2( -3, -3 ) );
sketch.Sketch.Add( new SketchLine( corner, stray ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
var report = EditAndRebuild( studio, extrude );
Check( "ProfileFinder reports the dangling line rather than dropping it",
ProfileFinder.Find( sketch.Sketch ).Warnings.Count > 0 );
Check( "the extrude still builds a body rather than failing outright",
!report.HasErrors && studio.Bodies.Count >= 1,
$"{studio.Bodies.Count} bodies, {report}" );
Check( "and it says what it ignored",
report.HasWarnings && extrude.Warning is not null
&& extrude.Warning.Contains( "ignored" ), extrude.Warning ?? "no warning" );
// A sketch with NOTHING closed is still a hard error - there is no geometry to show.
var empty = new PartStudio();
var emptySketch = empty.Add( new SketchFeature() );
var a = emptySketch.Sketch.AddPoint( new Vec2( 0, 0 ) );
var b = emptySketch.Sketch.AddPoint( new Vec2( 1, 0 ) );
emptySketch.Sketch.Add( new SketchLine( a, b ) );
empty.Add( new ExtrudeFeature() ).Distance.Value = 1f;
var emptyReport = empty.Rebuild();
Check( "a sketch with no closed region is still an error, not a warning",
emptyReport.HasErrors && empty.Bodies.Count == 0, emptyReport.ToString() );
}
// --- what the editor does, named the way the editor names it ---------------------------
/// <summary>
/// The editor's OnFeatureEdited: mark the edited feature dirty, then rebuild.
///
/// The mark is the whole thing. PartStudio re-runs only from the first dirty feature and
/// Rebuild() clears the mark on its way out, so a rebuild with nothing marked reuses the entire
/// cache and re-executes nothing at all.
/// </summary>
static RebuildReport EditAndRebuild( PartStudio studio, Feature edited )
{
studio.MarkDirty( edited );
return studio.Rebuild();
}
static (Vec3 Min, Vec3 Max) Bounds( PolyMesh mesh )
{
var min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
var max = new Vec3( float.MinValue, float.MinValue, float.MinValue );
foreach ( var p in mesh.Positions )
{
min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
}
return (min, max);
}
// --- the flows --------------------------------------------------------------------------
/// <summary>
/// Press Sketch, pick a plane, draw a rectangle, finish, press Extrude, type a distance.
/// Run for all three planes, because "it only works on Top" was the reported symptom.
/// </summary>
static void TestSketchThenExtrudeOnEveryPlane()
{
var planes = new[] { ("Top (XY)", 0), ("Front (XZ)", 1), ("Right (YZ)", 2) };
foreach ( var (name, index) in planes )
{
var studio = new PartStudio();
// Press Sketch. The feature is added before a plane is chosen, exactly as the toolbar
// button does it.
var sketch = studio.Add( new SketchFeature() );
studio.Rebuild();
// Pick a plane in the viewport. This is the edit that was being discarded.
sketch.Plane.Index = index;
EditAndRebuild( studio, sketch );
var expected = index switch { 0 => SketchPlane.XY, 1 => SketchPlane.XZ, _ => SketchPlane.YZ };
Check( $"{name}: the sketch actually moved to the chosen plane",
sketch.Sketch.Plane.Normal.AlmostEquals( expected.Normal ),
$"normal {sketch.Sketch.Plane.Normal}, expected {expected.Normal}" );
// Draw a 2x3 rectangle, then press Extrude and type 4.
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 3 ) );
EditAndRebuild( studio, sketch );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 4f;
var report = EditAndRebuild( studio, extrude );
Check( $"{name}: it builds one body with no errors",
!report.HasErrors && studio.Bodies.Count == 1,
$"{studio.Bodies.Count} bodies, {report}" );
if ( studio.Bodies.Count != 1 )
continue;
var (min, max) = Bounds( studio.Bodies[0].Mesh );
var span = max - min;
Check( $"{name}: it grew 4 along that plane's normal",
MathF.Abs( MathF.Abs( Vec3.Dot( span, expected.Normal ) ) - 4f ) < 1e-3f,
$"got {MathF.Abs( Vec3.Dot( span, expected.Normal ) )}" );
}
}
/// <summary>
/// The exact thing that looked broken: build on Top, then switch the sketch to Front and check
/// the SOLID follows. Everything downstream has to re-run, not just the sketch.
/// </summary>
static void TestChangingPlaneMovesTheSolid()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 3 ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 4f;
studio.Rebuild();
var onTop = Bounds( studio.Bodies[0].Mesh );
Check( "starts out growing along +Z",
MathF.Abs( onTop.Max.z - onTop.Min.z - 4f ) < 1e-3f, $"{onTop.Max.z - onTop.Min.z}" );
// Switch the sketch to Front. The extrude is DOWNSTREAM and must rebuild too.
sketch.Plane.Index = 1;
EditAndRebuild( studio, sketch );
var onFront = Bounds( studio.Bodies[0].Mesh );
Check( "after switching to Front, the solid grows along Y instead",
MathF.Abs( onFront.Max.y - onFront.Min.y - 4f ) < 1e-3f, $"{onFront.Max.y - onFront.Min.y}" );
Check( "and it is no longer 4 deep in Z",
MathF.Abs( onFront.Max.z - onFront.Min.z - 3f ) < 1e-3f,
$"{onFront.Max.z - onFront.Min.z}, expected the profile's own 3" );
}
/// <summary>
/// Undo has to put NUMBERS back. Snapshotting the feature list alone keeps the same Feature
/// objects, so it restores membership and order while every edited value survives untouched -
/// which is undo appearing to do nothing at all.
/// </summary>
static void TestUndoRestoresValues()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
studio.Rebuild();
// The editor's RecordUndo: capture every parameter's value, keyed by the parameter object.
var snapshot = studio.Features
.SelectMany( f => f.Parameters )
.OfType<FloatParam>()
.ToDictionary( p => p, p => p.Value );
var listOnly = studio.Features.ToList(); // the old, broken snapshot
extrude.Distance.Value = 9f;
EditAndRebuild( studio, extrude );
Check( "the edit took effect", MathF.Abs( extrude.Distance.Value - 9f ) < 1e-6f );
// Restoring the LIST alone changes nothing, because the objects in it are the same ones.
studio.Features = listOnly.ToList();
studio.MarkAllDirty();
studio.Rebuild();
Check( "restoring only the feature list leaves the edited value behind",
MathF.Abs( extrude.Distance.Value - 9f ) < 1e-6f, $"{extrude.Distance.Value}" );
// Restoring values is what actually undoes it.
foreach ( var (param, value) in snapshot )
param.Value = value;
studio.MarkAllDirty();
studio.Rebuild();
Check( "restoring parameter values undoes the edit",
MathF.Abs( extrude.Distance.Value - 1f ) < 1e-6f, $"{extrude.Distance.Value}" );
}
/// <summary>Roll to here / roll to end, which is one int on the studio.</summary>
static void TestRollback()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
studio.Add( new SubdivideFeature() ).Levels.Value = 1;
studio.Rebuild();
var full = studio.Bodies[0].Mesh.FaceCount;
// Roll to the Subdivide: everything from it down stops being evaluated.
studio.RollbackIndex = 2;
studio.Rebuild();
var rolled = studio.Bodies[0].Mesh.FaceCount;
Check( "rolling back past Subdivide leaves the un-subdivided body",
rolled < full, $"{rolled} faces rolled back vs {full} at the end" );
// And rolling forward puts it back exactly.
studio.RollbackIndex = int.MaxValue;
studio.Rebuild();
Check( "rolling to the end restores the full result",
studio.Bodies[0].Mesh.FaceCount == full, $"{studio.Bodies[0].Mesh.FaceCount} vs {full}" );
}
/// <summary>
/// Reorder, suppress, delete, rename — the operations that make a feature list a parametric
/// history rather than a list of labels. The reported complaint was that tree entries did not
/// seem to be holding their data; these check the kernel side of every one of them.
/// </summary>
static void TestTreeOperations()
{
// --- suppress -------------------------------------------------------------------------
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
var subdivide = studio.Add( new SubdivideFeature() );
subdivide.Levels.Value = 1;
studio.Rebuild();
var subdivided = studio.Bodies[0].Mesh.FaceCount;
subdivide.Suppressed = true;
EditAndRebuild( studio, subdivide );
var suppressed = studio.Bodies[0].Mesh.FaceCount;
Check( "suppressing a feature takes its effect out", suppressed < subdivided,
$"{suppressed} vs {subdivided}" );
subdivide.Suppressed = false;
EditAndRebuild( studio, subdivide );
Check( "un-suppressing puts it back exactly",
studio.Bodies[0].Mesh.FaceCount == subdivided,
$"{studio.Bodies[0].Mesh.FaceCount} vs {subdivided}" );
// --- rename ---------------------------------------------------------------------------
// Bodies take the name of the feature that made them, so a rename has to survive a rebuild.
var extrude = studio.Features.OfType<ExtrudeFeature>().First();
extrude.Name = "Boss";
EditAndRebuild( studio, extrude );
Check( "a renamed feature names the body it produces",
studio.Bodies[0].Name == "Boss", studio.Bodies[0].Name );
// --- reorder --------------------------------------------------------------------------
// Subdivide BEFORE Extrude is a different model: it would subdivide nothing, then extrude.
// Whatever it produces, the point is that reordering re-runs and changes the result.
var before = studio.Bodies[0].Mesh.FaceCount;
var subIndex = studio.Features.IndexOf( subdivide );
var extIndex = studio.Features.IndexOf( extrude );
studio.Move( subIndex, extIndex );
var report = studio.Rebuild();
Check( "reordering re-runs the tree rather than reusing the old cache",
studio.Bodies.Count == 0 || studio.Bodies[0].Mesh.FaceCount != before || report.HasErrors,
$"{studio.Bodies.Count} bodies, {studio.Bodies.FirstOrDefault()?.Mesh.FaceCount} faces, {report}" );
// Put it back and confirm the original result returns - reorder has to be reversible or
// dragging in the tree is a one-way trip.
studio.Move( studio.Features.IndexOf( subdivide ), studio.Features.Count - 1 );
studio.Rebuild();
Check( "moving it back restores the original result",
studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount == before,
$"{studio.Bodies.Count} bodies, {studio.Bodies.FirstOrDefault()?.Mesh.FaceCount} vs {before}" );
// --- delete ---------------------------------------------------------------------------
// Deleting the sketch has to break the extrude that consumes it, loudly.
studio.Remove( sketch );
var afterDelete = studio.Rebuild();
Check( "deleting an upstream sketch makes its consumer report an error",
afterDelete.HasErrors, afterDelete.ToString() );
}
/// <summary>What the toolbar does after a face is already selected: the new feature arrives
/// already pointed at that face, so Draft/Hole/Sketch do not open asking you to pick it again.
/// </summary>
static void TestAPickedFaceSeedsASketchAndADraft()
{
var (studio, face, _) = BoxAndTopFace();
var sketch = new SketchFeature();
sketch.ApplyGeometrySelection( new[] { face }, Array.Empty<string>(), studio.Bodies );
Check( "a sketch takes the picked face as its plane",
sketch.Face is { } attached && attached.BodyId == face.BodyId,
sketch.Face?.BodyId ?? "none" );
var draft = new DraftFeature();
draft.ApplyGeometrySelection( new[] { face }, Array.Empty<string>(), studio.Bodies );
Check( "a draft takes the picked face", draft.Faces.Count == 1, $"{draft.Faces.Count} faces" );
var hole = new HoleFeature();
hole.ApplyGeometrySelection( new[] { face }, Array.Empty<string>(), studio.Bodies );
Check( "a hole takes the picked face", hole.Faces.Count == 1, $"{hole.Faces.Count} faces" );
}
/// <summary>Selecting a part in the Parts list, then Fillet, should fillet that part rather
/// than every body in the studio.</summary>
static void TestAPickedPartSeedsAFillet()
{
var studio = new PartStudio();
studio.Add( new PrimitiveFeature() );
var second = studio.Add( new PrimitiveFeature() );
second.Position.Value = new Vec3( 3f, 0f, 0f );
studio.Rebuild();
Check( "two parts to choose between", studio.Bodies.Count == 2, $"{studio.Bodies.Count}" );
var fillet = new FilletFeature();
fillet.ApplyGeometrySelection( Array.Empty<FaceRef>(), new[] { studio.Bodies[1].Id },
studio.Bodies );
Check( "the fillet is pointed at the picked part",
fillet.Bodies.BodyIds.Count == 1 && fillet.Bodies.BodyIds[0] == studio.Bodies[1].Id,
string.Join( ",", fillet.Bodies.BodyIds ) );
}
/// <summary>A face pick on a shell is an opening, not just a body filter.</summary>
static void TestAPickedFaceOpensAShell()
{
var (studio, face, index) = BoxAndTopFace();
var shell = new ShellFeature();
shell.ApplyGeometrySelection( new[] { face }, Array.Empty<string>(), studio.Bodies );
Check( "the shell acts on the face's body",
shell.Bodies.BodyIds.Count == 1 && shell.Bodies.BodyIds[0] == studio.Bodies[0].Id,
string.Join( ",", shell.Bodies.BodyIds ) );
Check( "and opens the picked face",
shell.OpenFaces.Count == 1 && shell.OpenFaces[0] == index,
string.Join( ",", shell.OpenFaces ) );
}
/// <summary>Selecting a face and then Fillet means the edges of that face, not every sharp
/// edge on the part.</summary>
static void TestAPickedFaceSeedsAFilletWithItsEdges()
{
var (studio, face, _) = BoxAndTopFace();
var before = studio.Bodies[0].Mesh.FaceCount;
var fillet = studio.Add( new FilletFeature() );
fillet.ApplyGeometrySelection( new[] { face }, Array.Empty<string>(), studio.Bodies );
studio.MarkDirty( fillet );
studio.Rebuild();
Check( "the fillet stored the face's four edges", fillet.Edges.Count == 4, $"{fillet.Edges.Count}" );
Check( "and it built", fillet.Error is null, fillet.Error ?? "" );
Check( "the solid gained faces, but not a full-cube worth",
studio.Bodies[0].Mesh.FaceCount > before && studio.Bodies[0].Mesh.FaceCount < before + 20,
$"{before} → {studio.Bodies[0].Mesh.FaceCount}" );
}
static void TestAPickedEdgeSeedsAFillet()
{
var (studio, _, index) = BoxAndTopFace();
var body = studio.Bodies[0];
var edges = FacePlane.CaptureBoundary( body, index );
Check( "the top face has edges to pick", edges.Count > 0 );
var fillet = new FilletFeature();
fillet.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(), studio.Bodies,
new[] { edges[0] } );
Check( "the fillet took that one edge", fillet.Edges.Count == 1, $"{fillet.Edges.Count}" );
Check( "and the body that owns it",
fillet.Bodies.BodyIds.Count == 1 && fillet.Bodies.BodyIds[0] == body.Id,
string.Join( ",", fillet.Bodies.BodyIds ) );
}
/// <summary>An EdgeRef is geometry, so making the box taller must not attach the fillet to a
/// bottom edge that happens to keep the old indices.</summary>
static void TestAnEdgeRefSurvivesATallerBox()
{
var (studio, _, index) = BoxAndTopFace();
var body = studio.Bodies[0];
var edge = FacePlane.CaptureBoundary( body, index )[0];
var zBefore = edge.Point.z;
var box = studio.Features.OfType<PrimitiveFeature>().Single();
box.SizeZ.Value = 4f;
EditAndRebuild( studio, box );
Check( "the edge still resolves after the box grew",
FacePlane.TryResolveEdge( studio.Bodies, edge, out _, out var key ) );
var a = studio.Bodies[0].Mesh.Positions[key.A];
var b = studio.Bodies[0].Mesh.Positions[key.B];
var midZ = (a.z + b.z) * 0.5f;
Check( "and it is still on the top, not the bottom",
midZ > 0f && midZ > zBefore - 0.1f, $"was {zBefore}, now {midZ}" );
}
/// <summary>Click the shaded sketch, then Extrude — the toolbar's AwaitingPick is overwritten
/// by the sketch that was already selected, so a solid appears without a second pick.
/// </summary>
static void TestAPickedSketchSeedsAnExtrude()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0f, 0f ), new Vec2( 2f, 2f ) );
studio.Rebuild();
var extrude = new ExtrudeFeature();
extrude.SketchFeatureId = SketchConsumingFeature.AwaitingPick;
extrude.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(), studio.Bodies,
Array.Empty<EdgeRef>(), sketch.Id );
studio.Add( extrude );
studio.Rebuild();
Check( "awaiting-pick is replaced by the selected sketch",
extrude.SketchFeatureId == sketch.Id, extrude.SketchFeatureId );
Check( "and the extrude builds", extrude.Error is null && studio.Bodies.Count == 1,
$"{extrude.Error ?? "ok"}, {studio.Bodies.Count} bodies" );
}
/// <summary>Clicking one face of a two-face sketch, then Extrude, builds only that face.
/// </summary>
static void TestAPickedSketchRegionSeedsAnExtrude()
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0f, 0f ), new Vec2( 2f, 2f ) );
sketch.Sketch.AddRectangle( new Vec2( 4f, 0f ), new Vec2( 6f, 2f ) );
studio.Rebuild();
var both = new ExtrudeFeature();
both.SketchFeatureId = sketch.Id;
studio.Add( both );
studio.Rebuild();
var bothVolume = studio.Bodies.Sum( b => b.Mesh.SignedVolume() );
studio.Remove( both );
studio.Rebuild();
var extrude = new ExtrudeFeature();
extrude.SketchFeatureId = SketchConsumingFeature.AwaitingPick;
extrude.ApplyGeometrySelection( Array.Empty<FaceRef>(), Array.Empty<string>(), studio.Bodies,
Array.Empty<EdgeRef>(), sketch.Id, new[] { new Vec2( 1f, 1f ) } );
studio.Add( extrude );
studio.Rebuild();
var oneVolume = studio.Bodies.Sum( b => b.Mesh.SignedVolume() );
Check( "the picked face is stored", extrude.RegionSeeds.Count == 1, $"{extrude.RegionSeeds.Count}" );
Check( "and only that face is built",
extrude.Error is null && oneVolume < bothVolume * 0.75f,
$"one {oneVolume} vs both {bothVolume}, {extrude.Error ?? "ok"}" );
}
static (PartStudio Studio, FaceRef Face, int Index) BoxAndTopFace()
{
var studio = new PartStudio();
studio.Add( new PrimitiveFeature() );
studio.Rebuild();
var body = studio.Bodies[0];
var mesh = body.Mesh;
var top = mesh.Faces
.Select( ( f, i ) => (Index: i, Normal: mesh.FaceNormal( f ), Centroid: mesh.FaceCentroid( f )) )
.Where( t => t.Normal.z > 0.99f )
.OrderByDescending( t => t.Centroid.z )
.First();
return (studio, FacePlane.Capture( body, top.Index, top.Centroid ), top.Index);
}
static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}