Editor/Effigy/Features/StudioDocument.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
namespace Effigy;
/// <summary>
/// Reading and writing a Part Studio.
///
/// WHY THIS IS THE MOST IMPORTANT FILE IN THE FOLDER. Everything else here exists to make the model
/// parametric — the ordered history, rollback, incremental rebuild, references that survive an edit
/// — and none of it means anything if the history dies with the window. Without this, every session
/// is a one-shot bake: you keep the OBJ and you lose the model, and the feature tree is decoration.
/// The whole point of a history is coming back to it in a week and changing the 4 to a 6.
///
/// TEXT, HAND-WRITTEN, LIKE EVERY OTHER FORMAT IN HERE. ObjWriter, SmdWriter and DmxWriter are all
/// written by hand and so is the expression evaluator, for the reason the README gives: the kernel
/// has no dependencies, and it has none because it is meant to be dropped into s&box or Godot or a
/// console runner as loose .cs files. A serializer that reached for a library would be the first
/// thing to break that. Text also diffs, which matters more than it sounds for a format holding
/// somebody's model: a corrupt binary is a shrug, a corrupt text file is usually one bad line you
/// can see.
///
/// FIELDS ARE FOUND BY REFLECTION, NOT LISTED. A feature's Parameters property is not usable as the
/// list to save: PrimitiveFeature changes its parameters with the shape dropdown, so a box saved
/// today would not know what to do with the radius it will want tomorrow. The public FIELDS are
/// stable — SizeX is SizeX whatever the dropdown says — and reflecting over them means a new
/// feature is saved the moment it is written. There is no step to forget.
///
/// It also means an unhandled field type is possible, so DocumentTests asserts that every feature
/// type in the assembly round-trips every field it declares. Adding state a save cannot carry fails
/// the suite rather than quietly not saving, which is the failure this design could otherwise have.
/// </summary>
public static class StudioDocument
{
/// <summary>Bumped when the format changes in a way a reader has to know about. Written on the
/// first line so a file from the future can be refused by name rather than by crash.</summary>
public const int Version = 1;
public const string Extension = ".effigy";
// --- writing ------------------------------------------------------------------------------
public static void WriteFile( PartStudio studio, string path ) =>
File.WriteAllText( path, Write( studio ) );
public static string Write( PartStudio studio )
{
if ( studio is null )
throw new ArgumentNullException( nameof( studio ) );
var sb = new StringBuilder();
sb.Append( "effigy " ).Append( Version ).Append( '\n' );
sb.Append( "rollback " ).Append( studio.RollbackIndex ).Append( '\n' );
// Only when it has been moved. A pivot at zero is what a reader that has never heard of
// this line already has, so writing it unconditionally would add a line to every existing
// document and change the bytes of files nobody edited.
if ( studio.Origin.Length > 0f )
sb.Append( "origin " ).Append( Vec( studio.Origin ) ).Append( '\n' );
// Sorted, so two saves of the same document are the same bytes. A dictionary's order is not
// promised, and a format that reshuffles itself makes every diff useless.
foreach ( var (slot, name) in studio.MaterialNames.OrderBy( kv => kv.Key ) )
{
if ( !string.IsNullOrWhiteSpace( name ) )
sb.Append( "material " ).Append( slot ).Append( ' ' ).Append( OneLine( name ) ).Append( '\n' );
}
// Only the slots somebody has resized. A slot at 1:1 renders exactly as a reader that has
// never heard of this line already renders it, so writing them all would add lines to every
// existing document and change the bytes of files nobody edited — the same rule the origin
// follows above. MaterialScale.SetScale removes the entry when it returns to 1:1, so this
// stays true without a check here.
foreach ( var (slot, scale) in studio.MaterialScales.OrderBy( kv => kv.Key ) )
sb.Append( "materialscale " ).Append( slot ).Append( ' ' )
.Append( Num( scale.x ) ).Append( ' ' ).Append( Num( scale.y ) ).Append( '\n' );
foreach ( var (id, name) in studio.BodyNames.OrderBy( kv => kv.Key, StringComparer.Ordinal ) )
{
if ( !string.IsNullOrWhiteSpace( id ) && !string.IsNullOrWhiteSpace( name ) )
sb.Append( "bodyname " ).Append( id ).Append( ' ' ).Append( OneLine( name ) ).Append( '\n' );
}
foreach ( var id in studio.HiddenBodyIds.OrderBy( k => k, StringComparer.Ordinal ) )
{
if ( !string.IsNullOrWhiteSpace( id ) )
sb.Append( "hiddenbody " ).Append( id ).Append( '\n' );
}
foreach ( var feature in studio.Features )
WriteFeature( sb, feature );
return sb.ToString();
}
static void WriteFeature( StringBuilder sb, Feature feature )
{
sb.Append( "feature " ).Append( feature.GetType().Name ).Append( '\n' );
sb.Append( "\tid " ).Append( feature.Id ).Append( '\n' );
// A name can be anything the user typed, so it takes the rest of the line and newlines are
// stripped rather than escaped — a name spanning two lines is not worth a quoting scheme.
if ( !string.IsNullOrEmpty( feature.Name ) )
sb.Append( "\tname " ).Append( OneLine( feature.Name ) ).Append( '\n' );
sb.Append( "\tsuppressed " ).Append( feature.Suppressed ? 1 : 0 ).Append( '\n' );
sb.Append( "\tvisible " ).Append( feature.Visible ? 1 : 0 ).Append( '\n' );
foreach ( var field in StateFields( feature.GetType() ) )
WriteField( sb, feature, field );
sb.Append( "end\n" );
}
static void WriteField( StringBuilder sb, Feature feature, FieldInfo field )
{
var value = field.GetValue( feature );
switch ( value )
{
case FloatParam p:
sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( Num( p.Value ) ).Append( '\n' );
return;
case IntParam p:
sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( p.Value ).Append( '\n' );
return;
case BoolParam p:
sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( p.Value ? 1 : 0 ).Append( '\n' );
return;
case ChoiceParam p:
// The INDEX, not the label. Labels are user-facing text and get reworded; an index
// survives that. It does not survive the options being reordered, which is why
// ResultRemove exists as a named constant rather than a bare 3.
sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( p.Index ).Append( '\n' );
return;
case Vec3Param p:
sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( Vec( p.Value ) ).Append( '\n' );
return;
case BodySelectionParam p:
sb.Append( "\tbodies " ).Append( field.Name );
foreach ( var id in p.BodyIds )
sb.Append( ' ' ).Append( id );
sb.Append( '\n' );
return;
case Sketch sketch:
WriteSketch( sb, field.Name, sketch );
return;
case FaceRef face:
sb.Append( "\tface " ).Append( field.Name ).Append( ' ' ).Append( Face( face ) ).Append( '\n' );
return;
case List<int> ints:
// Shell's OpenFaces, and anything like it. Written even when empty, unlike a null
// nullable: an empty list and an unmentioned one are the same on load, and writing
// the line keeps a diff between two saves readable.
sb.Append( "\tints " ).Append( field.Name );
foreach ( var n in ints )
sb.Append( ' ' ).Append( n );
sb.Append( '\n' );
return;
case List<string> texts:
// Loft's Sections, and anything like it. One line with every entry on it, like
// ints rather than like facelist, because these are short ids and a line each
// would bury the rest of the feature.
sb.Append( "\ttexts " ).Append( field.Name );
foreach ( var text in texts )
sb.Append( ' ' ).Append( text );
sb.Append( '\n' );
return;
case List<Vec2> vecs:
// Extrude's RegionSeeds, and anything like it. Pairs on one line, same shape as
// ints, because a handful of in-plane points does not deserve a line each.
sb.Append( "\tvec2s " ).Append( field.Name );
foreach ( var v in vecs )
sb.Append( ' ' ).Append( Num( v.x ) ).Append( ' ' ).Append( Num( v.y ) );
sb.Append( '\n' );
return;
case List<FaceRef> faces:
foreach ( var f in faces )
sb.Append( "\tfacelist " ).Append( field.Name ).Append( ' ' ).Append( Face( f ) ).Append( '\n' );
return;
case Vec2 v:
sb.Append( "\tvec2 " ).Append( field.Name ).Append( ' ' )
.Append( Num( v.x ) ).Append( ' ' ).Append( Num( v.y ) ).Append( '\n' );
return;
case string s:
if ( s.Length > 0 )
sb.Append( "\ttext " ).Append( field.Name ).Append( ' ' ).Append( OneLine( s ) ).Append( '\n' );
return;
case null:
// A null nullable — no Face — is written as nothing at all. Absence IS the value,
// and a reader starting from a fresh feature already has it.
return;
}
// Unreachable while DocumentTests passes: it asserts every field of every feature type is a
// type this switch handles. Throwing rather than skipping is what makes that test able to
// fail — a silent skip would save a file that quietly lost half a feature.
throw new InvalidOperationException(
$"{feature.GetType().Name}.{field.Name} is a {field.FieldType.Name}, which StudioDocument cannot save. "
+ "Add a case for it here and in ReadField." );
}
static void WriteSketch( StringBuilder sb, string fieldName, Sketch sketch )
{
sb.Append( "\tsketch " ).Append( fieldName ).Append( '\n' );
sb.Append( "\t\ttolerance " ).Append( Num( sketch.Tolerance ) ).Append( '\n' );
sb.Append( "\t\tplane " ).Append( Vec( sketch.Plane.Origin ) ).Append( ' ' )
.Append( Vec( sketch.Plane.XAxis ) ).Append( ' ' ).Append( Vec( sketch.Plane.YAxis ) ).Append( '\n' );
foreach ( var p in sketch.Points )
sb.Append( "\t\tpoint " ).Append( Num( p.x ) ).Append( ' ' ).Append( Num( p.y ) ).Append( '\n' );
foreach ( var curve in sketch.Curves )
{
switch ( curve )
{
case SketchLine line:
sb.Append( "\t\tline " ).Append( line.Start ).Append( ' ' ).Append( line.End );
break;
case SketchArc arc:
sb.Append( "\t\tarc " ).Append( arc.Center ).Append( ' ' ).Append( arc.Start ).Append( ' ' )
.Append( arc.End ).Append( ' ' ).Append( arc.Clockwise ? 1 : 0 );
break;
case SketchCircle circle:
sb.Append( "\t\tcircle " ).Append( circle.Center ).Append( ' ' ).Append( Num( circle.Radius ) );
break;
case SketchEllipse ellipse:
sb.Append( "\t\tellipse " ).Append( ellipse.Center ).Append( ' ' )
.Append( ellipse.MajorPoint ).Append( ' ' ).Append( Num( ellipse.MinorRadius ) );
break;
// The point COUNT is written before the points, because everything else in this
// format has a fixed field count and the reader finds a curve's id and construction
// flag at a known offset. A variable-length record without a count would make that
// offset unknowable without counting backwards from the end, which works right up
// until a field is added.
case SketchSpline spline:
sb.Append( "\t\tspline " ).Append( spline.Closed ? 1 : 0 ).Append( ' ' )
.Append( spline.Points.Count );
foreach ( var index in spline.Points )
sb.Append( ' ' ).Append( index );
break;
default:
throw new InvalidOperationException( $"StudioDocument cannot save a {curve.GetType().Name}" );
}
// Id and construction come last and in the same order for every curve type, so the
// reader can strip them before it looks at what kind of curve it has.
sb.Append( ' ' ).Append( curve.Id ).Append( ' ' ).Append( curve.Construction ? 1 : 0 ).Append( '\n' );
}
foreach ( var c in sketch.Constraints )
{
sb.Append( "\t\tconstraint " ).Append( (int)c.Kind ).Append( ' ' )
.Append( c.PointA ).Append( ' ' ).Append( c.PointB ).Append( ' ' )
.Append( c.PointC ).Append( ' ' ).Append( c.PointD ).Append( ' ' )
.Append( Num( c.Value ) ).Append( ' ' )
.Append( string.IsNullOrEmpty( c.CurveId ) ? "-" : c.CurveId ).Append( ' ' )
.Append( Num( c.ValueY ) ).Append( '\n' );
}
sb.Append( "\tendsketch\n" );
}
// --- reading ------------------------------------------------------------------------------
public static PartStudio ReadFile( string path ) => Read( File.ReadAllText( path ) );
/// <summary>
/// Parse a document back into a studio. Throws with the line number on anything malformed.
///
/// The studio comes back NOT rebuilt. Loading is about restoring the tree; running it is the
/// caller's business and its errors are the model's errors, not the file's — an editor wants to
/// show a file that loads and fails to build, because that is exactly the state you opened it to
/// fix.
/// </summary>
public static PartStudio Read( string text )
{
var studio = new PartStudio();
var lines = (text ?? "").Replace( "\r\n", "\n" ).Split( '\n' );
var rollback = int.MaxValue;
var i = 0;
string Line() => lines[i];
if ( lines.Length == 0 || !Line().StartsWith( "effigy " ) )
throw new InvalidDataException( "Not an Effigy document — the first line should read 'effigy <version>'." );
var version = ParseInt( Line()[7..].Trim(), 1 );
if ( version > Version )
{
throw new InvalidDataException(
$"This file was written by a newer Effigy (format {version}; this build reads {Version})." );
}
i++;
for ( ; i < lines.Length; i++ )
{
var line = Line().Trim();
if ( line.Length == 0 )
continue;
if ( line.StartsWith( "rollback " ) )
{
rollback = ParseInt( line[9..], int.MaxValue );
continue;
}
if ( line.StartsWith( "origin " ) )
{
studio.Origin = ParseVec3( line[7..] );
continue;
}
// BEFORE "material ", so the longer key is never read as the shorter one with a strange
// name. They do not actually collide today — "materialscale" has no space at index 8 —
// but that is a property of the spelling rather than of the parser, and the next key
// starting with "material" would not be so lucky.
if ( line.StartsWith( "materialscale " ) )
{
var parts = line[14..].Split( ' ', StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length >= 3 )
{
// Through SetScale rather than straight into the dictionary, so a hand-edited
// zero is caught here rather than dividing every UV on the slot into infinity,
// and a 1:1 written by an older tool leaves no entry behind.
MaterialScale.SetScale( studio, ParseInt( parts[0], -1 ),
new Vec2( ParseFloat( parts[1] ), ParseFloat( parts[2] ) ) );
}
continue;
}
if ( line.StartsWith( "material " ) )
{
var (slot, name) = Split( line[9..] );
studio.MaterialNames[ParseInt( slot, 0 )] = name;
continue;
}
if ( line.StartsWith( "bodyname " ) )
{
var (id, name) = Split( line[9..] );
if ( !string.IsNullOrWhiteSpace( id ) && !string.IsNullOrWhiteSpace( name ) )
studio.BodyNames[id] = name;
continue;
}
if ( line.StartsWith( "hiddenbody " ) )
{
var id = line[11..].Trim();
if ( !string.IsNullOrWhiteSpace( id ) )
studio.HiddenBodyIds.Add( id );
continue;
}
if ( !line.StartsWith( "feature " ) )
throw new InvalidDataException( $"Line {i + 1}: expected a feature, found '{line}'" );
studio.Add( ReadFeature( lines, ref i ) );
}
// After the features, so it can be clamped against a tree that actually exists. A rollback
// index past the end is not corruption — deleting the last feature of a rolled-back tree
// leaves exactly that — and PartStudio treats it as "roll to end".
studio.RollbackIndex = Math.Min( rollback, studio.Features.Count );
return studio;
}
static Feature ReadFeature( string[] lines, ref int i )
{
var typeName = lines[i].Trim()[8..].Trim();
var feature = Create( typeName )
?? throw new InvalidDataException( $"Line {i + 1}: no feature type named '{typeName}' in this build." );
var fields = StateFields( feature.GetType() ).ToDictionary( f => f.Name, f => f );
// A list field accumulates across lines, so it is cleared the first time one is seen rather
// than up front — otherwise loading would wipe a default that the file simply does not
// mention.
var clearedLists = new HashSet<string>();
for ( i++; i < lines.Length; i++ )
{
var line = lines[i].Trim();
if ( line.Length == 0 )
continue;
if ( line == "end" )
return feature;
var (key, rest) = Split( line );
switch ( key )
{
case "id": feature.Id = rest; continue;
case "name": feature.Name = rest; continue;
case "suppressed": feature.Suppressed = rest == "1"; continue;
case "visible": feature.Visible = rest == "1"; continue;
}
var (fieldName, value) = Split( rest );
if ( !fields.TryGetValue( fieldName, out var field ) )
{
// Documents written before RegionSeeds was a list stored one point as
// `vec2 RegionSeed`. The field is gone; the list is what it became.
if ( fieldName == "RegionSeed" && key == "vec2"
&& fields.TryGetValue( "RegionSeeds", out field ) )
{
ReadField( feature, field, key, value, lines, ref i, clearedLists );
continue;
}
// A field this build does not have. Ignored on purpose: a file written by a version
// with an extra parameter should still open, minus that parameter, rather than
// refusing outright.
if ( key == "sketch" )
SkipSketch( lines, ref i );
continue;
}
ReadField( feature, field, key, value, lines, ref i, clearedLists );
}
throw new InvalidDataException( $"The document ends inside a {typeName} — no 'end' line." );
}
static void ReadField( Feature feature, FieldInfo field, string key, string value, string[] lines, ref int i,
HashSet<string> clearedLists )
{
var current = field.GetValue( feature );
switch ( key )
{
case "param":
switch ( current )
{
case FloatParam p: p.Value = ParseFloat( value ); return;
case IntParam p: p.Value = ParseInt( value, p.Value ); return;
case BoolParam p: p.Value = value == "1"; return;
case ChoiceParam p: p.Index = ParseInt( value, p.Index ); return;
case Vec3Param p: p.Value = ParseVec3( value ); return;
}
return;
case "bodies":
if ( current is BodySelectionParam bodies )
{
bodies.BodyIds.Clear();
bodies.BodyIds.AddRange( value.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) );
}
return;
case "text":
field.SetValue( feature, value );
return;
case "vec2":
{
var parts = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
var parsed = new Vec2( ParseFloat( parts[0] ), ParseFloat( parts[1] ) );
// Old documents, and the RegionSeed alias above, write one point as vec2. The
// field it now lands on is a list.
if ( field.GetValue( feature ) is List<Vec2> one )
{
if ( clearedLists.Add( field.Name ) )
one.Clear();
one.Add( parsed );
return;
}
field.SetValue( feature, parsed );
return;
}
case "vec2s":
{
if ( current is not List<Vec2> vecs )
return;
vecs.Clear();
var parts = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
for ( var n = 0; n + 1 < parts.Length; n += 2 )
vecs.Add( new Vec2( ParseFloat( parts[n] ), ParseFloat( parts[n + 1] ) ) );
return;
}
case "face":
field.SetValue( feature, ParseFace( value ) );
return;
case "texts":
{
if ( current is not List<string> texts )
return;
texts.Clear();
foreach ( var part in value.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) )
texts.Add( part );
return;
}
case "ints":
{
if ( current is not List<int> ints )
return;
ints.Clear();
foreach ( var part in value.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) )
ints.Add( ParseInt( part, 0 ) );
return;
}
case "facelist":
{
if ( field.GetValue( feature ) is not List<FaceRef> list )
return;
if ( clearedLists.Add( field.Name ) )
list.Clear();
list.Add( ParseFace( value ) );
return;
}
case "sketch":
field.SetValue( feature, ReadSketch( lines, ref i ) );
return;
}
}
static Sketch ReadSketch( string[] lines, ref int i )
{
var sketch = new Sketch();
for ( i++; i < lines.Length; i++ )
{
var line = lines[i].Trim();
if ( line.Length == 0 )
continue;
if ( line == "endsketch" )
return sketch;
var (key, rest) = Split( line );
var parts = rest.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
switch ( key )
{
case "tolerance":
sketch.Tolerance = ParseFloat( rest );
break;
case "plane":
sketch.Plane = new SketchPlane(
new Vec3( ParseFloat( parts[0] ), ParseFloat( parts[1] ), ParseFloat( parts[2] ) ),
new Vec3( ParseFloat( parts[3] ), ParseFloat( parts[4] ), ParseFloat( parts[5] ) ),
new Vec3( ParseFloat( parts[6] ), ParseFloat( parts[7] ), ParseFloat( parts[8] ) ) );
break;
case "point":
sketch.AddPoint( ParseFloat( parts[0] ), ParseFloat( parts[1] ) );
break;
case "line":
sketch.Add( Tagged( new SketchLine( ParseInt( parts[0], 0 ), ParseInt( parts[1], 0 ) ), parts, 2 ) );
break;
case "arc":
sketch.Add( Tagged( new SketchArc(
ParseInt( parts[0], 0 ), ParseInt( parts[1], 0 ), ParseInt( parts[2], 0 ),
parts[3] == "1" ), parts, 4 ) );
break;
case "circle":
sketch.Add( Tagged( new SketchCircle( ParseInt( parts[0], 0 ), ParseFloat( parts[1] ) ), parts, 2 ) );
break;
case "ellipse":
sketch.Add( Tagged( new SketchEllipse(
ParseInt( parts[0], 0 ), ParseInt( parts[1], 0 ), ParseFloat( parts[2] ) ), parts, 3 ) );
break;
case "spline":
{
var count = ParseInt( parts[1], 0 );
var indices = new List<int>( count );
for ( var k = 0; k < count && 2 + k < parts.Length; k++ )
indices.Add( ParseInt( parts[2 + k], 0 ) );
sketch.Add( Tagged( new SketchSpline( indices, parts[0] == "1" ), parts, 2 + count ) );
break;
}
case "constraint":
{
var constraint = new SketchConstraint( (SketchConstraintKind)ParseInt( parts[0], 0 ),
ParseInt( parts[1], -1 ), ParseInt( parts[2], -1 ) )
{
PointC = ParseInt( parts[3], -1 ),
PointD = ParseInt( parts[4], -1 ),
Value = ParseFloat( parts[5] ),
CurveId = parts[6] == "-" ? null : parts[6],
// Appended after the CurveId rather than beside Value, so every index before it
// keeps its meaning and a document written before Fixed existed still reads.
// Absent means zero, which is what those documents meant.
ValueY = parts.Length > 7 ? ParseFloat( parts[7] ) : 0f
};
sketch.Constraints.Add( constraint );
break;
}
}
}
throw new InvalidDataException( "The document ends inside a sketch — no 'endsketch' line." );
}
/// <summary>Attach the id and construction flag every curve line ends with.</summary>
static T Tagged<T>( T curve, string[] parts, int at ) where T : SketchCurve
{
if ( parts.Length > at )
curve.Id = parts[at];
if ( parts.Length > at + 1 )
curve.Construction = parts[at + 1] == "1";
return curve;
}
/// <summary>Walk past a sketch belonging to a field this build does not know about, so its
/// contents are not read as feature lines.</summary>
static void SkipSketch( string[] lines, ref int i )
{
for ( i++; i < lines.Length; i++ )
{
if ( lines[i].Trim() == "endsketch" )
return;
}
}
// --- shared -------------------------------------------------------------------------------
/// <summary>
/// The fields a feature's state lives in.
///
/// Public instance fields, minus the four the writer handles by name. Declared-only would miss
/// what a feature inherits — SketchFeatureId and RegionSeeds live on SketchConsumingFeature, and
/// forgetting them would lose which sketch an extrude consumes.
/// </summary>
static IEnumerable<FieldInfo> StateFields( Type type ) => type
.GetFields( BindingFlags.Public | BindingFlags.Instance )
.Where( f => f.Name is not ("Id" or "Name" or "Suppressed" or "Visible") )
.OrderBy( f => f.Name, StringComparer.Ordinal );
/// <summary>
/// What a feature type used to be called, for documents written before it was renamed.
///
/// A SAVED FILE IS A PROMISE. The type token in it is a C# class name, so renaming a class is a
/// breaking change to every document already on disk unless the old name keeps resolving.
/// `BevelFeature` became `ChamferFeature` when the flat cut and the rounded one were split into
/// the two operations Onshape names — the parameters are unchanged, so an old bevel loads as
/// the chamfer it always was, with its width and angle intact.
///
/// Entries are never removed. The cost of one line is nothing next to a document that opens
/// with a line number and a type name nobody recognises.
/// </summary>
static readonly Dictionary<string, string> RenamedFeatures = new()
{
["BevelFeature"] = "ChamferFeature",
};
/// <summary>Find a feature type by name, in whatever assembly the kernel ended up in.</summary>
static Feature Create( string typeName )
{
if ( RenamedFeatures.TryGetValue( typeName, out var current ) )
typeName = current;
var type = typeof( Feature ).Assembly.GetTypes()
.FirstOrDefault( t => t.Name == typeName && !t.IsAbstract && typeof( Feature ).IsAssignableFrom( t ) );
return type is null ? null : (Feature)Activator.CreateInstance( type );
}
/// <summary>Round-trip float formatting. "R" rather than a fixed number of decimals: a
/// dimension typed as 0.1 has to come back as 0.1, and a rounded one comes back as a model that
/// has moved very slightly every time it is opened and saved.</summary>
static string Num( float f ) => f.ToString( "R", CultureInfo.InvariantCulture );
static string Vec( Vec3 v ) => $"{Num( v.x )} {Num( v.y )} {Num( v.z )}";
static string Face( FaceRef f ) =>
$"{f.BodyId} {Vec( f.Point )} {Vec( f.Normal )} {Num( f.Anchor.x )} {Num( f.Anchor.y )} "
+ $"{(f.AnchorFromMaxX ? 1 : 0)} {(f.AnchorFromMaxY ? 1 : 0)} {(f.Anchored ? 1 : 0)}";
static FaceRef ParseFace( string value )
{
var p = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
var point = new Vec3( ParseFloat( p[1] ), ParseFloat( p[2] ), ParseFloat( p[3] ) );
var normal = new Vec3( ParseFloat( p[4] ), ParseFloat( p[5] ), ParseFloat( p[6] ) );
// Anchored is the last flag, and it decides which constructor is right: the unanchored one
// leaves Anchored false, which means "sit at the centre of whatever face this resolves to".
// Reading an anchor into a reference that never had one would move every old sketch.
if ( p.Length < 12 || p[11] != "1" )
return new FaceRef( p[0], point, normal );
return new FaceRef( p[0], point, normal,
new Vec2( ParseFloat( p[7] ), ParseFloat( p[8] ) ), p[9] == "1", p[10] == "1" );
}
static Vec3 ParseVec3( string value )
{
var p = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
return new Vec3( ParseFloat( p[0] ), ParseFloat( p[1] ), ParseFloat( p[2] ) );
}
static (string Key, string Value) Split( string line )
{
var space = line.IndexOf( ' ' );
return space < 0 ? (line, "") : (line[..space], line[(space + 1)..].Trim());
}
static float ParseFloat( string s ) =>
float.TryParse( s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f ) ? f : 0f;
static int ParseInt( string s, int fallback ) =>
int.TryParse( s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i ) ? i : fallback;
static string OneLine( string s ) => s.Replace( '\n', ' ' ).Replace( '\r', ' ' ).Trim();
}