Editor/UI/RiggerViewport.cs
using Editor;
using Sandbox;
using Vec=System.Numerics.Vector3;
using Quat=System.Numerics.Quaternion;
using System.Diagnostics;
namespace HumanoidRigger.Editor;

public sealed class RiggerViewport : SceneRenderingWidget
{
    readonly Wizard wizard;
    readonly List<SceneModel> models=[];
    readonly ScenePointLight keyLight,fillLight;
    Vector3 characterCenter;
    float characterRadius=180;
    readonly List<Mesh> meshes=[];
    readonly List<Vertex[]> vertexBuffers=[];
    readonly List<Vec[]> normalBuffers=[];
    ImportedCharacter displayedCharacter;
    GeneratedRig previewRig;
    readonly PoseTransition transition=new();
    long transitionStarted;
    Vec[][] poseBuffers;
    Vec focus;float cameraDistance=250,yaw=15,pitch=8;Vector2 last;string selected;
    internal enum Navigation {None,Marker,Rotate,Pan,Zoom,Centerline,PoseBone}
    Navigation navigation;
    MouseButtons dragButton;
    bool framePending;
    bool hasCameraFrame,cameraAnimating,showingBind;
    Transform cameraFrom,cameraTo;
    long cameraStarted;
    Landmark[] stagePoints=[];
    IReadOnlyDictionary<string,Quat> dragPose;
    Vec dragPivot,dragGrab;
    Vector3 dragCursor,dragPlaneNormal;
    bool poseDirty;
    bool allowEditing=true;
    public bool AllowEditing
    {
        get=>allowEditing;
        set{allowEditing=value;if(!value&&navigation is Navigation.Marker or Navigation.Centerline or Navigation.PoseBone)EndPointerDrag();}
    }
    internal int GeometryUploadCount {get;private set;}
    internal float RightInset {get;set;}
    internal Action Resized {get;set;}
    internal Vector2 ViewSize=>new(Math.Max(Width-RightInset,1),Math.Max(Height,1));
    internal int CameraTransitionFrames {get;private set;}
    internal bool CameraTransitioning=>cameraAnimating||framePending;
    internal string LastPointerEvent {get;private set;}="";
    Vec[] displayedBones=[];
    readonly BoneOverlay bones;
    LandmarkOverlay markers;
    CenterlineOverlay centerline;
    internal bool CenterlineVisible=>centerline?.Show??false;
    bool NavigationEnabled=>wizard.Step!=WizardStep.Centerline;
    internal bool NavigationHintsVisible=>stats?.ShowNavigation??false;
    Vec CenterlinePoint=>new(wizard.Anatomy!.SymmetryPlaneX,wizard.Anatomy["Pelvis"].Y,wizard.Anatomy["Pelvis"].Z);
    internal Vector2 CenterlinePosition=>new(Project(CenterlinePoint).x,Height*.5f);
    ViewportStats stats;
    ImportedCharacter statsCharacter;
    public int DisplayedMarkerCount=>markers?.Count??0;
    public int DisplayedBoneCount=>bones.BoneCount;
    public bool CharacterVisible=>models.Any(m=>m.RenderingEnabled);
    public bool IsTransitioning=>transition.Active;
    public int PoseFrameCount {get;private set;}
    internal SceneModel[] SceneObjects=>models.Concat(bones.SceneObjects).ToArray();
    internal Vec[][] DisplayedPositions=>poseBuffers;
    public Action Changed {get;set;}
    internal IReadOnlyDictionary<int,string> MaterialPaths {get;set;}=new Dictionary<int,string>();
    internal Action<string> SelectionChanged {get;set;}
    internal Action<IReadOnlyDictionary<string,Quat>> PoseEdited {get;set;}
    internal IReadOnlyDictionary<string,Quat> PreviewRotations=>transition.Current;
    internal string SelectedJoint=>selected;
    internal IEnumerable<Landmark> EditablePoints()=>VisiblePoints();
    internal void SelectJoint(string role){EndPointerDrag();selected=role;bones.Select(role);SelectionChanged?.Invoke(role);Update();}
    public RiggerViewport(Widget parent,Wizard wizard):base(parent)
    {
        this.wizard=wizard;MinimumSize=new(360,360);MouseTracking=true;Cursor=CursorShape.Arrow;
        Scene=Scene.CreateEditorScene();
        bones=new BoneOverlay(Scene.SceneWorld);
        using(Scene.Push())
        {
            Camera=new GameObject(true,"camera").GetOrAddComponent<CameraComponent>(false);
            Camera.BackgroundColor=Theme.ControlBackground;Camera.ZNear=.1f;Camera.ZFar=10000;Camera.FieldOfView=45;Camera.FovAxis=CameraComponent.Axis.Vertical;Camera.Enabled=true;
        }
        keyLight=new ScenePointLight(Scene.SceneWorld,new(120,100,160),1000,Color.White*3.5f){ShadowsEnabled=false};
        fillLight=new ScenePointLight(Scene.SceneWorld,new(-120,-100,90),1000,Color.White*2){ShadowsEnabled=false};
        OnPreFrame+=UpdateFrame;
    }
    public static Vector3 Engine(Vec v)=>new(v.Z,-v.X,v.Y);
    static Vec Canonical(Vector3 v)=>new(-v.y,v.z,v.x);
    public void ShowCharacter(Vec[][] positions=null,Vec[] bonePositions=null,System.Numerics.Quaternion[] boneRotations=null)
    {
        Cursor=CursorShape.Arrow;
        displayedBones=bonePositions??wizard.Rig?.Bones.Select(b=>b.Position).ToArray()??[];
        if(displayedCharacter!=wizard.Character)
        {
            foreach(var model in models)model.Delete();models.Clear();meshes.Clear();vertexBuffers.Clear();normalBuffers.Clear();
            displayedCharacter=wizard.Character;previewRig=null;transition.Reset();showingBind=false;hasCameraFrame=false;
            poseBuffers=wizard.Character?.Meshes.Select(m=>new Vec[m.Vertices.Length]).ToArray();
            if(wizard.Character is {} character)
            {
                characterCenter=Engine((character.Minimum+character.Maximum)*.5f);characterRadius=(character.Maximum-character.Minimum).Length()*.5f;
                float scale=character.AnatomicalHeight/180;
                var origin=Engine(new Vec(wizard.Anatomy.SymmetryPlaneX,character.Minimum.Y,wizard.Anatomy["Pelvis"].Z));
                keyLight.Position=origin+new Vector3(120,100,160)*scale;fillLight.Position=origin+new Vector3(-120,-100,90)*scale;
                keyLight.Radius=fillLight.Radius=1000*scale;
            }
        }
        if(wizard.Character is null)return;
        bool upload=positions is not null||!showingBind;
        for(int part=0;upload&&part<wizard.Character.Meshes.Length;part++)
        {
            var source=wizard.Character.Meshes[part];var points=positions?[part] ?? source.Vertices;
            bool corners=source.CornerNormals.Length>0||source.CornerTexCoords.Length>0||source.CornerColors.Length>0;
            bool create=part>=meshes.Count;
            if(create){normalBuffers.Add(new Vec[points.Length]);vertexBuffers.Add(new Vertex[corners?source.Triangles.Length:points.Length]);}
            var normals=normalBuffers[part];Array.Clear(normals);
            for(int t=0;t<source.Triangles.Length;t+=3)
            {
                var a=source.Triangles[t];var b=source.Triangles[t+1];var c=source.Triangles[t+2];var normal=Vec.Cross(points[b]-points[a],points[c]-points[a]);normals[a]+=normal;normals[b]+=normal;normals[c]+=normal;
            }
            var vertices=vertexBuffers[part];
            for(int i=0;i<vertices.Length;i++)
            {
                int v=corners?source.Triangles[i]:i;var normal=normals[v];
                if(source.CornerNormals.Length>0&&(positions is null||boneRotations is not null))
                {
                    normal=source.CornerNormals[i];
                    if(positions is not null&&wizard.Rig is not null)
                    {
                        var bind=normal;normal=Vec.Zero;
                        foreach(var influence in wizard.Rig.Weights[part][v])normal+=Vec.Transform(bind,boneRotations[influence.Bone])*influence.Weight;
                    }
                }
                var uv=source.CornerTexCoords.Length>0?new Vector2(source.CornerTexCoords[i].X,1-source.CornerTexCoords[i].Y):Vector2.Zero;
                vertices[i]=new Vertex(Engine(points[v]),Engine(normal.LengthSquared()>1e-12f?Vec.Normalize(normal):Vec.UnitY),new Vector4(1,0,0,1),uv);
                if(source.CornerColors.Length>0){var color=source.CornerColors[i];vertices[i].Color=new Color(color.X,color.Y,color.Z,color.W);}
            }
            if(source.CornerTexCoords.Length>0)UpdateTangents(vertices);
            var mesh=create?new Mesh(Material.Load("materials/dev/gray_grid_8.vmat")):meshes[part];
            if(create)
            {
                mesh.CreateVertexBuffer(vertices.Length,vertices);
                var indices=PreviewIndices(source,corners);
                if(source.TriangleMaterials.Length>0)
                {
                    var groups=Enumerable.Range(0,indices.Length/3).GroupBy(t=>source.TriangleMaterials[t]).ToArray();
                    var sorted=groups.SelectMany(g=>g.SelectMany(t=>indices.Skip(t*3).Take(3))).ToArray();mesh.CreateIndexBuffer(sorted.Length,sorted);
                    int offset=0;
                    foreach(var group in groups)
                    {
                        int count=group.Count()*3;
                        var material=Material.Load(MaterialPaths.TryGetValue(group.Key,out var path)?path:"materials/dev/gray_grid_8.vmat");
                        if(offset==0){mesh.Material=material;mesh.SetIndexRange(0,count);}
                        else mesh.AddSubMesh(material,offset,count);
                        offset+=count;
                    }
                }
                else mesh.CreateIndexBuffer(indices.Length,indices);
            }
            else mesh.SetVertexBufferData<Vertex>(vertices.AsSpan());
            // The canonical X axis is negated in engine space, so its extrema swap.
            var minimum=points.Aggregate(Vec.Min);var maximum=points.Aggregate(Vec.Max);
            mesh.Bounds=new BBox(new Vector3(minimum.Z,-maximum.X,minimum.Y),new Vector3(maximum.Z,-minimum.X,maximum.Y));
            if(create){meshes.Add(mesh);models.Add(new SceneModel(Scene.SceneWorld,Model.Builder.AddMesh(mesh).Create(),Transform.Zero));}
            GeometryUploadCount++;
        }
        showingBind=positions is null;
        RefreshLandmarks();
        if(wizard.Rig is not null)bones.Draw(wizard.Rig,wizard.Anatomy!,displayedBones,boneRotations);else bones.Clear();
    }
    internal static int[] PreviewIndices(MeshPart source,bool corners)
    {
        // Engine() reflects canonical X. Reverse each face to keep its front
        // and authored normal facing outward after that coordinate conversion.
        var indices=corners?Enumerable.Range(0,source.Triangles.Length).ToArray():(int[])source.Triangles.Clone();
        for(int t=0;t<indices.Length;t+=3)(indices[t+1],indices[t+2])=(indices[t+2],indices[t+1]);
        return indices;
    }
    static void UpdateTangents(Vertex[] vertices)
    {
        for(int t=0;t<vertices.Length;t+=3)
        {
            var edge1=vertices[t+1].Position-vertices[t].Position;var edge2=vertices[t+2].Position-vertices[t].Position;
            var uv1=vertices[t+1].TexCoord0-vertices[t].TexCoord0;var uv2=vertices[t+2].TexCoord0-vertices[t].TexCoord0;
            float determinant=uv1.x*uv2.y-uv1.y*uv2.x;
            var tangent=Math.Abs(determinant)>1e-10f?(edge1*uv2.y-edge2*uv1.y)/determinant:Vector3.Zero;
            var bitangent=Math.Abs(determinant)>1e-10f?(edge2*uv1.x-edge1*uv2.x)/determinant:Vector3.Zero;
            for(int c=t;c<t+3;c++)
            {
                var normal=vertices[c].Normal;var direction=tangent-normal*Vector3.Dot(normal,tangent);
                if(direction.LengthSquared<1e-12f)direction=Vector3.Cross(normal,Math.Abs(normal.z)<.9f?Vector3.Up:Vector3.Forward);
                direction=direction.Normal;float sign=Vector3.Dot(Vector3.Cross(normal,direction),bitangent)<0?-1:1;
                vertices[c].Tangent=new Vector4(direction.x,direction.y,direction.z,sign);
            }
        }
    }
    public void SetPose(IReadOnlyDictionary<string,Quat> rotations,bool animate=true)
    {
        EndPointerDrag();poseDirty=false;
        if(previewRig!=wizard.Rig){transition.Reset();previewRig=wizard.Rig;}
        if(previewRig is null){ShowCharacter();return;}
        if(displayedCharacter!=wizard.Character)ShowCharacter();
        transition.Begin(rotations);transitionStarted=0;
        if(!animate){transition.Sample(1);RenderPose();}
        Update();
    }
    void UpdateFrame()
    {
        Camera.Hud.list.Reset();
        Camera.FovAxis=CameraComponent.Axis.Vertical;
        Camera.CustomSize=Size*DpiScale;
        Camera.Viewport=new Vector4(0,0,1,1);
        // Model units and translations vary by importer. Fit the clipping planes
        // to the actual scene instead of discarding large characters at 10,000.
        Camera.ZNear=Math.Max(.001f,cameraDistance*.0001f);
        Camera.ZFar=Math.Max(Camera.ZNear*100,Vector3.DistanceBetween(Camera.WorldPosition,characterCenter)+characterRadius*2);
        if(framePending&&Width>0&&Height>0){framePending=false;FrameNow();}
        if(cameraAnimating)
        {
            CameraTransitionFrames++;
            float t=Math.Clamp((float)Stopwatch.GetElapsedTime(cameraStarted).TotalSeconds/.28f,0,1);t=t*t*(3-2*t);
            Camera.WorldPosition=Vector3.Lerp(cameraFrom.Position,cameraTo.Position,t);Camera.WorldRotation=Rotation.Slerp(cameraFrom.Rotation,cameraTo.Rotation,t);
            if(t>=1)cameraAnimating=false;else Update();
        }
        stats??=new ViewportStats(Scene.SceneWorld);
        stats.Scale=DpiScale;
        stats.ShowNavigation=NavigationEnabled;
        if(statsCharacter!=wizard.Character)
        {
            statsCharacter=wizard.Character;float height=statsCharacter?.AnatomicalHeight??0;
            int wholeInches=(int)MathF.Round(height/2.54f,MidpointRounding.AwayFromZero);
            stats.Text=statsCharacter is null?"":$"Total tris: {statsCharacter.Meshes.Sum(m=>m.Triangles.Length/3):N0}\nCharacter height: {height:F2} cm / {height/2.54f:F2} inches / {wholeInches/12}'{wholeInches%12}\" feet";
        }
        markers??=new LandmarkOverlay(Scene.SceneWorld);
        markers.Draw(VisiblePoints(),DpiScale,selected,wizard.Step is WizardStep.LeftHand or WizardStep.RightHand?4.75f:7);
        centerline??=new CenterlineOverlay(Scene.SceneWorld);
        centerline.Show=wizard.Step==WizardStep.Centerline;centerline.Scale=DpiScale;
        if(centerline.Show)centerline.X=CenterlinePosition.x/Math.Max(Width,1);
        if(previewRig!=wizard.Rig){transition.Reset();poseDirty=false;return;}
        if(poseDirty){poseDirty=false;RenderPose();return;}
        if(!transition.Active)return;
        if(transitionStarted==0)transitionStarted=Stopwatch.GetTimestamp();
        transition.Sample((float)Stopwatch.GetElapsedTime(transitionStarted).TotalSeconds/.22f);
        RenderPose();if(transition.Active)Update();
    }
    void RenderPose()
    {
        var posed=Deformation.BoneTransforms(previewRig,transition.Current);
        if(transition.Current.Count==0)
        {
            for(int i=0;i<poseBuffers.Length;i++)wizard.Character!.Meshes[i].Vertices.CopyTo(poseBuffers[i],0);
            ShowCharacter(null,posed.Positions,posed.Rotations);
        }
        else{Deformation.ApplyTransforms(wizard.Character!,previewRig,posed.Positions,posed.Rotations,poseBuffers);ShowCharacter(poseBuffers,posed.Positions,posed.Rotations);}
        PoseFrameCount++;
    }
    public void Frame()
    {
        EndPointerDrag();framePending=true;Update();
    }
    void FrameNow()
    {
        var model=wizard.Character;if(model is null)return;
        var minimum=model.Minimum;var maximum=model.Maximum;
        focus=(minimum+maximum)*.5f;
        if(wizard.Step is WizardStep.LeftHand or WizardStep.RightHand)
        {
            var side=wizard.Step==WizardStep.LeftHand?"L":"R";
            // The wrist was confirmed with the body. Retain it for framing,
            // including hands without fingers, without making it another handle.
            var wrist=wizard.Anatomy!["Hand."+side];
            var points=VisiblePoints().Select(p=>p.Position).Append(wrist).Append(wizard.Anatomy.PalmCenters.GetValueOrDefault(side,wrist)).ToArray();
            var min=points.Aggregate(Vec.Min);var max=points.Aggregate(Vec.Max);
            minimum=min-Vec.One*model.AnatomicalHeight*.015f;maximum=max+Vec.One*model.AnatomicalHeight*.015f;focus=(min+max)*.5f;
            if(wizard.Anatomy!.Hands.TryGetValue(side,out var hand))
            {
                // A palm plane has two normals. View its outward side so the
                // torso or thigh does not hide a hand resting beside the body.
                var normal=hand.Normal;
                var outward=focus-new Vec(wizard.Anatomy.SymmetryPlaneX,focus.Y,wizard.Anatomy["Pelvis"].Z);
                if(Vec.Dot(normal,outward)<0)normal=-normal;
                var angles=Rotation.LookAt(Engine(normal)).Angles();yaw=angles.yaw;pitch=angles.pitch;
            }
        }
        else if(wizard.Step==WizardStep.Centerline){yaw=0;pitch=0;}
        else{yaw=15;pitch=8;}
        var direction=new Angles(pitch,yaw,0).Forward;var rotation=Rotation.LookAt(-direction,Vector3.Up);
        float tangent=MathF.Tan(Camera.FieldOfView*MathF.PI/360),aspect=ViewSize.x/ViewSize.y;
        cameraDistance=0;
        for(int x=0;x<2;x++)for(int y=0;y<2;y++)for(int z=0;z<2;z++)
        {
            var offset=Engine(new Vec(x==0?minimum.X:maximum.X,y==0?minimum.Y:maximum.Y,z==0?minimum.Z:maximum.Z)-focus);
            float depth=Vector3.Dot(offset,direction);
            cameraDistance=Math.Max(cameraDistance,depth+Math.Max(Math.Abs(Vector3.Dot(offset,rotation.Up))/tangent,Math.Abs(Vector3.Dot(offset,rotation.Left))/(tangent*aspect)));
        }
        cameraDistance=Math.Max(cameraDistance*1.12f,model.AnatomicalHeight*.025f);
        var previous=Camera.WorldTransform;UpdateCamera();
        if(hasCameraFrame)
        {
            cameraFrom=previous;cameraTo=Camera.WorldTransform;Camera.WorldTransform=previous;
            cameraStarted=Stopwatch.GetTimestamp();cameraAnimating=true;
        }
        hasCameraFrame=true;Update();
    }
    void UpdateCamera()
    {
        var direction=new Angles(pitch,yaw,0).Forward;
        Camera.WorldRotation=Rotation.LookAt(-direction,Vector3.Up);
        // Keep one full-sized render surface. Offset the camera so its subject
        // is centered beside the overlaid joint panel; rendering and picking
        // then share the engine's full-window projection.
        float inset=RightInset*cameraDistance*MathF.Tan(Camera.FieldOfView*MathF.PI/360)/Math.Max(Height,1);
        Camera.WorldPosition=Engine(focus)+direction*cameraDistance-Camera.WorldRotation.Left*inset;
    }
    IEnumerable<Landmark> VisiblePoints()
        =>stagePoints;
    void RefreshLandmarks()
    {
        if(wizard.Anatomy is null||wizard.Step is WizardStep.Generating or WizardStep.Validation or WizardStep.Finish){stagePoints=[];return;}
        if(wizard.Step==WizardStep.Centerline){stagePoints=[];return;}
        var points=wizard.Anatomy.Points.Values;
        if(wizard.Step==WizardStep.Body){stagePoints=points.Where(p=>!Profiles.Fingers.Any(p.Role.StartsWith)&&p.Role is not ("Root" or "SpineLower" or "SpineMid")).ToArray();return;}
        string side=wizard.Step==WizardStep.LeftHand?"L":"R";
        stagePoints=points.Where(p=>p.Role.EndsWith("."+side)&&Profiles.Fingers.Any(p.Role.StartsWith)).ToArray();
        if(selected is not null&&!stagePoints.Any(p=>p.Role==selected))selected=null;
    }
    Vector2 Project(Vec position)
    {
        var point=Camera.PointToScreenNormal(Engine(position),out bool behind);
        return behind?new(-9999,-9999):point*Size;
    }
    protected override void OnResize(){base.OnResize();Resized?.Invoke();}
    protected override void OnMousePress(MouseEvent e)
    {
        LastPointerEvent=$"Press {e.Button} / {e.ButtonState} / {e.LocalPosition}";
        base.OnMousePress(e);last=e.LocalPosition;
        BeginNavigation(e.LocalPosition,e.Button,e.HasAlt);e.Accepted=true;
    }
    protected override void OnMouseReleased(MouseEvent e){base.OnMouseReleased(e);EndPointerDrag();e.Accepted=true;}
    internal void EndPointerDrag(){navigation=Navigation.None;dragButton=MouseButtons.None;}
    internal static Navigation Gesture(MouseButtons button,bool alt)=>button switch
    {
        MouseButtons.Left=>alt?Navigation.Pan:Navigation.Rotate,
        MouseButtons.Right=>alt?Navigation.Rotate:Navigation.Pan,
        MouseButtons.Middle=>Navigation.Zoom,_=>Navigation.None
    };
    internal void BeginNavigation(Vector2 position,MouseButtons button,bool alt)
    {
        EndPointerDrag();dragButton=button;
        if(!NavigationEnabled)
        {
            last=position;navigation=Navigation.None;
            if(button==MouseButtons.Left&&!alt&&AllowEditing)BeginPointerDrag(position);
            return;
        }
        if(cameraAnimating)
        {
            cameraAnimating=false;var angles=Rotation.LookAt(-Camera.WorldRotation.Forward).Angles();yaw=angles.yaw;pitch=angles.pitch;
            focus=Canonical(Camera.WorldPosition+Camera.WorldRotation.Forward*cameraDistance);
        }
        last=position;navigation=Gesture(button,alt);
        if(button==MouseButtons.Left&&!alt&&AllowEditing)BeginPointerDrag(position);
    }
    internal Vector2 MarkerPosition(string role)=>Project(wizard.Anatomy![role]);
    internal string BeginPointerDrag(Vector2 mouse)
    {
        last=mouse;dragButton=MouseButtons.Left;
        if(wizard.Step==WizardStep.Finish&&wizard.Rig is not null)
        {
            if(poseDirty){poseDirty=false;RenderPose();}
            var shape=PickBone(mouse);selected=shape?.Role;
            navigation=shape is null?Navigation.Rotate:Navigation.PoseBone;
            bones.Select(selected);SelectionChanged?.Invoke(selected);
            if(shape is not null)
            {
                dragPose=new Dictionary<string,Quat>(transition.Current);transition.Begin(dragPose);transition.Sample(1);
                dragPivot=shape.Start;dragGrab=Vec.Lerp(shape.Start,shape.End,.6f);dragPlaneNormal=Camera.WorldRotation.Forward;
                dragCursor=OnDragPlane(mouse,dragGrab,dragPlaneNormal);
            }
            return selected;
        }
        if(wizard.Step==WizardStep.Centerline)
        {
            selected=null;navigation=Navigation.None;
            if(AllowEditing&&Math.Abs(mouse.x-CenterlinePosition.x)<(Math.Abs(mouse.y-Height*.5f)<24?36:10))
            {navigation=Navigation.Centerline;return "Centerline";}
            return null;
        }
        selected=VisiblePoints().Where(p=>(Project(p.Position)-mouse).Length<11).OrderBy(p=>(Project(p.Position)-mouse).Length).ThenBy(p=>Vector3.DistanceBetween(Engine(p.Position),Camera.WorldPosition)).FirstOrDefault()?.Role;
        navigation=selected is null?Navigation.Rotate:Navigation.Marker;
        SelectionChanged?.Invoke(selected);
        return selected;
    }
    internal void MovePointer(Vector2 mouse)
    {
        var delta=mouse-last;last=mouse;
        if(delta.LengthSquared==0)return;
        if(!NavigationEnabled&&navigation!=Navigation.Centerline){EndPointerDrag();return;}
        if(navigation==Navigation.None)return;
        if(navigation==Navigation.PoseBone&&selected is not null)
        {
            var target=dragGrab+Canonical(OnDragPlane(mouse,dragGrab,dragPlaneNormal)-dragCursor);
            var rotations=RigPoseEditing.Rotate(wizard.Rig,dragPose,selected,dragGrab-dragPivot,target-dragPivot);
            transition.Begin(rotations);transition.Sample(1);poseDirty=true;PoseEdited?.Invoke(transition.Current);
        }
        else if(navigation==Navigation.Centerline)
        {
            var p=CenterlinePoint;var normal=Camera.WorldRotation.Forward;
            Vector3 OnPlane(Vector2 pixel)
            {
                var ray=Camera.ScreenPixelToRay(pixel*DpiScale);
                return ray.Position+ray.Forward*(Vector3.Dot(Engine(p)-ray.Position,normal)/Vector3.Dot(ray.Forward,normal));
            }
            wizard.CorrectCenterline(p.X+Canonical(OnPlane(mouse)-OnPlane(mouse-new Vector2(delta.x,0))).X);
        }
        else if(navigation==Navigation.Marker&&selected is not null)
        {
            // Intersect both pointer rays with the selected handle's camera-facing plane.
            // Using the camera's projection keeps picking correct at every aspect ratio and DPI.
            var p=wizard.Anatomy![selected];var normal=Camera.WorldRotation.Forward;
            Vector3 OnPlane(Vector2 point)
            {
                var ray=Camera.ScreenPixelToRay(point*DpiScale);
                float distance=Vector3.Dot(Engine(p)-ray.Position,normal)/Vector3.Dot(ray.Forward,normal);
                return ray.Position+ray.Forward*distance;
            }
            wizard.Correct(selected,p+Canonical(OnPlane(mouse)-OnPlane(mouse-delta)));RefreshLandmarks();Changed?.Invoke();
        }
        else if(navigation==Navigation.Rotate){yaw-=delta.x*.4f;pitch=Math.Clamp(pitch+delta.y*.3f,-85,85);UpdateCamera();}
        else if(navigation==Navigation.Pan)
        {
            float unitsPerPixel=2*cameraDistance*MathF.Tan(Camera.FieldOfView*MathF.PI/360)/Math.Max(Height,1);
            focus+=Canonical((-Camera.WorldRotation.Left*delta.x-Camera.WorldRotation.Up*delta.y)*unitsPerPixel);UpdateCamera();
        }
        else if(navigation==Navigation.Zoom)Zoom(delta.y*.015f);
        Update();
    }
    protected override void OnMouseMove(MouseEvent e)
    {
        LastPointerEvent=$"Move {e.Button} / {e.ButtonState} / {e.LocalPosition} / {navigation}";
        base.OnMouseMove(e);
        e.Accepted=MovePointerFromEvent(e.LocalPosition,e.ButtonState);
    }
    internal bool MovePointerFromEvent(Vector2 position,MouseButtons buttons)
    {
        // A release can occur outside the viewport. Another pressed button must
        // never continue the previous marker drag with a stale pointer position.
        if(dragButton!=MouseButtons.None&&(buttons&dragButton)==dragButton&&navigation!=Navigation.None){MovePointer(position);return true;}
        else
        {
            EndPointerDrag();
            last=position;
            var point=VisiblePoints().Where(p=>(Project(p.Position)-last).Length<11).OrderBy(p=>(Project(p.Position)-last).Length).FirstOrDefault();
            ToolTip=wizard.Step==WizardStep.Centerline&&Math.Abs(last.x-CenterlinePosition.x)<36?"Drag left or right":wizard.Step==WizardStep.Finish?PickBone(position)?.Role is {} role?"Drag to pose "+role:"":point is null?"":point.Role;
        }
        return false;
    }
    Vector3 OnDragPlane(Vector2 pixel,Vec point,Vector3 normal)
    {
        var ray=Camera.ScreenPixelToRay(pixel*DpiScale);float dot=Vector3.Dot(ray.Forward,normal);
        return Math.Abs(dot)<1e-6f?Engine(point):ray.Position+ray.Forward*(Vector3.Dot(Engine(point)-ray.Position,normal)/dot);
    }
    internal Vector2 BoneHandlePosition(string role)
    {
        var shape=bones.PickShapes.Single(s=>s.Role==role);return Project(Vec.Lerp(shape.Start,shape.End,.6f));
    }
    BoneOverlay.PickShape PickBone(Vector2 mouse)
    {
        var hits=new List<(BoneOverlay.PickShape Shape,float Depth)>();
        foreach(var shape in bones.PickShapes)
        {
            var a=Project(shape.Start);var b=Project(shape.End);if(a.x== -9999||b.x== -9999)continue;
            var direction=b-a;float t=direction.LengthSquared<1e-5f?0:Math.Clamp(Vector2.Dot(mouse-a,direction)/direction.LengthSquared,0,1);
            float radius=7*DpiScale;
            // Match the visible octahedron, including the broad base section.
            foreach(var vertex in shape.Vertices.Skip(1).Take(4))
            {
                var p=Project(vertex);float along=direction.LengthSquared<1e-5f?0:Math.Clamp(Vector2.Dot(p-a,direction)/direction.LengthSquared,0,1);
                float width=(p-a-direction*along).Length;
                radius=Math.Max(radius,width*(t<.1f?t/.1f:(1-t)/.9f));
            }
            if((mouse-a-direction*t).Length>radius)continue;
            var point=Engine(Vec.Lerp(shape.Start,shape.End,t));float depth=Vector3.Dot(point-Camera.WorldPosition,Camera.WorldRotation.Forward);
            if(depth>0)hits.Add((shape,depth));
        }
        return hits.OrderBy(h=>h.Shape.Role==selected?0:1).ThenBy(h=>h.Depth).Select(h=>h.Shape).FirstOrDefault();
    }
    internal void Zoom(float amount)
    {
        if(!NavigationEnabled)return;
        float height=wizard.Character?.AnatomicalHeight??100;
        cameraDistance=Math.Clamp(cameraDistance*MathF.Exp(amount),height*.005f,height*20);UpdateCamera();Update();
    }
    protected override void OnMouseWheel(WheelEvent e){Zoom(-e.Delta*.001f);e.Accepted=true;}
    public override void OnDestroyed(){OnPreFrame-=UpdateFrame;stats?.Delete();markers?.Delete();centerline?.Delete();bones.Clear();foreach(var model in models)model.Delete();Scene?.Destroy();Scene=null;base.OnDestroyed();}
    public void Capture(string path)
    {
        var pixmap=new Pixmap(800,800);
        if(!Camera.RenderToPixmap(pixmap))throw new InvalidOperationException("Viewport render failed.");
        pixmap.SavePng(path);
    }
}