Editor build-script parser and executor for the Effigy studio. It tokenises a simple line-based DSL (add, set, render, export, match, save, etc.), maps tokens to Feature creation and parameter assignment, runs rebuilds, writes outputs (PNGs, OBJ/VMDL, layout.txt), and returns structured Result with logs and errors.
using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using Effigy.Render; namespace Effigy; /// <summary> /// A build script: what to add to the studio, in order, in one text file that runs headlessly /// and in the editor alike. /// /// # a comment /// add Primitive name=Torso shape=Box sizex=20 sizey=12 sizez=30 position=@ground+0,0,15 /// add Part name=Buckle part=Buckle size=6 length=4 [email protected]+1,0,-6 /// add Spline name=Cable [email protected];-10,4,20;-14,0,8 radius=0.8 /// add CurveDeform bodies=Tail /// set Torso sizez=32 /// fix Torso all /// render out/torso.png view=sheet /// describe /// match front reference.png out/match.png /// /// `add TYPE` takes a feature type (Primitive, Spline, Part, Profile, Transform, Mirror, /// Subdivide…) and `key=value` pairs naming its parameters by label or field. Anywhere a point /// goes, `@landmark` or `@landmark+dx,dy,dz` names a place on the model — a body's side, a /// bone, the ground — resolved by <see cref="ModelMeasure.TryLandmark"/> against the model AS /// BUILT SO FAR, so a part can sit on the body the line before it made. /// /// Every command runs a rebuild when it changes the model, and a feature that fails stops the /// script with the feature's own diagnostic — its cause and its remedies — so what comes back /// is what to change, not a stack trace. /// </summary> public static class BuildScript { public sealed class Result { public bool Ok = true; public List<string> Log = new(); public List<string> Written = new(); public string Error; public int ErrorLine; /// <summary>Where each body turns or stands, by body name, set with `pivot`. Export moves the /// body so this point is its model origin, and the layout says where that point was.</summary> public Dictionary<string, Vec3> Pivots = new( StringComparer.OrdinalIgnoreCase ); public override string ToString() { var sb = new StringBuilder(); foreach ( var line in Log ) sb.AppendLine( line ); if ( Error is not null ) sb.AppendLine( $"ERROR line {ErrorLine}: {Error}" ); return sb.ToString().TrimEnd(); } } /// <summary>Run <paramref name="text"/> against <paramref name="studio"/>. Files a `render`, /// `match` or `save` writes go under <paramref name="baseDir"/> when their path is relative.</summary> public static Result Run( PartStudio studio, string text, string baseDir = null ) { var result = new Result(); baseDir ??= Directory.GetCurrentDirectory(); if ( studio is null ) { result.Ok = false; result.Error = "No studio."; return result; } var lines = (text ?? "").Replace( "\r\n", "\n" ).Split( '\n' ); for ( var i = 0; i < lines.Length; i++ ) { var line = lines[i].Trim(); if ( line.Length == 0 || line.StartsWith( '#' ) ) continue; try { if ( !Command( studio, Tokenise( line ), baseDir, result ) ) { result.Ok = false; result.ErrorLine = i + 1; return result; } } catch ( Exception e ) { result.Ok = false; result.ErrorLine = i + 1; result.Error = e.Message; return result; } } return result; } static bool Command( PartStudio studio, List<string> tokens, string baseDir, Result r ) { if ( tokens.Count == 0 ) return true; var verb = tokens[0].ToLowerInvariant(); var args = tokens.Skip( 1 ).ToList(); switch ( verb ) { case "add": { if ( args.Count == 0 ) return Fail( r, "add needs a feature type: add Primitive shape=Box …" ); var feature = Create( args[0] ); if ( feature is null ) return Fail( r, $"No feature type '{args[0]}'. Types: {string.Join( ", ", FeatureTypeNames() )}." ); var pairs = Pairs( args.Skip( 1 ) ); if ( !Assign( studio, feature, pairs, r ) ) return false; studio.Add( feature ); return RebuildAndCheck( studio, feature, r, $"added {feature.TypeName} '{feature.Name ?? feature.Id}'" ); } case "set": { if ( args.Count < 2 ) return Fail( r, "set needs a feature name and key=value pairs." ); var feature = FindFeature( studio, args[0] ); if ( feature is null ) return Fail( r, $"No feature called '{args[0]}'. Features: {string.Join( ", ", studio.Features.Select( f => f.Name ?? f.TypeName ) )}." ); if ( !Assign( studio, feature, Pairs( args.Skip( 1 ) ), r ) ) return false; studio.MarkDirty( feature ); return RebuildAndCheck( studio, feature, r, $"set {feature.Name ?? feature.TypeName}" ); } case "remove": { if ( args.Count < 1 ) return Fail( r, "remove needs a feature name." ); var feature = FindFeature( studio, args[0] ); if ( feature is null ) return Fail( r, $"No feature called '{args[0]}'." ); studio.Remove( feature ); studio.Rebuild(); r.Log.Add( $"removed {feature.Name ?? feature.TypeName}" ); return true; } case "rebuild": studio.Rebuild(); r.Log.Add( "rebuilt" ); return true; case "fix": { if ( args.Count < 2 ) return Fail( r, "fix needs a body (or all) and the fixes: fix Torso loose holes normals" ); var targets = args[0].Equals( "all", StringComparison.OrdinalIgnoreCase ) ? studio.Bodies.ToList() : new List<Body> { ModelMeasure.FindBody( studio, args[0] ) }; if ( targets.Count == 0 || targets[0] is null ) return Fail( r, $"No body called '{args[0]}'. Bodies: {string.Join( ", ", studio.Bodies.Select( b => b.Name ?? b.Id ) )}." ); foreach ( var body in targets ) foreach ( var line in MeshFix.Apply( body.Mesh, args.Skip( 1 ) ) ) r.Log.Add( $"{body.Name ?? body.Id} {line}" ); return true; } case "render": { if ( args.Count < 1 ) return Fail( r, "render needs an output path: render out/model.png view=sheet" ); var path = Resolve( baseDir, args[0] ); var opts = Pairs( args.Skip( 1 ) ); var viewName = opts.GetValueOrDefault( "view", "sheet" ); var size = (int)Number( opts.GetValueOrDefault( "size", "512" ) ); var options = new RenderOptions { Wireframe = Truthy( opts.GetValueOrDefault( "wire", "1" ) ), Bones = Truthy( opts.GetValueOrDefault( "bones", "0" ) ), Labels = Truthy( opts.GetValueOrDefault( "labels", "1" ) ), Grid = Truthy( opts.GetValueOrDefault( "grid", "1" ) ), FocusBodyId = opts.TryGetValue( "focus", out var focus ) ? ModelMeasure.FindBody( studio, focus )?.Id : null, }; Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? "." ); ModelRender.Named( studio.Bodies, viewName, size, options, studio.Rig ).SavePng( path ); r.Written.Add( path ); r.Log.Add( $"rendered {viewName} to {path}" ); return true; } case "describe": r.Log.Add( ModelMeasure.Describe( studio ) ); return true; case "measure": case "at": { if ( args.Count < 1 ) return Fail( r, "measure needs a landmark or a point: measure Torso.top" ); if ( !Point( studio, args[0], out var p, out var why ) ) return Fail( r, why ); r.Log.Add( ModelMeasure.At( studio, p ) ); return true; } case "distance": { if ( args.Count < 2 ) return Fail( r, "distance needs two landmarks: distance Hand_L Hand_R" ); if ( !Point( studio, args[0], out var a, out var whyA ) ) return Fail( r, whyA ); if ( !Point( studio, args[1], out var b, out var whyB ) ) return Fail( r, whyB ); var d = b - a; r.Log.Add( $"{args[0]} to {args[1]}: {d.Length:0.###} (dx {d.x:0.###}, dy {d.y:0.###}, dz {d.z:0.###})" ); return true; } case "symmetry": { foreach ( var body in args.Count > 0 ? new[] { ModelMeasure.FindBody( studio, args[0] ) } : studio.Bodies.ToArray() ) { if ( body?.Mesh is null ) continue; r.Log.Add( $"{body.Name ?? body.Id}: left-right symmetry error {ModelMeasure.SymmetryError( body.Mesh ) * 100f:0.#}% of width" ); } return true; } case "match": { if ( args.Count < 2 ) return Fail( r, "match needs a view and a reference PNG: match front ref.png [out.png]" ); var view = RenderView.Named( args[0] ); if ( view is null ) return Fail( r, $"No view called '{args[0]}'." ); var refPath = Resolve( baseDir, args[1] ); if ( !File.Exists( refPath ) ) return Fail( r, $"No reference image at {refPath}." ); const int size = 256; var (w, h, rgb, alpha) = Raster.LoadPng( refPath ); var reference = ReferenceMatch.ReferenceSilhouette( w, h, rgb, alpha, size, out var aspect ); var match = ReferenceMatch.Compare( studio.Bodies, view, reference, aspect, size ); r.Log.Add( match.Report ); if ( args.Count > 2 ) { var outPath = Resolve( baseDir, args[2] ); Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( outPath ) ) ?? "." ); match.Diff.SavePng( outPath ); r.Written.Add( outPath ); } return true; } case "save": { if ( args.Count < 1 ) return Fail( r, "save needs a path." ); var path = Resolve( baseDir, args[0] ); Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? "." ); StudioDocument.WriteFile( studio, path ); r.Written.Add( path ); r.Log.Add( $"saved {path}" ); return true; } case "echo": r.Log.Add( string.Join( " ", args ) ); return true; case "material": { // material 1 materials/alley/brass.vmat — what slot 1 is exported as. if ( args.Count < 2 || !int.TryParse( args[0], out var slot ) ) return Fail( r, "material needs a slot number and a .vmat path: material 1 materials/alley/brass.vmat" ); studio.MaterialNames[slot] = args[1]; studio.MarkDirty( 0 ); r.Log.Add( $"slot {slot} is {args[1]}" ); return true; } case "pivot": { // pivot Bob3 @Beam.bottom — the point this part turns about, which export makes its origin. if ( args.Count < 2 ) return Fail( r, "pivot needs a body and a point: pivot Arm @Hub.centre" ); if ( ModelMeasure.FindBody( studio, args[0] ) is null ) return Fail( r, $"No body called '{args[0]}'." ); if ( !Point( studio, args[1], out var p, out var why ) ) return Fail( r, why ); r.Pivots[args[0]] = p; return true; } case "export": { // export models/civic_art/orrery split=1 assets=C:/proj/Assets name=orrery if ( args.Count < 1 ) return Fail( r, "export needs a folder under the assets root: export models/civic_art/orrery [split=1] [assets=root] [name=x]" ); var folder = args[0].Replace( '\\', '/' ).Trim( '/' ); var opts = Pairs( args.Skip( 1 ) ); var root = opts.TryGetValue( "assets", out var assets ) ? assets : baseDir; var split = Truthy( opts.GetValueOrDefault( "split", "0" ) ); var name = opts.GetValueOrDefault( "name", folder.Contains( '/' ) ? folder[(folder.LastIndexOf( '/' ) + 1)..] : folder ); var dir = Path.Combine( root, folder ); Directory.CreateDirectory( dir ); var layout = new StringBuilder(); layout.AppendLine( "# part\tmodel\tpivot x\ty\tz\tsize x\ty\tz" ); var bodies = studio.Bodies.Where( b => b?.Mesh is not null && b.Mesh.Faces.Count > 0 && b.Visible ).ToList(); if ( bodies.Count == 0 ) return Fail( r, "Nothing to export: no visible bodies." ); if ( split ) { foreach ( var body in bodies ) { var partName = Safe( body.Name ?? body.Id ); var mesh = body.Mesh.Clone(); var (min, max) = ModelMeasure.Bounds( mesh ); var pivot = r.Pivots.TryGetValue( body.Name ?? "", out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z ); MeshTransform.Apply( mesh, Xform.Translate( -pivot ) ); WriteModel( studio, mesh, dir, folder, partName ); layout.AppendLine( $"{body.Name ?? body.Id}\t{folder}/{partName}.vmdl\t{N( pivot.x )}\t{N( pivot.y )}\t{N( pivot.z )}\t{N( max.x - min.x )}\t{N( max.y - min.y )}\t{N( max.z - min.z )}" ); } } else { var mesh = new PolyMesh(); foreach ( var body in bodies ) MeshTransform.Append( mesh, body.Mesh ); var (min, max) = ModelMeasure.Bounds( mesh ); var pivot = r.Pivots.TryGetValue( name, out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z ); MeshTransform.Apply( mesh, Xform.Translate( -pivot ) ); WriteModel( studio, mesh, dir, folder, Safe( name ) ); layout.AppendLine( $"{name}\t{folder}/{Safe( name )}.vmdl\t{N( pivot.x )}\t{N( pivot.y )}\t{N( pivot.z )}\t{N( max.x - min.x )}\t{N( max.y - min.y )}\t{N( max.z - min.z )}" ); } var layoutPath = Path.Combine( dir, "layout.txt" ); File.WriteAllText( layoutPath, layout.ToString() ); r.Written.Add( layoutPath ); r.Log.Add( $"exported {(split ? bodies.Count : 1)} model(s) to {dir}; layout in layout.txt" ); return true; } default: return Fail( r, $"Unknown command '{verb}'. Commands: add, set, remove, rebuild, fix, render, describe, measure, distance, symmetry, match, material, pivot, export, save, echo." ); } } /// <summary>One .obj and the .vmdl that loads it, materials bound from the studio's slots.</summary> static void WriteModel( PartStudio studio, PolyMesh mesh, string dir, string folder, string name ) { var objPath = Path.Combine( dir, name + ".obj" ); ObjWriter.WriteFile( mesh, objPath, name, materialName: studio.NameForSlot ); var materials = VmdlMaterials.GroupList( studio, mesh ); File.WriteAllText( Path.Combine( dir, name + ".vmdl" ), VmdlDocument.Static( $"{folder}/{name}.obj", "", materials ) ); } static string Safe( string name ) { var sb = new StringBuilder(); foreach ( var c in (name ?? "part").Trim().ToLowerInvariant() ) sb.Append( char.IsLetterOrDigit( c ) ? c : '_' ); return sb.Length == 0 ? "part" : sb.ToString(); } static string N( float v ) => v.ToString( "0.###", CultureInfo.InvariantCulture ); static bool RebuildAndCheck( PartStudio studio, Feature feature, Result r, string did ) { studio.Rebuild(); if ( feature.Error is not null ) { var d = feature.Diagnostic; var sb = new StringBuilder(); sb.Append( $"{feature.TypeName} '{feature.Name ?? feature.Id}' failed: {feature.Error}" ); if ( d is not null ) { if ( !string.IsNullOrEmpty( d.Cause ) ) sb.Append( $" Because: {d.Cause}" ); foreach ( var remedy in d.Remedies ) sb.Append( $" Try: {remedy}." ); } return Fail( r, sb.ToString() ); } r.Log.Add( did + (feature.Warning is not null ? $" (note: {feature.Warning})" : "" ) ); return true; } static bool Fail( Result r, string message ) { r.Error = message; return false; } // --- assigning parameters --------------------------------------------------------------------- static bool Assign( PartStudio studio, Feature feature, Dictionary<string, string> pairs, Result r ) { foreach ( var (key, value) in pairs ) { if ( key == "name" ) { feature.Name = value; continue; } var target = FindParam( feature, key, out var field ); if ( target is null && field is null ) return Fail( r, $"{feature.TypeName} has no parameter '{key}'. It has: {string.Join( ", ", ParamNames( feature ) )}." ); try { if ( target is not null && !AssignParam( studio, target, value, out var why ) ) return Fail( r, $"{key}: {why}" ); if ( target is null && !AssignField( studio, feature, field, value, out var whyField ) ) return Fail( r, $"{key}: {whyField}" ); } catch ( Exception e ) { return Fail( r, $"{key}: {e.Message}" ); } } return true; } static bool AssignParam( PartStudio studio, IParam param, string value, out string why ) { why = null; switch ( param ) { case FloatParam f: if ( !TryNumber( value, out var fv ) ) { why = $"'{value}' is not a number."; return false; } f.Value = fv; f.Expr = null; return true; case IntParam i: if ( !TryNumber( value, out var iv ) ) { why = $"'{value}' is not a number."; return false; } i.Value = (int)MathF.Round( iv ); return true; case BoolParam b: b.Value = Truthy( value ); return true; case ChoiceParam c: { var index = Array.FindIndex( c.Options, o => string.Equals( o, value, StringComparison.OrdinalIgnoreCase ) || string.Equals( o.Replace( ' ', '_' ), value, StringComparison.OrdinalIgnoreCase ) ); // "Left" for "Left (y)", "box" for "Box": the start of an option, then anywhere in it. if ( index < 0 ) index = Array.FindIndex( c.Options, o => o.StartsWith( value, StringComparison.OrdinalIgnoreCase ) ); if ( index < 0 ) index = Array.FindIndex( c.Options, o => o.Contains( value, StringComparison.OrdinalIgnoreCase ) ); if ( index < 0 && int.TryParse( value, out var n ) && n >= 0 && n < c.Options.Length ) index = n; if ( index < 0 ) { why = $"'{value}' is not one of {string.Join( ", ", c.Options )}."; return false; } c.Index = index; return true; } case Vec3Param v: if ( !Point( studio, value, out var p, out why ) ) return false; v.Value = p; return true; case StringParam s: s.Value = value; return true; case BodySelectionParam bodies: { bodies.BodyIds.Clear(); foreach ( var name in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) ) { var body = ModelMeasure.FindBody( studio, name.Trim() ); if ( body is null ) { why = $"No body called '{name.Trim()}'. Bodies: {string.Join( ", ", studio.Bodies.Select( b => b.Name ?? b.Id ) )}."; return false; } bodies.BodyIds.Add( body.Id ); } return true; } } why = $"a {param.GetType().Name} cannot be set from a script."; return false; } static bool AssignField( PartStudio studio, Feature feature, FieldInfo field, string value, out string why ) { why = null; var current = field.GetValue( feature ); switch ( current ) { case List<Vec3> points: { points.Clear(); foreach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) ) { if ( !Point( studio, part.Trim(), out var p, out why ) ) return false; points.Add( p ); } return true; } case List<Vec2> pairs: { pairs.Clear(); foreach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) ) { var xy = part.Split( ',' ); if ( xy.Length != 2 || !TryNumber( xy[0], out var x ) || !TryNumber( xy[1], out var y ) ) { why = $"'{part}' is not a pair a,b."; return false; } pairs.Add( new Vec2( x, y ) ); } return true; } case string: { // A field holding another feature's id — a path sketch, a spline — takes its name. var other = FindFeature( studio, value ); field.SetValue( feature, other?.Id ?? value ); return true; } case List<string> texts: { texts.Clear(); foreach ( var part in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) ) texts.Add( FindFeature( studio, part.Trim() )?.Id ?? part.Trim() ); return true; } } why = $"a {field.FieldType.Name} cannot be set from a script."; return false; } static IParam FindParam( Feature feature, string key, out FieldInfo field ) { field = null; var wanted = Normalise( key ); foreach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ) ) if ( Normalise( param.Label ) == wanted ) return param; foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) { if ( Normalise( f.Name ) != wanted ) continue; if ( f.GetValue( feature ) is IParam p ) return p; field = f; return null; } return null; } static IEnumerable<string> ParamNames( Feature feature ) { foreach ( var p in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() ) yield return p.Label; foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) if ( f.GetValue( feature ) is not IParam && f.Name is not ("Id" or "Name" or "Suppressed" or "Visible") ) yield return f.Name; } static string Normalise( string s ) => (s ?? "").ToLowerInvariant().Replace( " ", "" ).Replace( "_", "" ).Replace( "'", "" ); // --- points and landmarks --------------------------------------------------------------------- /// <summary>`x,y,z`, `@landmark`, or `@landmark+dx,dy,dz` / `@landmark-dx,dy,dz`.</summary> public static bool Point( PartStudio studio, string text, out Vec3 point, out string why ) { why = null; point = Vec3.Zero; text = (text ?? "").Trim(); if ( !text.StartsWith( '@' ) ) { if ( ModelMeasure.TryParseVec3( text, out point ) ) return true; // A bare name is a landmark too. return ModelMeasure.TryLandmark( studio, text, out point, out why ); } var body = text[1..]; var offset = Vec3.Zero; var plus = body.IndexOfAny( new[] { '+', '-' }, 1 ); if ( plus > 0 ) { var sign = body[plus] == '-' ? -1f : 1f; var offsetText = body[(plus + 1)..]; body = body[..plus]; if ( !ModelMeasure.TryParseVec3( offsetText, out offset ) ) { why = $"'{offsetText}' after the landmark is not an offset dx,dy,dz."; return false; } offset *= sign; } if ( !ModelMeasure.TryLandmark( studio, body, out point, out why ) ) return false; point += offset; return true; } // --- features by type and name ---------------------------------------------------------------- static Feature Create( string typeName ) { var wanted = Normalise( typeName ); foreach ( var type in FeatureTypes() ) { var name = Normalise( type.Name ); var bare = name.EndsWith( "feature" ) ? name[..^7] : name; if ( name == wanted || bare == wanted ) return (Feature)Activator.CreateInstance( type ); } return null; } static IEnumerable<Type> FeatureTypes() => typeof( Feature ).Assembly.GetTypes() .Where( t => t.IsSubclassOf( typeof( Feature ) ) && !t.IsAbstract ) .OrderBy( t => t.Name, StringComparer.Ordinal ); /// <summary>A feature type's parameters, one per line, with what each takes — the help a script /// author reads before writing `add`.</summary> public static string Describe( string typeName ) { var feature = Create( typeName ); if ( feature is null ) return $"No feature type '{typeName}'. Types: {string.Join( ", ", FeatureTypeNames() )}."; var sb = new StringBuilder(); sb.AppendLine( $"{feature.TypeName} (add {typeName})" ); foreach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() ) { var key = Normalise( param.Label ); var kind = param switch { FloatParam f => $"number, default {f.Value}" + (f.Min > float.MinValue ? $", min {f.Min}" : "") + (f.Max < float.MaxValue ? $", max {f.Max}" : ""), IntParam i => $"whole number, default {i.Value}, {i.Min}..{i.Max}", BoolParam b => $"on/off, default {(b.Value ? "on" : "off")}", ChoiceParam c => $"one of {string.Join( "|", c.Options )}, default {c.Value}", Vec3Param v => $"point x,y,z or @landmark, default {v.Value.x},{v.Value.y},{v.Value.z}", StringParam => "text", BodySelectionParam => "body names, comma-separated (empty = every body)", _ => param.GetType().Name, }; sb.AppendLine( $" {key} = {kind}" ); } foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) { if ( f.GetValue( feature ) is IParam || f.Name is "Id" or "Name" or "Suppressed" or "Visible" ) continue; var kind = f.FieldType == typeof( List<Vec3> ) ? "points x,y,z;x,y,z;… (landmarks allowed)" : f.FieldType == typeof( List<Vec2> ) ? "pairs a,b;a,b;…" : f.FieldType == typeof( string ) ? "a feature name" : f.FieldType == typeof( List<string> ) ? "feature names, comma-separated" : null; if ( kind is not null ) sb.AppendLine( $" {Normalise( f.Name )} = {kind}" ); } sb.AppendLine( " name = what to call it" ); return sb.ToString().TrimEnd(); } public static IEnumerable<string> FeatureTypeNames() => FeatureTypes().Select( t => t.Name.EndsWith( "Feature" ) ? t.Name[..^7] : t.Name ); static Feature FindFeature( PartStudio studio, string name ) { if ( string.IsNullOrWhiteSpace( name ) ) return null; if ( name.StartsWith( '#' ) && int.TryParse( name[1..], out var index ) && index >= 1 && index <= studio.Features.Count ) return studio.Features[index - 1]; return studio.Features.FirstOrDefault( f => f.Id == name ) ?? studio.Features.LastOrDefault( f => string.Equals( f.Name, name, StringComparison.OrdinalIgnoreCase ) ) ?? studio.Features.LastOrDefault( f => string.Equals( f.Name?.Replace( ' ', '_' ), name, StringComparison.OrdinalIgnoreCase ) ) ?? studio.Features.LastOrDefault( f => string.Equals( f.TypeName, name, StringComparison.OrdinalIgnoreCase ) ); } // --- lexing ------------------------------------------------------------------------------------- static List<string> Tokenise( string line ) { var tokens = new List<string>(); var sb = new StringBuilder(); var quoted = false; foreach ( var c in line ) { if ( c == '"' ) { quoted = !quoted; continue; } if ( char.IsWhiteSpace( c ) && !quoted ) { if ( sb.Length > 0 ) { tokens.Add( sb.ToString() ); sb.Clear(); } continue; } sb.Append( c ); } if ( sb.Length > 0 ) tokens.Add( sb.ToString() ); return tokens; } static Dictionary<string, string> Pairs( IEnumerable<string> tokens ) { var pairs = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase ); foreach ( var token in tokens ) { var eq = token.IndexOf( '=' ); if ( eq <= 0 ) throw new ArgumentException( $"'{token}' is not key=value." ); pairs[token[..eq].Trim().ToLowerInvariant()] = token[(eq + 1)..].Trim(); } return pairs; } static bool TryNumber( string text, out float value ) => float.TryParse( (text ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value ); static float Number( string text ) => TryNumber( text, out var v ) ? v : throw new ArgumentException( $"'{text}' is not a number." ); static bool Truthy( string text ) => (text ?? "").Trim().ToLowerInvariant() is "1" or "true" or "on" or "yes"; static string Resolve( string baseDir, string path ) => Path.IsPathRooted( path ) ? path : Path.Combine( baseDir, path ); }