Effigy.Tests/SketchTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Checks on the sketch layer and the features that consume it.
///
/// The interesting failures here are orientation ones. A prism built from a clockwise loop, or a
/// revolve whose rings wind the wrong way, produces a solid that looks perfectly normal in
/// wireframe and renders inside-out. Every solid-producing test therefore asserts on enclosed
/// volume, which is signed and catches exactly that.
/// </summary>
public static class SketchTests
{
public static void Run()
{
Section( "sketch planes" );
TestPlanes();
Section( "arc tessellation" );
TestArcs();
Section( "finding closed regions" );
TestProfiles();
Section( "extrude" );
TestExtrude();
Section( "revolve" );
TestRevolve();
Section( "sketches in the feature tree" );
TestSketchInTree();
Section( "picking one region out of a sketch" );
TestRegionSelection();
Section( "overlapping sketches make the middle a region too" );
TestOverlappingRegions();
Section( "shading a sketch covers the union exactly once" );
TestShadedRegions();
Section( "each sketch plane builds where it should" );
TestPlaneOrientation();
Section( "editing a parameter actually re-runs the feature" );
TestIncrementalRebuildSeesEdits();
}
/// <summary>
/// A rectangle sketched on each of the three planes, extruded, and checked for where the solid
/// actually landed.
///
/// The symptom this pins: sketches appearing to work only on Top, with Front and Right
/// behaving as though they were Top. That turned out to be an editor bug rather than a kernel
/// one - the plane change never reached the feature - but nothing here asserted the kernel's
/// half of it either, so a real regression in SketchPlane would have looked identical.
///
/// Expected, from SketchPlane's own axes:
/// XY X=(1,0,0) Y=(0,1,0) N=(0,0,1) -> u along world x, v along world y, grows +z
/// XZ X=(1,0,0) Y=(0,0,1) N=(0,-1,0) -> u along world x, v along world z, grows -y
/// YZ X=(0,1,0) Y=(0,0,1) N=(1,0,0) -> u along world y, v along world z, grows +x
/// </summary>
static void TestPlaneOrientation()
{
var cases = new[]
{
("Top (XY)", 0, new Vec3( 0, 0, 1 )),
("Front (XZ)", 1, new Vec3( 0, -1, 0 )),
("Right (YZ)", 2, new Vec3( 1, 0, 0 )),
};
foreach ( var (name, index, normal) in cases )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Plane.Index = index;
// 2 wide in the plane's u, 3 in its v.
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 3 ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
var report = studio.Rebuild();
Check( $"{name} builds", !report.HasErrors && studio.Bodies.Count == 1, report.ToString() );
if ( studio.Bodies.Count != 1 )
continue;
var (min, max) = Bounds( studio.Bodies[0].Mesh );
var plane = index switch { 0 => SketchPlane.XY, 1 => SketchPlane.XZ, _ => SketchPlane.YZ };
// The extrusion has to run along the plane's normal and nowhere else.
var span = max - min;
var along = MathF.Abs( Vec3.Dot( span, normal ) );
Check( $"{name} extrudes 1 unit along its own normal",
MathF.Abs( along - 1f ) < 1e-3f, $"got {along}" );
// And the profile has to keep its 2x3 proportions in the plane's own axes.
var u = MathF.Abs( Vec3.Dot( span, plane.XAxis ) );
var v = MathF.Abs( Vec3.Dot( span, plane.YAxis ) );
Check( $"{name} keeps the profile 2 x 3 in plane axes",
MathF.Abs( u - 2f ) < 1e-3f && MathF.Abs( v - 3f ) < 1e-3f, $"got {u} x {v}" );
}
}
/// <summary>
/// The contract the editor leans on: change a parameter, mark the feature dirty, rebuild, and
/// the change is actually reflected.
///
/// PartStudio only re-runs from the first dirty feature and Rebuild() ends by clearing the
/// dirty mark, so a rebuild with nothing marked reuses the entire cache and re-executes
/// nothing. The editor was doing exactly that on every parameter edit, which silently threw
/// away every change made through a feature dialog.
/// </summary>
static void TestIncrementalRebuildSeesEdits()
{
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();
var before = Volume( studio.Bodies[0].Mesh );
// Edit WITHOUT marking dirty - the stale path the editor was taking.
extrude.Distance.Value = 5f;
studio.Rebuild();
Check( "an unmarked edit is NOT picked up (documents why MarkDirty is required)",
MathF.Abs( Volume( studio.Bodies[0].Mesh ) - before ) < 1e-3f,
$"{Volume( studio.Bodies[0].Mesh )} vs {before}" );
// Now the correct path.
studio.MarkDirty( extrude );
studio.Rebuild();
Check( "a marked edit IS picked up",
MathF.Abs( Volume( studio.Bodies[0].Mesh ) - before * 5f ) < 1e-2f,
$"{Volume( studio.Bodies[0].Mesh )}, expected {before * 5f}" );
}
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);
}
/// <summary>
/// A sketch with two separate rectangles in it, and an Extrude pointed at one of them.
///
/// This is what the viewport's face picking rests on: RegionSeed is a POINT inside the wanted
/// region rather than an index into the profile list, because profiles are re-found from the
/// curve graph every rebuild and carry no stable order. These checks are the ones that would
/// catch that going wrong - a seed selecting the wrong region, or silently selecting all of
/// them.
/// </summary>
static void TestRegionSelection()
{
static PartStudio TwoSquares( out ExtrudeFeature extrude )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
// 2x2 at the origin, and a 1x1 well clear of it.
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
sketch.Sketch.AddRectangle( new Vec2( 5, 5 ), new Vec2( 6, 6 ) );
extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
return studio;
}
// No seed - every region, which is the behaviour that existed before faces were pickable
// and has to keep working.
var studio = TwoSquares( out var all );
var report = studio.Rebuild();
Check( "no seed extrudes every region", studio.Bodies.Count == 2 && !report.HasErrors,
$"{studio.Bodies.Count} bodies, {report}" );
// Seeded inside the big square.
studio = TwoSquares( out var big );
big.RegionSeed = new Vec2( 1f, 1f );
report = studio.Rebuild();
Check( "a seed picks exactly one region", studio.Bodies.Count == 1 && !report.HasErrors,
$"{studio.Bodies.Count} bodies, {report}" );
Check( "the seed picks the region it is inside",
MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"volume {Volume( studio.Bodies[0].Mesh )}, expected 4" );
// Seeded inside the small square - same sketch, different face.
studio = TwoSquares( out var small );
small.RegionSeed = new Vec2( 5.5f, 5.5f );
studio.Rebuild();
Check( "a different seed picks the other region",
studio.Bodies.Count == 1 && MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 1f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {Volume( studio.Bodies[0].Mesh )}, expected 1" );
// A seed in empty space is an error, not a silent fallback to every region. Falling back
// would quietly extrude the whole sketch when the face you picked stopped existing.
studio = TwoSquares( out var gone );
gone.RegionSeed = new Vec2( 100f, 100f );
report = studio.Rebuild();
Check( "a seed that matches nothing reports an error", report.HasErrors, report.ToString() );
Check( "and produces no body from that feature", studio.Bodies.Count == 0,
$"{studio.Bodies.Count} bodies" );
// Two seeds, two faces of the same sketch — the thing the viewport's multi-pick is for.
studio = TwoSquares( out var both );
both.Result.Index = 1;
both.RegionSeeds.Add( new Vec2( 1f, 1f ) );
both.RegionSeeds.Add( new Vec2( 5.5f, 5.5f ) );
report = studio.Rebuild();
Check( "two seeds extrude both regions",
!report.HasErrors && studio.Bodies.Count == 2,
$"{studio.Bodies.Count} bodies, {report}" );
if ( studio.Bodies.Count == 2 )
{
var volumes = studio.Bodies.Select( b => Volume( b.Mesh ) ).OrderBy( v => v ).ToList();
Check( "as the small square and the big one, not two copies of either",
MathF.Abs( volumes[0] - 1f ) < 1e-3f && MathF.Abs( volumes[1] - 4f ) < 1e-3f,
string.Join( ", ", volumes.Select( v => v.ToString( "0.###" ) ) ) );
}
// Two clicks in the same face must not emit it twice.
studio = TwoSquares( out var twice );
twice.Result.Index = 1;
twice.RegionSeeds.Add( new Vec2( 0.5f, 0.5f ) );
twice.RegionSeeds.Add( new Vec2( 1.5f, 1.5f ) );
report = studio.Rebuild();
Check( "two seeds in one region still make one body",
!report.HasErrors && studio.Bodies.Count == 1
&& MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}, {report}" );
}
/// <summary>
/// ShadedRegions is the PAINTING answer where Profiles is the picking one, and the difference
/// is the whole point: pieces that do not overlap each other.
///
/// The symptom that put this here — drawing a second rectangle over the first made the shared
/// strip go opaque, with a seam across it where the two fills met. A translucent colour laid
/// down twice is not the same colour, so "shade every profile" cannot be right however the
/// alpha is tuned. Area is the assertion because it is what stacking gets wrong: the wholes
/// and the lens sum to 9 over a shape that covers 7.
/// </summary>
static void TestShadedRegions()
{
var sketch = new Sketch();
sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
sketch.AddRectangle( new Vec2( 1, 1 ), new Vec2( 3, 3 ) );
var found = ProfileFinder.Find( sketch );
var shaded = ProfileFinder.ShadedRegions( sketch, found );
var total = shaded.Sum( p => p.Area );
Check( "the shaded pieces cover the union (7), not the profiles' 9",
MathF.Abs( total - 7f ) < 1e-3f,
$"{shaded.Count} pieces totalling {total:0.###}" );
// Disjointness directly: no piece's interior seed falls inside another piece.
var overlapping = 0;
foreach ( var piece in shaded )
{
var seed = piece.Outer.Aggregate( Vec2.Zero, ( acc, v ) => new Vec2( acc.x + v.x, acc.y + v.y ) );
seed = new Vec2( seed.x / piece.Outer.Count, seed.y / piece.Outer.Count );
if ( !piece.Contains( seed ) )
continue;
if ( shaded.Any( other => !ReferenceEquals( other, piece ) && other.Contains( seed ) ) )
overlapping++;
}
Check( "and no piece sits on top of another", overlapping == 0, $"{overlapping} pieces overlap" );
// A shape with nothing overlapping is handed straight back — no arrangement, no change in
// what gets drawn.
var plain = new Sketch();
plain.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
var plainFound = ProfileFinder.Find( plain );
var plainShaded = ProfileFinder.ShadedRegions( plain, plainFound );
Check( "one rectangle still shades as one region of area 4",
plainShaded.Count == 1 && MathF.Abs( plainShaded[0].Area - 4f ) < 1e-3f,
$"{plainShaded.Count} pieces, area {(plainShaded.Count == 1 ? plainShaded[0].Area : 0f):0.###}" );
}
/// <summary>
/// Two closed shapes that overlap are three pickable faces: each whole, and the lens in the
/// middle. Without that, a click in the overlap could only name one of the wholes — or, worse,
/// both, because both contain the point.
///
/// The sketch itself is not rewritten. Coincidence stays identity for editing; the overlay is
/// only how regions are found.
/// </summary>
static void TestOverlappingRegions()
{
// Two 2x2 squares sharing a 1x1. First vertex of B sits inside A, which is the case that
// used to classify B as a hole in A and eat both the overlap and the part that sticks out.
var sketch = new Sketch();
sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
sketch.AddRectangle( new Vec2( 1, 1 ), new Vec2( 3, 3 ) );
var curvesBefore = sketch.Curves.Count;
var pointsBefore = sketch.Points.Count;
var found = ProfileFinder.Find( sketch );
Check( "finding regions does not rewrite the sketch",
sketch.Curves.Count == curvesBefore && sketch.Points.Count == pointsBefore,
$"{sketch.Curves.Count} curves / {sketch.Points.Count} points, were {curvesBefore}/{pointsBefore}" );
Check( "two overlapping squares are three regions, not one with a hole",
found.Profiles.Count == 3, $"{found.Profiles.Count} regions, warnings: {string.Join( "; ", found.Warnings )}" );
Check( "and the middle is marked as an overlap, not a third whole",
found.Profiles.Count( p => p.FromOverlap ) == 1,
$"{found.Profiles.Count( p => p.FromOverlap )} overlap faces" );
if ( found.Profiles.Count == 3 )
{
var areas = found.Profiles.Select( p => p.Area ).OrderBy( a => a ).ToList();
Check( "the three areas are the lens and the two wholes (1, 4, 4)",
MathF.Abs( areas[0] - 1f ) < 1e-3f
&& MathF.Abs( areas[1] - 4f ) < 1e-3f
&& MathF.Abs( areas[2] - 4f ) < 1e-3f,
string.Join( ", ", areas.Select( a => a.ToString( "0.###" ) ) ) );
var lens = found.Profiles.First( p => p.FromOverlap );
Check( "a point in the middle is inside the lens", lens.Contains( new Vec2( 1.5f, 1.5f ) ) );
Check( "a point in only A is not inside the lens", !lens.Contains( new Vec2( 0.5f, 0.5f ) ) );
Check( "a point in only B is not inside the lens", !lens.Contains( new Vec2( 2.5f, 2.5f ) ) );
}
// The pick contract: exclusive A extrudes whole A, exclusive B whole B, the middle the lens.
static PartStudio Overlapping( out ExtrudeFeature extrude )
{
var studio = new PartStudio();
var feature = studio.Add( new SketchFeature() );
feature.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
feature.Sketch.AddRectangle( new Vec2( 1, 1 ), new Vec2( 3, 3 ) );
extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
extrude.Result.Index = 1; // keep bodies apart so volumes can be read
return studio;
}
var studio = Overlapping( out var all );
var report = studio.Rebuild();
Check( "no seed still extrudes the two wholes, not the lens on top of them",
!report.HasErrors && studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies, {report}" );
if ( studio.Bodies.Count == 2 )
{
Check( "and each whole is volume 4",
studio.Bodies.All( b => MathF.Abs( Volume( b.Mesh ) - 4f ) < 1e-3f ),
string.Join( ", ", studio.Bodies.Select( b => Volume( b.Mesh ).ToString( "0.###" ) ) ) );
}
studio = Overlapping( out var lensExtrude );
lensExtrude.RegionSeed = new Vec2( 1.5f, 1.5f );
report = studio.Rebuild();
Check( "a seed in the middle extrudes only the lens",
!report.HasErrors && studio.Bodies.Count == 1
&& MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 1f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}, {report}" );
studio = Overlapping( out var left );
left.RegionSeed = new Vec2( 0.5f, 0.5f );
studio.Rebuild();
Check( "a seed in only A still extrudes whole A",
studio.Bodies.Count == 1 && MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}" );
studio = Overlapping( out var right );
right.RegionSeed = new Vec2( 2.5f, 2.5f );
studio.Rebuild();
Check( "a seed in only B still extrudes whole B",
studio.Bodies.Count == 1 && MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}" );
// Two overlapping circles: the same three faces, recovered from closed curves that never
// sit in the integer graph at all.
var circles = new Sketch();
circles.AddCircle( new Vec2( 0, 0 ), 2f );
circles.AddCircle( new Vec2( 2, 0 ), 2f );
var circleFound = ProfileFinder.Find( circles );
Check( "two overlapping circles are three regions",
circleFound.Profiles.Count == 3, $"{circleFound.Profiles.Count} regions" );
Check( "with one overlap face",
circleFound.Profiles.Count( p => p.FromOverlap ) == 1,
$"{circleFound.Profiles.Count( p => p.FromOverlap )} overlap faces" );
if ( circleFound.Profiles.Count( p => p.FromOverlap ) == 1 )
{
var lens = circleFound.Profiles.First( p => p.FromOverlap );
Check( "the circle lens contains the midpoint", lens.Contains( new Vec2( 1f, 0f ) ) );
Check( "and not a point that belongs to only one circle",
!lens.Contains( new Vec2( -1f, 0f ) ) && !lens.Contains( new Vec2( 3f, 0f ) ) );
// Two circles r=2, centres 2 apart: 2 r² acos(d/2r) − 0.5 d √(4r² − d²) = 8π/3 − 2√3.
var expected = 8f * MathF.PI / 3f - 2f * MathF.Sqrt( 3f );
Check( "and its area is the analytic intersection, to tessellation tolerance",
MathF.Abs( lens.Area - expected ) < 0.3f,
$"{lens.Area:0.###} vs {expected:0.###}" );
}
// Two rectangles that only kiss at a corner must not become a bowtie, and must not grow
// a zero-area overlap face.
var kissing = new Sketch();
kissing.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
kissing.AddRectangle( new Vec2( 1, 1 ), new Vec2( 2, 2 ) );
var kissFound = ProfileFinder.Find( kissing );
Check( "rectangles that touch at a corner stay two regions",
kissFound.Profiles.Count == 2 && kissFound.Profiles.Count( p => p.FromOverlap ) == 0,
$"{kissFound.Profiles.Count} regions, {kissFound.Profiles.Count( p => p.FromOverlap )} overlaps" );
// THE ACTUAL CASE: two Sketch features on the same plane, not two loops in one sketch.
var host = new Sketch();
host.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
var guest = new Sketch();
guest.AddRectangle( new Vec2( 1, 1 ), new Vec2( 3, 3 ) );
var alone = ProfileFinder.Find( host );
Check( "a sketch on its own does not invent a lens from a neighbour it was not given",
alone.Profiles.Count == 1 && alone.Profiles.Count( p => p.FromOverlap ) == 0,
$"{alone.Profiles.Count} regions, {alone.Profiles.Count( p => p.FromOverlap )} overlaps" );
var together = ProfileFinder.Find( host, new[] { guest } );
Check( "two Sketch features that overlap are the host whole plus the lens",
together.Profiles.Count == 2 && together.Profiles.Count( p => p.FromOverlap ) == 1,
$"{together.Profiles.Count} regions, {together.Profiles.Count( p => p.FromOverlap )} overlaps" );
if ( together.Profiles.Count( p => p.FromOverlap ) == 1 )
{
var lens = together.Profiles.First( p => p.FromOverlap );
Check( "the cross-sketch lens is the 1x1 in the middle",
MathF.Abs( lens.Area - 1f ) < 1e-3f, $"{lens.Area:0.###}" );
Check( "and a click there names the lens, not either whole",
lens.Contains( new Vec2( 1.5f, 1.5f ) ) );
Check( "a click in only the host is not the lens",
!lens.Contains( new Vec2( 0.5f, 0.5f ) ) );
Check( "and exclusive faces of the neighbour are not this sketch's to pick",
!together.Profiles.Any( p => p.Contains( new Vec2( 2.5f, 2.5f ) ) ) );
}
static PartStudio TwoSketchFeatures( out SketchFeature a, out SketchFeature b, out ExtrudeFeature extrude )
{
var studio = new PartStudio();
a = studio.Add( new SketchFeature() );
a.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
b = studio.Add( new SketchFeature() );
b.Sketch.AddRectangle( new Vec2( 1, 1 ), new Vec2( 3, 3 ) );
extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
extrude.Result.Index = 1;
return studio;
}
studio = TwoSketchFeatures( out var sketchA, out _, out var fromA );
fromA.SketchFeatureId = sketchA.Id;
fromA.RegionSeed = new Vec2( 1.5f, 1.5f );
report = studio.Rebuild();
Check( "extrude pointed at one sketch, seed in the overlap with the other, builds the lens",
!report.HasErrors && studio.Bodies.Count == 1
&& MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 1f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}, {report}" );
studio = TwoSketchFeatures( out sketchA, out _, out var wholeA );
wholeA.SketchFeatureId = sketchA.Id;
studio.Rebuild();
Check( "no seed on one of two overlapping sketches still builds that whole, not the lens",
studio.Bodies.Count == 1 && MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}" );
studio = TwoSketchFeatures( out sketchA, out _, out var exclusive );
exclusive.SketchFeatureId = sketchA.Id;
exclusive.RegionSeed = new Vec2( 0.5f, 0.5f );
studio.Rebuild();
Check( "a seed in only one sketch still builds that whole",
studio.Bodies.Count == 1 && MathF.Abs( Volume( studio.Bodies[0].Mesh ) - 4f ) < 1e-3f,
$"{studio.Bodies.Count} bodies, volume {(studio.Bodies.Count == 0 ? 0 : Volume( studio.Bodies[0].Mesh ))}" );
// Two Sketch features on the same plane, seeds in both exclusive faces. The pick filter
// is the plane: pointing the extrude at A and clicking B's square still builds B.
studio = TwoSketchFeatures( out sketchA, out var sketchB, out var bothSketches );
bothSketches.SketchFeatureId = sketchA.Id;
bothSketches.RegionSeeds.Add( new Vec2( 0.5f, 0.5f ) );
bothSketches.RegionSeeds.Add( new Vec2( 2.5f, 2.5f ) );
report = studio.Rebuild();
Check( "seeds in two coplanar sketches extrude both exclusive faces",
!report.HasErrors && studio.Bodies.Count == 2,
$"{studio.Bodies.Count} bodies, {report}" );
if ( studio.Bodies.Count == 2 )
{
Check( "and each is the 2x2 whole, not the lens",
studio.Bodies.All( b => MathF.Abs( Volume( b.Mesh ) - 4f ) < 1e-3f ),
string.Join( ", ", studio.Bodies.Select( b => Volume( b.Mesh ).ToString( "0.###" ) ) ) );
}
}
/// <summary>Shoelace area of a curve as the extrude actually tessellates it. Comparing against
/// pi r squared instead would be comparing against a circle the mesh never contained.</summary>
static float TessellatedArea( Sketch sketch, SketchCurve curve )
{
var points = curve.Tessellate( sketch, sketch.Tolerance );
// Tessellate returns both endpoints, so the closing point repeats the first and is dropped
// rather than counted twice.
var n = points.Count - 1;
var sum = 0f;
for ( var i = 0; i < n; i++ )
{
var a = points[i];
var b = points[(i + 1) % n];
sum += a.x * b.y - b.x * a.y;
}
return MathF.Abs( sum * 0.5f );
}
static float Volume( PolyMesh m ) => m.SignedVolume();
static void TestPlanes()
{
var plane = SketchPlane.XY;
Check( "XY normal is +Z", plane.Normal.AlmostEquals( new Vec3( 0, 0, 1 ) ), plane.Normal.ToString() );
Check( "XZ normal is -Y", SketchPlane.XZ.Normal.AlmostEquals( new Vec3( 0, -1, 0 ) ),
SketchPlane.XZ.Normal.ToString() );
Check( "YZ normal is +X", SketchPlane.YZ.Normal.AlmostEquals( new Vec3( 1, 0, 0 ) ),
SketchPlane.YZ.Normal.ToString() );
// Plane coordinates must survive a trip through world space and back, on every plane, or
// sketches drawn on anything but XY end up subtly displaced.
foreach ( var (name, p) in new[] { ("XY", SketchPlane.XY), ("XZ", SketchPlane.XZ), ("YZ", SketchPlane.YZ) } )
{
var original = new Vec2( 3.5f, -1.25f );
var back = p.ToPlane( p.ToWorld( original ) );
Check( $"{name} round-trips plane coords",
MathF.Abs( back.x - original.x ) < 1e-4f && MathF.Abs( back.y - original.y ) < 1e-4f,
back.ToString() );
}
var offset = SketchPlane.XY.Offset( 5f );
Check( "offset plane moves along its normal", offset.Origin.AlmostEquals( new Vec3( 0, 0, 5 ) ),
offset.Origin.ToString() );
Check( "offset plane keeps its normal", offset.Normal.AlmostEquals( new Vec3( 0, 0, 1 ) ) );
}
static void TestArcs()
{
// Segment count comes from the allowed sagitta, so a tighter tolerance must give more
// segments and a bigger radius must too.
var coarse = SketchArc.SegmentsForArc( 1f, MathF.Tau, 0.1f );
var fine = SketchArc.SegmentsForArc( 1f, MathF.Tau, 0.001f );
var big = SketchArc.SegmentsForArc( 100f, MathF.Tau, 0.1f );
Check( "tighter tolerance gives more segments", fine > coarse, $"{coarse} -> {fine}" );
Check( "bigger radius gives more segments", big > coarse, $"{coarse} -> {big}" );
Check( "segment count stays bounded", fine <= 4096, $"{fine}" );
var sketch = new Sketch { Tolerance = 0.01f };
var c = sketch.AddPoint( 0, 0 );
var s = sketch.AddPoint( 1, 0 );
var e = sketch.AddPoint( 0, 1 );
var arc = sketch.Add( new SketchArc( c, s, e ) );
var pts = arc.Tessellate( sketch, sketch.Tolerance );
Check( "arc starts exactly on its start point",
MathF.Abs( pts[0].x - 1f ) < 1e-5f && MathF.Abs( pts[0].y ) < 1e-5f, pts[0].ToString() );
Check( "arc ends exactly on its end point",
MathF.Abs( pts[^1].x ) < 1e-5f && MathF.Abs( pts[^1].y - 1f ) < 1e-5f, pts[^1].ToString() );
var offRadius = pts.Count( p => MathF.Abs( MathF.Sqrt( p.x * p.x + p.y * p.y ) - 1f ) > 1e-3f );
Check( "arc samples sit on the radius", offRadius == 0, $"{offRadius} off" );
// A quarter turn counter-clockwise from +X to +Y must not go the long way round.
Check( "arc takes the short way", pts.All( p => p.x >= -1e-4f && p.y >= -1e-4f ) );
}
static void TestProfiles()
{
var rect = new Sketch();
rect.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 3 ) );
var found = ProfileFinder.Find( rect );
Check( "rectangle gives one profile", found.Profiles.Count == 1, $"{found.Profiles.Count}" );
Check( "rectangle has four corners", found.Profiles[0].Outer.Count == 4,
$"{found.Profiles[0].Outer.Count}" );
Check( "rectangle outer loop is counter-clockwise",
ProfileFinder.SignedArea( found.Profiles[0].Outer ) > 0 );
Check( "rectangle area is 6", MathF.Abs( found.Profiles[0].Area - 6f ) < 1e-4f,
$"{found.Profiles[0].Area}" );
Check( "rectangle has no open chains", found.OpenChains == 0 );
// A clockwise-drawn rectangle must come back oriented the same way as a counter-clockwise
// one, or extruding it would produce an inside-out solid.
var cw = new Sketch();
cw.AddPolygon( new Vec2( 0, 0 ), new Vec2( 0, 3 ), new Vec2( 2, 3 ), new Vec2( 2, 0 ) );
var cwFound = ProfileFinder.Find( cw );
Check( "clockwise input is re-oriented",
ProfileFinder.SignedArea( cwFound.Profiles[0].Outer ) > 0 );
var circle = new Sketch();
circle.AddCircle( new Vec2( 0, 0 ), 2f );
var circleFound = ProfileFinder.Find( circle );
Check( "circle gives one profile", circleFound.Profiles.Count == 1, $"{circleFound.Profiles.Count}" );
Check( "circle area is about pi r squared",
MathF.Abs( circleFound.Profiles[0].Area - MathF.PI * 4f ) < 0.1f,
$"{circleFound.Profiles[0].Area}" );
// Nesting detection: a circle inside a rectangle is a hole, not a second region.
var withHole = new Sketch();
withHole.AddRectangle( new Vec2( -5, -5 ), new Vec2( 5, 5 ) );
withHole.AddCircle( new Vec2( 0, 0 ), 1f );
var holeFound = ProfileFinder.Find( withHole );
Check( "nested loop is one profile, not two", holeFound.Profiles.Count == 1,
$"{holeFound.Profiles.Count}" );
Check( "nested loop is detected as a hole", holeFound.Profiles[0].HasHoles );
Check( "hole is wound opposite to the outer loop",
ProfileFinder.SignedArea( holeFound.Profiles[0].Holes[0] ) < 0 );
Check( "hole is subtracted from the area",
MathF.Abs( holeFound.Profiles[0].Area - (100f - MathF.PI) ) < 0.1f,
$"{holeFound.Profiles[0].Area}" );
// Two separate shapes are two profiles.
var two = new Sketch();
two.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
two.AddRectangle( new Vec2( 5, 5 ), new Vec2( 6, 6 ) );
Check( "disjoint shapes give two profiles", ProfileFinder.Find( two ).Profiles.Count == 2 );
// An unclosed chain is reported rather than silently treated as a region.
// A revolve past a full turn swept over itself and welded the overlap, producing a mesh with
// edges shared by four or more faces and no error at all.
var over = new PartStudio();
var os = over.Add( new SketchFeature() );
os.Sketch.AddRectangle( new Vec2( 3, -0.5f ), new Vec2( 5, 0.5f ) );
var overRevolve = over.Add( new RevolveFeature() );
overRevolve.AxisDirection.Value = new Vec3( 0, 1, 0 );
overRevolve.Angle.Value = 720f;
over.Rebuild();
Check( "a revolve past a full turn is refused", overRevolve.Error is not null, overRevolve.Error );
// A circle at or below the sketch tolerance tessellates to fewer than three points. Walked
// loops are guarded; the circle path was not, and produced faces with two corners.
var tiny = new Sketch { Tolerance = 0.1f };
var tc = tiny.AddPoint( 0, 0 );
tiny.Add( new SketchCircle( tc, 0.01f ) );
var tinyResult = ProfileFinder.Find( tiny );
Check( "a circle too small to be a region is reported, not extruded",
tinyResult.Profiles.Count == 0 && tinyResult.Warnings.Count > 0,
$"{tinyResult.Profiles.Count} profiles, {tinyResult.Warnings.Count} warnings" );
// An open chain seeded from its MIDDLE used to be counted twice: the walk goes one way, and
// the untouched half was then picked up as a fresh seed.
var middle = new Sketch();
var m0 = middle.AddPoint( 0, 0 );
var m1 = middle.AddPoint( 1, 0 );
var m2 = middle.AddPoint( 2, 0 );
var m3 = middle.AddPoint( 3, 0 );
middle.Add( new SketchLine( m1, m2 ) ); // the middle segment is seeded first
middle.Add( new SketchLine( m0, m1 ) );
middle.Add( new SketchLine( m2, m3 ) );
Check( "an open chain seeded from its middle counts once",
ProfileFinder.Find( middle ).OpenChains == 1,
$"{ProfileFinder.Find( middle ).OpenChains}" );
var open = new Sketch();
var p0 = open.AddPoint( 0, 0 );
var p1 = open.AddPoint( 1, 0 );
var p2 = open.AddPoint( 1, 1 );
open.Add( new SketchLine( p0, p1 ) );
open.Add( new SketchLine( p1, p2 ) );
var openFound = ProfileFinder.Find( open );
Check( "open chain gives no profile", openFound.Profiles.Count == 0, $"{openFound.Profiles.Count}" );
Check( "open chain is counted", openFound.OpenChains == 1, $"{openFound.OpenChains}" );
// Construction geometry guides but never forms a region.
var construction = new Sketch();
foreach ( var line in construction.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) ) )
line.Construction = true;
Check( "construction geometry forms no profile",
ProfileFinder.Find( construction ).Profiles.Count == 0 );
// Rounded rectangle: lines and arcs in one loop, which is the real test of the walker
// stitching different curve types together.
var slot = new Sketch();
var a0 = slot.AddPoint( 0, 0 );
var a1 = slot.AddPoint( 4, 0 );
var a2 = slot.AddPoint( 4, 2 );
var a3 = slot.AddPoint( 0, 2 );
var c0 = slot.AddPoint( 4, 1 );
var c1 = slot.AddPoint( 0, 1 );
slot.Add( new SketchLine( a0, a1 ) );
slot.Add( new SketchArc( c0, a1, a2 ) );
slot.Add( new SketchLine( a2, a3 ) );
slot.Add( new SketchArc( c1, a3, a0 ) );
var slotFound = ProfileFinder.Find( slot );
Check( "lines and arcs stitch into one loop", slotFound.Profiles.Count == 1,
$"{slotFound.Profiles.Count}, {slotFound.OpenChains} open" );
Check( "stitched loop has no duplicated joins",
slotFound.Profiles.Count == 1 && NoDuplicatePoints( slotFound.Profiles[0].Outer ) );
}
static bool NoDuplicatePoints( List<Vec2> loop )
{
for ( var i = 0; i < loop.Count; i++ )
{
var a = loop[i];
var b = loop[(i + 1) % loop.Count];
if ( MathF.Abs( a.x - b.x ) < 1e-6f && MathF.Abs( a.y - b.y ) < 1e-6f )
return false;
}
return true;
}
static PartStudio ExtrudedRectangle( float w, float h, float d, bool symmetric = false )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( w, h ) );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = d;
extrude.Symmetric.Value = symmetric;
studio.Rebuild();
return studio;
}
static void TestExtrude()
{
var studio = ExtrudedRectangle( 2, 3, 4 );
Check( "extrude produces a body", studio.Bodies.Count == 1, $"{studio.Bodies.Count}" );
var mesh = studio.Bodies[0].Mesh;
Check( "extruded rectangle has 6 faces", mesh.FaceCount == 6, $"{mesh.FaceCount}" );
var v = MeshValidator.Validate( mesh );
Check( "extrusion is valid and closed", v.IsValid && v.IsClosed, v.ToString() );
Check( "extrusion has X = 2", MeshValidator.EulerCharacteristic( mesh ) == 2 );
var vol = Volume( mesh );
Check( "extruded volume is 2x3x4 = 24", MathF.Abs( vol - 24f ) < 1e-3f, $"{vol:0.####}" );
// A negative distance must still give a solid the right way out, not an inside-out one.
var negative = ExtrudedRectangle( 2, 3, -4 );
var negVol = Volume( negative.Bodies[0].Mesh );
Check( "NEGATIVE distance still winds outward", negVol > 0, $"{negVol:0.####}" );
Check( "negative distance gives the same volume", MathF.Abs( negVol - 24f ) < 1e-3f, $"{negVol:0.####}" );
// Symmetric straddles the sketch plane.
var sym = ExtrudedRectangle( 2, 2, 4, symmetric: true );
var zs = sym.Bodies[0].Mesh.Positions.Select( p => p.z ).ToList();
Check( "symmetric extrude straddles the plane",
MathF.Abs( zs.Min() + 2f ) < 1e-4f && MathF.Abs( zs.Max() - 2f ) < 1e-4f,
$"{zs.Min()}..{zs.Max()}" );
// A circle extrudes to a cylinder, and the cap arrives as one n-gon rather than a fan.
var circleStudio = new PartStudio();
var cs = circleStudio.Add( new SketchFeature() );
cs.Sketch.AddCircle( new Vec2( 0, 0 ), 1f );
circleStudio.Add( new ExtrudeFeature() ).Distance.Value = 2f;
circleStudio.Rebuild();
var cyl = circleStudio.Bodies[0].Mesh;
var caps = cyl.Faces.Count( f => f.Count > 4 );
Check( "circular extrude caps are n-gons, not fans", caps == 2, $"{caps}" );
// An inscribed polygon is always slightly SMALLER than the circle it samples, so the right
// assertion is "just under pi r squared h", not "equal to it". Demanding equality here
// would only be satisfiable by over-tessellating.
var cylVolume = Volume( cyl );
var trueVolume = MathF.PI * 2f;
Check( "cylinder volume is just under pi r squared h",
cylVolume < trueVolume && cylVolume > trueVolume * 0.97f,
$"{cylVolume:0.####} vs {trueVolume:0.####}" );
// On a non-default plane the solid must be built in the right orientation too.
var front = new PartStudio();
var fs = front.Add( new SketchFeature() );
fs.Plane.Index = 1; // XZ
fs.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
front.Add( new ExtrudeFeature() ).Distance.Value = 2f;
front.Rebuild();
Check( "extrude on XZ still winds outward", Volume( front.Bodies[0].Mesh ) > 0,
$"{Volume( front.Bodies[0].Mesh ):0.####}" );
Check( "extrude on XZ has volume 8",
MathF.Abs( Volume( front.Bodies[0].Mesh ) - 8f ) < 1e-3f );
// A plate with a hole in it. This used to be refused outright on the grounds that capping
// around a hole was "the same problem as a boolean subtract"; it is a 2D triangulation
// problem, and it builds now.
var holed = new PartStudio();
var hs = holed.Add( new SketchFeature() );
hs.Sketch.AddRectangle( new Vec2( -5, -5 ), new Vec2( 5, 5 ) );
var holeCircle = hs.Sketch.AddCircle( new Vec2( 0, 0 ), 1f );
var holedExtrude = holed.Add( new ExtrudeFeature() );
holedExtrude.Distance.Value = 2f;
holed.Rebuild();
Check( "a profile with a hole extrudes", holedExtrude.Error is null, holedExtrude.Error );
if ( holedExtrude.Error is null )
{
var plate = holed.Bodies[0].Mesh;
// 100 minus the circle, times 2 deep. The circle is tessellated, so the comparison is
// against the polygon's area rather than pi r squared — otherwise the tolerance would
// have to be loose enough to also pass a plate with no hole at all.
var holeArea = TessellatedArea( hs.Sketch, holeCircle );
var expected = (100f - holeArea) * 2f;
Check( "and its volume is the plate minus the hole",
MathF.Abs( Volume( plate ) - expected ) < 0.05f,
$"{Volume( plate ):0.####}, expected {expected:0.####}" );
// GENUS 1, the same as the Tube primitive. A cap that quietly filled the hole in could
// slip past a volume check with a small enough hole; it cannot slip past this.
var x = MeshValidator.EulerCharacteristic( plate );
Check( "a plate with one hole is genus 1, so X = 0", x == 0, $"X = {x}" );
var validation = MeshValidator.Validate( plate );
Check( "it is a valid closed mesh", validation.IsValid && validation.IsClosed, validation.ToString() );
}
// Revolve handles holes too, now that a holed cap exists for it to borrow. PAPPUS' THEOREM is
// the check: a region of area A whose centroid sits distance d from the axis sweeps a volume
// of 2*pi*d*A through a full turn, holes and all.
var revolvedHole = new PartStudio();
var rhs = revolvedHole.Add( new SketchFeature() );
rhs.Sketch.AddRectangle( new Vec2( 2, 2 ), new Vec2( 6, 6 ) );
var boreCircle = rhs.Sketch.AddCircle( new Vec2( 4, 4 ), 1f );
var holedRevolve = revolvedHole.Add( new RevolveFeature() );
revolvedHole.Rebuild();
Check( "revolving a holed profile builds", holedRevolve.Error is null, holedRevolve.Error );
if ( holedRevolve.Error is null )
{
var ring = revolvedHole.Bodies[0].Mesh;
// The square and the bore share a centroid, so the composite one is still at y = 4.
var area = 16f - TessellatedArea( rhs.Sketch, boreCircle );
var expected = 2f * MathF.PI * 4f * area;
// MEASURED AGAINST THE SAME SQUARE WITH NO HOLE, not against Pappus directly.
//
// A revolve is faceted — 24 segments by default — so its volume runs about 1.1% under
// the true solid of revolution whether or not there is a hole in it. Comparing the holed
// result to Pappus therefore measures the faceting, and needs a tolerance loose enough to
// hide a real error in the hole. Comparing it to the UNHOLED sweep of the same square
// cancels the faceting exactly: both are approximated identically, so what is left is
// purely the proportion the hole removed. That ratio came out identical to five decimal
// places, which is what says the hole is handled exactly rather than approximately.
var control = new PartStudio();
var controlSketch = control.Add( new SketchFeature() );
controlSketch.Sketch.AddRectangle( new Vec2( 2, 2 ), new Vec2( 6, 6 ) );
control.Add( new RevolveFeature() );
control.Rebuild();
var solidVolume = Volume( control.Bodies[0].Mesh );
Check( "the hole removes exactly its share of the solid",
MathF.Abs( Volume( ring ) / solidVolume - area / 16f ) < 1e-4f,
$"removed {1f - Volume( ring ) / solidVolume:0.#####}, expected {1f - area / 16f:0.#####}" );
// And a loose absolute check, so a systematically wrong sweep cannot hide behind a ratio
// that is right for the wrong reason.
Check( "and Pappus agrees to within the faceting",
MathF.Abs( Volume( ring ) - expected ) < expected * 0.02f,
$"{Volume( ring ):0.####}, expected about {expected:0.####}" );
// Two disjoint torus surfaces, one inside the other: X = 0 + 0.
Check( "its boundary is two tori, so X = 0",
MeshValidator.EulerCharacteristic( ring ) == 0,
$"X = {MeshValidator.EulerCharacteristic( ring )}" );
Check( "and it is closed and valid",
MeshValidator.Validate( ring ) is { IsValid: true, IsClosed: true } );
}
// A PARTIAL revolution is the case that needs the holed cap, since a full one has no caps at
// all. Ninety degrees is a quarter of the volume above.
var quarter = new PartStudio();
var qs = quarter.Add( new SketchFeature() );
qs.Sketch.AddRectangle( new Vec2( 2, 2 ), new Vec2( 6, 6 ) );
var quarterBore = qs.Sketch.AddCircle( new Vec2( 4, 4 ), 1f );
var quarterRevolve = quarter.Add( new RevolveFeature() );
quarterRevolve.Angle.Value = 90f;
quarter.Rebuild();
Check( "a partial revolve with a hole builds", quarterRevolve.Error is null, quarterRevolve.Error );
if ( quarterRevolve.Error is null )
{
var arc = quarter.Bodies[0].Mesh;
var expected = 2f * MathF.PI * 4f * (16f - TessellatedArea( qs.Sketch, quarterBore )) * 0.25f;
Check( "at a quarter of the full volume",
MathF.Abs( Volume( arc ) - expected ) < expected * 0.02f,
$"{Volume( arc ):0.####}, expected about {expected:0.####}" );
Check( "capped closed at both ends",
MeshValidator.Validate( arc ) is { IsValid: true, IsClosed: true },
MeshValidator.Validate( arc ).ToString() );
}
// A hole crossing the axis is as meaningless as an outer loop doing it, and is refused for
// the same reason: each half would sweep the same surface.
var crossing = new PartStudio();
var crossingSketch = crossing.Add( new SketchFeature() );
crossingSketch.Sketch.AddRectangle( new Vec2( -6, -6 ), new Vec2( 6, 6 ) );
crossingSketch.Sketch.AddCircle( new Vec2( 0, 0 ), 1f );
var crossingRevolve = crossing.Add( new RevolveFeature() );
crossing.Rebuild();
Check( "a hole straddling the axis is refused", crossingRevolve.Error is not null );
Check( "and says which way to move it",
crossingRevolve.Error?.Contains( "crosses the axis" ) == true, crossingRevolve.Error );
// Two disjoint regions extrude to two bodies.
var twoStudio = new PartStudio();
var ts = twoStudio.Add( new SketchFeature() );
ts.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
ts.Sketch.AddRectangle( new Vec2( 5, 5 ), new Vec2( 6, 6 ) );
twoStudio.Add( new ExtrudeFeature() ).Distance.Value = 1f;
twoStudio.Rebuild();
Check( "two regions extrude to two bodies", twoStudio.Bodies.Count == 2, $"{twoStudio.Bodies.Count}" );
}
static void TestRevolve()
{
// A square offset from the axis sweeps a torus — genus 1, so Euler characteristic 0. If
// the rings failed to close, this reads 2 and the seam is open.
//
// The profile sits entirely ABOVE the axis (y from 1 to 2, axis along y=0). A profile
// straddling the axis is invalid and separately tested below.
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
sketch.Sketch.AddRectangle( new Vec2( 3, 1f ), new Vec2( 4, 2f ) );
var revolve = studio.Add( new RevolveFeature() );
revolve.AxisPoint.Value = Vec3.Zero;
revolve.AxisDirection.Value = new Vec3( 1, 0, 0 ); // sketch-space +X
revolve.Angle.Value = 360f;
revolve.Segments.Value = 24;
studio.Rebuild();
Check( "revolve produces a body", studio.Bodies.Count == 1 && revolve.Error is null, revolve.Error );
var torus = studio.Bodies[0].Mesh;
var tv = MeshValidator.Validate( torus );
Check( "revolved torus is valid", tv.IsValid, tv.ToString() );
Check( "revolved torus is CLOSED (the seam welded)", tv.IsClosed, tv.ToString() );
Check( "revolved torus has X = 0", MeshValidator.EulerCharacteristic( torus ) == 0,
$"{MeshValidator.EulerCharacteristic( torus )}" );
Check( "revolved torus winds outward", Volume( torus ) > 0, $"{Volume( torus ):0.####}" );
// Pappus: volume = area * 2 pi * centroid radius. The 1x1 profile's centroid is 1.5 from
// the axis, so 1 * 2pi * 1.5.
var expected = 1f * MathF.Tau * 1.5f;
Check( "torus volume matches Pappus' theorem",
MathF.Abs( Volume( torus ) - expected ) < expected * 0.02f,
$"{Volume( torus ):0.###} vs {expected:0.###}" );
// A profile touching the axis must collapse to a closed solid rather than leaving a hole.
var sphereish = new PartStudio();
var ss = sphereish.Add( new SketchFeature() );
ss.Sketch.AddPolygon( new Vec2( 0, 0 ), new Vec2( 2, 0 ), new Vec2( 0, 2 ) );
var sphereRevolve = sphereish.Add( new RevolveFeature() );
sphereRevolve.AxisPoint.Value = Vec3.Zero;
sphereRevolve.AxisDirection.Value = new Vec3( 0, 1, 0 ); // sketch-space +Y
sphereRevolve.Angle.Value = 360f;
sphereish.Rebuild();
Check( "profile on the axis revolves without error",
sphereRevolve.Error is null, sphereRevolve.Error );
var cone = sphereish.Bodies[0].Mesh;
var cv = MeshValidator.Validate( cone );
Check( "on-axis profile gives a valid solid", cv.IsValid, cv.ToString() );
Check( "on-axis profile gives a CLOSED solid", cv.IsClosed, cv.ToString() );
Check( "on-axis solid has X = 2", MeshValidator.EulerCharacteristic( cone ) == 2,
$"{MeshValidator.EulerCharacteristic( cone )}" );
Check( "on-axis solid winds outward", Volume( cone ) > 0, $"{Volume( cone ):0.####}" );
// Partial revolutions get capped at both ends.
var partial = new PartStudio();
var ps = partial.Add( new SketchFeature() );
ps.Sketch.AddRectangle( new Vec2( 3, 1f ), new Vec2( 4, 2f ) );
var partialRevolve = partial.Add( new RevolveFeature() );
partialRevolve.AxisDirection.Value = new Vec3( 1, 0, 0 );
partialRevolve.Angle.Value = 90f;
partial.Rebuild();
var wedge = partial.Bodies[0].Mesh;
var pv = MeshValidator.Validate( wedge );
Check( "partial revolve is capped and closed", pv.IsValid && pv.IsClosed, pv.ToString() );
Check( "partial revolve winds outward", Volume( wedge ) > 0, $"{Volume( wedge ):0.####}" );
Check( "quarter turn is a quarter of the volume",
MathF.Abs( Volume( wedge ) - expected / 4f ) < expected * 0.02f,
$"{Volume( wedge ):0.###} vs {expected / 4f:0.###}" );
// A profile straddling the axis must be refused. Left to run it produces a mesh where every
// face exists twice with opposite winding: it encloses zero volume, welds vertices that
// should be distinct, and looks entirely plausible until measured.
var crossing = new PartStudio();
var xs = crossing.Add( new SketchFeature() );
xs.Sketch.AddRectangle( new Vec2( 3, -0.5f ), new Vec2( 4, 0.5f ) );
var crossingRevolve = crossing.Add( new RevolveFeature() );
crossingRevolve.AxisDirection.Value = new Vec3( 1, 0, 0 );
crossing.Rebuild();
Check( "profile crossing the axis is refused", crossingRevolve.Error is not null );
Check( "and the error says why",
crossingRevolve.Error?.Contains( "crosses the axis" ) == true, crossingRevolve.Error );
// Touching the axis is fine — only crossing it is not.
Check( "touching the axis is still allowed", sphereRevolve.Error is null, sphereRevolve.Error );
}
static void TestSketchInTree()
{
var studio = new PartStudio();
var sketchFeature = studio.Add( new SketchFeature() );
sketchFeature.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 2, 2 ) );
studio.Add( new ExtrudeFeature() ).Distance.Value = 2f;
var subdiv = studio.Add( new SubdivideFeature() );
subdiv.Levels.Value = 2;
studio.Rebuild();
Check( "sketch, extrude and subdivide chain together",
studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount == 96,
$"{studio.Bodies.Count} bodies, {studio.Bodies[0].Mesh.FaceCount} faces" );
// The whole point of referencing the sketch by feature id: editing the sketch and
// rebuilding must feed through, with nothing to re-wire.
sketchFeature.Sketch = new Sketch();
sketchFeature.Sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 10, 2 ) );
studio.MarkDirty( sketchFeature );
studio.Rebuild();
var width = studio.Bodies[0].Mesh.Positions.Max( p => p.x ) - studio.Bodies[0].Mesh.Positions.Min( p => p.x );
Check( "editing the sketch changes the solid", width > 6f, $"width {width:0.##}" );
// Rolling back above the extrude leaves the sketch with nothing built from it.
studio.RollbackIndex = 1;
studio.MarkAllDirty();
studio.Rebuild();
Check( "rollback above extrude leaves no bodies", studio.Bodies.Count == 0, $"{studio.Bodies.Count}" );
// An extrude with no sketch at all explains itself rather than throwing something opaque.
var orphan = new PartStudio();
var lonely = orphan.Add( new ExtrudeFeature() );
orphan.Rebuild();
Check( "extrude with no sketch reports a clear error",
lonely.Error?.Contains( "no sketch" ) == true, lonely.Error );
}
}