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&amp;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>The newest format this build can WRITE and the newest it can read. A file claiming
	/// more than this is refused by name rather than by crash.</summary>
	public const int Version = 2;

	/// <summary>
	/// The oldest format that can express any document - everything before the rig block existed.
	///
	/// A DOCUMENT IS STAMPED WITH WHAT IT ACTUALLY NEEDS, not with whatever this build happens to
	/// be. That is what keeps the version honest in both directions: a part with no rig in it is
	/// still perfectly readable by the build that shipped before rigs were saved, so telling that
	/// build to refuse it would be a lie - and stamping every existing document with a 2 the moment
	/// it is re-saved would rewrite the first line of every file in the repository for a feature
	/// none of them use. Same rule the origin and the material scales follow one level down.
	/// </summary>
	const int VersionBase = 1;

	/// <summary>The format that first carried a rig block.</summary>
	const int VersionRig = 2;

	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();

		// See VersionBase: the stamp says what a reader NEEDS, so a part nobody has rigged still
		// claims 1 and still opens in the build that shipped before this block existed.
		var version = studio.Rig is { Count: > 0 } ? VersionRig : VersionBase;

		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' );
		}

		// Same rule as origin: a document with no variables must not grow a line. Names sorted so
		// two saves of the same table are the same bytes.
		foreach ( var variable in studio.Variables
			.Where( v => v is not null && !string.IsNullOrWhiteSpace( v.Name ) )
			.OrderBy( v => v.Name, StringComparer.OrdinalIgnoreCase ) )
		{
			sb.Append( "variable " ).Append( OneLine( variable.Name ) ).Append( ' ' )
				.Append( OneLine( string.IsNullOrWhiteSpace( variable.Expr )
					? Num( variable.Value )
					: variable.Expr ) )
				.Append( '\n' );
		}

		// BEFORE the features, with the rest of the document-level state, because that is what a
		// note is — see PartStudio.Notes. Writing them after the tree would read as if they were
		// part of it, and the one thing this format should not imply is that a note is a feature.
		foreach ( var note in studio.Notes )
			WriteNote( sb, note );

		// WITH THE NOTES, BEFORE THE TREE, and for the same reason they are: a rig is document
		// state, not a modelling operation. Writing it after the features would read as if it were
		// one of them, which is the single thing this format should not imply about a rig.
		WriteRig( sb, studio );

		foreach ( var feature in studio.Features )
			WriteFeature( sb, feature );

		return sb.ToString();
	}

	/// <summary>
	/// One grease-pencil note as a block.
	///
	/// A BLOCK RATHER THAN A LINE, and one point per line inside it, which is what every sketch
	/// already does. A stroke is however many samples the hand made — hundreds is ordinary — and
	/// putting them on one line gives the format its first unbounded line, which is exactly the
	/// thing that turns a readable diff into a wall. Per line, moving a note shows up as the lines
	/// that moved.
	/// </summary>
	static void WriteNote( StringBuilder sb, Note note )
	{
		if ( note is null || note.IsEmpty )
			return;

		sb.Append( "note " ).Append( note.Color ).Append( ' ' ).Append( Num( note.Width ) ).Append( '\n' );

		if ( !string.IsNullOrEmpty( note.Text ) )
			sb.Append( "\ttext " ).Append( OneLine( note.Text ) ).Append( '\n' );

		foreach ( var p in note.Points )
			sb.Append( "\tp " ).Append( Vec( p ) ).Append( '\n' );

		sb.Append( "endnote\n" );
	}

	/// <summary>
	/// The skeleton, its softness and its body bindings.
	///
	/// NOTHING AT ALL WHEN THERE ARE NO BONES, which is most documents. The same rule the origin
	/// and the material scales follow: a document that has never been rigged should have exactly
	/// the bytes it had before this block existed, so adding the feature does not rewrite every
	/// file in the repository the first time each is opened and saved.
	///
	/// ONE BONE PER BLOCK, its numbers one per line. A bone carries a name, a parent, twelve floats
	/// of basis and origin, a length, and possibly four more for softness - as one line that is an
	/// unreadable run of nineteen numbers, and the format already made this call once for notes
	/// (see WriteNote, "the first unbounded line ... turns a readable diff into a wall"). Per line,
	/// nudging one bone shows up as the one line that moved.
	///
	/// THE PARENT IS AN INDEX, not a name, even though the bindings below key on names. Skeleton
	/// stores bones in topological order and the reader rebuilds them in file order through
	/// AddBone, which refuses a parent that does not exist yet - so an index is checked by
	/// construction on the way back in, where a name would have to be resolved against a
	/// half-built skeleton and could name a bone that had not arrived.
	/// </summary>
	static void WriteRig( StringBuilder sb, PartStudio studio )
	{
		var rig = studio.Rig;

		if ( rig is null || rig.Count == 0 )
			return;

		sb.Append( "rig\n" );

		foreach ( var bone in rig.Bones )
		{
			sb.Append( "\tbone " ).Append( OneLine( bone.Name ) ).Append( '\n' );
			sb.Append( "\t\tparent " ).Append( bone.Parent ).Append( '\n' );
			sb.Append( "\t\tx " ).Append( Vec( bone.Local.X ) ).Append( '\n' );
			sb.Append( "\t\ty " ).Append( Vec( bone.Local.Y ) ).Append( '\n' );
			sb.Append( "\t\tz " ).Append( Vec( bone.Local.Z ) ).Append( '\n' );
			sb.Append( "\t\torigin " ).Append( Vec( bone.Local.Origin ) ).Append( '\n' );
			sb.Append( "\t\tlength " ).Append( Num( bone.Length ) ).Append( '\n' );

			// Absent means rigid, which is what Bone.Soft being null means and what nearly every
			// bone is. Four zeros would be a different thing entirely - a bone with no spring that
			// still gets simulated - so this is not a case where a default can stand in.
			if ( bone.Soft is { } soft )
			{
				sb.Append( "\t\tsoft " ).Append( Num( soft.Stiffness ) ).Append( ' ' )
					.Append( Num( soft.Damping ) ).Append( ' ' )
					.Append( Num( soft.Weight ) ).Append( ' ' )
					.Append( Num( soft.MaxAngle ) ).Append( '\n' );
			}

			sb.Append( "\tendbone\n" );
		}

		// Sorted by body id, for the reason the material names are: two saves of one document
		// should be the same bytes, and a dictionary promises no order.
		foreach ( var (body, bone) in studio.BodyBoneMap.OrderBy( kv => kv.Key, StringComparer.Ordinal ) )
		{
			if ( !string.IsNullOrWhiteSpace( body ) && !string.IsNullOrWhiteSpace( bone ) )
				sb.Append( "\tbind " ).Append( body ).Append( ' ' ).Append( OneLine( bone ) ).Append( '\n' );
		}

		// INSIDE THE RIG BLOCK so an older reader skips it rather than refusing the file — ReadRig
		// ignores keys it does not recognise. Weight paint without bones is not a thing, so a
		// document that has never been rigged still writes no rig block at all.
		if ( studio.WeightPaint is { Count: > 0 } layer )
			WriteWeights( sb, layer );

		sb.Append( "endrig\n" );
	}

	static void WriteWeights( StringBuilder sb, WeightPaintLayer layer )
	{
		sb.Append( "\tweights " ).Append( layer.Topology ).Append( '\n' );

		foreach ( var (vertex, weights) in layer.Painted.OrderBy( p => p.Vertex ) )
		{
			sb.Append( "\t\tv " ).Append( vertex );

			foreach ( var (bone, weight) in weights )
				sb.Append( ' ' ).Append( OneLine( bone ) ).Append( ' ' ).Append( Num( weight ) );

			sb.Append( '\n' );
		}

		sb.Append( "\tendweights\n" );
	}

	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:
				// The expression is the source of truth when there is one; writing the evaluated
				// number would freeze #thickness at whatever it was the day the file was saved.
				sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' )
					.Append( string.IsNullOrWhiteSpace( p.Expr ) ? Num( p.Value ) : OneLine( p.Expr ) )
					.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 StringParam p:
				if ( !string.IsNullOrEmpty( p.Value ) )
					sb.Append( "\tparam " ).Append( field.Name ).Append( ' ' ).Append( OneLine( 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 List<EdgeRef> edges:
				foreach ( var e in edges )
					sb.Append( "\tedgelist " ).Append( field.Name ).Append( ' ' ).Append( Edge( e ) ).Append( '\n' );

				return;

			case List<PaintStroke> strokes:
				// Paint's stroke log. One line per stroke — colour, brush and spacing once, then the
				// path as position/normal pairs — so a painted document still diffs one stroke at a
				// time rather than becoming one unbounded line. Written in list order on purpose:
				// strokes are a log and colour blending does not commute, so the order the writer
				// emits is the order replay must reproduce.
				//
				// AN ERASE IS ITS OWN LINE KIND, NOT A NINTH HEADER FIELD. The header is exactly
				// eight numbers and the path is whatever follows it, six floats a point — so a ninth
				// field would make every older document's first path point read as the flag and shift
				// the entire path by one. A second keyword costs nothing, reads as what it is in a
				// format meant to be diffed by hand, and lands in the same list at the same position,
				// which is all the ordering an interleaved log needs.
				foreach ( var s in strokes )
					sb.Append( s.Erase ? "\terase " : "\tstroke " )
						.Append( field.Name ).Append( ' ' ).Append( Stroke( s ) ).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( "variable " ) )
			{
				var (name, expr) = Split( line[9..] );

				if ( VariableResolver.IsLegalName( name ) )
					studio.SetVariable( name, string.IsNullOrWhiteSpace( expr ) ? "0" : expr );

				continue;
			}

			if ( line.StartsWith( "note " ) )
			{
				var note = ReadNote( lines, ref i );

				// A note with nothing in it is dropped rather than loaded. WriteNote never produces
				// one, so this only fires on a hand-edited file, and an invisible entry that only
				// the file knows about is worse than no entry.
				if ( !note.IsEmpty )
					studio.Notes.Add( note );

				continue;
			}

			// Exact rather than StartsWith: the block header is the bare word, and a prefix test
			// would also swallow any later key beginning "rig".
			if ( line == "rig" )
			{
				ReadRig( studio, lines, ref i );
				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;
	}

	/// <summary>
	/// Read one note block, positioned on its "note" line and leaving <paramref name="i"/> on the
	/// "endnote".
	///
	/// UNFAMILIAR KEYS INSIDE THE BLOCK ARE SKIPPED rather than thrown on, which is the opposite of
	/// what ReadFeature does with them, and deliberately so. A feature carrying a key this build
	/// cannot read is a model that will rebuild into the wrong shape, and refusing to open is the
	/// honest answer. A note is a scribble: losing a property a later build added costs the user a
	/// colour, and taking the whole document down over it would cost them the part.
	/// </summary>
	/// <summary>
	/// Read the rig block back.
	///
	/// SKIPS WHAT IT DOES NOT RECOGNISE, following ReadNote rather than ReadFeature. The reasoning
	/// is the same and it is worth repeating because the two rules look inconsistent side by side:
	/// a feature carrying an unknown key rebuilds into the WRONG SHAPE, so refusing to open is the
	/// honest answer, while a bone carrying one loses a property a later build added - a wobble
	/// setting, say - and taking the whole part down over a wobble setting is a bad trade.
	///
	/// A BONE WITH NO NAME OR A BAD PARENT IS DROPPED, not thrown on, and its children go with it
	/// because AddBone will refuse a parent index that never arrived. That is a hand-edited file or
	/// a truncated one; the rest of the rig is still worth having.
	/// </summary>
	static void ReadRig( PartStudio studio, string[] lines, ref int i )
	{
		for ( i++; i < lines.Length; i++ )
		{
			var line = lines[i].Trim();

			if ( line.Length == 0 )
				continue;

			if ( line == "endrig" )
				return;

			if ( line.StartsWith( "bind " ) )
			{
				var parts = line[5..].Split( ' ', 2, StringSplitOptions.RemoveEmptyEntries );

				if ( parts.Length == 2 && !string.IsNullOrWhiteSpace( parts[0] ) )
					studio.BodyBoneMap[parts[0]] = parts[1].Trim();

				continue;
			}

			if ( line.StartsWith( "bone " ) )
			{
				ReadBone( studio.Rig, lines, ref i );
				continue;
			}

			if ( line.StartsWith( "weights " ) || line == "weights" )
			{
				ReadWeights( studio, lines, ref i );
				continue;
			}
		}
	}

	static void ReadWeights( PartStudio studio, string[] lines, ref int i )
	{
		var header = lines[i].Trim();
		var topology = 0L;

		if ( header.StartsWith( "weights " ) )
			long.TryParse( header[8..].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out topology );

		var rows = new List<(int Vertex, List<(string Bone, float Weight)> Weights)>();

		for ( i++; i < lines.Length; i++ )
		{
			var line = lines[i].Trim();

			if ( line.Length == 0 )
				continue;

			if ( line == "endweights" )
				break;

			if ( !line.StartsWith( "v " ) )
				continue;

			var parts = line[2..].Split( ' ', StringSplitOptions.RemoveEmptyEntries );

			if ( parts.Length < 1 )
				continue;

			var vertex = ParseInt( parts[0], -1 );

			if ( vertex < 0 )
				continue;

			var weights = new List<(string, float)>();

			for ( var p = 1; p + 1 < parts.Length; p += 2 )
				weights.Add( (parts[p], ParseFloat( parts[p + 1] )) );

			if ( weights.Count > 0 )
				rows.Add( (vertex, weights) );
		}

		studio.WeightPaint = WeightPaintLayer.FromSaved( topology, rows );
	}

	/// <summary>One bone block. Everything is read before anything is added, because AddBone takes
	/// the whole bone at once and can refuse it.</summary>
	static void ReadBone( Skeleton rig, string[] lines, ref int i )
	{
		var name = lines[i].Trim()[5..].Trim();

		var parent = -1;
		var length = 1f;
		Vec3 x = new( 1, 0, 0 ), y = new( 0, 1, 0 ), z = new( 0, 0, 1 ), origin = default;
		SoftBone soft = null;

		for ( i++; i < lines.Length; i++ )
		{
			var line = lines[i].Trim();

			if ( line.Length == 0 )
				continue;

			if ( line == "endbone" )
				break;

			if ( line.StartsWith( "parent " ) ) { parent = ParseInt( line[7..], -1 ); continue; }
			if ( line.StartsWith( "x " ) ) { x = ParseVec3( line[2..] ); continue; }
			if ( line.StartsWith( "y " ) ) { y = ParseVec3( line[2..] ); continue; }
			if ( line.StartsWith( "z " ) ) { z = ParseVec3( line[2..] ); continue; }
			if ( line.StartsWith( "origin " ) ) { origin = ParseVec3( line[7..] ); continue; }
			if ( line.StartsWith( "length " ) ) { length = ParseFloat( line[7..] ); continue; }

			if ( line.StartsWith( "soft " ) )
			{
				var parts = line[5..].Split( ' ', StringSplitOptions.RemoveEmptyEntries );

				// A short soft line keeps SoftBone's own defaults for whatever is missing, rather
				// than zeroing them. Zero stiffness and a zero cone are both meaningful values - a
				// dead limb and a bone pinned to its pose - so filling a gap with one would invent
				// a deliberate-looking setting nobody chose.
				soft = new SoftBone();

				if ( parts.Length > 0 ) soft.Stiffness = ParseFloat( parts[0] );
				if ( parts.Length > 1 ) soft.Damping = ParseFloat( parts[1] );
				if ( parts.Length > 2 ) soft.Weight = ParseFloat( parts[2] );
				if ( parts.Length > 3 ) soft.MaxAngle = ParseFloat( parts[3] );

				continue;
			}
		}

		if ( string.IsNullOrWhiteSpace( name ) )
			return;

		try
		{
			var index = rig.AddBone( name, parent, new Xform( x, y, z, origin ), length );
			rig.Bones[index].Soft = soft;
		}
		catch ( ArgumentException )
		{
			// A duplicate name, a blank one, or a parent that is not there yet - a hand-edited or
			// truncated file. AddBone is the only thing that knows the rules, so it is left to
			// enforce them rather than having them restated here and allowed to drift.
			//
			// ONE CATCH COVERS BOTH throws: AddBone raises ArgumentOutOfRangeException for a bad
			// parent, and that derives from ArgumentException, so a second clause for it would be
			// unreachable rather than thorough.
		}
	}

	static Note ReadNote( string[] lines, ref int i )
	{
		var header = lines[i].Trim()[5..].Split( ' ', StringSplitOptions.RemoveEmptyEntries );
		var note = new Note
		{
			Color = header.Length > 0 ? ParseInt( header[0], 0 ) : 0,
			Width = header.Length > 1 ? ParseFloat( header[1] ) : 0.4f,
		};

		for ( i++; i < lines.Length; i++ )
		{
			var line = lines[i].Trim();

			if ( line.Length == 0 )
				continue;

			if ( line == "endnote" )
				return note;

			var (key, rest) = Split( line );

			if ( key == "p" )
				note.Points.Add( ParseVec3( rest ) );
			else if ( key == "text" )
				note.Text = rest;
		}

		throw new InvalidDataException( "A note block was never closed with 'endnote'." );
	}

	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:
						// A number is a literal. Anything else is an expression — #thickness, 1/8 —
						// stored as Expr and evaluated on rebuild. Old files only ever wrote numbers.
						if ( LooksLikeNumber( value ) )
						{
							p.Value = ParseFloat( value );
							p.Expr = null;
						}
						else
						{
							p.Expr = 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;
					case StringParam p: p.Value = 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 "edgelist":
			{
				if ( field.GetValue( feature ) is not List<EdgeRef> list )
					return;

				if ( clearedLists.Add( field.Name ) )
					list.Clear();

				list.Add( ParseEdge( value ) );
				return;
			}

			case "stroke":
			case "erase":
			{
				// BOTH KINDS LAND IN ONE LIST, IN FILE ORDER. Paint and erase interleave — the log is
				// what makes an erase survive a rebuild — so they share the field, and clearedLists
				// is keyed on the field name rather than on the keyword so the first line of either
				// kind clears it exactly once. A document written before erasing existed has no
				// "erase" lines at all, which reads as "nothing is an erase" without a version field.
				//
				// Strokes is null until the first one lands, so a document that paints has to create
				// the list rather than assume it — unlike the facelist/edgelist fields, which are
				// never null and can rely on their initialiser.
				if ( field.GetValue( feature ) is List<PaintStroke> strokes )
				{
					if ( clearedLists.Add( field.Name ) )
						strokes.Clear();
				}
				else
				{
					strokes = new List<PaintStroke>();
					field.SetValue( feature, strokes );
					clearedLists.Add( field.Name );
				}

				var stroke = ParseStroke( value );
				stroke.Erase = key == "erase";
				strokes.Add( stroke );
				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 string Edge( EdgeRef e ) => $"{e.BodyId} {Vec( e.Point )} {Vec( e.Direction )}";

	/// <summary>One stroke as a single line: colour, radius, strength, falloff and spacing, then the
	/// path as position/normal pairs. The point count is implied by what is left after the header.</summary>
	static string Stroke( PaintStroke s )
	{
		var sb = new StringBuilder();

		sb.Append( Num( s.R ) ).Append( ' ' ).Append( Num( s.G ) ).Append( ' ' ).Append( Num( s.B ) ).Append( ' ' ).Append( Num( s.A ) );
		sb.Append( ' ' ).Append( Num( s.Radius ) );
		sb.Append( ' ' ).Append( Num( s.Strength ) );
		sb.Append( ' ' ).Append( (int)s.Falloff );
		sb.Append( ' ' ).Append( Num( s.Spacing ) );

		foreach ( var p in s.Path )
			sb.Append( ' ' ).Append( Vec( p.Position ) ).Append( ' ' ).Append( Vec( p.Normal ) );

		return sb.ToString();
	}

	static EdgeRef ParseEdge( string value )
	{
		var p = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );

		return new EdgeRef( p[0],
			new Vec3( ParseFloat( p[1] ), ParseFloat( p[2] ), ParseFloat( p[3] ) ),
			new Vec3( ParseFloat( p[4] ), ParseFloat( p[5] ), ParseFloat( p[6] ) ) );
	}

	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 PaintStroke ParseStroke( string value )
	{
		var p = value.Split( ' ', StringSplitOptions.RemoveEmptyEntries );

		var stroke = new PaintStroke
		{
			R = ParseFloat( p[0] ),
			G = ParseFloat( p[1] ),
			B = ParseFloat( p[2] ),
			A = ParseFloat( p[3] ),
			Radius = ParseFloat( p[4] ),
			Strength = ParseFloat( p[5] ),
			Falloff = (BrushFalloff)ParseInt( p[6], 0 ),
			Spacing = ParseFloat( p[7] ),
		};

		// The path follows the fixed header: six floats per point, position then normal. An empty
		// path is a header with nothing after it, which the loop simply never visits.
		for ( var n = 8; n + 5 < p.Length; n += 6 )
		{
			stroke.Path.Add( new PaintStrokePoint(
				new Vec3( ParseFloat( p[n] ), ParseFloat( p[n + 1] ), ParseFloat( p[n + 2] ) ),
				new Vec3( ParseFloat( p[n + 3] ), ParseFloat( p[n + 4] ), ParseFloat( p[n + 5] ) ) ) );
		}

		return stroke;
	}

	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());
	}

	/// <summary>
	/// A parameter that is a literal number, not an expression. Scientific notation is a number;
	/// <c>#thickness</c> and <c>1/8</c> are not — they have to round-trip as text so rebuild can
	/// re-evaluate them.
	/// </summary>
	static bool LooksLikeNumber( string s )
	{
		if ( string.IsNullOrWhiteSpace( s ) )
			return false;

		return float.TryParse( s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out _ );
	}

	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();
}