Effigy.Tests/BranchTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Sketches where more than two curves meet at a point.
///
/// Drawing a line across a shape to divide it is how anyone splits a profile, and it used to be
/// reported as "branching sketches are not supported yet" — the whole sketch fell back to whatever
/// loops happened not to touch the branch. The fix is the planar face traversal ProfileFinder's own
/// comment had been describing as the upgrade path for a while.
///
/// What these tests are really checking is that the traversal picks the RIGHT face at each branch.
/// A walk that turns the wrong way at a junction still terminates and still produces closed loops —
/// they are just the wrong loops, made of the wrong halves, and they extrude into plausible solids.
/// So every case here asserts on areas that are known in advance, not merely on counts.
/// </summary>
public static class BranchTests
{
public static void Run()
{
Report.Section( "branching: a line across a rectangle makes two regions" );
TestSplitRectangle();
Report.Section( "branching: more than one cut, and cuts that meet" );
TestMultipleCuts();
Report.Section( "branching: a line across a rectangle that was never split at the edges" );
TestUnsplitCuts();
Report.Section( "branching: arcs at a junction" );
TestArcJunction();
Report.Section( "branching: what still does not enclose anything" );
TestOpenGeometry();
Report.Section( "branching: unbranched sketches are unchanged" );
TestNoRegression();
}
static void TestSplitRectangle()
{
// A 4x2 rectangle with a line down the middle. Two 2x2 halves — the case the old finder
// refused outright.
var sketch = new Sketch();
var bottomLeft = sketch.AddPoint( 0, 0 );
var bottomMid = sketch.AddPoint( 2, 0 );
var bottomRight = sketch.AddPoint( 4, 0 );
var topRight = sketch.AddPoint( 4, 2 );
var topMid = sketch.AddPoint( 2, 2 );
var topLeft = sketch.AddPoint( 0, 2 );
sketch.Add( new SketchLine( bottomLeft, bottomMid ) );
sketch.Add( new SketchLine( bottomMid, bottomRight ) );
sketch.Add( new SketchLine( bottomRight, topRight ) );
sketch.Add( new SketchLine( topRight, topMid ) );
sketch.Add( new SketchLine( topMid, topLeft ) );
sketch.Add( new SketchLine( topLeft, bottomLeft ) );
sketch.Add( new SketchLine( bottomMid, topMid ) );
var found = ProfileFinder.Find( sketch );
Report.Check( "it finds two regions, not one and a complaint",
found.Profiles.Count == 2, $"{found.Profiles.Count} regions, warnings: {string.Join( "; ", found.Warnings )}" );
Report.Check( "and says nothing was skipped", found.Warnings.Count == 0,
string.Join( "; ", found.Warnings ) );
if ( found.Profiles.Count != 2 )
return;
// EACH HALF IS 4, NOT ONE OF THEM 8. A traversal that turned the wrong way at the junction
// would happily return the whole rectangle plus a sliver, and both would be closed loops.
var areas = found.Profiles.Select( p => MathF.Abs( ProfileFinder.SignedArea( p.Outer ) ) ).OrderBy( a => a ).ToList();
Report.Check( "both halves measure 4",
MathF.Abs( areas[0] - 4f ) < 1e-3f && MathF.Abs( areas[1] - 4f ) < 1e-3f,
string.Join( ", ", areas.Select( a => a.ToString( "0.####" ) ) ) );
Report.Check( "and both come back counter-clockwise",
found.Profiles.All( p => ProfileFinder.SignedArea( p.Outer ) > 0f ) );
// End to end: two regions means two bodies, and their volumes are the two halves.
var studio = new PartStudio();
var feature = studio.Add( new SketchFeature() );
feature.Sketch = sketch;
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
extrude.Result.Index = 1; // keep them apart so each can be measured
var report = studio.Rebuild();
Report.Check( "it extrudes without error", !report.HasErrors, report.ToString() );
Report.Check( "into two bodies", studio.Bodies.Count == 2, $"{studio.Bodies.Count} bodies" );
Report.Check( "of 4 units each",
studio.Bodies.All( b => MathF.Abs( Volume( b.Mesh ) - 4f ) < 1e-3f ),
string.Join( ", ", studio.Bodies.Select( b => Volume( b.Mesh ).ToString( "0.####" ) ) ) );
}
static void TestMultipleCuts()
{
// A 6x2 rectangle cut twice: three 2x2 regions, and the middle one is bounded by two cuts
// rather than by any original edge on either side.
var sketch = new Sketch();
var p = new int[8];
p[0] = sketch.AddPoint( 0, 0 );
p[1] = sketch.AddPoint( 2, 0 );
p[2] = sketch.AddPoint( 4, 0 );
p[3] = sketch.AddPoint( 6, 0 );
p[4] = sketch.AddPoint( 6, 2 );
p[5] = sketch.AddPoint( 4, 2 );
p[6] = sketch.AddPoint( 2, 2 );
p[7] = sketch.AddPoint( 0, 2 );
for ( var i = 0; i < 8; i++ )
sketch.Add( new SketchLine( p[i], p[(i + 1) % 8] ) );
sketch.Add( new SketchLine( p[1], p[6] ) );
sketch.Add( new SketchLine( p[2], p[5] ) );
var found = ProfileFinder.Find( sketch );
Report.Check( "two cuts make three regions", found.Profiles.Count == 3,
$"{found.Profiles.Count} regions" );
if ( found.Profiles.Count == 3 )
{
Report.Check( "each measuring 4",
found.Profiles.All( f => MathF.Abs( MathF.Abs( ProfileFinder.SignedArea( f.Outer ) ) - 4f ) < 1e-3f ),
string.Join( ", ", found.Profiles.Select( f => ProfileFinder.SignedArea( f.Outer ).ToString( "0.###" ) ) ) );
}
// A CROSS: four curves meeting at one interior point, which is the highest-degree junction a
// sketch normally produces and the one most likely to be ordered wrongly.
var cross = new Sketch();
var corner = new int[4];
corner[0] = cross.AddPoint( 0, 0 );
corner[1] = cross.AddPoint( 4, 0 );
corner[2] = cross.AddPoint( 4, 4 );
corner[3] = cross.AddPoint( 0, 4 );
var midBottom = cross.AddPoint( 2, 0 );
var midRight = cross.AddPoint( 4, 2 );
var midTop = cross.AddPoint( 2, 4 );
var midLeft = cross.AddPoint( 0, 2 );
var centre = cross.AddPoint( 2, 2 );
cross.Add( new SketchLine( corner[0], midBottom ) );
cross.Add( new SketchLine( midBottom, corner[1] ) );
cross.Add( new SketchLine( corner[1], midRight ) );
cross.Add( new SketchLine( midRight, corner[2] ) );
cross.Add( new SketchLine( corner[2], midTop ) );
cross.Add( new SketchLine( midTop, corner[3] ) );
cross.Add( new SketchLine( corner[3], midLeft ) );
cross.Add( new SketchLine( midLeft, corner[0] ) );
cross.Add( new SketchLine( midBottom, centre ) );
cross.Add( new SketchLine( centre, midTop ) );
cross.Add( new SketchLine( midLeft, centre ) );
cross.Add( new SketchLine( centre, midRight ) );
var crossFound = ProfileFinder.Find( cross );
Report.Check( "a cross through a square makes four quadrants",
crossFound.Profiles.Count == 4, $"{crossFound.Profiles.Count} regions" );
if ( crossFound.Profiles.Count == 4 )
{
Report.Check( "each a quarter of it",
crossFound.Profiles.All( f => MathF.Abs( MathF.Abs( ProfileFinder.SignedArea( f.Outer ) ) - 4f ) < 1e-3f ),
string.Join( ", ", crossFound.Profiles.Select( f => ProfileFinder.SignedArea( f.Outer ).ToString( "0.###" ) ) ) );
// Four quadrants, four distinct centres — a traversal that returned the same face twice
// would pass the count and the area and still be wrong.
var centres = crossFound.Profiles
.Select( f => (MathF.Round( f.Outer.Average( v => v.x ), 2 ), MathF.Round( f.Outer.Average( v => v.y ), 2 )) )
.Distinct()
.Count();
Report.Check( "and all four are different regions", centres == 4, $"{centres} distinct centres" );
}
}
/// <summary>
/// The drawing the user actually produces: a rectangle of four lines, then two more lines
/// whose ends sit on those edges and whose interiors cross, without sharing a point index
/// with anything. Coincidence is identity, so the integer walk prunes them as dangling.
/// Region finding has to recover the four panes anyway — that is the whole of this test.
/// </summary>
static void TestUnsplitCuts()
{
var chord = UnsplitRectangleWith( vertical: true, horizontal: false );
var pointsBefore = chord.Points.Count;
var curvesBefore = chord.Curves.Count;
var chordFound = ProfileFinder.Find( chord );
Report.Check( "finding regions does not rewrite the sketch",
chord.Points.Count == pointsBefore && chord.Curves.Count == curvesBefore,
$"{chord.Points.Count} pts / {chord.Curves.Count} curves, were {pointsBefore}/{curvesBefore}" );
Report.Check( "one unsplit chord makes two regions",
chordFound.Profiles.Count == 2, $"{chordFound.Profiles.Count} regions, warnings: {string.Join( "; ", chordFound.Warnings )}" );
if ( chordFound.Profiles.Count == 2 )
{
Report.Check( "each a half of the rectangle",
chordFound.Profiles.All( f => MathF.Abs( f.Area - 8f ) < 1e-3f ),
string.Join( ", ", chordFound.Profiles.Select( f => f.Area.ToString( "0.###" ) ) ) );
}
var cross = UnsplitRectangleWith( vertical: true, horizontal: true );
var crossFound = ProfileFinder.Find( cross );
Report.Check( "two unsplit lines through a rectangle make four faces",
crossFound.Profiles.Count == 4, $"{crossFound.Profiles.Count} regions, warnings: {string.Join( "; ", crossFound.Warnings )}" );
if ( crossFound.Profiles.Count == 4 )
{
Report.Check( "each a quarter of it",
crossFound.Profiles.All( f => MathF.Abs( f.Area - 4f ) < 1e-3f ),
string.Join( ", ", crossFound.Profiles.Select( f => f.Area.ToString( "0.###" ) ) ) );
var centres = crossFound.Profiles
.Select( f => (MathF.Round( f.Outer.Average( v => v.x ), 2 ), MathF.Round( f.Outer.Average( v => v.y ), 2 )) )
.Distinct()
.Count();
Report.Check( "and all four are different regions", centres == 4, $"{centres} distinct centres" );
}
Report.Check( "the recovered faces are not overlap lenses",
crossFound.Profiles.Count( p => p.FromOverlap ) == 0,
$"{crossFound.Profiles.Count( p => p.FromOverlap )} overlap faces" );
// End to end: four regions, four bodies.
var studio = new PartStudio();
var feature = studio.Add( new SketchFeature() );
feature.Sketch = UnsplitRectangleWith( vertical: true, horizontal: true );
var extrude = studio.Add( new ExtrudeFeature() );
extrude.Distance.Value = 1f;
extrude.Result.Index = 1;
var report = studio.Rebuild();
Report.Check( "the unsplit cross extrudes without error", !report.HasErrors, report.ToString() );
Report.Check( "into four bodies", studio.Bodies.Count == 4, $"{studio.Bodies.Count} bodies" );
Report.Check( "of 4 units each",
studio.Bodies.All( b => MathF.Abs( Volume( b.Mesh ) - 4f ) < 1e-3f ),
string.Join( ", ", studio.Bodies.Select( b => Volume( b.Mesh ).ToString( "0.####" ) ) ) );
}
/// <summary>A 4x4 rectangle as four lines, plus optional full-span dividers whose endpoints
/// sit on the edges without splitting them.</summary>
static Sketch UnsplitRectangleWith( bool vertical, bool horizontal )
{
var sketch = new Sketch();
sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 4, 4 ) );
if ( vertical )
sketch.Add( new SketchLine( sketch.AddPoint( 2, 0 ), sketch.AddPoint( 2, 4 ) ) );
if ( horizontal )
sketch.Add( new SketchLine( sketch.AddPoint( 0, 2 ), sketch.AddPoint( 4, 2 ) ) );
return sketch;
}
static void TestArcJunction()
{
// A half disc split down the middle: two quarter arcs meeting the vertical radius at the top.
//
// The arcs are what make this worth testing. Order the junction by the straight-line
// direction to each curve's far endpoint rather than by the TANGENT it actually leaves in,
// and the two arcs sort onto the wrong sides of the vertical line — the walk then leaves
// along the wrong curve and returns two closed loops that are not these two.
//
// NOTE THE ARC IS SPLIT AT THE TOP POINT, and it has to be. A single semicircle from right to
// left would pass THROUGH (0,2) geometrically while sharing no point index with the line that
// ends there, and in this kernel coincidence is identity: touching is not joining. The
// vertical line would have a free end and be pruned, correctly, leaving one half disc. That
// is the model working as designed, and the first version of this test got it wrong.
var sketch = new Sketch();
var left = sketch.AddPoint( -2, 0 );
var right = sketch.AddPoint( 2, 0 );
var centre = sketch.AddPoint( 0, 0 );
var top = sketch.AddPoint( 0, 2 );
sketch.Add( new SketchArc( centre, right, top ) );
sketch.Add( new SketchArc( centre, top, left ) );
sketch.Add( new SketchLine( left, centre ) );
sketch.Add( new SketchLine( centre, right ) );
sketch.Add( new SketchLine( centre, top ) );
var found = ProfileFinder.Find( sketch );
Report.Check( "an arc meeting lines at a junction splits into two quarters",
found.Profiles.Count == 2, $"{found.Profiles.Count} regions" );
if ( found.Profiles.Count != 2 )
return;
// Two quarter-discs of radius 2: pi r^2 / 4 each, tessellated so a little under.
var areas = found.Profiles.Select( f => MathF.Abs( ProfileFinder.SignedArea( f.Outer ) ) ).ToList();
var quarter = MathF.PI * 4f / 4f;
Report.Check( "each about a quarter of the disc",
areas.All( a => MathF.Abs( a - quarter ) < 0.05f ),
string.Join( ", ", areas.Select( a => a.ToString( "0.####" ) ) ) + $", expected about {quarter:0.####} each" );
Report.Check( "and together they are the half disc",
MathF.Abs( areas.Sum() - MathF.PI * 2f ) < 0.05f, $"{areas.Sum():0.####}" );
}
static void TestOpenGeometry()
{
// Pruning has to repeat. A tail of three curves retracts one at a time: removing the last
// leaves the second dangling, and so on. A single pass would leave two of them in the graph
// and put a zero-width spur through the middle of the region.
var sketch = new Sketch();
var a = sketch.AddPoint( 0, 0 );
var b = sketch.AddPoint( 2, 0 );
var c = sketch.AddPoint( 2, 2 );
var d = sketch.AddPoint( 0, 2 );
sketch.Add( new SketchLine( a, b ) );
sketch.Add( new SketchLine( b, c ) );
sketch.Add( new SketchLine( c, d ) );
sketch.Add( new SketchLine( d, a ) );
var t1 = sketch.AddPoint( 4, 0 );
var t2 = sketch.AddPoint( 6, 0 );
var t3 = sketch.AddPoint( 8, 0 );
sketch.Add( new SketchLine( b, t1 ) );
sketch.Add( new SketchLine( t1, t2 ) );
sketch.Add( new SketchLine( t2, t3 ) );
var found = ProfileFinder.Find( sketch );
Report.Check( "a tail hanging off a square is pruned entirely",
found.Profiles.Count == 1, $"{found.Profiles.Count} regions" );
Report.Check( "leaving the square intact",
found.Profiles.Count == 1 && MathF.Abs( MathF.Abs( ProfileFinder.SignedArea( found.Profiles[0].Outer ) ) - 4f ) < 1e-3f,
found.Profiles.Count == 1 ? $"{ProfileFinder.SignedArea( found.Profiles[0].Outer ):0.####}" : "" );
Report.Check( "and the tail is reported rather than silently dropped",
found.Warnings.Count > 0 && found.OpenChains == 1,
$"{found.OpenChains} chains, warnings: {string.Join( "; ", found.Warnings )}" );
// Two separate tails are two chains, so the message can say the right number.
var two = new Sketch();
var x0 = two.AddPoint( 0, 0 );
var x1 = two.AddPoint( 1, 0 );
var y0 = two.AddPoint( 5, 0 );
var y1 = two.AddPoint( 6, 0 );
two.Add( new SketchLine( x0, x1 ) );
two.Add( new SketchLine( y0, y1 ) );
var twoFound = ProfileFinder.Find( two );
Report.Check( "two disconnected open chains count as two",
twoFound.OpenChains == 2, $"{twoFound.OpenChains}" );
Report.Check( "and no regions come out of them", twoFound.Profiles.Count == 0 );
}
static void TestNoRegression()
{
// Every ordinary sketch has to come out exactly as before. A rectangle is one region and NOT
// two — the outer infinite face has to be the one that gets dropped, and dropping the wrong
// one is the failure that would show up here first.
var rect = new Sketch();
rect.AddRectangle( new Vec2( 0, 0 ), new Vec2( 3, 2 ) );
var found = ProfileFinder.Find( rect );
Report.Check( "a plain rectangle is still exactly one region", found.Profiles.Count == 1,
$"{found.Profiles.Count} regions" );
Report.Check( "of the right area and winding",
found.Profiles.Count == 1 && MathF.Abs( ProfileFinder.SignedArea( found.Profiles[0].Outer ) - 6f ) < 1e-3f,
found.Profiles.Count == 1 ? $"{ProfileFinder.SignedArea( found.Profiles[0].Outer ):0.####}" : "" );
// Two separate rectangles are two regions, each with its own outer face to discard.
var pair = new Sketch();
pair.AddRectangle( new Vec2( 0, 0 ), new Vec2( 1, 1 ) );
pair.AddRectangle( new Vec2( 5, 0 ), new Vec2( 6, 1 ) );
Report.Check( "two disconnected rectangles are two regions",
ProfileFinder.Find( pair ).Profiles.Count == 2 );
// Nesting still classifies: a square inside a square is a region with a hole, not two
// regions. The face traversal finds both loops; the depth pass decides what they mean.
var nested = new Sketch();
nested.AddRectangle( new Vec2( 0, 0 ), new Vec2( 6, 6 ) );
nested.AddRectangle( new Vec2( 2, 2 ), new Vec2( 4, 4 ) );
var nestedFound = ProfileFinder.Find( nested );
Report.Check( "a square inside a square is one region with a hole",
nestedFound.Profiles.Count == 1 && nestedFound.Profiles[0].Holes.Count == 1,
$"{nestedFound.Profiles.Count} regions, {nestedFound.Profiles.FirstOrDefault()?.Holes.Count ?? 0} holes" );
Report.Check( "and the hole is wound the other way",
nestedFound.Profiles.Count == 1 && nestedFound.Profiles[0].Holes.Count == 1
&& ProfileFinder.SignedArea( nestedFound.Profiles[0].Holes[0] ) < 0f );
// Construction geometry stays out of it, branch or no branch: a construction line across a
// rectangle must not split anything.
var construction = new Sketch();
var c0 = construction.AddPoint( 0, 0 );
var c1 = construction.AddPoint( 2, 0 );
var c2 = construction.AddPoint( 2, 2 );
var c3 = construction.AddPoint( 0, 2 );
construction.Add( new SketchLine( c0, c1 ) );
construction.Add( new SketchLine( c1, c2 ) );
construction.Add( new SketchLine( c2, c3 ) );
construction.Add( new SketchLine( c3, c0 ) );
construction.Add( new SketchLine( c0, c2 ) ).Construction = true;
var constructionFound = ProfileFinder.Find( construction );
Report.Check( "a construction line across a square does not divide it",
constructionFound.Profiles.Count == 1
&& MathF.Abs( ProfileFinder.SignedArea( constructionFound.Profiles[0].Outer ) - 4f ) < 1e-3f,
$"{constructionFound.Profiles.Count} regions" );
}
// --- helpers ------------------------------------------------------------------------------
static float Volume( PolyMesh mesh ) => mesh.SignedVolume();
}