Editor/EffigyEditor/EffigyViewport.MaterialDrop.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// Dropping a material onto a face.
///
/// The other half of <see cref="EffigyMaterialBrowser"/>, and the reason the browser is worth
/// having: a material list you can only look at is a material list you may as well have opened in
/// the asset browser. The gesture is the feature.
///
/// It is deliberately NOT a mode. Nothing has to be armed, no tool has to be selected, and the
/// viewport does not care what it was doing a moment ago — the same reasoning as the right-click
/// face menu in EffigyViewport.FaceMenu.cs, which this shares its raycast with. You point at a
/// face, you let go, that face wears the material.
///
/// WHERE THE SLOT COMES FROM. Nowhere on screen. A face carries a slot number and the drop names
/// only a material, so something has to choose the number; Effigy.MaterialDrop does, and does it in
/// the kernel where MaterialDropTests can hold the choice to account. This file's job stops at
/// turning a drop position into a face.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>Raised when a material is dropped on a face: the face, and the material's relative
/// path. As with the context menu, the viewport resolves what was pointed at and stops — what
/// it MEANS for the document is the window's business, because the window owns the studio, the
/// undo stack and the rebuild.</summary>
public Action<EffigyFaceHit, string> MaterialDropped { get; set; }
/// <summary>The face the drag is currently over, held between hover events so the frame loop
/// can light it up. Null when the drag is over nothing pickable, or over nothing at all.</summary>
private EffigyFaceHit? _dropHit;
/// <summary>The material the current drag carries, or null when it carries something that is
/// not one. Kept only so the highlight can be dropped the moment the drag leaves.</summary>
private string _dropMaterial;
/// <summary>Green rather than the pick blue or the selected amber, because it is neither: those
/// two say "this is what your click will choose", and this says "letting go here changes the
/// model". A third meaning gets a third colour.</summary>
private static readonly Color MaterialDropColor = new( 0.35f, 0.9f, 0.5f, 1f );
/// <summary>Turn drops on. Called from the constructor — a widget that has not asked for them
/// never sees a drag at all, and the failure is silent in exactly the way that costs an hour.
/// </summary>
private void EnableMaterialDrops() => AcceptDrops = true;
/// <summary>Light up the face the drag is over. Called from OnPreFrame with the rest of the
/// per-frame drawing, because Gizmo.Draw only means anything inside the scene's own frame and a
/// drag event is nowhere near one.</summary>
private void MaterialDropFrame()
{
TellCameraItsSize();
ProbeDropFrame();
if ( _dropHit is { } hit )
DrawFace( hit.Body, hit.FaceIndex, MaterialDropColor );
}
private float _lastFrameProbe;
/// <summary>The same geometry as ReportDropGeometry, but from INSIDE the frame. Whether those
/// two disagree is the question — a camera that only knows its viewport while it is rendering
/// cannot be asked for a ray from a Qt event handler.</summary>
private void ProbeDropFrame()
{
if ( !_probeDrop || RealTime.Now - _lastFrameProbe < 1f )
return;
_lastFrameProbe = RealTime.Now;
var canvas = _canvas.IsValid() ? _canvas.Size : Vector2.Zero;
// The ray through the MIDDLE of the canvas, which must come out parallel to the way the
// camera is facing. If it does not, ScreenPixelToRay is not usable here at all and every
// drop has been aiming at nothing — which no amount of care about the position could fix.
// Where the gizmo thinks the cursor is, and the ray it built from it. That ray is the one
// every other pick in this viewport uses and they are all accurate, so it is the reference
// to calibrate against: whichever FOV convention reproduces it is the right one.
// THE DECIDING MEASUREMENT. Two opposite corners of the canvas must give two clearly
// different rays. A spread of exactly 1 means they are the same ray — the pixel is being
// ignored, every drop is cast through the middle of the view, and only the face in the
// middle can ever be hit.
var topLeft = _camera.ScreenPixelToRay( new Vector2( 4f, 4f ) * DpiScale );
var bottomRight = _camera.ScreenPixelToRay( (canvas - new Vector2( 4f, 4f )) * DpiScale );
Log.Info( $"[effigy-drop] IN FRAME: canvas={canvas}"
+ $" viewport={_camera.ScreenRect.Size} customSize={_camera.CustomSize}"
+ $" cornerSpread={Vector3.Dot( topLeft.Forward, bottomRight.Forward ):0.#####}"
+ " (1 = the pixel is ignored and only one face is reachable)" );
}
public override void OnDragHover( DragEvent ev )
{
base.OnDragHover( ev );
_dropHit = null;
_dropMaterial = MaterialFromDrag( ev.Data );
ProbeDrop( ev, _dropMaterial );
if ( _dropMaterial is null || !DropsAllowed )
{
ev.Action = DropAction.Ignore;
return;
}
// Copy whether or not a face is under the cursor. Refusing the drag over empty space would
// flick the cursor between "yes" and "no" as you cross the model's silhouette on the way to
// the face you are aiming at, which reads as the window rejecting the material rather than
// as you not being on a face yet. What actually stops a miss is the hit test in OnDragDrop.
ev.Action = DropAction.Copy;
if ( TryPickFaceAt( ev.LocalPosition, out var hit ) )
_dropHit = hit;
}
public override void OnDragDrop( DragEvent ev )
{
base.OnDragDrop( ev );
var material = MaterialFromDrag( ev.Data );
_dropHit = null;
_dropMaterial = null;
if ( material is null || !DropsAllowed )
return;
// Picked again from the drop position rather than trusting the last hover. The two are
// usually the same face, but a drop can arrive without a hover in front of it — dropping
// straight in from another window is one way — and acting on a face resolved from a stale
// position would paint whatever the cursor last passed over.
if ( !TryPickFaceAt( ev.LocalPosition, out var hit ) )
return;
MaterialDropped?.Invoke( hit, material );
}
public override void OnDragLeave()
{
base.OnDragLeave();
_dropHit = null;
_dropMaterial = null;
}
/// <summary>
/// Whether a drop can land right now.
///
/// The same list the right-click menu refuses on, and for the same reason: while a sketch is
/// open or a dialog has armed a pick, the bodies are not what you are pointing at, and painting
/// one mid-pick would edit a body the feature being configured may not even be allowed to
/// touch. Sculpting is in the list too — a drop there would land on the cage.
/// </summary>
private bool DropsAllowed =>
!IsSketching && !PlanePickMode && !SketchPickMode && !FacePickMode && !BodyPickMode && !BoneToolActive;
/// <summary>
/// The face under a point in THIS widget, for a drag that has no cursor ray to borrow.
///
/// Everywhere else in the viewport aims with Gizmo.CurrentRay, which is built once a frame from
/// where the mouse is hovering. A drag is not a hover: the canvas does not report itself under
/// the mouse while one is in progress, so CaptureCursorRay holds whatever ray was current when
/// the drag began — the far side of the screen, usually the browser you dragged out of. The ray
/// has to be built from the position the drag event carries instead.
///
/// THE PIXEL SPACE IS MEASURED, NOT ASSUMED, and that is the whole point of this function's
/// shape. It multiplied the widget-local position by DpiScale at first, copying the scene
/// viewport, whose comment says the camera renders at physical pixels while Qt reports logical
/// ones. That is true THERE because that widget sets `CustomSize = Renderer.Size * DpiScale`
/// itself; this one never sets CustomSize at all, so whatever pixel space its camera ended up in
/// was a guess — and a guess that is wrong by any factor at all does not miss by a little. It
/// scales the position AWAY FROM THE TOP-LEFT CORNER, so drops near the corner still land near
/// where you aimed and everything further out walks off the model entirely: exactly one region
/// of the viewport appears to accept materials and the rest silently does nothing.
///
/// So ask the camera. ScreenRect is the viewport it actually renders into, whatever set it, and
/// the same fraction across the canvas is the same fraction across that. No DpiScale, no
/// CustomSize, nothing that has to stay in agreement with a widget we do not own.
/// </summary>
private bool TryPickFaceAt( Vector2 position, out EffigyFaceHit hit )
{
hit = default;
if ( !_canvas.IsValid() || !_camera.IsValid() )
return false;
if ( !TryCanvasPixel( position, out var pixel ) )
return false;
var ray = _camera.ScreenPixelToRay( pixel );
// Vector3 -> Vec3 is a straight re-type, not a transform: the kernel's axes and s&box's
// line up exactly. Same conversion CaptureCursorRay does.
return TryPickFace(
new Vec3( ray.Position.x, ray.Position.y, ray.Position.z ),
new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z ),
out hit );
}
/// <summary>
/// Turn a dragged payload into a material path, or null.
///
/// Text first, because that is where both the editor's own asset list and
/// <see cref="EffigyMaterialBrowser"/> put the relative path, and because a multi-selection
/// arrives as one line per asset — the first is the one under the cursor.
///
/// Then RESOLVED THROUGH THE ASSET SYSTEM rather than believed. Two things fall out of that.
/// A model or a sound dragged onto a face is refused instead of being written into MaterialNames
/// as a material that will never load, which is a document you cannot tell is broken by looking
/// at it. And the path stored is the asset's own RelativePath, so a drag and a pick through the
/// browse button in EffigyMaterialSlot write the same spelling for the same file — which is what
/// lets MaterialDrop.SlotFor recognise a material it has already given a slot to.
/// </summary>
private static string MaterialFromDrag( DragData data )
{
if ( data is null )
return null;
foreach ( var candidate in DraggedPaths( data ) )
{
var path = candidate?.Trim();
if ( string.IsNullOrWhiteSpace( path ) )
continue;
if ( AssetSystem.FindByPath( path ) is { } asset )
{
if ( asset.AssetType == AssetType.Material )
return asset.RelativePath;
continue;
}
// Not something the asset system knows. Accepted only when it is plainly a relative
// material path — a document that arrived from another machine and refers to a material
// this one has not compiled yet is a real case, and refusing it would make the drop
// silently do nothing. An ABSOLUTE path is refused whatever it ends in: it is true on
// one machine, and storing it would export a material nobody else can resolve.
if ( !Path.IsPathRooted( path ) && path.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) )
return path.Replace( '\\', '/' );
}
return null;
}
/// <summary>
/// Tell the camera how big it is, every frame.
///
/// CustomSize's own documentation says the camera size is "screen size or render target size"
/// when this is null — and for a camera living in a SceneRenderingWidget it is neither: its
/// ScreenRect comes back 0x0. Everything that renders works anyway, because rendering is handed
/// the target's dimensions directly; everything that asks the camera to turn a PIXEL into
/// something does not, because there is no pixel space to divide by.
///
/// ScreenPixelToRay is the one that mattered here. With a zero ScreenRect it returns the same
/// centre ray for every pixel — the probe below measures exactly that, by casting through two
/// opposite corners and comparing — so every material drop, wherever it was released, was cast
/// through the middle of the view and hit whichever face happened to be there. One face would
/// take materials and the rest silently would not.
///
/// The scene viewport sets this on its own camera for the same reason, in the same place: once a
/// frame, from the frame tick, because the dock can be resized between any two of them.
/// </summary>
private void TellCameraItsSize()
{
if ( !_camera.IsValid() || !_canvas.IsValid() )
return;
var size = _canvas.Size * DpiScale;
if ( size.x >= 1f && size.y >= 1f )
_camera.CustomSize = size;
}
/// <summary>
/// A ray through a pixel of the canvas, built by hand.
///
/// CameraComponent.ScreenPixelToRay cannot do this here, and quietly: this camera renders into a
/// SceneRenderingWidget rather than the game's screen, so its ScreenRect is 0x0, and the pixel it
/// is handed divides out to nothing. It returns the camera's own centre ray for EVERY pixel —
/// top-left and bottom-right corners come back identical, which the probe below measures. That
/// is why the drop only ever hit one face: every release, anywhere in the viewport, was cast
/// through the middle of the view.
///
/// So: an ordinary pinhole, out of THE CAMERA'S OWN PROJECTION MATRIX rather than out of its
/// FieldOfView. Whether that angle is the horizontal or the vertical one is a convention nothing
/// in the API states, and getting it wrong scales every ray by the aspect ratio — which on a
/// nearly square dock is a 5% error that looks like imprecision rather than like a bug, and on a
/// wide one is badly wrong. The projection matrix has already resolved it: M11 and M22 are the
/// reciprocals of the half-angle tangents it is actually rendering with, so dividing by them
/// needs no aspect, no field of view and no convention.
/// </summary>
private Ray RayFrom( Vector2 pixel, Vector2 size )
{
var rotation = _camera.WorldRotation;
var origin = _camera.WorldPosition;
if ( size.x < 1f || size.y < 1f )
return new Ray( origin, rotation.Forward );
var projection = _camera.ProjectionMatrix;
// A projection that has not been set up yet would divide the ray out to the horizon.
if ( MathF.Abs( projection.M11 ) < 1e-6f || MathF.Abs( projection.M22 ) < 1e-6f )
return new Ray( origin, rotation.Forward );
// +1 at the right edge and +1 at the TOP, which is the flip that matters: pixels count down
// the screen and the camera's up axis counts up it.
var x = (pixel.x / size.x) * 2f - 1f;
var y = 1f - (pixel.y / size.y) * 2f;
var direction = rotation.Forward
+ rotation.Right * (x / projection.M11)
+ rotation.Up * (y / projection.M22);
return new Ray( origin, direction.Normal );
}
/// <summary>
/// A position in this widget, turned into a pixel in the camera's own viewport.
///
/// Separate from the pick so the probe below can report the numbers without casting a ray, which
/// is the difference between "the drop missed" and "the drop was aimed somewhere else".
/// </summary>
private bool TryCanvasPixel( Vector2 position, out Vector2 pixel )
{
pixel = default;
var local = position - _canvas.Position;
var size = _canvas.Size;
if ( size.x < 1f || size.y < 1f )
return false;
// Outside the 3D canvas entirely — over a tool strip, or past an edge. A ray built from here
// still resolves to something, which is exactly the problem: it would paint a face while you
// were letting go over a button.
if ( local.x < 0f || local.y < 0f || local.x > size.x || local.y > size.y )
return false;
var viewport = _camera.ScreenRect.Size;
// A camera with no viewport yet — the first frame, or a dock being dragged — would divide the
// position by nothing and aim at the corner.
if ( viewport.x < 1f || viewport.y < 1f )
return false;
pixel = new Vector2( viewport.x * (local.x / size.x), viewport.y * (local.y / size.y) );
return true;
}
// --- MATERIAL DROP PROBE ----------------------------------------------------------------------
//
// For the one question that costs an afternoon: when a drop does nothing, was it aimed at the
// wrong place or was it never allowed to act? Those look identical on screen — no highlight, no
// change — and they have nothing to do with each other.
//
// WHAT EACH FIELD RULES OUT, in the order the chain breaks:
//
// material What the drag carries, after the asset system has vouched for it. "none" means
// nothing else below matters: it is being refused as not-a-material.
// allowed DropsAllowed. False means a sketch is open or a dialog has armed a pick, and
// the drop is being declined on purpose.
// local Where the event says the cursor is, in this widget.
// canvas The 3D canvas's size. local should be inside it.
// viewport What the camera thinks it renders into. IF THESE TWO DIFFER, every ray is
// built through that ratio - which is the bug this probe was written for.
// dpi What the old code multiplied by. Kept only so a report can say whether the
// ratio above happens to equal it.
// pixel Where the ray is actually aimed.
// hit The face it found, or none. A miss with sane numbers above means you are
// genuinely off the model.
private static bool _probeDrop;
/// <summary>
/// Turn the material drop probe on or off: `effigy_probe_drop 1`.
///
/// Reports the geometry IMMEDIATELY as well as arming the per-hover line, because the one number
/// that matters — whether the camera's viewport matches the canvas it is drawn in — does not
/// need a drag to be true, and needing one made the question impossible to ask from a console.
/// </summary>
[ConCmd( "effigy_probe_drop" )]
public static void SetDropProbe( int on )
{
_probeDrop = on != 0;
if ( !_probeDrop )
{
Log.Info( "[effigy-drop] off" );
return;
}
Log.Info( "[effigy-drop] on — drag a material over the viewport for a line per move" );
if ( EffigyWindow.Current?.DiagnosticViewport is not { } viewport )
{
Log.Info( "[effigy-drop] no Effigy window open, so no canvas to measure" );
return;
}
viewport.ReportDropGeometry();
}
/// <summary>The canvas, the camera's viewport, and the ratio between them. If that ratio is not
/// 1:1 every drop ray is aimed through it, which is the whole bug this was written to find.
/// </summary>
internal void ReportDropGeometry()
{
var canvas = _canvas.IsValid() ? _canvas.Size : Vector2.Zero;
var viewport = _camera.IsValid() ? _camera.ScreenRect.Size : Vector2.Zero;
var ratio = canvas.x > 0f && canvas.y > 0f
? $"{viewport.x / canvas.x:0.###} x {viewport.y / canvas.y:0.###}"
: "unknown";
Log.Info( $"[effigy-drop] canvas={canvas} viewport={viewport} ratio={ratio} dpi={DpiScale}"
+ $" — a ratio of 1 x 1 means the canvas and the camera agree and the old DpiScale"
+ $" multiply was the fault; anything else is the ratio every ray was skewed by." );
}
private float _lastDropProbe;
/// <summary>Called from the hover, rate-limited: a drag fires these as fast as the mouse moves
/// and an unthrottled line per event is a console you cannot read.</summary>
private void ProbeDrop( DragEvent ev, string material )
{
if ( !_probeDrop || RealTime.Now - _lastDropProbe < 0.25f )
return;
_lastDropProbe = RealTime.Now;
var canvas = _canvas.IsValid() ? _canvas.Size : Vector2.Zero;
var viewport = _camera.IsValid() ? _camera.ScreenRect.Size : Vector2.Zero;
var pixel = TryCanvasPixel( ev.LocalPosition, out var p ) ? p.ToString() : "outside";
var face = TryPickFaceAt( ev.LocalPosition, out var hit ) ? $"{hit.Body?.Name}#{hit.FaceIndex}" : "none";
Log.Info( $"[effigy-drop] material={material ?? "none"} allowed={DropsAllowed}"
+ $" local={ev.LocalPosition} canvas={canvas} viewport={viewport} dpi={DpiScale}"
+ $" pixel={pixel} hit={face}" );
}
private static IEnumerable<string> DraggedPaths( DragData data )
{
if ( !string.IsNullOrWhiteSpace( data.Text ) )
{
foreach ( var line in data.Text.Split( '\n' ) )
yield return line;
}
if ( data.Files is { } files )
{
foreach ( var file in files )
yield return file;
}
}
}