Editor/Effigy/Features/MaterialScale.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// How big a material is, in world units, per slot.
///
/// THE PROBLEM THIS SOLVES. Extrude caps take sketch coordinates straight through as UVs, so one
/// texture repeat covers one unit — and a unit is an inch. Drop a floor tile on a diner floor and
/// you get two hundred repeats across the room, which reads as noise rather than as tile. Nothing
/// short of adding a UV Project feature could change that, and a UV Project is a whole-body,
/// material-blind operation buried in the tree: the wrong instrument for "this one material is the
/// wrong size".
///
/// ON THE SLOT, NOT THE FACE. s&box's own mesh editor keeps TextureScale per face, because its
/// faces carry a material directly and there is nothing else for it to hang on. Effigy has the slot
/// indirection and <see cref="MaterialDrop"/> spends its whole existence keeping one material on one
/// slot, so the same fact belongs where the material name already lives: slot 2 is tile at 48 units
/// everywhere in the document, or it is nothing. It also means the number survives a rebuild without
/// being a feature, exactly as MaterialNames does and for the same reason — bodies are remade from
/// scratch every time, and a number written onto a face would be gone.
///
/// The cost, said out loud: changing a slot's scale changes it everywhere that material is used.
/// That is almost always the point. When it is not, the escape hatches are a second slot, or the
/// per-body UVProjectFeature, which was always the tool for "these faces, differently".
///
/// UNITS PER TILE, not units per texel. s&box says 0.25 and means a quarter of a unit per texel;
/// UVProjectFeature.Scale already says "Units per tile" and means the world size of one full repeat.
/// Two vocabularies for one idea inside one program is worse than disagreeing with the engine, so
/// this follows the one Effigy already had.
///
/// WHAT IT DOES NOT FIX. Extrude SIDES are parameterised 0..1 around the perimeter and 0..1 up the
/// height, so their UVs are not in units at all and a units-per-tile number cannot be literally true
/// there — it comes out as "this many repeats across the whole side". <see cref="Fit"/> is the
/// honest answer on such a face, because it measures the UVs it actually finds rather than assuming
/// what they mean. The real fix is to make sides project in world units like the caps do, which
/// changes the mapping on every existing model and is therefore its own job.
/// </summary>
public static class MaterialScale
{
/// <summary>One tile per unit: the mapping the features produce, left alone.</summary>
public static readonly Vec2 Unscaled = new( 1f, 1f );
/// <summary>
/// The size one repeat of a slot's material covers, or <see cref="Unscaled"/> when nobody has
/// said.
///
/// Absence means one-to-one rather than "unset", which is what lets every document written
/// before this existed keep rendering exactly as it did.
/// </summary>
public static Vec2 ScaleFor( PartStudio studio, int slot )
{
if ( studio is null || !studio.MaterialScales.TryGetValue( slot, out var scale ) )
return Unscaled;
return Sanitise( scale );
}
/// <summary>
/// Set a slot's size, and report whether anything changed.
///
/// A scale back at 1:1 REMOVES the entry rather than storing it. The document writes one line
/// per stored scale, and a line saying "this slot is the size it would have been anyway" is a
/// diff for nothing — the same reason StudioDocument only writes an origin that has moved.
/// </summary>
public static bool SetScale( PartStudio studio, int slot, Vec2 scale )
{
if ( studio is null || slot < 0 || slot > MaterialDrop.HighestSlot )
return false;
var wanted = Sanitise( scale );
if ( Same( ScaleFor( studio, slot ), wanted ) )
return false;
if ( Same( wanted, Unscaled ) )
return studio.MaterialScales.Remove( slot );
studio.MaterialScales[slot] = wanted;
return true;
}
/// <summary>
/// Divide every scaled slot's UVs through the model, in place.
///
/// CALLED ONCE, AT THE END OF THE REBUILD, and it has to be: a feature's UVs are the input to
/// this, so running it mid-tree would let the next feature inherit already-scaled UVs and the
/// one after that scale them again. PartStudio.Rebuild replaces Bodies wholesale and the
/// incremental cache holds clones taken inside the feature loop, so what this touches is never
/// what the next rebuild reads back — which is the property that keeps it from compounding.
///
/// A slot at 1:1 is skipped rather than divided by one, so a document with no scales set does no
/// float work per corner.
/// </summary>
public static void Apply( PartStudio studio )
{
if ( studio is null || studio.MaterialScales.Count == 0 )
return;
foreach ( var body in studio.Bodies ?? Enumerable.Empty<Body>() )
Apply( body?.Mesh, studio.MaterialScales );
}
/// <summary>The same divide over one mesh, for tests and for anything holding a mesh rather than
/// a studio.</summary>
public static void Apply( PolyMesh mesh, IReadOnlyDictionary<int, Vec2> scales )
{
if ( mesh is null || scales is null || scales.Count == 0 )
return;
// Sanitised once per SLOT rather than once per corner: a stored zero would otherwise divide
// every UV on the slot into infinity, and the check has the same answer every time.
var clean = new Dictionary<int, Vec2>( scales.Count );
foreach ( var (slot, scale) in scales )
{
var value = Sanitise( scale );
if ( !Same( value, Unscaled ) )
clean[slot] = value;
}
if ( clean.Count == 0 )
return;
foreach ( var face in mesh.Faces )
{
if ( face.UVs is null || !clean.TryGetValue( face.Material, out var scale ) )
continue;
for ( var i = 0; i < face.UVs.Length; i++ )
face.UVs[i] = new Vec2( face.UVs[i].x / scale.x, face.UVs[i].y / scale.y );
}
}
/// <summary>
/// The scale that makes a material repeat <paramref name="repeats"/> times across one face —
/// the mesh editor's Fit button, and the only answer that is right on a face whose UVs are not
/// in units.
///
/// MEASURED FROM THE BUILT MESH, WHICH IS ALREADY SCALED. <see cref="Apply"/> has run by the
/// time anything can point at a face, so the extent read here is the extent AFTER the slot's
/// current scale was divided out. <paramref name="current"/> multiplies it back, so fitting the
/// same face twice lands on the same number instead of walking the scale down by a factor of
/// itself every time.
///
/// A face with no extent on an axis — a side seen edge-on in UV space, a degenerate cap — keeps
/// whatever it had on that axis rather than dividing by zero.
/// </summary>
public static Vec2 Fit( PolyMesh mesh, int faceIndex, Vec2 current, float repeats = 1f )
{
var scale = Sanitise( current );
if ( mesh is null || faceIndex < 0 || faceIndex >= mesh.Faces.Count || repeats <= 0f )
return scale;
var face = mesh.Faces[faceIndex];
if ( face.UVs is null || face.UVs.Length == 0 )
return scale;
var minU = float.MaxValue;
var minV = float.MaxValue;
var maxU = float.MinValue;
var maxV = float.MinValue;
foreach ( var uv in face.UVs )
{
minU = MathF.Min( minU, uv.x );
minV = MathF.Min( minV, uv.y );
maxU = MathF.Max( maxU, uv.x );
maxV = MathF.Max( maxV, uv.y );
}
var spanU = (maxU - minU) * scale.x;
var spanV = (maxV - minV) * scale.y;
return new Vec2(
spanU > 1e-6f ? spanU / repeats : scale.x,
spanV > 1e-6f ? spanV / repeats : scale.y );
}
/// <summary>
/// A scale that can be divided by: positive and finite on both axes.
///
/// A zero or a NaN reaches here from a document somebody edited by hand and from a field
/// mid-keystroke, and either one turns every UV on the slot into infinity — which renders as
/// nothing at all, with no clue where it came from. Falling back per axis rather than wholesale
/// keeps a usable u when only v is broken.
/// </summary>
public static Vec2 Sanitise( Vec2 scale ) =>
new( Usable( scale.x ) ? scale.x : 1f, Usable( scale.y ) ? scale.y : 1f );
private static bool Usable( float value ) =>
float.IsFinite( value ) && value > 1e-6f;
private static bool Same( Vec2 a, Vec2 b ) =>
MathF.Abs( a.x - b.x ) < 1e-6f && MathF.Abs( a.y - b.y ) < 1e-6f;
}