Editor/Effigy/Features/MaterialDrop.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Dropping a material onto a face.
///
/// THE PROBLEM THIS SOLVES. Faces carry a slot number, not a material — see FaceMaterialEdit for
/// why that has to stay true — and PartStudio.MaterialNames maps the number to a name. Every
/// existing way in names a slot you have already chosen: the Materials panel browses FOR slot 5,
/// the face menu browses for the slot the face is already on. Dragging a material out of a browser
/// and letting go over a face names no slot at all. It says "this face, this material" and leaves
/// the number entirely to us.
///
/// So this is the half that was missing: turn a material into the slot that should carry it, then
/// do the ordinary face assignment with it. The rule is one slot per material, reused —
/// <see cref="SlotFor"/> hands back the slot that already carries the material if there is one, so
/// dropping the same material on thirty faces produces one slot and one assignment feature rather
/// than thirty of each. Only a material nobody has used yet takes a fresh slot.
///
/// AND IT PUTS BACK WHAT IT TOOK. A drop that moves a face off a slot nothing else is holding
/// retires that slot's name too — see <see cref="ReleaseVacatedSlot"/>. Without it, changing your
/// mind about one face is a one-way ratchet: the slot count only ever goes up, the rejected
/// materials stay bound to slots no face wears, and the exporters write every one of them.
///
/// It edits the HISTORY, never the mesh, exactly as FaceMaterialEdit does, and for the same reason:
/// bodies are remade from scratch on every rebuild.
/// </summary>
public static class MaterialDrop
{
/// <summary>The highest slot a face can be on — FaceMaterialFeature.Material clamps to 0..63,
/// so a slot past this could be stored and would never come back.</summary>
public const int HighestSlot = 63;
/// <summary>
/// Which slot should carry <paramref name="material"/>, or -1 when there is nowhere to put it.
///
/// Three answers, in order:
///
/// 1. THE SLOT ALREADY CARRYING IT. Checked first and by name, so a second drop of the same
/// material joins the first rather than opening a second slot that renders identically. The
/// lowest such slot wins if a document somehow named two, purely so the answer is stable.
///
/// 2. THE LOWEST SLOT NOBODY IS USING, counting from 1. Used means named OR painted on — a slot
/// with an assignment feature and no name is the result of the face menu's "put this face on
/// slot 3", and taking it here would silently repaint those faces with the dropped material.
///
/// 3. NOTHING, when all 63 are spoken for.
///
/// SLOT 0 IS NEVER ALLOCATED, though it is returned by rule 1 if somebody has named it. It is
/// the slot every face starts on and the one the viewport pointedly does not tint: handing it to
/// a drop would paint the whole part instead of the one face under the cursor. Naming slot 0
/// remains something you do deliberately, from the Materials panel, where the consequence is on
/// screen next to it.
/// </summary>
public static int SlotFor( PartStudio studio, string material )
{
if ( studio is null )
return -1;
if ( Normalise( material ) is null )
return -1;
if ( SlotCarrying( studio, material ) is var carrying && carrying >= 0 )
return carrying;
var taken = new HashSet<int>( FaceMaterialEdit.UsedSlots( studio ) );
for ( var slot = 1; slot <= HighestSlot; slot++ )
{
if ( !taken.Contains( slot ) )
return slot;
}
return -1;
}
/// <summary>
/// The slot already carrying <paramref name="material"/>, or -1 if no slot does.
///
/// Rule 1 of <see cref="SlotFor"/>, on its own, because a browser asking "does this part already
/// use this material, and where" must not be answered with the free slot SlotFor would hand back
/// — that would badge every material in the project with the same number and claim the document
/// uses all of them.
///
/// The LOWEST such slot if a document somehow named two, purely so the answer is stable, and
/// matched through <see cref="Normalise"/> so a slot named with backslashes still recognises the
/// asset a picker hands over with forward ones.
/// </summary>
public static int SlotCarrying( PartStudio studio, string material )
{
if ( studio is null )
return -1;
var wanted = Normalise( material );
if ( wanted is null )
return -1;
foreach ( var (slot, name) in studio.MaterialNames.OrderBy( kv => kv.Key ) )
{
if ( Normalise( name ) == wanted )
return slot;
}
return -1;
}
/// <summary>
/// Put <paramref name="material"/> on one face, and report whether anything changed.
///
/// The face is identified the way the right-click menu identifies it — the body and face index
/// a raycast just returned, plus the FaceRef captured at the hit point, which is the half that
/// survives a rebuild. <paramref name="slot"/> comes back so the caller can say which slot it
/// landed on, because that number is the only thing on screen afterwards that explains where the
/// material went; it is -1 when nothing was done.
///
/// Call Rebuild afterwards. Deliberately not done here, for the same reason FaceMaterialEdit
/// does not: a caller dropping onto several faces should pay for one rebuild, not one each.
/// </summary>
public static bool Drop( PartStudio studio, string bodyId, int faceIndex, FaceRef reference,
string material, out int slot ) =>
Drop( studio, bodyId, faceIndex, reference, material, out slot, out _ );
/// <summary>
/// The same drop, also reporting the slot it retired — see <see cref="ReleaseVacatedSlot"/> —
/// or -1 when it retired none.
///
/// Worth saying out loud rather than doing quietly. The drop already has to announce the slot it
/// chose, because nothing else on screen explains where the material went; a slot that stopped
/// existing on the same gesture is the same kind of fact, and the Materials panel's count is
/// about to change because of it.
/// </summary>
public static bool Drop( PartStudio studio, string bodyId, int faceIndex, FaceRef reference,
string material, out int slot, out int released )
{
slot = -1;
released = -1;
if ( studio is null )
return false;
var name = material?.Trim();
if ( string.IsNullOrWhiteSpace( name ) )
return false;
slot = SlotFor( studio, name );
if ( slot < 0 )
return false;
// The NAME first, then the face. Both are edits and either can be the only one: dropping a
// material the document has never seen names a fresh slot and moves the face onto it, while
// dropping it onto a second face names nothing new and only moves the face.
//
// Compared through Normalise, not by string equality, so re-dropping the same asset spelled
// with backslashes does not rewrite the name to the other spelling. The stored value would
// still resolve to the same material, but the document would come back dirty, an undo step
// would appear, and every open control would refresh — for a change nobody made.
var named = false;
if ( !studio.MaterialNames.TryGetValue( slot, out var existing ) || Normalise( existing ) != Normalise( name ) )
{
studio.MaterialNames[slot] = name;
named = true;
}
// Whether the face is ALREADY on this slot, asked before Assign rather than inferred from
// what it returns. Assign detaches before it attaches, so putting a face back where it
// already was reports a change every time — true of the mechanism, wrong as an answer, and
// the reason the right-click menu checks the same thing before calling it. Here it is not
// an optimisation: dropping a material onto the face already wearing it is the ordinary way
// to MISS by a few pixels, and reporting it as an edit puts a do-nothing step on the undo
// stack that then has to be pressed through.
var previous = FaceSlot( studio, bodyId, faceIndex );
var moved = previous != slot
&& FaceMaterialEdit.Assign( studio, bodyId, faceIndex, reference, slot );
// The face has left a slot behind. If it was the last thing holding that slot, the slot goes
// with it — otherwise re-dropping onto one face walks it through a trail of named slots that
// nothing wears and every exporter still writes.
if ( moved && ReleaseVacatedSlot( studio, previous, slot ) )
released = previous;
return named || moved || released >= 0;
}
/// <summary>
/// Let go of the binding on the slot a drop just emptied, and say whether it did.
///
/// WHY A DROP HAS TO CLEAN UP AFTER ITSELF. Every other way of naming a slot names a slot you
/// picked; a drop invents the number, so the numbers it invents are the ones nobody is watching.
/// Changing your mind about one face five times walks it through five slots, and Detach does
/// retire the four assignment features it emptied — but the four NAMES stay, and a name is what
/// the exporters write. A box wearing three materials exports nine, and the first anyone hears
/// of it is a material list in the engine that does not match the part.
///
/// NARROW ON PURPOSE. This retires ONE slot — the one this face just left — and only when
/// nothing else is holding it:
///
/// - An assignment feature still targeting it means other faces are on it. A SUPPRESSED one
/// counts as holding it too, because un-suppressing is one click away and the name has to
/// still be there when it happens.
/// - More than one face on it in the mesh means the slot did not come from an assignment at all
/// — a feature that built geometry straight onto it — and those faces still wear it. The mesh
/// read here is the one from BEFORE this edit, so the face being moved is still counted on its
/// old slot: a count of one is that face alone, two is somebody else as well.
///
/// Slot 0 is never retired. It is the absence of an assignment rather than a binding this drop
/// is entitled to clear, and a name on it is the part's base material that every untouched face
/// is still wearing.
///
/// A slot named in the Materials panel and never painted is untouched by all of this, because no
/// face ever left it. Reserving a slot now and filling it in later stays a thing you can do.
/// </summary>
private static bool ReleaseVacatedSlot( PartStudio studio, int vacated, int landedOn )
{
if ( vacated <= 0 || vacated == landedOn )
return false;
if ( !studio.MaterialNames.ContainsKey( vacated ) )
return false;
if ( studio.Features.OfType<FaceMaterialFeature>().Any( f => f.Material.Clamped == vacated ) )
return false;
if ( FacesOn( studio, vacated ) > 1 )
return false;
// The SIZE goes with the name. A slot number that has been handed back is going to be handed
// out again by SlotFor, and a scale left on it is inherited by whatever material lands there
// next — brushed steel arriving at 48 units per tile because a floor tile used to be on slot
// 3. The scale is only meaningful alongside the binding it was chosen for.
MaterialScale.SetScale( studio, vacated, MaterialScale.Unscaled );
return studio.MaterialNames.Remove( vacated );
}
/// <summary>How many faces sit on a slot, across every body, in the mesh as it currently
/// stands.</summary>
private static int FacesOn( PartStudio studio, int slot )
{
var count = 0;
foreach ( var body in studio.Bodies ?? Enumerable.Empty<Body>() )
{
if ( body?.Mesh is not { } mesh )
continue;
count += mesh.Faces.Count( f => f.Material == slot );
}
return count;
}
/// <summary>
/// The slot a face is on right now, or -1 if the body or face cannot be found.
///
/// Read off the BUILT mesh rather than worked out from the assignments in the tree, because the
/// mesh is where they have all already been applied in order — including a later assignment
/// overriding an earlier one on the same face, which reading the features would have to redo.
/// </summary>
private static int FaceSlot( PartStudio studio, string bodyId, int faceIndex )
{
var body = studio?.Bodies?.FirstOrDefault( b => b?.Id == bodyId );
if ( body?.Mesh is not { } mesh || faceIndex < 0 || faceIndex >= mesh.Faces.Count )
return -1;
return mesh.Faces[faceIndex].Material;
}
/// <summary>
/// A material path reduced to something two spellings of the same asset agree on.
///
/// Separators and case, because a path typed by hand, one from an asset picker and one from a
/// drag can differ in both while naming one file, and a document that disagrees with itself
/// about that grows a second slot for a material it already has.
///
/// Public because the Materials dock has to key a lookup of every material in the project by the
/// same rule this file matches slots with. It could have asked <see cref="SlotCarrying"/> once
/// per material instead, and that is a scan of the whole project against the whole slot table on
/// every rebuild — which includes every tick of a dragged parameter. Exporting the rule lets it
/// build the index once and walk the handful of named slots instead. What must not happen is a
/// second copy of the rule over there: the two would agree until one of them learned about
/// trailing slashes.
/// </summary>
public static string Normalise( string path ) =>
string.IsNullOrWhiteSpace( path ) ? null : path.Trim().Replace( '\\', '/' ).ToLowerInvariant();
}