Editor window partial for the Clothing workspace in the Effigy editor. It builds UI stages and tools for wearer selection, garment creation, shaping, material/fur sync, testing poses for clipping, checks, push-out fixes, and publishing (writing .vmat, .cloth.json and .clothing files), plus helper functions for deforming/marking meshes and installing a model's rig into the document.
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// The Clothing workspace: bring a body in to dress, cut a garment from it, grow fur on it — and
/// the fur materials the Fur features need.
///
/// A WORKSPACE RATHER THAN A STRIP, since v-next. Clothing started as two buttons borrowed onto the
/// Model home bar and the Rig bar, which was the right size for two buttons and the wrong size for
/// the job: dressing a body is its own pass with its own order (wearer, garment, fur, fabric), and
/// a workflow that has an order wants a bar that states it. It also needed docks of its own —
/// Materials open, because a fabric is what you reach for the moment the garment fits.
///
/// NOTHING TO FINISH, exactly like Rig. Every tool here adds an ordinary feature, so the work is in
/// the tree the instant the button is pressed; a green tick that only changed which glyphs were on
/// screen would teach the wrong thing about what Finish means in this editor.
/// </summary>
public sealed partial class EffigyWindow
{
/// <summary>The clothing stage set, built with the others in RebuildStages.</summary>
private List<EffigyStage> _clothingStages;
/// <summary>Which clothing stage was last looked at, so leaving and coming back lands where you
/// were — the same courtesy _partStage does for CAD and _rigStage for Rig.</summary>
private int _clothingStage;
/// <summary>
/// "Clothing" as a workspace. A plain mode change, for the reason EnterRig gives: there is no
/// session to open and no feature to roll back to, only a different set of tools.
/// </summary>
private void EnterClothing()
{
if ( _viewport is null || _stageBar is null )
return;
LeaveCurrentWorkspace();
// A modal feature dialog and the clothing bar would be two things claiming the model at
// once — same argument EnterSculpt and EnterRig both make.
_dialog?.Close();
BarMode = EffigyBarMode.Clothing;
_stageBar.Mode = "CLOTHING";
_stageBar.SetFinish( null, null );
_stageBar.SetStages( _clothingStages, _clothingStage );
SetPrompt( HasWearableBody()
? "Clothing: select the body to dress, then add a Garment."
: "Clothing: press Wearer to load a rigged model to dress, or rig a body first." );
}
/// <summary>Is there anything here a garment could be cut from — a body that is not itself a
/// garment, under a rig with bones? The prompt asks this and nothing else does.</summary>
private bool HasWearableBody() => _studio is { Rig.Count: > 0 } && _studio.Features.Count > 0;
private List<EffigyStage> BuildClothingStages() =>
new() { BuildWearerStage(), BuildClothingStage(), BuildGarmentShapeStage(), BuildGarmentMaterialStage(),
BuildGarmentTestStage(), BuildGarmentCheckStage(), BuildGarmentPublishStage() };
/// <summary>
/// Material: what the garment is made of, to look at.
///
/// ONE BUTTON, BECAUSE IT IS ONE DECISION. Painting is a workspace of its own and always will
/// be; picking "denim, this blue" is not painting, it is the thing you do before deciding
/// whether to paint at all, and most garments never need more. See FabricFeature.
/// </summary>
private EffigyStage BuildGarmentMaterialStage()
{
var stage = new EffigyStage { Name = "Material" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.FaceMaterial,
Label = "Fabric",
Tip = "Give the garment a fabric - jersey, denim, leather, satin, plaid and more - as a real "
+ "material: a tiling weave with normal and roughness maps, and the Complex shader switch "
+ "that kind of cloth needs. Select a garment first, or it goes on every garment.",
Clicked = () => AddFeature( NewFeature( ToolKind.Fabric, -1 ) ),
} );
return stage;
}
/// <summary>
/// Write each Fabric feature's maps and .vmat and bind them to its garments' slots. The same
/// shape as SyncFurMaterials: after every rebuild, and only rewriting what changed.
/// </summary>
private void SyncFabricMaterials()
{
if ( _studio is null )
return;
foreach ( var fabric in _studio.Features.OfType<FabricFeature>() )
{
if ( fabric.Suppressed || fabric.ResolvedSlots.Count == 0 || fabric.Error is not null )
continue;
try
{
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy/fabric" );
Directory.CreateDirectory( folder );
var tag = new string( fabric.Id.Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray() );
var colour = FurMaterial.ParseHex( fabric.Colour.Value, new Vec3( 0.35f, 0.43f, 0.55f ) );
var accent = FurMaterial.ParseHex( fabric.Accent.Value, new Vec3( 0.91f, 0.89f, 0.83f ) );
var relColor = $"models/effigy/fabric/fabric_{tag}_color.png";
var relNormal = $"models/effigy/fabric/fabric_{tag}_normal.png";
var relRough = $"models/effigy/fabric/fabric_{tag}_rough.png";
var relVmat = $"models/effigy/fabric/fabric_{tag}.vmat";
// The maps are half a second of maths at 512; a recipe string over the settings says
// whether that has to be paid again, so a rebuild for an unrelated slider does not.
var recipe = $"{fabric.PresetName}|{fabric.Colour.Value}|{fabric.Accent.Value}|{fabric.Scale.Clamped:0.##}|{fabric.Wear.Clamped:0.###}";
var recipeFile = Path.Combine( folder, $"fabric_{tag}.recipe" );
var changed = false;
if ( !File.Exists( recipeFile ) || File.ReadAllText( recipeFile ) != recipe
|| !File.Exists( Path.Combine( folder, $"fabric_{tag}_color.png" ) ) )
{
var (color, normal, rough) = FabricMaterial.Maps( fabric.PresetName, colour, accent,
fabric.Scale.Clamped, fabric.Wear.Clamped, fabric.Id.GetHashCode() & 0xffff );
changed |= WriteIfChanged( Path.Combine( folder, $"fabric_{tag}_color.png" ),
PngWriter.ToBytesRgba( color, FabricMaterial.Size, FabricMaterial.Size ) );
changed |= WriteIfChanged( Path.Combine( folder, $"fabric_{tag}_normal.png" ),
PngWriter.ToBytesRgba( normal, FabricMaterial.Size, FabricMaterial.Size ) );
changed |= WriteIfChanged( Path.Combine( folder, $"fabric_{tag}_rough.png" ),
PngWriter.ToBytesRgba( rough, FabricMaterial.Size, FabricMaterial.Size ) );
File.WriteAllText( recipeFile, recipe );
}
changed |= WriteIfChanged( Path.Combine( folder, $"fabric_{tag}.vmat" ),
System.Text.Encoding.UTF8.GetBytes( FabricMaterial.VmatSource( fabric.PresetName, relColor, relNormal, relRough ) ) );
if ( changed )
{
foreach ( var rel in new[] { relColor, relNormal, relRough, relVmat } )
{
var asset = AssetSystem.FindByPath( rel )
?? AssetSystem.RegisterFile( Path.Combine( folder, Path.GetFileName( rel ) ) );
asset?.Compile( true );
}
Log.Info( $"[Effigy] fabric material {relVmat} written ({fabric.PresetName})" );
}
foreach ( var slot in fabric.ResolvedSlots )
{
if ( !_studio.MaterialNames.TryGetValue( slot, out var bound ) || bound != relVmat )
{
_studio.MaterialNames[slot] = relVmat;
_livePreview = null;
}
}
}
catch ( Exception e )
{
Log.Warning( $"[Effigy] could not write the fabric material for {fabric.Name ?? fabric.Id}: {e.Message}" );
}
}
}
// --- Test: the garment in the poses that break it ---------------------------------------
/// <summary>The rig's bind pose while a test pose is on it, and null otherwise. The document's
/// rig is bent IN PLACE for the test, so this is what puts it back - see TestRelax.</summary>
private Skeleton _testBind;
/// <summary>Nearest-bone weights for bodies that carry none (a wearer read off a compiled
/// model), keyed by body id, so eight poses do not bind the wearer eight times.</summary>
private readonly Dictionary<string, (int Vertices, SkinWeights Weights)> _testWeights = new();
/// <summary>
/// Test: the garment with the arms up, bent over, sat down.
///
/// THE FIT IS ONLY TRUE FOR THE POSE IT WAS MADE IN. Check finds a shoulder that is through a
/// sleeve now; it cannot find one that will be through it the first time the character reaches
/// for something, and that is the one people ship. Every clothing tool has a pose library for
/// this reason. Each button bends the rig into one extreme, deforms the wearer and the garment
/// with it, and marks every vertex that ends up inside the body. Relax puts the rig back.
/// See GarmentPoses for the poses.
/// </summary>
private EffigyStage BuildGarmentTestStage()
{
var stage = new EffigyStage { Name = "Test" };
foreach ( var pose in GarmentPoses.All )
{
var captured = pose;
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Bone,
Label = pose.Name,
Tip = pose.Tip + " Clipping vertices are marked in red and counted in the prompt.",
Clicked = () => TestPose( captured ),
} );
}
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.SoftRest,
Label = "Relax",
Tip = "Back to the bind pose, and the marks come off.",
Clicked = () => TestRelax( true ),
} );
return stage;
}
/// <summary>The pose on the rig now, for the scrub to re-apply at another amount.</summary>
private GarmentPoses.Pose _testPose;
private void OnTestScrubbed( float amount )
{
if ( _testPose is not null )
TestPose( _testPose, amount );
}
/// <summary>Bend the rig into a pose, show everything deformed by it, and mark what clips.</summary>
private void TestPose( GarmentPoses.Pose pose, float amount = 1f )
{
if ( _studio is null || _viewport is null )
return;
if ( _studio.Rig.Count == 0 )
{
SetPrompt( "Nothing to pose - load a Wearer or rig the body first." );
return;
}
if ( !_studio.Bodies.Any( b => b.IsGarment ) )
{
SetPrompt( "Nothing to test yet - add a Garment first." );
return;
}
// Any pose before this one comes off first, so poses do not stack. The bind snapshot is
// taken once, on the first pose, and kept until Relax.
if ( _testBind is not null )
RestoreTestBind();
else
_testBind = _studio.Rig.Clone();
var turned = GarmentPoses.Apply( _studio.Rig, pose, amount );
if ( turned == 0 )
{
SetPrompt( $"{pose.Name}: nothing moved - Effigy does not recognise the bones this pose bends "
+ "(it reads names like arm_upper_L, spine_1, leg_upper_R)." );
return;
}
_testPose = pose;
_testBar?.Show( pose.Name, amount );
var bind = new Xform[_testBind.Count];
for ( var i = 0; i < bind.Length; i++ )
bind[i] = _testBind.WorldBind( i );
var posed = new Xform[_studio.Rig.Count];
for ( var i = 0; i < posed.Length; i++ )
posed[i] = _studio.Rig.WorldBind( i );
var shown = new PolyMesh();
var garments = new List<(string Name, PolyMesh Mesh)>();
var worn = new List<PolyMesh>();
foreach ( var body in _studio.Bodies )
{
if ( !body.Visible || _studio.HiddenBodyIds.Contains( body.Id ) )
continue;
var deformed = DeformForTest( body, bind, posed );
if ( body.IsGarment )
garments.Add( (body.Name, deformed) );
else
worn.Add( deformed );
MeshTransform.Append( shown, deformed );
}
_viewport.SetModel( BuildPreview( shown ), frameCamera: false );
var (clipping, deepest) = MarkClipping( garments, worn, $"{pose.Name} {amount:P0}" );
SetPrompt( clipping == 0
? $"{pose.Name}: nothing clips. Scrub the bar to see where it starts; try the others, then Relax."
: $"{pose.Name}: {clipping} {(clipping == 1 ? "vertex clips" : "vertices clip")} through the body, deepest "
+ $"{deepest:0.##} in - marked yellow to red. Push out on Check fixes it, or more Looseness there." );
}
/// <summary>
/// Check every garment against what it is worn over and mark what clips in the viewport,
/// yellow for a graze and red for the deepest. Returns the count and the depth for the prompt;
/// the per-garment detail goes to the console. Shared by Test and Check: the picture is the
/// same, only the pose differs.
/// </summary>
private (int Clipping, float Deepest) MarkClipping( List<(string Name, PolyMesh Mesh)> garments, List<PolyMesh> worn, string label )
{
var marks = new List<(Vector3, float)>();
var clipping = 0;
var deepest = 0f;
var reports = new List<(string Name, GarmentReport Report)>();
foreach ( var (name, mesh) in garments )
{
var report = GarmentCheck.Run( mesh, worn );
reports.Add( (name, report) );
clipping += report.Clipping;
deepest = Math.Max( deepest, report.DeepestClip );
}
// Heat is depth against the worst in this check, so the picture always has a red end
// to read from, and a garment that grazes everywhere is not painted all-red for it.
var scale = deepest > 1e-4f ? 1f / deepest : 0f;
foreach ( var (name, report) in reports )
{
for ( var i = 0; i < report.ClipPoints.Count; i++ )
{
var p = report.ClipPoints[i];
marks.Add( (new Vector3( p.x, p.y, p.z ), report.ClipDepths[i] * scale) );
}
if ( report.Clipping > 0 )
Log.Info( $"[Effigy] {label}: {name} - {report.Clipping} vertices clip, deepest {report.DeepestClip:0.##} in" );
}
_viewport.Markers = marks;
_viewport.Update();
return (clipping, deepest);
}
/// <summary>A body deformed from the bind pose to the posed one. Its own weights when it has
/// them; a wearer read off a compiled model has none Effigy can read, so it gets a nearest-bone
/// bind, which is right enough to show where a shoulder goes.</summary>
private PolyMesh DeformForTest( Body body, Xform[] bind, Xform[] posed )
{
var mesh = body.Mesh;
SkinWeights weights;
if ( mesh.IsRigged )
weights = mesh.Skin;
else
{
if ( !_testWeights.TryGetValue( body.Id, out var cached ) || cached.Vertices != mesh.VertexCount )
{
cached = (mesh.VertexCount, SkinBinder.BindSmooth( mesh, _testBind ));
_testWeights[body.Id] = cached;
}
weights = cached.Weights;
}
if ( weights is null || weights.Count != mesh.VertexCount )
return mesh;
var result = mesh.Clone();
var moved = SkinBinder.Deform( mesh.Positions, weights, bind, posed );
for ( var i = 0; i < moved.Length; i++ )
result.Positions[i] = moved[i];
return result;
}
private void RestoreTestBind()
{
if ( _testBind is null || _studio is null )
return;
for ( var i = 0; i < _studio.Rig.Count; i++ )
{
var at = _testBind.IndexOf( _studio.Rig.Bones[i].Name );
if ( at < 0 )
continue;
_studio.Rig.Bones[i].Local = _testBind.Bones[at].Local;
_studio.Rig.Bones[i].Length = _testBind.Bones[at].Length;
}
}
/// <summary>
/// Put the rig back and take the marks off. Called by Relax, and by anything that is about to
/// rebuild or save: the rig is bent in place, and a garment cut on a bent rig, or a document
/// saved with one, would be the pose baked in.
/// </summary>
private void TestRelax( bool refresh )
{
if ( _testBind is null )
{
if ( refresh && _viewport is not null && _viewport.Markers.Count > 0 )
{
_viewport.Markers = Array.Empty<(Vector3, float)>();
_viewport.Update();
}
return;
}
RestoreTestBind();
_testBind = null;
_testPose = null;
if ( _testBar is not null )
_testBar.Visible = false;
if ( _viewport is not null )
{
_viewport.Markers = Array.Empty<(Vector3, float)>();
_viewport.Update();
}
if ( refresh )
{
RefreshPreview();
SetPrompt( "Relaxed - back to the bind pose." );
}
}
/// <summary>
/// Check: is this fit to publish?
///
/// THE STEP THAT IS MISSING FROM EVERY HOBBY PIPELINE. You can see a shirt is wrong when it is
/// obviously wrong; you cannot see a shoulder poking a hundredth of an inch through a sleeve, or
/// that the thing has no skin weights and will hang in the air when its wearer walks, until it is
/// on a character in a game. That is the worst moment to find out, so the question gets asked
/// here instead, before export.
///
/// IT CHECKS EVERYTHING, WITHOUT A SELECTION. "Are my clothes all right" is the question people
/// actually have, and making them pick a garment first would turn one button into a chore for
/// anybody wearing more than one thing. See GarmentCheck for what it reports and why almost none
/// of it is phrased as a refusal.
/// </summary>
private EffigyStage BuildGarmentCheckStage()
{
var stage = new EffigyStage { Name = "Check" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.ProfileInspectorTool,
Label = "Check",
Tip = "Check every garment before you export it: does it clip through the body, has it got "
+ "UVs and skin weights, how many triangles, how many openings. Clipping is marked on the "
+ "model, yellow to red by depth; the rest is in the console.",
Clicked = CheckGarments,
} );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Shrinkwrap,
Label = "Push out",
Tip = "Fix the clipping the last Check found: each garment that clips gets enough more "
+ "Clearance to clear its deepest point, and is rebuilt. One undo step.",
Clicked = PushOutClipping,
} );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.SoftRest,
Label = "Clear marks",
Tip = "Take the clipping marks off the model.",
Clicked = () => TestRelax( true ),
} );
return stage;
}
/// <summary>What the last Check found clipping, per garment feature, for Push out.</summary>
private readonly Dictionary<string, float> _lastClipDepth = new();
/// <summary>
/// Push out: the one-click answer to a red mark.
///
/// A mark says where; it does not say what to do, and "more Clearance" is a slider three
/// panels away. So this reads the depths the last Check measured and gives each clipping
/// garment exactly that much more room, plus a hair, then rebuilds. It is the fix a person
/// would make, made for them, and it is one undo step because that is what it would be by hand.
/// </summary>
private void PushOutClipping()
{
if ( _studio is null )
return;
if ( _lastClipDepth.Count == 0 )
{
SetPrompt( "Nothing to push out - run Check first, or the last Check found no clipping." );
return;
}
var targets = _lastClipDepth
.Select( kv => (Garment: _studio.Features.FirstOrDefault( f => f.Id == kv.Key ) as GarmentFeature, Depth: kv.Value) )
.Where( t => t.Garment is not null )
.ToList();
_lastClipDepth.Clear();
if ( targets.Count == 0 )
{
SetPrompt( "The garments that clipped are no longer in the document." );
return;
}
// The snapshot goes on the stack BEFORE the change, the way every other edit here does.
RecordUndo();
foreach ( var (garment, depth) in targets )
{
garment.Clearance.Value = MathF.Round( garment.Clearance.Clamped + depth + 0.05f, 2 );
garment.Clearance.Expr = null;
_studio.MarkDirty( garment );
}
RebuildStudio();
CheckGarments();
}
/// <summary>Run GarmentCheck over every garment in the document and report it.</summary>
private void CheckGarments()
{
if ( _studio is null )
return;
var garments = _studio.Bodies.Where( b => b.IsGarment ).ToList();
if ( garments.Count == 0 )
{
SetPrompt( "Nothing to check yet - add a Garment first." );
return;
}
// Everything that is not a garment is something the garment is worn OVER, wearer bodies
// included. A garment is not checked against itself, and layered garments are deliberately
// not checked against each other: inter-garment collision is not implemented, so reporting
// a jacket as clipping the shirt underneath would be reporting a fault nothing can fix.
var worn = _studio.Bodies.Where( b => !b.IsGarment ).Select( b => b.Mesh ).ToList();
var faults = 0;
var marks = new List<(Vector3, float)>();
var deepest = 0f;
var reports = new List<(Body Body, GarmentReport Report)>();
_lastClipDepth.Clear();
foreach ( var garment in garments )
{
var report = GarmentCheck.Run( garment.Mesh, worn );
report.Name = garment.Name;
reports.Add( (garment, report) );
deepest = Math.Max( deepest, report.DeepestClip );
if ( !report.Clean )
faults++;
if ( report.Clipping > 0 && garment.FeatureId is { } id )
_lastClipDepth[id] = Math.Max( _lastClipDepth.GetValueOrDefault( id ), report.DeepestClip );
foreach ( var line in report.Lines() )
Log.Info( $"[Effigy] {garment.Name}: {line}" );
}
// THE ANSWER IS ON THE MODEL, not only in the console: a count says how much, a mark says
// where, and where is the thing you need to know to fix it. Heat is depth against the
// worst found, so there is always a red end to read the scale from.
var scale = deepest > 1e-4f ? 1f / deepest : 0f;
foreach ( var (_, report) in reports )
for ( var i = 0; i < report.ClipPoints.Count; i++ )
{
var p = report.ClipPoints[i];
marks.Add( (new Vector3( p.x, p.y, p.z ), report.ClipDepths[i] * scale) );
}
if ( _viewport is not null )
{
_viewport.Markers = marks;
_viewport.Update();
}
var clipping = reports.Sum( r => r.Report.Clipping );
SetPrompt( faults == 0
? $"Checked {garments.Count} garment{(garments.Count == 1 ? "" : "s")}: nothing wrong. Details in the console."
: clipping > 0
? $"{clipping} vertices clip, deepest {deepest:0.##} in - marked yellow to red. Push out fixes it; the rest is in the console."
: $"{faults} of {garments.Count} garments have something to fix - see the console." );
}
/// <summary>
/// Publish: compile the garment and write the .clothing item that makes it wearable.
///
/// A .vmdl IS NOT CLOTHING. s&box dresses a citizen from a Clothing resource that names the
/// model and says what kind of garment it is, which slots it takes and which parts of the body it
/// hides. Compile .vmdl stopped one step short of that, so a finished shirt could not be put on
/// anybody. This is that step - see ClothingDefinition for where every field comes from.
/// </summary>
private EffigyStage BuildGarmentPublishStage()
{
var stage = new EffigyStage { Name = "Publish" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Wearer,
Label = "Make clothing",
Tip = "Compile the garments to a skinned .vmdl and write a .clothing item beside it - the "
+ "asset a citizen can actually wear. Slots and hidden body parts come from the garment's "
+ "own recipe. The wearer is never exported.",
Clicked = PublishClothing,
} );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Fabric,
Label = "Make live cloth",
Tip = "Write the garment as live cloth - models/effigy/NAME.cloth.json - for the Garment Cloth "
+ "component. Put that component on the character and the shirt hangs and swings with "
+ "real cloth physics instead of riding the bones stiffly. Works in the editor without Play. "
+ "It collides with the body only: no self-collision yet, and a jacket over a shirt will pass "
+ "through the shirt.",
Clicked = PublishLiveCloth,
} );
return stage;
}
/// <summary>
/// Write every garment in the document as one live-cloth file. See ClothExport.
///
/// NO COMPILE. Live cloth is not a model, so there is nothing for the asset system to build -
/// the component reads the JSON directly, which is also why this works on a garment whose
/// .vmdl has never been compiled.
/// </summary>
private void PublishLiveCloth()
{
if ( _studio is null )
return;
var garments = _studio.Bodies.Where( b => b.IsGarment ).ToList();
if ( garments.Count == 0 )
{
SetPrompt( "Nothing to make live - add a Garment first." );
return;
}
if ( _studio.Rig.Count == 0 )
{
SetPrompt( "Live cloth hangs on the wearer's bones - load a Wearer first." );
return;
}
var mesh = new PolyMesh();
var pins = new List<float>();
ClothExport.Settings fabric = null;
foreach ( var body in garments )
{
var offset = mesh.VertexCount;
foreach ( var p in body.Mesh.Positions )
mesh.AddVertex( p );
foreach ( var face in body.Mesh.Faces )
mesh.AddFace( face.Indices.Select( i => i + offset ).ToArray(), face.UVs, face.Material );
// The feature that made it says what it is made of and where it is held on. One fabric
// per file - the first garment's - because the component has one set of sliders; a
// jacket over a shirt is two files and two components.
var feature = _studio.Features.OfType<GarmentFeature>().FirstOrDefault( g => g.Id == body.FeatureId );
fabric ??= feature is null ? null : ClothExport.FabricSettings( feature.FabricValue, feature.Stiffness.Clamped );
pins.AddRange( ClothExport.PinTop( body.Mesh, feature?.LivePinFraction ?? 0.12f ) );
}
var map = new BodyRegions.Map( _studio.Rig );
var worn = _studio.Bodies.Where( b => !b.IsGarment ).Select( b => b.Mesh ).ToList();
var capsules = BoneCapsules.Build( map, worn );
if ( capsules.Count == 0 )
{
SetPrompt( "No bones to hang the cloth on - the wearer's skeleton has no torso or arms Effigy recognises." );
return;
}
var slot = mesh.Faces.Select( f => f.Material ).DefaultIfEmpty( 0 ).First();
var material = _studio.MaterialNames.TryGetValue( slot, out var bound ) ? bound : "";
var name = ExportBaseName();
if ( name is null )
return;
try
{
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" );
Directory.CreateDirectory( folder );
var file = Path.Combine( folder, $"{name}.cloth.json" );
File.WriteAllText( file, ClothExport.Write( mesh, capsules, _studio.Rig, material, fabric, pins.ToArray() ) );
Log.Info( $"[Effigy] wrote {file} - {mesh.VertexCount} cloth vertices on {capsules.Count} capsules" );
var rel = $"models/effigy/{name}.cloth.json";
SetPrompt( WearLiveCloth( rel ) ?? $"{rel} is ready - add a Garment Cloth component to the character and point it at this file." );
}
catch ( Exception e )
{
Log.Warning( $"[Effigy] could not write the live cloth for {name}: {e.Message}" );
}
}
// --- wearing it, in the open scene ------------------------------------------------------
/// <summary>
/// The renderer in the open scene that is the wearer, or null with a reason.
///
/// PUBLISH ENDED WITH HOMEWORK. "Add a Garment Cloth component and point it at this file" is a
/// sentence that assumes you know what a component is, where the file went and which of the
/// forty objects in the scene is the character - and the whole point of the workspace is that
/// you do not have to. So Publish finishes the job when it can tell where the job is: the
/// wearer is the character whose model this document was loaded from, and if it is standing
/// in the open scene, the garment goes on it. When it cannot tell, it says what it wanted.
/// </summary>
private SkinnedModelRenderer FindSceneWearer( out string why )
{
why = null;
var session = SceneEditorSession.Active;
if ( session?.Scene is not { } scene )
{
why = "no scene is open";
return null;
}
var renderers = scene.GetAllComponents<SkinnedModelRenderer>()
.Where( r => r.IsValid() && r.Model is not null && !r.GameObject.Flags.HasFlag( GameObjectFlags.NotSaved ) )
.ToList();
if ( renderers.Count == 0 )
{
why = "there is no character in the open scene";
return null;
}
// The model the document was dressed on, from its Wearer. Matching that is the only
// answer that is right for certain.
var wanted = _studio?.Features.OfType<WearerFeature>()
.Select( w => w.Model.Value )
.FirstOrDefault( v => !string.IsNullOrWhiteSpace( v ) );
static string Norm( string path ) => (path ?? "").Replace( '\\', '/' ).Trim().ToLowerInvariant();
if ( !string.IsNullOrEmpty( wanted ) )
{
var want = Norm( wanted );
var matches = renderers.Where( r => Norm( r.Model.ResourcePath ) == want || Norm( r.Model.Name ) == want ).ToList();
if ( matches.Count == 1 )
return matches[0];
if ( matches.Count > 1 )
{
// Several of the same character: the selected one, if one of them is selected.
var selected = matches.FirstOrDefault( r => EditorScene.Selection.Contains( r.GameObject ) );
if ( selected is not null )
return selected;
why = $"{matches.Count} objects in the scene wear {wanted} - select the one to dress";
return null;
}
}
// No Wearer, or it is not in the scene: the selected character, or the only one.
var picked = renderers.FirstOrDefault( r => EditorScene.Selection.Contains( r.GameObject ) );
if ( picked is not null )
return picked;
if ( renderers.Count == 1 )
return renderers[0];
why = string.IsNullOrEmpty( wanted )
? "the scene has several characters - select the one to dress"
: $"{wanted} is not in the open scene - select the character to dress";
return null;
}
/// <summary>Put a Garment Cloth on the scene's wearer, pointed at the file. Returns what
/// happened, or null when it could not, so the caller can fall back to telling the user what
/// to do by hand.</summary>
private string WearLiveCloth( string clothPath )
{
var wearer = FindSceneWearer( out var why );
if ( wearer is null )
{
Log.Info( $"[Effigy] {clothPath} not put on a character: {why}." );
return null;
}
var session = SceneEditorSession.Active;
using ( session.UndoScope( "Wear garment" ).WithComponentCreations().WithComponentChanges( wearer ).Push() )
{
// The same garment again replaces itself; a different one is a second component,
// because a jacket over a shirt is two cloths.
var cloth = wearer.GameObject.Components.GetAll<GarmentCloth>( FindMode.EverythingInSelf )
.FirstOrDefault( c => string.Equals( c.Cloth, clothPath, StringComparison.OrdinalIgnoreCase ) )
?? wearer.GameObject.Components.Create<GarmentCloth>();
cloth.Wearer = wearer;
cloth.Cloth = clothPath;
}
EditorScene.Selection.Set( wearer.GameObject );
return $"{clothPath} is on {wearer.GameObject.Name} in the scene - it is hanging there now. Ctrl+Z takes it off.";
}
/// <summary>Put a .clothing item on the scene's wearer's Dresser. Only a citizen has a
/// Dresser, and a Dresser only fits citizen's body, so anything else falls back to the
/// drag-it-on prompt.</summary>
private string WearClothing( string clothingPath )
{
var wearer = FindSceneWearer( out _ );
var dresser = wearer?.GameObject.Components.Get<Dresser>( FindMode.EverythingInSelfAndParent );
if ( wearer is null || dresser is null )
return null;
var clothing = ResourceLibrary.Get<Clothing>( clothingPath );
if ( clothing is null )
return null;
var session = SceneEditorSession.Active;
using ( session.UndoScope( "Wear clothing" ).WithComponentChanges( dresser ).Push() )
{
dresser.Source = Dresser.ClothingSource.Manual;
dresser.Clothing ??= new List<ClothingContainer.ClothingEntry>();
if ( dresser.Clothing.All( e => e.Clothing != clothing ) )
dresser.Clothing.Add( new ClothingContainer.ClothingEntry( clothing ) );
}
return $"{clothingPath} is on {wearer.GameObject.Name}'s Dresser in the scene. Ctrl+Z takes it off.";
}
/// <summary>Compile, then write models/effigy/NAME.clothing pointing at the compiled model.</summary>
private void PublishClothing()
{
if ( _studio is null )
return;
var garments = _studio.Features.OfType<GarmentFeature>().Where( g => !g.Suppressed ).ToList();
if ( garments.Count == 0 )
{
SetPrompt( "Nothing to publish - add a Garment first." );
return;
}
if ( _rigPanel is not { HasBones: true } )
{
SetPrompt( "Clothing needs a rig to move with its wearer - load a Wearer first." );
return;
}
CompileVmdl();
// Null unless the SKINNED compile got all the way through. A failed compile has already
// said why in the console; a .clothing pointing at it would load and silently show nothing.
if ( _lastSkinnedVmdl is not { } modelPath )
{
SetPrompt( "The model did not compile, so no clothing was written - see the console." );
return;
}
var recipe = ClothingDefinition.Combine( garments.Select( g => g.BuildRecipe() ) );
var name = Path.GetFileNameWithoutExtension( modelPath );
var title = garments.Count == 1 ? garments[0].Name ?? recipe.Name : name;
try
{
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy" );
var file = Path.Combine( folder, $"{name}.clothing" );
File.WriteAllText( file, ClothingDefinition.Write( recipe, modelPath, title ) );
var asset = AssetSystem.FindByPath( $"models/effigy/{name}.clothing" )
?? AssetSystem.RegisterFile( file );
asset?.Compile( true );
Log.Info( $"[Effigy] wrote {file} - {ClothingDefinition.CategoryFor( recipe )}, slots "
+ $"{string.Join( ", ", ClothingDefinition.SlotsFor( recipe ) )}" );
SetPrompt( WearClothing( $"models/effigy/{name}.clothing" )
?? $"models/effigy/{name}.clothing is ready - drag it onto a Dresser or a citizen to wear it." );
}
catch ( Exception e )
{
Log.Warning( $"[Effigy] could not write the .clothing for {name}: {e.Message}" );
SetPrompt( "The model compiled, but the .clothing could not be written - see the console." );
}
}
/// <summary>
/// Shape: the step between "the garment exists" and "the garment is right".
///
/// ONE BUTTON INTO TOOLS THAT ALREADY EXIST. Shrinkwrap, Solidify, Drape and the fabric presets
/// all live on Model > Edit's Surface and Cloth stages and have done since the mesh editor
/// landed. They are exactly the fitting tools the clothing workflow calls for — and somebody who
/// came here to make a shirt has no reason to suspect they are behind a different workspace, a
/// different bar and a mode they have to know to open. So Clothing points at them rather than
/// growing a second copy: press this with the garment selected and you are editing it, with
/// Cloth one tab along.
/// </summary>
private EffigyStage BuildGarmentShapeStage()
{
var stage = new EffigyStage { Name = "Shape" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.MeshEdit,
Label = "Shape garment",
Tip = "Edit the selected garment by hand - move its vertices, shrinkwrap it to the body, "
+ "solidify it, or pin part of it and let the rest drape. Saved as a Mesh edit in the history.",
Clicked = AddMeshEdit,
} );
return stage;
}
/// <summary>
/// The Wearer stage: get a body in here to dress.
///
/// THE STAGE CLOTHING WAS MISSING. A Garment is a recipe read off the rig — it finds the torso
/// and the arms by bone name and lifts the cloth off the body's own surface — so it needs a
/// RIGGED BODY in the document before it can do anything at all. Every way of having one ran
/// through modelling it here, which meant the one thing people actually want from a clothing
/// tool, making a jacket for a character that already exists, was the one thing it could not do.
/// </summary>
private EffigyStage BuildWearerStage()
{
var stage = new EffigyStage { Name = "Wearer" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Wearer,
Label = "Wearer",
Tip = "Load a rigged model to dress - Citizen, your own playermodel, anything compiled. "
+ "Its surface comes in as a body to cut the garment from and its skeleton becomes this "
+ "document's rig. It is never exported.",
Clicked = PickWearer,
} );
return stage;
}
/// <summary>Ask for a model, then load it as the wearer. Cancelling adds nothing, the same way
/// cancelling Import's file picker does.</summary>
private void PickWearer()
{
if ( _studio is null )
return;
var picker = AssetPicker.Create( this, AssetType.Model, new AssetPicker.PickerOptions() );
picker.Title = "Choose a body to dress";
picker.OnAssetPicked = assets =>
{
if ( assets.FirstOrDefault() is { } asset )
LoadWearer( asset.Path, asset.Name );
};
picker.Show();
}
/// <summary>
/// Bring a compiled model in as the body to be dressed: its surface as a reference body, its
/// skeleton as this document's rig.
///
/// THE MESH GOES THROUGH OBJ, which looks like a detour and is not. WearerFeature IS an
/// ImportFeature, and every hard part of holding a borrowed mesh is already solved there - the
/// triangles live in a side-car instead of bloating the .effigy, a reopen does not depend on the
/// model still being installed, and a piece can be deleted without touching the source. Handing
/// it OBJ bytes in memory reuses all of it and writes nothing to the project.
/// </summary>
private void LoadWearer( string path, string name )
{
if ( _studio is null )
return;
var model = Model.Load( path );
if ( model is null || model.IsError )
{
SetPrompt( $"{name} did not load as a model." );
return;
}
var mesh = WearerSurface( model );
if ( mesh is null || mesh.FaceCount == 0 )
{
SetPrompt( $"{name} has no render mesh to dress - a garment needs a surface to be cut from." );
return;
}
var feature = new WearerFeature { Name = name };
feature.Model.Value = path;
feature.LoadMesh( System.Text.Encoding.UTF8.GetBytes( ObjWriter.Write( mesh, name ) ) );
var bones = InstallWearerRig( model, name );
AddFeature( feature );
SetPrompt( bones > 0
? $"{name} is in, {bones} bones. Add a Garment to dress it."
: $"{name} is in, but it has no skeleton - a garment needs bones to find the torso and arms." );
}
/// <summary>
/// A compiled model's render surface as a PolyMesh.
///
/// GetVertices/GetIndices is the whole of it, and it is worth saying why the obvious alternative
/// is wrong: Model.Physics is the mesh this codebase already reads (RigViewport's posing
/// collision takes it), but a physics hull is a handful of convex lumps. You cannot cut a collar
/// out of it. Dressing needs the surface you can see.
///
/// ONE SLOT, NO SEAMS PRESERVED. The wearer is measured against, not shipped, so per-drawcall
/// material groups would be detail nothing downstream reads. Triangles are taken as triangles -
/// GarmentFit works off vertex normals and the BVH, neither of which wants them merged.
/// </summary>
internal static PolyMesh WearerSurface( Model model )
{
var vertices = model.GetVertices();
var indices = model.GetIndices();
if ( vertices is null || indices is null || indices.Length < 3 )
return null;
var mesh = new PolyMesh();
// WELDED BY POSITION. A render vertex buffer is split wherever the normal or the UV
// changes, which on a hard-surface model is nearly every edge - Camhead came in as 21k
// triangles that shared no corners at all. A garment lifted off that is 21k loose
// scraps, the drape has nothing holding them together, and the shirt came out shattered.
// The wearer is only measured against, so the seams the split kept are worth nothing here.
var weld = new Dictionary<(long, long, long), int>();
var remap = new int[vertices.Length];
for ( var i = 0; i < vertices.Length; i++ )
{
var p = vertices[i].Position;
var key = ((long)MathF.Round( p.x * 1000f ), (long)MathF.Round( p.y * 1000f ), (long)MathF.Round( p.z * 1000f ));
if ( !weld.TryGetValue( key, out var index ) )
{
index = mesh.Positions.Count;
weld[key] = index;
mesh.Positions.Add( new Vec3( p.x, p.y, p.z ) );
}
remap[i] = index;
}
for ( var i = 0; i + 2 < indices.Length; i += 3 )
{
var a = indices[i] < remap.Length ? remap[indices[i]] : -1;
var b = indices[i + 1] < remap.Length ? remap[indices[i + 1]] : -1;
var c = indices[i + 2] < remap.Length ? remap[indices[i + 2]] : -1;
if ( a < 0 || b < 0 || c < 0 )
continue;
// A degenerate triangle is not a refusal - compiled models carry them, and one dropped
// face in a body nobody exports is not worth stopping a load for.
if ( a == b || b == c || a == c )
continue;
if ( a >= mesh.Positions.Count || b >= mesh.Positions.Count || c >= mesh.Positions.Count )
continue;
mesh.Faces.Add( new Face( new[] { a, b, c } ) );
}
return mesh;
}
/// <summary>
/// Put the model's skeleton on the document, and say how many bones landed.
///
/// NOT THROUGH THE FEATURE, deliberately. A rebuild re-runs every feature, and the rig is one
/// skeleton shared by the whole document - a feature quietly rewriting it on every rebuild would
/// be fighting the Rig workspace for ownership of the same data, and the last writer would win
/// by accident. So this happens ONCE, here, when the wearer is loaded.
///
/// IT REFUSES TO OVERWRITE A RIG YOU BUILT. Bones placed by hand are work that undo cannot get
/// back - the snapshot captures features and parameters, not the skeleton, the same way bone
/// edits in the Rig workspace have never been undoable. Better to leave the rig alone and say so
/// than to be right about the common case and destructive about the other one.
/// </summary>
private int InstallWearerRig( Model model, string name )
{
if ( _studio is null || model.BoneCount <= 0 )
return 0;
if ( _studio.Rig.Count > 0 )
{
Log.Info( $"[Effigy] {name}'s skeleton was left out - this document already has a rig with "
+ $"{_studio.Rig.Count} bones. Delete those bones first if you want the model's own." );
return 0;
}
var result = EffigySkeletonImport.FromModel( model );
_studio.Rig.Bones.Clear();
_studio.Rig.Bones.AddRange( result.Skeleton.Bones );
foreach ( var note in result.Notes )
Log.Info( $"[Effigy] wearer rig: {note}" );
_rigPanel?.Refresh();
return _studio.Rig.Count;
}
private EffigyStage BuildClothingStage()
{
var stage = new EffigyStage { Name = "Garment" };
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Shell,
Label = "Garment",
Tip = "Add a Garment - a T-shirt, trousers, beanie, gloves and more, cut from the body, fitted "
+ "to it, draped and thickened. Needs a rig. Select the body to wear it first, or it goes on every body.",
Clicked = () => AddFeature( NewFeature( ToolKind.Garment, -1 ) ),
} );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Frill,
Label = "Trim",
Tip = "Frills, ruffles, ribbons, pleats and fringe, hung off the garment's own openings - "
+ "the hem, the collar, the cuffs. Add one per detail: a frill at the hem and a ribbon "
+ "at the collar are two Trims. (Lace is a cut-out material, not geometry.)",
Clicked = () => AddFeature( NewFeature( ToolKind.Trim, -1 ) ),
} );
stage.Add( new EffigyStageTool
{
Icon = EffigyIcon.Paint,
Label = "Fur",
Tip = "Add Fur - shell layers for s&box's fur shader. Pick faces first, or it covers every garment. "
+ "Coverage > Trim the openings does collars, cuffs and hems.",
Clicked = () => AddFeature( NewFeature( ToolKind.Fur, -1 ) ),
} );
return stage;
}
/// <summary>
/// Write each Fur feature's material and bind it to the feature's slot. Run after every rebuild,
/// and cheap when nothing changed: a file is only rewritten (and recompiled) when its content
/// differs from what is on disk.
/// </summary>
private void SyncFurMaterials()
{
if ( _studio is null )
return;
foreach ( var fur in _studio.Features.OfType<FurFeature>() )
{
if ( fur.Suppressed || fur.ResolvedSlot < 0 || fur.Error is not null )
continue;
try
{
var folder = EffigyAssetFolder.ResolveAssetFolder( "models/effigy/fur" );
Directory.CreateDirectory( folder );
var tag = new string( fur.Id.Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray() );
var colour = FurMaterial.ParseHex( fur.Colour.Value, new Vec3( 0.54f, 0.43f, 0.33f ) );
var rim = FurMaterial.ParseHex( fur.RimColour.Value, new Vec3( 0.23f, 0.2f, 0.18f ) );
var relColor = $"models/effigy/fur/fur_{tag}_color.png";
var relNoise = $"models/effigy/fur/fur_{tag}_noise.png";
var relVmat = $"models/effigy/fur/fur_{tag}.vmat";
var changed = false;
changed |= WriteIfChanged( Path.Combine( folder, $"fur_{tag}_color.png" ),
PngWriter.ToBytesRgba( FurMaterial.ColorRgba( colour ), 16, 16 ) );
changed |= WriteIfChanged( Path.Combine( folder, $"fur_{tag}_noise.png" ),
PngWriter.ToBytesRgba( FurMaterial.NoiseRgba( fur.Id.GetHashCode() & 0xffff, fur.Clump.Clamped ),
FurMaterial.NoiseSize, FurMaterial.NoiseSize ) );
changed |= WriteIfChanged( Path.Combine( folder, $"fur_{tag}.vmat" ),
System.Text.Encoding.UTF8.GetBytes( FurMaterial.VmatSource( relColor, relNoise,
fur.Density.Clamped, fur.DarkRoots.Clamped, rim, fur.Wind.Clamped ) ) );
if ( changed )
{
foreach ( var rel in new[] { relColor, relNoise, relVmat } )
{
var asset = AssetSystem.FindByPath( rel )
?? AssetSystem.RegisterFile( Path.Combine( folder, Path.GetFileName( rel ) ) );
asset?.Compile( true );
}
Log.Info( $"[Effigy] fur material {relVmat} written for slot {fur.ResolvedSlot}" );
}
if ( !_studio.MaterialNames.TryGetValue( fur.ResolvedSlot, out var bound ) || bound != relVmat )
{
_studio.MaterialNames[fur.ResolvedSlot] = relVmat;
_livePreview = null;
}
}
catch ( Exception e )
{
Log.Warning( $"[Effigy] could not write the fur material for {fur.Name ?? fur.Id}: {e.Message}" );
}
}
}
static bool WriteIfChanged( string path, byte[] bytes )
{
if ( File.Exists( path ) && File.ReadAllBytes( path ).AsSpan().SequenceEqual( bytes ) )
return false;
File.WriteAllBytes( path, bytes );
return true;
}
}