Editor/EffigyEditor/EffigyViewport.Notes.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Grease-pencil notes in the viewport: rays in, scribbles out.
///
/// AS THIN AS EffigyViewport.Sculpting.cs, AND FOR THE SAME REASON. Every decision — where a sample
/// lands in depth, whether the pointer travelled far enough to earn one, which note an erase click
/// is aiming at, what undo means — is in <see cref="NoteSession"/> in the kernel, where NoteTests
/// runs it without an engine. This file turns Vector3 into Vec3, calls four methods, and draws
/// lines.
///
/// NOTES DRAW WHETHER OR NOT THE TOOL IS ARMED, which is the one behaviour here that is not
/// borrowed from sculpting. A sculpt brush ring is feedback about a mode you are in; a note is a
/// message you left yourself, and a message that is only visible while you are holding the pen is
/// not a message. So <see cref="DrawNotes"/> runs from the ordinary frame and <see cref="ShowNotes"/>
/// is what turns them off — one toggle, for when the handwriting is in front of the thing you are
/// trying to look at.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>The live note session, or null when the pen is not armed. Drawing does not need
/// it — see the class header — so this being null does not hide anything.</summary>
public NoteSession NoteSession { get; private set; }
public bool IsNoting => NoteSession is not null;
/// <summary>Whether notes are painted at all. Off hides them everywhere, including while the
/// pen is armed, because "let me see the model for a second" is a thing you want without
/// putting the pen down.</summary>
public bool ShowNotes = true;
/// <summary>The eraser rather than the pen. A mode on the session's owner rather than on the
/// session, because it changes what a click MEANS rather than what a stroke does.</summary>
public bool NoteErasing;
/// <summary>The notes to paint. Set to the studio's own list, so a note appears the moment a
/// stroke commits with nothing to copy or invalidate.</summary>
public IReadOnlyList<Note> Notes { get; set; } = Array.Empty<Note>();
/// <summary>Raised after a stroke commits or a note is erased, so the window can mark the
/// document dirty. Not raised per sample — a stroke is one edit, the same rule sculpting
/// follows.</summary>
public Action NoteChanged { get; set; }
/// <summary>Raised when the pen is clicked on a note that already exists, carrying that note, so
/// the window can put a text box in front of it. The viewport has no idea how to prompt for a
/// string, and should not learn.</summary>
public Action<Note> NoteTextRequested { get; set; }
/// <summary>
/// Roughly how big the part is, in world units, so the things drawn AROUND a note — the caption's
/// offset, the dot on a degenerate note — scale with the model the way NoteSession.ScaleTo makes
/// the stroke itself scale. Set by the window from the studio's bounds; one unit is the default
/// primitive, which is the right guess for an empty studio.
/// </summary>
public float PartSize { get; set; } = 1f;
/// <summary>Where the pen would put a point this frame, or null when there is nowhere to put
/// one.</summary>
private Vec3? _noteCursor;
/// <summary>The note the eraser is over, so it can be shown before it is destroyed. An erase
/// with no preview is a click that deletes something you had not identified yet.</summary>
private Note _noteHovered;
/// <summary>Guards against the click that opened the caption box immediately starting a stroke
/// underneath it.</summary>
private RealTimeSince _sinceNoteText;
public void BeginNotes( NoteSession session )
{
NoteSession = session ?? throw new ArgumentNullException( nameof( session ) );
ShowNotes = true;
}
public void EndNotes()
{
// A stroke left running when the mode ends would hold a note nobody will ever commit.
// Cancel rather than commit: leaving the mode is not a way to finish a stroke.
NoteSession?.CancelStroke();
NoteSession = null;
_noteCursor = null;
_noteHovered = null;
NoteErasing = false;
}
// --- input ---------------------------------------------------------------------------------
private void NoteFrame()
{
if ( NoteSession is null )
return;
_noteCursor = null;
_noteHovered = null;
var stroking = NoteSession.IsStroking;
// The pointer leaving the canvas does not end a stroke, only stops new samples — same rule
// as a sculpt stroke, and for the same reason: dragging off the model and back is ordinary.
if ( _canvasHasCursor )
{
var ray = Gizmo.CurrentRay;
var origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
var direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );
if ( NoteErasing )
{
_noteHovered = NoteSession.Pick( origin, direction );
// HELD, NOT JUST PRESSED — the same shape as the sketch Cut tool: hold the button and
// drag through what you want gone, rather than one click per note. A click still works,
// it is just a drag that happens to end where it started.
if ( Gizmo.IsLeftMouseDown && NoteSession.Erase( origin, direction ) )
NoteChanged?.Invoke();
}
else
{
_noteCursor = NoteSession.Project( origin, direction );
if ( !stroking && Gizmo.WasLeftMousePressed )
{
// A press on an existing note is a request to caption it rather than the start
// of a stroke drawn on top of it. Writing on your own handwriting is the one
// place a scribble tool has an unambiguous second meaning, and it saves the
// caption needing a mode of its own.
var under = NoteSession.Pick( origin, direction );
if ( under is not null )
{
NoteTextRequested?.Invoke( under );
_sinceNoteText = 0f;
}
else if ( _sinceNoteText > 0.25f && NoteSession.BeginStroke( origin, direction ) )
{
stroking = true;
}
}
else if ( stroking && Gizmo.IsLeftMouseDown )
{
NoteSession.MoveTo( origin, direction );
}
}
}
// Released. There is no WasLeftMouseReleased in the Gizmo input this editor uses, so the end
// of a stroke is the frame the button is no longer down — which is the same thing and needs
// no API that might not be there.
if ( stroking && !Gizmo.IsLeftMouseDown )
{
if ( NoteSession.EndStroke() is not null )
NoteChanged?.Invoke();
}
DrawNoteCursor();
}
/// <summary>
/// E for the eraser, H to hide the notes. Both are [Shortcut]s on the window now, so they show
/// up in Settings > Hotkeys and can be rebound. Undo is NOT here — it is Ctrl+Z like
/// everywhere else, routed to the session by EffigyWindow.Undo.
/// </summary>
// --- drawing -------------------------------------------------------------------------------
/// <summary>
/// Every committed note, plus the stroke in progress.
///
/// IgnoreDepth, so a note is never buried by the part it is about. A note behind the model that
/// you cannot read is worse than useless: you know something is written there and you have to
/// orbit to find out what. Annotation in every tool that has it floats over the geometry, and
/// the lift NoteSession applies is what keeps it from looking detached.
/// </summary>
private void DrawNotes()
{
if ( !ShowNotes )
return;
Gizmo.Draw.IgnoreDepth = true;
// THE SESSION'S LIST WINS WHILE THE PEN IS ARMED, and that is not belt-and-braces - it is
// the fix for a stroke that vanished the instant you let go of the mouse. The stroke in
// progress is drawn from Working, so it was visible on the way down; on release it moved
// into the studio's list, and Notes still pointed at the empty default because the only
// thing that ever assigned it was RefreshNotes, which only runs on a rebuild. Drawing a
// note therefore did not itself make the note appear.
//
// Reading through the session removes the dependency on anybody remembering to assign
// anything: while armed, what is drawn IS what the session is writing into.
foreach ( var note in NoteSession?.Notes ?? Notes )
DrawNote( note, hovered: ReferenceEquals( note, _noteHovered ) );
// The stroke being drawn, which is not in the list until it commits.
if ( NoteSession?.Working is { } working )
DrawNote( working, hovered: false );
// Put both back. DrawNote leaves the thickness wherever the last note wanted it, and every
// gizmo drawn after this in the frame would inherit it — which is how one fat note turns
// the origin handle and the reference planes fat too.
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.IgnoreDepth = false;
}
private void DrawNote( Note note, bool hovered )
{
if ( note is null || note.Points.Count == 0 )
return;
var swatch = NotePalette.At( note.Color );
var colour = new Color( swatch.R, swatch.G, swatch.B, 1f );
// The eraser's target goes red and heavy whatever colour it was drawn in, so "this is the
// one that is about to go" survives the note already being red.
Gizmo.Draw.Color = hovered ? NoteEraseColor : colour;
// Width is already a pixel thickness, so it goes straight in. It used to be multiplied by
// 3.5 here, which was compensating for a default that had been written as though it were a
// world distance.
Gizmo.Draw.LineThickness = MathF.Max( note.Width, 0.5f ) * (hovered ? 1.6f : 1f);
var previous = ToVector( note.Points[0] );
for ( var i = 1; i < note.Points.Count; i++ )
{
var point = ToVector( note.Points[i] );
Gizmo.Draw.Line( previous, point );
previous = point;
}
// A single-point note cannot happen through the pen — EndStroke drops those — but a
// hand-edited document can carry one, and a dot beats drawing nothing at all. The radius is
// a world distance where Width is pixels, so it cannot be Width: it is sized off the note
// itself, which is the only length this method has to hand.
if ( note.Points.Count == 1 )
Gizmo.Draw.SolidSphere( previous, NoteDotRadius, 8, 8 );
if ( string.IsNullOrWhiteSpace( note.Text ) )
return;
// Lifted off the anchor so the words sit beside the mark rather than across it. In world
// units on +z, which is up in this editor's Source-convention space.
var anchor = ToVector( note.Anchor ) + Vector3.Up * (PartSize * NoteTextLiftFraction);
Gizmo.Draw.Color = hovered ? NoteEraseColor : colour;
Gizmo.Draw.WorldText( note.Text, new Transform( anchor ), "Roboto", NoteTextSize, TextFlag.Center );
// A leader from the words back to the mark. Without it a caption on a busy part reads as
// belonging to whatever it happens to be floating over.
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.Color = (hovered ? NoteEraseColor : colour).WithAlpha( 0.4f );
Gizmo.Draw.Line( ToVector( note.Anchor ), anchor );
}
/// <summary>Where the next point would land. Small and unfilled — this is the only cursor in the
/// tool that has to not obscure the thing it is pointing at, because you are aiming it at your
/// own handwriting.</summary>
private void DrawNoteCursor()
{
if ( NoteErasing )
{
// Nothing of its own to draw: the hovered note is already painted red and fat by
// DrawNote, which says more than a ring around it would.
return;
}
if ( _noteCursor is not { } point || NoteSession is null )
return;
var swatch = NotePalette.At( NoteSession.Color );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = new Color( swatch.R, swatch.G, swatch.B, 0.9f );
Gizmo.Draw.SolidSphere( ToVector( point ), NoteDotRadius * 1.6f, 10, 10 );
Gizmo.Draw.IgnoreDepth = false;
}
private static Vector3 ToVector( Vec3 v ) => new( v.x, v.y, v.z );
/// <summary>How far above its anchor a caption floats, as a fraction of the part's size. A
/// constant here would sit on top of a one-unit box and a mile above a room — the same mistake
/// NoteSession.ScaleTo exists to undo.</summary>
private const float NoteTextLiftFraction = 0.12f;
/// <summary>Radius of the dot drawn for a degenerate one-point note, in world units, as a
/// fraction of the part.</summary>
private float NoteDotRadius => MathF.Max( PartSize * 0.006f, 1e-4f );
private const float NoteTextSize = 11f;
/// <summary>The colour of "this is what the eraser will take". The same red the rest of this
/// editor uses for a destructive action.</summary>
private static readonly Color NoteEraseColor = new( 1f, 0.35f, 0.32f, 1f );
}