PitchPerspective.cs

Static helper that builds the soccer pitch geometry: pitch outline, penalty and goal boxes, centre circle, and goal frame and net line segments. Also has a Project function that applies one shared perspective scale to points on the pitch.

Rough Code
using System;
using System.Collections.Generic;
namespace Socer;
// One shared projective transform for the turf, markings, players and goal geometry.
public static class PitchPerspective
{
    // Metres. Goal dimensions specify the clear opening, not post centre lines.
    public const float GoalWidth=7.32f,GoalHeight=2.44f,PostWidth=.12f;
    public const float PostLeft=34-GoalWidth/2-PostWidth/2,PostRight=34+GoalWidth/2+PostWidth/2;
    public const float CrossbarCentre=GoalHeight+PostWidth/2;
    public static float Scale(Point p)=>1/(1+(34-p.Y)*.006f);
    public static Point Project(Point p)=>new(52.5f+(p.X-52.5f)*Scale(p),34+(p.Y-34)*Scale(p));
    public readonly record struct Line(Point A,Point B,float ZA=0,float ZB=0,bool Net=false);
    public static IEnumerable<Line> Markings()
    {
        foreach(var line in Box(0,0,105,68)) yield return line;
        yield return new(new(52.5f,0),new(52.5f,68));
        foreach(float x in new[]{0f,88.5f}) foreach(var line in Box(x,13.84f,16.5f,40.32f)) yield return line;
        foreach(float x in new[]{0f,99.5f}) foreach(var line in Box(x,24.84f,5.5f,18.32f)) yield return line;
        for(int i=0;i<64;i++)
        {float a=i*MathF.PI/32,b=(i+1)*MathF.PI/32;yield return new(new(52.5f+9.15f*MathF.Cos(a),34+9.15f*MathF.Sin(a)),new(52.5f+9.15f*MathF.Cos(b),34+9.15f*MathF.Sin(b)));}
    }
    static IEnumerable<Line> Box(float x,float y,float w,float h)
    {yield return new(new(x,y),new(x+w,y));yield return new(new(x+w,y),new(x+w,y+h));yield return new(new(x+w,y+h),new(x,y+h));yield return new(new(x,y+h),new(x,y));}
    public static IEnumerable<Line> Goal(float x)
    {
        float rear=x==0?-2.4f:107.4f;
        Point a=new(x,PostLeft),b=new(x,PostRight),c=new(rear,PostLeft),d=new(rear,PostRight);
        // Box-shaped net: upright rear support, roof and two sides, not a flat triangle.
        yield return new(c,d);yield return new(c,c,0,CrossbarCentre);yield return new(d,d,0,CrossbarCentre);
        yield return new(c,d,CrossbarCentre,CrossbarCentre);yield return new(a,c,CrossbarCentre,CrossbarCentre);yield return new(b,d,CrossbarCentre,CrossbarCentre);
        for(int i=0;i<=20;i++)
        {float y=PostLeft+(PostRight-PostLeft)*i/20;yield return new(new(rear,y),new(rear,y),0,CrossbarCentre,true);yield return new(new(x,y),new(rear,y),CrossbarCentre,CrossbarCentre,true);}
        for(int i=0;i<=7;i++)
        {float z=CrossbarCentre*i/7;yield return new(c,d,z,z,true);yield return new(a,c,z,z,true);yield return new(b,d,z,z,true);}
        for(int i=1;i<7;i++)
        {float xx=x+(rear-x)*i/7;yield return new(new(xx,PostLeft),new(xx,PostLeft),0,CrossbarCentre,true);yield return new(new(xx,PostRight),new(xx,PostRight),0,CrossbarCentre,true);yield return new(new(xx,PostLeft),new(xx,PostRight),CrossbarCentre,CrossbarCentre,true);}
        // Front frame last, heavier and opaque.
        yield return new(a,a,0,CrossbarCentre);yield return new(b,b,0,CrossbarCentre);yield return new(a,b,CrossbarCentre,CrossbarCentre);
    }
}