s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=pooh.geppetto&take=20
Showing code results for query:
*
(483 total matches found)
Editor
library
using System;
using System.IO;
namespace Effigy;
/// <summary>
/// DMX export — the animation path. A skeleton and a set of per-bone channels, which is what
/// ModelDoc's `AnimFile` node wants pointed at it, and therefore what puts a hand-authored clip
/// inside a compiled model where AnimGraph can reach it.
///
/// WHY THIS IS NOT SMD, WHICH IS THE OBVIOUS ANSWER AND THE WRONG ONE. A sequence SMD is the
/// classic way to hand-write animation for a Source model, SmdWriter already emits the exact
/// `skeleton` / `time N` / bone-row block a sequence needs, and extending it looks like an
/// afternoon's work. ModelDoc does not read SMD at all — see DmxWriter's header for the loader
/// string that says so in the compiler's own words. The mesh path learned that the expensive way;
/// this file exists so the animation path does not learn it again.
///
/// COPIED, NOT GUESSED — and there is a command that produces the thing to copy. The engine ships
/// `bin/win64/fbx2dmx.exe`, whose <b>-a</b> flag converts animation rather than geometry, and
/// every element, attribute and spelling below was read off its output for a shipping clip:
///
/// fbx2dmx.exe -a -i addons/citizen/Assets/models/citizen/animations/face/Citizen@Eyes_Blink.fbx -o ref.dmx
///
/// That reference is the only evidence there is about this format, and regenerating it beats
/// reasoning from this comment if anything here ever stops working. What it settled, none of which
/// is inferable from the element names alone:
///
/// - `animationList` hangs off the ROOT DmElement, beside `skeleton` and `model` — not inside
/// the DmeModel, which is where it looks like it should go;
/// - a channel targets the bone's DmeTransform, not its DmeJoint, and does it by id;
/// - each bone needs TWO channels, suffixed `_p` and `_o`, writing `position` and `orientation`
/// respectively. One channel per bone carrying both does not exist;
/// - `mode` is 3 on every channel fbx2dmx writes;
/// - the log layer's `curvetypes` array is present and EMPTY, which is what "no per-key curve
/// override" looks like. Omitting the array is a different statement.
///
/// WHAT IS DELIBERATELY LEFT OUT. The reference also carries a `compressed` binary blob on each
/// log layer — empty in every layer of it. An empty blob says nothing its absence does not, and
/// KeyValues2's binary literal is a multi-line quoted form with no second example to check a guess
/// against, so this writes no `compressed` attribute rather than an invented one.
/// `dmxconvert.exe -i clip.dmx -o check.dmx` is the one-second check that this was the right call;
/// DmxAnimTests runs the same parse with no engine involved.
/// </summary>
public static class DmxAnimWriter
{
public static void WriteFile( string path, Skeleton skeleton, AnimClip clip, string modelName = null )
{
File.WriteAllText( path, Write( skeleton, clip, modelName ) );
}
/// <summary>
/// The animation as a DMX document.
///
/// The skeleton written here has to be the SAME skeleton the mesh was exported with. ModelDoc
/// matches a clip's channels to a model's bones by name, and a bone the clip poses that the
/// model does not have is dropped silently — so a rig edited between the two exports gives you
/// a clip that compiles, loads, and moves less of the model than it used to.
/// </summary>
public static string Write( Skeleton skeleton, AnimClip clip, string modelName = null )
{
if ( skeleton is null )
throw new ArgumentNullException( nameof( skeleton ) );
if ( clip is null )
throw new ArgumentNullException( nameof( clip ) );
if ( clip.Validate( skeleton ) is { } problem )
throw new InvalidOperationException( $"Animation clip does not fit its skeleton: {problem}" );
modelName ??= "effigy_model";
var w = new DmxText();
// Same id discipline as the mesh writer: counted, not random, so two exports of the same
// clip are byte-identical and a diff shows what actually changed.
var idRoot = w.NextId();
var idModel = w.NextId();
var idModelTransform = w.NextId();
var boneDagIds = new string[skeleton.Count];
var boneTransformIds = new string[skeleton.Count];
var bindTransformIds = new string[skeleton.Count];
for ( var i = 0; i < skeleton.Count; i++ )
{
boneDagIds[i] = w.NextId();
boneTransformIds[i] = w.NextId();
bindTransformIds[i] = w.NextId();
}
var idAnimList = w.NextId();
var idClip = w.NextId();
var idTimeFrame = w.NextId();
w.Raw( $"<!-- dmx encoding keyvalues2 1 format model {DmxWriter.ModelFormatVersion} -->" );
w.Raw( "" );
w.OpenElement( "DmElement", idRoot, "root" );
// No mesh child: an animation DMX carries the rig and the motion, and the geometry lives in
// the model file this clip gets compiled into.
DmxWriter.WriteSkeletonModel( w, skeleton, modelName, idModel, idModelTransform,
boneDagIds, boneTransformIds, bindTransformIds, null );
w.Attribute( "model", "element", idModel );
WriteAnimationList( w, skeleton, clip, idAnimList, idClip, idTimeFrame, boneTransformIds );
w.CloseElement();
return w.ToString();
}
// --- pieces -------------------------------------------------------------------------------
static void WriteAnimationList( DmxText w, Skeleton skeleton, AnimClip clip,
string idAnimList, string idClip, string idTimeFrame, string[] boneTransformIds )
{
w.OpenAttributeElement( "animationList", "DmeAnimationList", idAnimList, "anim" );
w.OpenArray( "animations", "element_array" );
w.OpenArrayElement( "DmeChannelsClip", idClip, clip.Name );
{
w.OpenAttributeElement( "timeFrame", "DmeTimeFrame", idTimeFrame, "timeFrame" );
w.Attribute( "start", "time", DmxText.Time( 0f ) );
w.Attribute( "duration", "time", DmxText.Time( clip.Duration ) );
w.Attribute( "offset", "time", DmxText.Time( 0f ) );
w.Attribute( "scale", "float", "1" );
w.CloseElement();
w.OpenArray( "channels", "element_array" );
// Two channels per bone, position then orientation, in skeleton order. The order is not
// load-bearing — channels name their own target — but keeping it stable is what makes
// two exports of the same clip diffable.
for ( var i = 0; i < skeleton.Count; i++ )
{
WritePositionChannel( w, skeleton, clip, i, boneTransformIds[i] );
WriteOrientationChannel( w, skeleton, clip, i, boneTransformIds[i] );
}
w.CloseArray();
}
w.CloseElement();
w.CloseArray();
w.CloseElement();
}
static void WritePositionChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )
{
w.OpenArrayElement( "DmeChannel", w.NextId(), $"{skeleton.Bones[bone].Name}_p" );
WriteChannelTarget( w, targetId, "position" );
w.OpenAttributeElement( "log", "DmeVector3Log", w.NextId(), "vector3 log" );
w.OpenArray( "layers", "element_array" );
w.OpenArrayElement( "DmeVector3LogLayer", w.NextId(), "vector3 log" );
WriteTimes( w, clip );
w.OpenArray( "values", "vector3_array" );
for ( var f = 0; f < clip.FrameCount; f++ )
w.ArrayValue( DmxText.Vector3( clip.Frames[f][bone].Origin ) );
w.CloseArray();
w.CloseElement();
w.CloseArray();
w.Attribute( "usedefaultvalue", "bool", "0" );
w.Attribute( "defaultvalue", "vector3", "0 0 0" );
w.CloseElement();
w.CloseElement();
}
static void WriteOrientationChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )
{
w.OpenArrayElement( "DmeChannel", w.NextId(), $"{skeleton.Bones[bone].Name}_o" );
WriteChannelTarget( w, targetId, "orientation" );
w.OpenAttributeElement( "log", "DmeQuaternionLog", w.NextId(), "quaternion log" );
w.OpenArray( "layers", "element_array" );
w.OpenArrayElement( "DmeQuaternionLogLayer", w.NextId(), "quaternion log" );
WriteTimes( w, clip );
w.OpenArray( "values", "quaternion_array" );
for ( var f = 0; f < clip.FrameCount; f++ )
w.ArrayValue( DmxText.Quaternion( clip.Frames[f][bone] ) );
w.CloseArray();
w.CloseElement();
w.CloseArray();
w.Attribute( "usedefaultvalue", "bool", "0" );
w.Attribute( "defaultvalue", "quaternion", "0 0 0 1" );
w.CloseElement();
w.CloseElement();
}
/// <summary>
/// The half of a channel that says where its values go.
///
/// `fromElement` and `fromAttribute` are empty because nothing drives this channel — it is a
/// stored curve, not a connection between two live elements, which is the other thing a
/// DmeChannel gets used for. `mode` 3 is what fbx2dmx writes on every channel of an exported
/// clip; the enum it comes from is not in anything readable here, so this is copied rather than
/// named.
/// </summary>
static void WriteChannelTarget( DmxText w, string targetId, string attribute )
{
w.Attribute( "fromElement", "element", "" );
w.Attribute( "fromAttribute", "string", "" );
w.Attribute( "fromIndex", "int", "0" );
w.Attribute( "toElement", "element", targetId );
w.Attribute( "toAttribute", "string", attribute );
w.Attribute( "toIndex", "int", "0" );
w.Attribute( "mode", "int", "3" );
}
/// <summary>
/// The sample times, plus the empty curvetypes array that goes with them.
///
/// Written once for both channel kinds because the two have to agree exactly: a position log
/// and an orientation log with different time arrays is a bone whose translation and rotation
/// drift apart, which reads as a rigging fault rather than an export one.
/// </summary>
static void WriteTimes( DmxText w, AnimClip clip )
{
w.OpenArray( "times", "time_array" );
for ( var f = 0; f < clip.FrameCount; f++ )
w.ArrayValue( DmxText.Time( clip.TimeOf( f ) ) );
w.CloseArray();
// Present and empty — see the header. This is "no per-key curve override", not "no curves".
w.OpenArray( "curvetypes", "int_array" );
w.CloseArray();
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Creates a primitive solid. Onshape's own Primitives feature works this way — one dropdown, and
/// the dialog shows only the fields that shape actually has.
///
/// Parameters deliberately changes with Shape rather than showing every field greyed out. That is
/// the behaviour being copied: a box dialog asks for three lengths and nothing else.
/// </summary>
public sealed class PrimitiveFeature : Feature
{
public override string TypeName => Shape.Value;
public readonly ChoiceParam Shape = new( "Shape",
new[] { "Box", "Cylinder", "Sphere", "Wedge", "Tube", "Plane" } );
public readonly FloatParam SizeX = new( "Width", 1f, 0.0001f, unit: "u" );
public readonly FloatParam SizeY = new( "Depth", 1f, 0.0001f, unit: "u" );
public readonly FloatParam SizeZ = new( "Height", 1f, 0.0001f, unit: "u" );
public readonly FloatParam Radius = new( "Radius", 0.5f, 0.0001f, unit: "u" );
public readonly FloatParam InnerRadius = new( "Inner radius", 0.3f, 0f, unit: "u" );
public readonly IntParam Segments = new( "Segments", 16, 3, 512 );
public readonly IntParam Divisions = new( "Divisions", 4, 1, 64 );
public readonly Vec3Param Position = new( "Position", Vec3.Zero );
/// <summary>
/// Per-axis scale, applied about the primitive's own origin before it is moved into place.
///
/// NOT THE SAME THING AS THE SIZE PARAMETERS, and worth having alongside them. Width/Depth/
/// Height build the shape at a size; this stretches whatever was built, which is the only way
/// to get an ellipsoid out of a sphere or an oval tube out of a round one — those are defined
/// by a radius and have no per-axis size to set.
/// </summary>
public readonly Vec3Param Scale = new( "Scale", Vec3.One );
/// <summary>
/// Whether the dialog keeps the three scale axes equal as you edit one of them.
///
/// A UI CONVENIENCE THAT IS PERSISTED, not something the kernel enforces. Scale stays the
/// single truth about the shape — Execute reads all three axes and never consults this — so a
/// document always builds exactly what its three numbers say. This only rides along so the
/// dialog can remember that you were editing that primitive uniformly.
/// </summary>
public readonly BoolParam UniformScale = new( "Uniform scale", false );
public readonly IntParam Material = new( "Material slot", 0, 0, 63 ) { Slider = false };
/// <summary>A turn about an axis through the primitive's own origin, applied before Position —
/// a cylinder lying along x is an axle, a wedge on its side is a roof. Folded away: most
/// primitives stand as they come.</summary>
public readonly Vec3Param RotationAxis = new( "Rotation axis", new Vec3( 0, 0, 1 ) );
public readonly FloatParam RotationAngle = new( "Rotation", 0f, unit: "deg" );
/// <summary>The slot, folded away with the other features' — a primitive is placed and sized,
/// and painted later from the Materials panel.</summary>
public override IReadOnlyList<IParam> AdvancedParameters => new IParam[] { RotationAxis, RotationAngle, Material };
public override IReadOnlyList<IParam> Parameters => Shape.Value switch
{
"Box" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
"Cylinder" => new IParam[] { Shape, Radius, SizeZ, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
"Sphere" => new IParam[] { Shape, Radius, Divisions, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
"Wedge" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
"Tube" => new IParam[] { Shape, Radius, InnerRadius, SizeZ, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
"Plane" => new IParam[] { Shape, SizeX, SizeY, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },
_ => new IParam[] { Shape }
};
protected override void Execute( FeatureContext ctx )
{
var mesh = Shape.Value switch
{
"Box" => Primitives.Box( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),
"Cylinder" => Primitives.Cylinder( Radius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped ),
"Sphere" => Primitives.QuadSphere( Radius.Clamped, Divisions.Clamped, Material.Clamped ),
"Wedge" => Primitives.Wedge( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),
"Tube" => BuildTube(),
"Plane" => Primitives.Plane( SizeX.Clamped, SizeY.Clamped, Segments.Clamped, Segments.Clamped, Material.Clamped ),
_ => throw new FeatureException( new FeatureDiagnostic(
DiagnosticSeverity.Error,
$"unknown shape '{Shape.Value}'",
"The shape dropdown has a value this feature does not build.",
"Shape",
remedies: new[] { "Pick Box, Cylinder, Sphere, Wedge, Tube or Plane" } ) )
};
var scale = Scale.Value;
if ( scale.x == 0f || scale.y == 0f || scale.z == 0f )
{
FailOn( "Scale",
"Scale cannot be zero on any axis",
$"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.",
"Set every scale axis to a non-zero value" );
}
// SCALE FIRST, ABOUT THE PRIMITIVE'S OWN ORIGIN. Applied after the translate it would
// multiply the position too, so nudging a scaled box would move it by the scale factor and
// the number in the Position field would stop meaning where the box is.
if ( scale.x != 1f || scale.y != 1f || scale.z != 1f )
MeshTransform.Apply( mesh, Xform.Scale( scale ) );
if ( RotationAngle.Value != 0f && RotationAxis.Value.LengthSquared > 1e-8f )
MeshTransform.Apply( mesh, Xform.Rotate( RotationAxis.Value.Normal, RotationAngle.Value * MathF.PI / 180f ) );
if ( Position.Value.LengthSquared > 0f )
MeshTransform.Apply( mesh, Xform.Translate( Position.Value ) );
ctx.Bodies.Add( new Body( ctx.NewBodyId(), Name, mesh ) );
}
PolyMesh BuildTube()
{
// Caught here rather than left to Primitives, so the message names the parameter the user
// can actually see in the dialog.
if ( InnerRadius.Clamped >= Radius.Clamped )
{
FailOn( "Inner radius",
"Inner radius must be smaller than radius",
$"Inner radius is {InnerRadius.Clamped:0.###} and radius is {Radius.Clamped:0.###}.",
"Reduce Inner radius",
"Increase Radius" );
}
return Primitives.Tube( Radius.Clamped, InnerRadius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped );
}
}
/// <summary>Move, rotate and scale bodies. Onshape's Transform.</summary>
public sealed class TransformFeature : Feature
{
public override string TypeName => "Transform";
public override GeometryKind Accepts => GeometryKind.Body;
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param Translate = new( "Translate", Vec3.Zero );
public readonly Vec3Param RotationAxis = new( "Rotation axis", new Vec3( 0, 0, 1 ) );
public readonly FloatParam RotationAngle = new( "Angle", 0f, unit: "deg" );
public readonly Vec3Param Scale = new( "Scale", Vec3.One );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, Translate, RotationAxis, RotationAngle, Scale };
protected override void Execute( FeatureContext ctx )
{
var scale = Scale.Value;
if ( scale.x == 0f || scale.y == 0f || scale.z == 0f )
{
FailOn( "Scale",
"Scale cannot be zero on any axis",
$"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.",
"Set every scale axis to a non-zero value" );
}
// Scale, then rotate, then translate — the order a user expects, and the one that keeps a
// rotation about the origin from being skewed by a non-uniform scale applied after it.
var xform =
Xform.Translate( Translate.Value )
* Xform.Rotate( RotationAxis.Value, RotationAngle.Value * MathF.PI / 180f )
* Xform.Scale( scale );
foreach ( var body in RequireBodies( ctx, Bodies ) )
MeshTransform.Apply( body.Mesh, xform );
}
}
/// <summary>Copies along a direction. Onshape's Linear pattern.</summary>
public sealed class LinearPatternFeature : Feature
{
public override string TypeName => "Linear pattern";
public override GeometryKind Accepts => GeometryKind.Body;
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param Direction = new( "Direction", new Vec3( 1, 0, 0 ) );
public readonly FloatParam Spacing = new( "Spacing", 1f, unit: "u" );
public readonly IntParam Count = new( "Instances", 3, 1, 4096 );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, Direction, Spacing, Count, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( Direction.Value.LengthSquared < 1e-12f )
{
FailOn( "Direction",
"Direction cannot be zero",
"A linear pattern copies along a direction, and this one has no length, so there is nowhere to put the copies.",
"Set Direction to the axis you want the copies to run along" );
}
var dir = Direction.Value.Normal;
var sources = RequireBodies( ctx, Bodies );
// Instance 0 is the original, so a count of 3 means the original plus two copies — which
// is what Onshape's instance count means too.
foreach ( var source in sources )
{
// Snapshot BEFORE the loop. With Merge on, the loop appends into source.Mesh, so
// reading source.Mesh each iteration would copy the copies too and the instance count
// would double rather than increment: 6, 12, 24, 48 faces instead of 6, 12, 18, 24.
var original = source.Mesh.Clone();
for ( var i = 1; i < Count.Clamped; i++ )
{
var copy = MeshTransform.Transformed( original, Xform.Translate( dir * (Spacing.Value * i) ) );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name} {i}", copy ) );
}
}
}
}
/// <summary>Copies around an axis. Onshape's Circular pattern.</summary>
public sealed class CircularPatternFeature : Feature
{
public override string TypeName => "Circular pattern";
public override GeometryKind Accepts => GeometryKind.Body;
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param AxisPoint = new( "Axis through", Vec3.Zero );
public readonly Vec3Param AxisDirection = new( "Axis", new Vec3( 0, 0, 1 ) );
public readonly IntParam Count = new( "Instances", 4, 1, 4096 );
public readonly FloatParam TotalAngle = new( "Angle", 360f, unit: "deg" );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, AxisPoint, AxisDirection, Count, TotalAngle, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( AxisDirection.Value.LengthSquared < 1e-12f )
{
FailOn( "Axis",
"Axis cannot be zero",
"A circular pattern spins around an axis, and this one has no length.",
"Set Axis to the direction to spin around" );
}
var sources = RequireBodies( ctx, Bodies );
var count = Count.Clamped;
// A full turn puts instance N back on instance 0, so the step divides by count. A partial
// sweep spreads the instances across the arc inclusive of both ends instead.
var full = MathF.Abs( MathF.Abs( TotalAngle.Value ) - 360f ) < 1e-3f;
var step = count <= 1 ? 0f : TotalAngle.Value / (full ? count : count - 1);
foreach ( var source in sources )
{
// Snapshot before the loop — same compounding trap as the linear pattern.
var original = source.Mesh.Clone();
for ( var i = 1; i < count; i++ )
{
var xform = Xform.RotateAbout(
AxisPoint.Value, AxisDirection.Value, step * i * MathF.PI / 180f );
var copy = MeshTransform.Transformed( original, xform );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name} {i}", copy ) );
}
}
}
}
/// <summary>Reflects bodies in a plane. Onshape's Mirror.</summary>
public sealed class MirrorFeature : Feature
{
public override string TypeName => "Mirror";
public override GeometryKind Accepts => GeometryKind.Body;
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param PlanePoint = new( "Plane through", Vec3.Zero );
public readonly Vec3Param PlaneNormal = new( "Plane normal", new Vec3( 1, 0, 0 ) );
public readonly BoolParam KeepOriginal = new( "Keep original", true );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, PlanePoint, PlaneNormal, KeepOriginal, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( PlaneNormal.Value.LengthSquared < 1e-12f )
{
FailOn( "Plane normal",
"Plane normal cannot be zero",
"A mirror needs a plane to reflect across, and a zero normal does not define one.",
"Set Plane normal to the direction the mirror should face" );
}
var xform = Xform.Mirror( PlanePoint.Value, PlaneNormal.Value );
var sources = RequireBodies( ctx, Bodies );
foreach ( var source in sources )
{
// MeshTransform.Apply reverses winding for us — the mirror flips handedness, and
// without that reversal every mirrored face would point into the solid.
var copy = MeshTransform.Transformed( source.Mesh, xform );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name}", copy ) );
}
if ( !KeepOriginal.Value )
{
foreach ( var source in sources )
ctx.Bodies.Remove( source );
}
}
}
/// <summary>
/// Catmull-Clark subdivision as a history feature.
///
/// Onshape has no equivalent, and this is the deliberate place where the tool stops being CAD.
/// Putting subdivision IN the tree rather than after it is what keeps the pipeline honest: roll
/// the bar back above this feature and you are editing the low-poly cage, roll it forward and you
/// see the dense surface. The cage is what the sculpt eventually bakes down onto, so it has to
/// stay reachable rather than being consumed by an export step.
/// </summary>
public sealed class SubdivideFeature : Feature
{
public override string TypeName => "Subdivide";
/// <summary>Faces as well as bodies: an empty Bodies list means the whole document to Execute,
/// so picking is how you say which part - see the refusal in the editor's AddFeature.</summary>
public override GeometryKind Accepts => GeometryKind.Face | GeometryKind.Body;
/// <summary>
/// Which faces to subdivide. EMPTY MEANS WHOLE BODY OR ALL FACES, depending on <see cref="AllFaces"/>,
/// and the whole-body form is the old behaviour and the honest default — a subdivision surface
/// is a property of a cage, not of a corner of one.
///
/// Picking faces switches the operation from smooth to linear, and the two really are different
/// operations rather than a flag on one. See CatmullClark.SubdivideFaces: you cannot apply the
/// limit rules to part of a mesh without moving the vertices the rest of it is standing on. So
/// this is density where you need it — a face about to be sculpted, a panel about to be bent —
/// and the whole-body form remains the one that smooths.
///
/// Held as FaceRefs, like every other face pick, so the choice survives the rebuild that
/// recreates the faces it names.
/// </summary>
public List<FaceRef> Faces = new();
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly IntParam Levels = new( "Levels", 1, 0, 6 );
/// <summary>
/// Subdivide EVERY face, not the whole body. Only read when <see cref="Faces"/> is empty — a
/// picked face already names its region, and a pick is more specific than "everything".
///
/// The whole-body form (this off, no picks) runs the full Catmull-Clark and SMOOTHS: it moves
/// the original vertices onto the limit surface. This is the density version of the same idea —
/// midpoints and centroids, so the shape stays exactly where it is and every face just gets
/// more polygons. You want it when the whole cage is too coarse but the silhouette is already
/// right, and it is why it is a tick rather than "pick 40 faces".
/// </summary>
public readonly BoolParam AllFaces = new( "All faces", false );
public override IReadOnlyList<IParam> Parameters => new IParam[] { Bodies, Levels, AllFaces };
/// <summary>What this feature will cost at the current settings, for a UI that warns before
/// rather than after. Levels are exponential and the jump from 4 to 6 is 16x.</summary>
public (int Vertices, int Faces) PredictCost( IEnumerable<Body> bodies )
{
var v = 0;
var f = 0;
var list = bodies as IList<Body> ?? bodies.ToList();
if ( Faces.Count > 0 )
{
// Local subdivision only touches the bodies that were picked on, so the bodies it did
// not touch still cost exactly what they already are.
var picked = Resolve( list, out _ );
foreach ( var body in list )
{
var (bv, bf) = picked.TryGetValue( body, out var indices )
? CatmullClark.PredictLocalCost( body.Mesh, indices, Levels.Clamped )
: (body.Mesh.VertexCount, body.Mesh.FaceCount);
v += bv;
f += bf;
}
return (v, f);
}
foreach ( var body in list.Where( Bodies.Matches ) )
{
var (bv, bf) = AllFaces.Value
? CatmullClark.PredictLocalCost( body.Mesh, AllIndices( body.Mesh ), Levels.Clamped )
: CatmullClark.PredictCost( body.Mesh, Levels.Clamped );
v += bv;
f += bf;
}
return (v, f);
}
/// <summary>Picked faces grouped by the body they landed on. <paramref name="lost"/> counts the
/// references that no longer resolve — geometry upstream changed under them.</summary>
Dictionary<Body, List<int>> Resolve( IEnumerable<Body> bodies, out int lost )
{
var byBody = new Dictionary<Body, List<int>>();
lost = 0;
foreach ( var reference in Faces )
{
if ( !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )
{
lost++;
continue;
}
if ( !byBody.TryGetValue( body, out var indices ) )
byBody[body] = indices = new List<int>();
indices.Add( faceIndex );
}
return byBody;
}
protected override void Execute( FeatureContext ctx )
{
if ( Levels.Clamped == 0 )
return;
if ( Faces.Count > 0 )
{
// A picked face already names its body, so the Bodies filter has nothing left to decide and
// is not consulted here. Two picks on the same body are subdivided in ONE call rather than
// one call each: the second call would be running against a mesh whose face indices the
// first has already renumbered.
var picked = Resolve( ctx.Bodies, out var lost );
if ( picked.Count == 0 )
{
Fail(
"None of the picked faces are still there",
"Every face this feature subdivides was removed or replaced by a change further up the tree.",
"Pick the faces again",
"Or clear the picks to subdivide the whole body" );
}
foreach ( var (body, indices) in picked )
body.Mesh = CatmullClark.SubdivideFaces( body.Mesh, indices, Levels.Clamped );
if ( lost > 0 )
{
Warn(
$"{lost} picked {(lost == 1 ? "face is" : "faces are")} no longer there",
"A change further up the tree removed or replaced them, so they were skipped.",
"Pick them again if the extra density is still wanted" );
}
return;
}
// No picks. AllFaces chooses between densifying every face (linear, shape unchanged) and
// the whole-body Catmull-Clark that smooths.
foreach ( var body in RequireBodies( ctx, Bodies ) )
{
if ( AllFaces.Value )
body.Mesh = CatmullClark.SubdivideFaces( body.Mesh, AllIndices( body.Mesh ), Levels.Clamped );
else
body.Mesh = CatmullClark.Subdivide( body.Mesh, Levels.Clamped );
}
}
/// <summary>Every face index of a mesh, as a list — the "all faces" version of a pick list.</summary>
static int[] AllIndices( PolyMesh mesh )
{
var all = new int[mesh.FaceCount];
for ( var i = 0; i < all.Length; i++ )
all[i] = i;
return all;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Effigy;
/// <summary>One solid in the studio. Onshape calls these parts; a Part Studio holds several.</summary>
/// <summary>
/// A picture stood in the viewport to model against. <see cref="View"/> is which way it faces:
/// "front" stands in the XZ plane facing the Front view, "side" in the YZ plane facing Right,
/// "top" lies flat facing Top. <see cref="Height"/> is its size in units (width follows the
/// picture); <see cref="Offset"/> slides it in its own plane; <see cref="Depth"/> pushes it back
/// away from the viewer, so it sits behind the model rather than through it.
/// </summary>
public sealed class ReferenceImage
{
public string Path = "";
public string View = "front";
public float Height = 72f;
public Vec2 Offset;
public float Depth = 0f;
public float Opacity = 0.5f;
public bool Visible = true;
public ReferenceImage Clone() => new()
{
Path = Path, View = View, Height = Height, Offset = Offset, Depth = Depth, Opacity = Opacity, Visible = Visible,
};
}
public sealed class Body
{
public string Id;
public string Name;
public PolyMesh Mesh;
/// <summary>Whether this body is drawn. Set from the feature that produced it — see
/// Feature.Visible for why this is not the same thing as suppression.</summary>
public bool Visible = true;
/// <summary>Id of the Feature that produced this body, recorded by the rebuild alongside
/// Visible. Bodies are re-made from scratch every rebuild and have no identity a UI can hold
/// onto, so a Parts list needs this to get from a row back to the thing that owns it.</summary>
public string FeatureId;
/// <summary>Made by a GarmentFeature. Later garments collide with it but are never cut from it,
/// and Fur with nothing picked grows on garments.</summary>
public bool IsGarment;
/// <summary>Here to be measured against, not to be shipped — the body a WearerFeature brought
/// in so a garment has something to be cut from and collide with. Everything reads it the way
/// it reads any other body; only export leaves it out. See WearerFeature.</summary>
public bool IsReference;
public Body( string id, string name, PolyMesh mesh )
{
Id = id;
Name = name;
Mesh = mesh;
}
public Body Clone() => new( Id, Name, Mesh.Clone() )
{ Visible = Visible, FeatureId = FeatureId, IsGarment = IsGarment, IsReference = IsReference };
}
// --- parameters -------------------------------------------------------------------------------
/// <summary>
/// Feature parameters describe themselves, so one generic panel can render any feature's dialog
/// rather than each feature needing hand-written UI.
///
/// This is copied from Onshape deliberately. Every feature dialog there has the same shape, and
/// that uniformity is most of why the tool feels coherent with a hundred features in it. Getting
/// it for free is worth the small indirection here.
///
/// The parameter object IS the storage — the feature reads `_length.Value` — so there is no
/// separate copy to keep in sync.
/// </summary>
public interface IParam
{
string Label { get; }
}
public sealed class FloatParam : IParam
{
public string Label { get; }
public float Value;
public float Min, Max;
public string Unit;
public FloatParam( string label, float value, float min = float.MinValue, float max = float.MaxValue, string unit = null )
{
Label = label;
Value = value;
Min = min;
Max = max;
Unit = unit;
}
/// <summary>
/// The expression this parameter was typed as, or null when the number is a literal.
///
/// A LITERAL STAYS A LITERAL. Typing <c>4</c> must not become a formula that re-evaluates;
/// typing <c>#thickness / 2</c> must, or changing the variable does not move the feature.
/// Rebuild evaluates this through the studio's variable table and writes the result into
/// <see cref="Value"/>. An empty or unparsable expression holds the last good value.
/// </summary>
public string Expr;
public float Clamped => Math.Clamp( Value, Min, Max );
}
public sealed class IntParam : IParam
{
public string Label { get; }
public int Value;
public int Min, Max;
/// <summary>
/// Whether dragging this number through its range means anything.
///
/// A slider is for a MAGNITUDE — segment counts, subdivision levels, numbers where the values
/// either side of the one you have are the neighbouring answers. A material slot is an
/// IDENTIFIER that happens to be stored as a number: slot 7 is not "more" than slot 6, sweeping
/// through 40 of them on the way says nothing, and the drag bar was the widest control in the
/// Extrude dialog doing the least. Those get a field and no slider.
///
/// The bounds test in the dialog cannot tell the two apart — 0..63 looks exactly as draggable
/// as 0..6 — so the parameter says which it is.
/// </summary>
public bool Slider = true;
public IntParam( string label, int value, int min = int.MinValue, int max = int.MaxValue )
{
Label = label;
Value = value;
Min = min;
Max = max;
}
public int Clamped => Math.Clamp( Value, Min, Max );
}
public sealed class BoolParam : IParam
{
public string Label { get; }
public bool Value;
public BoolParam( string label, bool value )
{
Label = label;
Value = value;
}
}
public sealed class Vec3Param : IParam
{
public string Label { get; }
public Vec3 Value;
public Vec3Param( string label, Vec3 value )
{
Label = label;
Value = value;
}
}
public sealed class ChoiceParam : IParam
{
public string Label { get; }
public string[] Options;
public int Index;
public ChoiceParam( string label, string[] options, int index = 0 )
{
Label = label;
Options = options;
Index = index;
}
public string Value => Options[Math.Clamp( Index, 0, Options.Length - 1 )];
}
/// <summary>
/// A free-text parameter. Paths, names, anything a number or a dropdown cannot hold.
///
/// Written as the rest of the line, so a path with spaces survives. The dialog renders it as a
/// field; Import is the feature that asked for it.
/// </summary>
public sealed class StringParam : IParam
{
public string Label { get; }
public string Value;
public StringParam( string label, string value = "" )
{
Label = label;
Value = value ?? "";
}
}
/// <summary>Which bodies a feature acts on. Empty means every body, which is what Onshape's
/// "all" behaves like and is the sane default for a studio holding one part.</summary>
public sealed class BodySelectionParam : IParam
{
public string Label { get; }
public List<string> BodyIds = new();
public BodySelectionParam( string label )
{
Label = label;
}
public bool Matches( Body b ) => BodyIds.Count == 0 || BodyIds.Contains( b.Id );
}
/// <summary>
/// What geometry a tool will take from the selection you already made.
///
/// ONE DECLARATION BECAUSE THERE WERE THREE. "Which tools consume a face" was written down in three
/// places that could not see each other - the type switch in <see cref="Feature.ApplyGeometrySelection"/>,
/// the prose the editor paints under the viewport, and the per-dialog pick-mode flags - so the strip
/// could be right while the hint was a lie, and a tool could be missing from a list it belonged on
/// with nothing to catch it. Nothing DECLARED it, so nothing could ASK.
///
/// A FLAGS ENUM ON A VIRTUAL PROPERTY, deliberately, and for the same reason EffigyWindow.ToolKind
/// is an enum rather than a Func: an int survives a hotload, a lambda or a System.Type held in
/// static editor state comes back pointing into the dead assembly.
///
/// This says what a tool will USE, not what it needs to run. Fillet accepts a body because selecting
/// a part and pressing Fillet fillets that part; it does not follow that a fillet with no selection
/// is invalid.
/// </summary>
[Flags]
public enum GeometryKind
{
None = 0,
Face = 1,
Edge = 2,
Body = 4,
SketchRegion = 8,
}
// --- features ---------------------------------------------------------------------------------
/// <summary>The state a feature reads and writes as it runs.</summary>
public sealed class FeatureContext
{
public List<Body> Bodies = new();
/// <summary>Sketches published by SketchFeature, keyed by that feature's id. Extrude and
/// Revolve look themselves up here rather than holding a reference, so editing the sketch
/// upstream and rebuilding feeds the new geometry through without any wiring to keep in sync.</summary>
public Dictionary<string, Sketch> Sketches = new();
/// <summary>Splines published by SplineFeature, keyed by that feature's id, and the id of the
/// one published last — what a curve deform or a sweep means by "the spline" when it names
/// none. Same arrangement as Sketches, for the same reason.</summary>
public Dictionary<string, Spline3> Splines = new();
public string LastSplineId;
/// <summary>
/// For a sketch drawn on a face, the id of the body that face belongs to. Keyed by sketch
/// feature id, same as Sketches.
///
/// This is what lets an extrude know it is growing OUT OF something rather than into thin air.
/// A Sketch is pure geometry and has no business knowing about bodies, and the consuming
/// feature never sees the SketchFeature itself — only what it published here — so the
/// attachment travels the same way the sketch does.
/// </summary>
public Dictionary<string, string> SketchHostBodies = new();
/// <summary>
/// Planes published by PlaneFeature, keyed by that feature's id. A sketch — or another plane —
/// looks itself up here rather than holding the SketchPlane object.
///
/// Beside Sketches and for exactly the same reason: move the plane, rebuild, and everything
/// drawn on it follows, with no second reference anywhere that could be left pointing at the old
/// one. It is also what makes a reference to a plane that is suppressed or below the rollback
/// bar fail honestly rather than quietly using last rebuild's answer — a feature that has not
/// run is not in here.
/// </summary>
public Dictionary<string, SketchPlane> Planes = new();
/// <summary>
/// For a plane built from a face, the id of the body that face belongs to — inherited along a
/// chain of planes. Keyed by plane feature id, same as Planes.
///
/// The plane half of SketchHostBodies above, and it feeds straight into it: a sketch on a plane
/// that came off a part is a sketch on that part as far as Extrude's Auto result is concerned,
/// which is what keeps "three bosses off one block" one part rather than four.
/// </summary>
public Dictionary<string, string> PlaneHostBodies = new();
/// <summary>
/// Resolve a variable name (without the <c>#</c>) during this rebuild. Null when the studio
/// has no table. Set once before the first feature runs, so a feature's value does not depend
/// on when it happened to be asked.
/// </summary>
public Func<string, float?> Resolve;
/// <summary>The studio's rig, read-only, for features that place things by bone - a garment
/// finds the torso and the arms from it. Null when the studio has none.</summary>
public Skeleton Rig;
/// <summary>
/// This rebuild is a PREVIEW: somebody is dragging a slider and wants to see the shape move, not
/// to see the finished article.
///
/// A feature that has an expensive final step and a cheap approximation of it should take the
/// cheap one here. A garment's drape is forty simulation steps and its solidify doubles the
/// mesh; neither changes the silhouette you are actually looking at while you drag Length, and
/// together they are the difference between a slider that moves and one that stutters.
///
/// NOT A QUALITY SETTING. Nothing is saved from a preview rebuild and nothing exports from one -
/// the full rebuild always follows, within a fraction of a second of the drag stopping. A
/// feature must therefore never let a preview change anything but geometry: no material slots
/// moving, no bodies appearing or disappearing, or the tree and the parts list would flicker
/// through states that never existed.
/// </summary>
public bool Preview;
int _nextId = 1;
string _featureId;
int _featureBodies;
/// <summary>
/// A body id belonging to the feature currently running: its feature id, plus a counter of the
/// bodies that feature has made this run.
///
/// IDS USED TO BE A SINGLE RUNNING COUNTER - body1, body2, body3 in creation order across the
/// whole rebuild - and that is the topological naming problem all over again, one level up from
/// the faces FaceRef was written to protect. Add a feature ANYWHERE upstream that produces a
/// body and every id after it shifts by one, so a sketch attached to "body1" silently lands on
/// whatever happens to be body1 now. Not an error, not a warning: a boss quietly reattaches
/// itself to an unrelated block.
///
/// Feature ids are assigned once at creation and never reused, so a body named after the
/// feature that made it cannot be renumbered by anything happening elsewhere in the tree.
/// </summary>
public string NewBodyId() =>
_featureId is null ? $"body{_nextId++}" : $"{_featureId}b{_featureBodies++}";
/// <summary>Called by Feature.Run before Execute, so bodies are named after their maker. The
/// per-feature counter restarts, which is what makes a feature that produces three bodies -
/// a pattern, say - name them the same three ids on every rebuild.</summary>
public void BeginFeature( string featureId )
{
_featureId = featureId;
_featureBodies = 0;
}
/// <summary>Vestigial: only the fallback numbering uses it, for a context nothing has called
/// BeginFeature on.</summary>
public void SeedIdCounter( int next ) => _nextId = Math.Max( _nextId, next );
}
/// <summary>
/// One step in the history. Features run in order, each seeing what the ones before it produced.
///
/// THE ORDERED TREE IS THE WHOLE POINT. It is what separates a parametric modeller from a stack of
/// bakes: roll back to before feature 3, change a number, roll forward and everything downstream
/// rebuilds against the new value. Every design decision here bends toward keeping that true —
/// features hold parameters rather than results, and nothing caches geometry inside a feature.
/// </summary>
public abstract class Feature
{
public string Id = Guid.NewGuid().ToString( "N" )[..8];
public string Name;
/// <summary>
/// Suppressed features do not run at all — their effect is removed from the model.
/// </summary>
public bool Suppressed;
/// <summary>
/// Whether the geometry this feature produced is DRAWN. Deliberately not the same thing as
/// Suppressed, and conflating the two would be wrong: suppression takes a feature's effect out
/// of the model, hiding only stops you looking at it. A hidden body is still there, still
/// exported, and everything downstream still builds on it.
///
/// Solvespace keeps the same two flags separately on a group for the same reason.
/// </summary>
public bool Visible = true;
/// <summary>Set by Run when Execute threw. A failed feature does not stop the rebuild — the
/// tree carries on with the bodies as they were, which is what lets you fix an upstream
/// mistake without every later feature also reporting failure.</summary>
public string Error { get; internal set; }
/// <summary>
/// Something the feature worked around rather than failed on — it built, but not from
/// everything it was given.
///
/// Distinct from Error because the two need opposite handling. An error means there is no
/// geometry and the tree below is standing on nothing; a warning means there IS geometry and
/// you should look at it. Collapsing the second into the first is why one stray branch
/// anywhere in a sketch used to fail every extrude that read it.
/// </summary>
public string Warning { get; internal set; }
/// <summary>
/// Structured form of Error or Warning. Null when the feature ran clean.
///
/// Error and Warning stay as strings because PartStudio, RebuildReport, the feature tree and
/// several tests already read them. They are set alongside this: Error = Diagnostic.Problem
/// on a failure, Warning = Diagnostic.Problem on a warning.
/// </summary>
public FeatureDiagnostic Diagnostic { get; internal set; }
public abstract string TypeName { get; }
public abstract IReadOnlyList<IParam> Parameters { get; }
/// <summary>
/// The subset of <see cref="Parameters"/> that belongs behind the dialog's Advanced disclosure
/// — folded away by default, and still every bit as much a parameter as the ones above it.
///
/// It is a SUBSET RATHER THAN A SECOND LIST. Everything generic — the snapshot Cancel restores
/// from, the document writer, the diagnostics that point at a parameter by label — walks
/// Parameters, and a parameter that only appeared here would be invisible to all of it. So this
/// says nothing about what a feature HAS; it only says which of them are answered once and then
/// left alone. Extrude is the case that asked for it: taper, a second distance and a material
/// slot are three rows of nothing-to-do-here sitting under the distance that is the whole point
/// of the feature.
///
/// Held by REFERENCE, matched by reference. Naming the parameters by label would tie which rows
/// fold up to the words on screen.
/// </summary>
public virtual IReadOnlyList<IParam> AdvancedParameters => Array.Empty<IParam>();
/// <summary>
/// Whether this feature's cached result is out of date even though nobody called MarkDirty.
///
/// The convention everywhere else is that whoever edits a feature marks it dirty, and for a
/// dialog full of numbers that is one call in one place. It does not hold for a feature whose
/// state is a live object somebody else is mutating — SculptFeature's levels are changed by a
/// brush, hundreds of times a stroke, nowhere near the code that owns the studio. Relying on
/// that caller to remember is how "every parameter edit is a silent no-op" happened once
/// already, and it looked like three unrelated UI faults for a day.
///
/// So a feature that owns mutable state outside its parameters answers this instead, and the
/// rebuild asks. Must be cheap: it is called for every reusable feature on every rebuild.
/// </summary>
public virtual bool IsStale => false;
/// <summary>
/// Which kinds of picked geometry this feature will actually use.
///
/// The one place that knows. <see cref="ApplyGeometrySelection"/> is what DOES the consuming, and
/// the two are kept honest against each other by AcceptsTests rather than by hope: a feature that
/// stores FaceRefs and forgets to say Face here fails a test, not a user.
///
/// Must be cheap and must not touch the studio - the editor asks this while painting the toolbar.
/// </summary>
public virtual GeometryKind Accepts => GeometryKind.None;
/// <summary>
/// Seed this feature from geometry that was already picked, the way Onshape's tools consume
/// the current selection instead of making you pick again after the button.
///
/// Faces go to anything that stores FaceRefs — a sketch's plane, a datum plane, draft, hole,
/// face material, subdivide, move face, and EXTRUDE, which pulls the face itself. Body ids go onto every
/// BodySelectionParam. A face selection with no explicit
/// body list still names those faces' bodies, so clicking a face and then Fillet fillets that
/// part rather than every part.
///
/// <paramref name="bodies"/> is only needed for Shell, whose openings are still stored as
/// indices rather than FaceRefs and have to be resolved against the live mesh.
/// </summary>
public void ApplyGeometrySelection( IReadOnlyList<FaceRef> faces, IReadOnlyList<string> bodyIds,
IEnumerable<Body> bodies = null, IReadOnlyList<EdgeRef> edges = null,
string sketchFeatureId = null, IReadOnlyList<Vec2> regionSeeds = null )
{
faces ??= Array.Empty<FaceRef>();
bodyIds ??= Array.Empty<string>();
edges ??= Array.Empty<EdgeRef>();
if ( this is SketchFeature sketch && faces.Count > 0 )
{
sketch.Face = faces[0];
sketch.PlaneFeatureId = "";
}
// A datum plane takes a face the same way a sketch does, and the two other things it could
// have been built from are cleared: they are three answers to one question, and a stale one
// left set would win on the next rebuild (see PlaneFeature.ResolveBasePlane's order).
if ( this is PlaneFeature datum && faces.Count > 0 )
{
datum.Face = faces[0];
datum.BasePlaneId = "";
}
if ( this is SketchConsumingFeature consumer
&& !string.IsNullOrEmpty( sketchFeatureId )
&& sketchFeatureId != SketchConsumingFeature.AwaitingPick )
{
consumer.SketchFeatureId = sketchFeatureId;
consumer.RegionSeeds.Clear();
if ( regionSeeds is { Count: > 0 } )
consumer.RegionSeeds.AddRange( regionSeeds );
}
var pickedFaces = this switch
{
FaceMaterialFeature material => material.Faces,
DraftFeature draft => draft.Faces,
HoleFeature hole => hole.Faces,
SubdivideFeature subdivide => subdivide.Faces,
MoveFaceFeature move => move.Faces,
ExtrudeFeature extrude => extrude.Faces,
FurFeature fur => fur.Faces,
_ => null,
};
if ( pickedFaces is not null && faces.Count > 0 )
{
pickedFaces.Clear();
pickedFaces.AddRange( faces );
}
// A FACE ANSWERS THE QUESTION AwaitingPick WAS ASKING. An Extrude created from the toolbar
// starts out refusing to help itself to the nearest sketch (see SketchConsumingFeature
// .AwaitingPick), and pointing it at a face is an answer just as much as pointing it at a
// sketch is. Leaving the flag set would have the feature build correctly from the face while
// every panel that reads it still said "pick a profile".
if ( this is SketchConsumingFeature awaiting && faces.Count > 0
&& pickedFaces is not null && awaiting.IsAwaitingPick )
awaiting.SketchFeatureId = "";
ApplyBlendEdges( faces, edges, bodies );
var ids = bodyIds.Count > 0
? bodyIds
: (IReadOnlyList<string>)faces.Select( f => f.BodyId )
.Concat( edges.Select( e => e.BodyId ) )
.Distinct()
.ToList();
if ( ids.Count > 0 )
{
foreach ( var param in Parameters )
{
if ( param is not BodySelectionParam selection )
continue;
selection.BodyIds.Clear();
selection.BodyIds.AddRange( ids );
}
}
if ( this is not ShellFeature shell || faces.Count == 0 || bodies is null )
return;
// OpenFaces is applied to every selected body, so it only means something when the
// openings all live on one part. Two parts and a mixed index list would punch the
// wrong faces — or fail — on the second body.
if ( ids.Count != 1 )
return;
shell.OpenFaces.Clear();
foreach ( var face in faces )
{
if ( !FacePlane.TryResolveFace( bodies, face, out _, out var index ) )
continue;
if ( !shell.OpenFaces.Contains( index ) )
shell.OpenFaces.Add( index );
}
}
/// <summary>
/// Fillet and Chamfer store edges, not faces. A picked edge list is copied across; a picked
/// FACE becomes that face's boundary, which is what "select the top, then Fillet" means in
/// Onshape.
/// </summary>
void ApplyBlendEdges( IReadOnlyList<FaceRef> faces, IReadOnlyList<EdgeRef> edges, IEnumerable<Body> bodies )
{
var dest = this switch
{
FilletFeature fillet => fillet.Edges,
ChamferFeature chamfer => chamfer.Edges,
_ => null,
};
if ( dest is null )
return;
dest.Clear();
if ( edges.Count > 0 )
{
dest.AddRange( edges );
return;
}
if ( faces.Count == 0 || bodies is null )
return;
var seen = new HashSet<(string BodyId, EdgeKey Key)>();
foreach ( var face in faces )
{
if ( !FacePlane.TryResolveFace( bodies, face, out var body, out var index ) )
continue;
foreach ( var edge in FacePlane.CaptureBoundary( body, index ) )
{
if ( !FacePlane.TryResolveEdge( bodies, edge, out _, out var key ) )
continue;
if ( !seen.Add( (body.Id, key) ) )
continue;
dest.Add( edge );
}
}
}
/// <summary>
/// Re-evaluate every <see cref="FloatParam"/> that still carries its typed expression, so a
/// variable change moves the feature without anyone reopening the dialog.
///
/// ALL PUBLIC FLOAT FIELDS, not only <see cref="Parameters"/>. PrimitiveFeature hides SizeX
/// when the shape is a cylinder, and Execute still reads it; skipping hidden ones would leave
/// a stale number the moment the dropdown flipped back.
/// </summary>
internal void EvaluateExpressions( Func<string, float?> resolve )
{
foreach ( var field in GetType().GetFields() )
{
if ( field.GetValue( this ) is not FloatParam param )
continue;
if ( string.IsNullOrWhiteSpace( param.Expr ) )
continue;
if ( !Expression.TryEvaluate( param.Expr, param.Unit, resolve, out var value ) )
continue;
param.Value = Math.Clamp( value, param.Min, param.Max );
}
}
protected abstract void Execute( FeatureContext ctx );
internal void Run( FeatureContext ctx )
{
Error = null;
Warning = null;
Diagnostic = null;
// Bodies made from here on belong to this feature and are named after it. Set before the
// Suppressed check is pointless; set here so every path into Execute is covered.
ctx.BeginFeature( Id );
if ( Suppressed )
return;
try
{
EvaluateExpressions( ctx.Resolve );
Execute( ctx );
}
catch ( FeatureException e )
{
ApplyDiagnostic( e.Diagnostic );
}
catch ( Exception e )
{
ApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Error, e.Message ) );
}
}
/// <summary>Refuse to proceed. The three arguments are the three things the dialog shows.</summary>
[DoesNotReturn]
protected static void Fail( string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, remedies: remedies ) );
/// <summary>Refuse, and name the control the dialog should highlight.</summary>
[DoesNotReturn]
protected static void FailOn( string parameterLabel, string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, remedies: remedies ) );
/// <summary>Refuse, highlight the control, and offer a button that writes <paramref name="suggested"/>
/// into it.</summary>
[DoesNotReturn]
protected static void FailOn( string parameterLabel, float suggested, string problem, string cause, params string[] remedies ) =>
throw new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, suggested, remedies ) );
/// <summary>The feature built, but not from what was asked. Geometry stays; the dialog goes yellow.</summary>
protected void Warn( string problem, string cause, params string[] remedies ) =>
ApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Warning, problem, cause, remedies: remedies ) );
/// <summary>
/// After a cut, put every piece the cut severed into the part list. Returns how many bodies
/// were added, so the caller can say so.
///
/// A boolean is allowed to sever a part and has no obligation to mention it — "one mesh" is a
/// fine answer to "subtract this". Whether that mesh is one SOLID is the part list's question,
/// and this is where it gets asked. See MeshSplit for why connectivity is by shared vertex and
/// why the order the pieces come back in is a promise rather than an implementation detail.
///
/// THE ORIGINAL BODY KEEPS ITS ID, and keeps the largest piece. Everything built on this part is
/// holding that id — a sketch drawn on one of its faces, a later feature's body selection — and
/// a cut must not invalidate them. The offcuts are new bodies named after the feature that made
/// them, which is the same rule every other body in the studio follows.
/// </summary>
protected int SeparatePieces( FeatureContext ctx, Body body )
{
if ( body?.Mesh is null || MeshSplit.PieceCount( body.Mesh ) < 2 )
return 0;
var pieces = MeshSplit.ConnectedPieces( body.Mesh );
body.Mesh = pieces[0];
// Straight after the body they came off, so the parts list reads in the order someone would
// expect rather than collecting offcuts at the bottom. IndexOf can legitimately miss - a
// feature is allowed to hand us a body it has not published - and appending is right then.
var at = ctx.Bodies.IndexOf( body );
for ( var i = 1; i < pieces.Count; i++ )
{
var piece = new Body( ctx.NewBodyId(), $"{body.Name} ({i + 1})", pieces[i] )
{
Visible = body.Visible,
FeatureId = Id,
};
if ( at < 0 )
ctx.Bodies.Add( piece );
else
ctx.Bodies.Insert( at + i, piece );
}
return pieces.Count - 1;
}
/// <summary>
/// The warning a severing cut earns. Never an error: the geometry is right, and the part list
/// having grown is something to look at rather than something to fix.
/// </summary>
protected void WarnSeparated( int added, string bodyName )
{
if ( added <= 0 )
return;
Warn(
added == 1
? $"This cut separated '{bodyName}' into two parts"
: $"This cut separated '{bodyName}' into {added + 1} parts",
$"The tool went all the way through, so what was one solid is now {added + 1} that touch nowhere. "
+ "The largest keeps the original part's name and id; the rest are new parts below it.",
"Reduce the depth if the cut was meant to be a pocket",
"Nothing to fix if separating the part was the intent" );
}
/// <summary>Names in a sentence: "'A'", "'A' and 'B'", "'A', 'B' and 'C'".</summary>
protected static string Listed( IReadOnlyList<string> names )
{
if ( names is null || names.Count == 0 )
return "nothing";
var quoted = new List<string>( names.Count );
foreach ( var name in names )
quoted.Add( $"'{name}'" );
if ( quoted.Count == 1 )
return quoted[0];
return string.Join( ", ", quoted.GetRange( 0, quoted.Count - 1 ) ) + " and " + quoted[^1];
}
protected void ApplyDiagnostic( FeatureDiagnostic diagnostic )
{
if ( diagnostic is null )
return;
Diagnostic = diagnostic;
if ( diagnostic.Severity == DiagnosticSeverity.Error )
Error = diagnostic.Problem;
else
Warning = diagnostic.Problem;
}
/// <summary>The bodies this feature acts on, or a refusal if there are none. A feature that
/// did nothing is never a success.</summary>
protected List<Body> RequireBodies( FeatureContext ctx, BodySelectionParam selection )
{
var bodies = new List<Body>();
foreach ( var b in ctx.Bodies )
{
if ( selection.Matches( b ) )
bodies.Add( b );
}
if ( bodies.Count > 0 )
return bodies;
if ( ctx.Bodies.Count == 0 )
{
Fail(
"This studio has no bodies yet",
"There is nothing to act on — the feature list has not produced a solid.",
"Add a Primitive, or extrude a sketch first" );
}
Fail(
"No matching body is selected",
$"The studio has {ctx.Bodies.Count} body/bodies but none match this feature's selection.",
"Clear the body selection to act on every body",
"Pick a body that is still in the studio" );
return bodies;
}
/// <summary>
/// Run a blend on each selected body, using picked edges when any were given.
///
/// <paramref name="edges"/> empty means the blend's own angle threshold, which is how Fillet
/// and Chamfer behaved before edges could be picked. Non-empty means only those edges, and a
/// body that none of them land on is left alone rather than blended by the threshold — mixing
/// the two would fillet a part you never pointed at.
/// </summary>
protected List<(Body Body, BlendReport Report)> BlendBodies( FeatureContext ctx, List<EdgeRef> edges,
Func<PolyMesh, HashSet<EdgeKey>, BlendReport> blend )
{
var reports = new List<(Body Body, BlendReport Report)>();
var picked = edges is { Count: > 0 };
foreach ( var body in RequireBodies( ctx, BodiesOf() ) )
{
if ( !picked )
{
reports.Add( (body, blend( body.Mesh, null )) );
continue;
}
var keys = FacePlane.ResolveEdges( body, edges );
if ( keys.Count == 0 )
continue;
reports.Add( (body, blend( body.Mesh, keys )) );
}
if ( picked && reports.Count == 0 )
{
Fail(
"None of the picked edges are on the selected bodies",
"The edges were stored as geometry so they could survive a rebuild, and none of them could be re-found.",
"Pick the edges again",
"Clear the edge selection to blend every sharp edge" );
}
return reports;
}
/// <summary>The BodySelectionParam on this feature, or a fresh empty one (meaning every body)
/// if the feature does not have one. Fillet and Chamfer both do.</summary>
BodySelectionParam BodiesOf()
{
foreach ( var param in Parameters )
{
if ( param is BodySelectionParam selection )
return selection;
}
return new BodySelectionParam( "Bodies" );
}
/// <summary>Assign blended meshes, or refuse, only after every body has been tried — so a
/// failure leaves the studio as it was, which is Feature.Run's contract.</summary>
protected void CommitBlend( List<(Body Body, BlendReport Report)> reports, string sizeLabel )
{
foreach ( var ( _, report ) in reports )
{
if ( report.Failure is null )
continue;
report.Failure.ParameterLabel ??= sizeLabel;
if ( report.SuggestedSize > 0f )
report.Failure.SuggestedValue ??= FloorThousandths( report.SuggestedSize );
throw new FeatureException( report.Failure );
}
foreach ( var ( body, report ) in reports )
body.Mesh = report.Mesh;
var warnings = new List<FeatureDiagnostic>();
foreach ( var ( _, report ) in reports )
warnings.AddRange( report.Warnings );
if ( warnings.Count == 0 )
return;
if ( warnings.Count == 1 )
{
ApplyDiagnostic( warnings[0] );
return;
}
Warn(
warnings[0].Problem,
string.Join( " ", warnings.Select( w => w.Cause ).Where( c => !string.IsNullOrEmpty( c ) ) ),
warnings.SelectMany( w => w.Remedies ).Distinct().ToArray() );
}
/// <summary>A number the user can type that is never larger than the true fit, so suggesting
/// it cannot immediately fail again to rounding.</summary>
protected static float FloorThousandths( float value ) => MathF.Floor( value * 1000f ) / 1000f;
public override string ToString() => $"{TypeName} '{Name ?? Id}'{(Suppressed ? " (suppressed)" : "")}";
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Effigy;
/// <summary>
/// Which part of a body a face belongs to, read from the rig rather than from the shape.
///
/// A GARMENT IS A RECIPE, NOT A MESH. "Torso and the top of the upper arms" means the same thing on
/// the Citizen, on Camhead and on Gearhead, and the rig is the one thing all three have in common.
/// The shape comes from the body itself - see <see cref="GarmentFit"/>.
/// </summary>
public enum BodyRegion
{
None,
Head,
Neck,
Torso,
Hips,
UpperArm,
LowerArm,
Hand,
UpperLeg,
LowerLeg,
Foot,
}
/// <summary>A garment's coverage: which regions, and how far along each region's bone.</summary>
public sealed class GarmentRecipe
{
public string Name;
/// <summary>
/// Region, the span of the bone it covers (0 at the bone's head, 1 at its tail), and which side
/// of the body it is on: 0 both, +1 left, -1 right.
///
/// THE SIDE IS WHAT MAKES REAL CLOTHES POSSIBLE. Everything a span system builds is symmetric by
/// construction, and a great deal of clothing is not - one sleeve, a strap over one shoulder, a
/// shirt hanging off one side. Tagging the span rather than the recipe means asymmetry composes:
/// a garment can be one-sleeved and off-shoulder at once, and each span still means what it
/// meant before for anything that does not use it.
/// </summary>
public List<(BodyRegion Region, float From, float To, int Side)> Spans = new();
public static readonly string[] Names =
{
"T-shirt", "Long sleeve", "Tank top", "Trousers", "Shorts", "Beanie", "Gloves", "Socks", "Bodysuit",
};
/// <summary>Which sleeves or legs a recipe keeps. Sides are the body's own - left is +y, the
/// way Source measures it - so "Left only" is the wearer's left, not the viewer's.</summary>
public static readonly string[] SideNames = { "Both", "Left only", "Right only" };
/// <summary>SideNames index to the span tag: both, left (+y), right (-y).</summary>
public static int SideOf( int index ) => index switch { 1 => 1, 2 => -1, _ => 0 };
/// <summary>
/// The recipe with its two lengths applied.
///
/// <paramref name="length"/> is the hem: how far a top reaches down over the hips, how far a leg
/// reaches down the leg, how deep a hat sits. <paramref name="sleeve"/> is how far down the arm a
/// sleeve goes, 0 bare to 1 at the wrist. Both are fractions so they mean the same thing on a
/// short body and a tall one.
/// </summary>
/// <param name="neckline">How far down the torso the garment STARTS, 0 at the neck. This is the
/// top edge, the counterpart of <paramref name="length"/>: raise it and the collar becomes a
/// scoop, then bares the shoulders, then becomes a tube top. The sleeve starts at the same drop,
/// because a sleeve still attached to a shoulder the body of the shirt has left behind is a
/// floating ring, not a garment.</param>
/// <param name="sleeveSide">Which sleeve to keep: 0 both, +1 left, -1 right.</param>
public static GarmentRecipe Build( int index, float length, float sleeve, float neckline = 0f,
int sleeveSide = 0 )
{
length = Math.Clamp( length, 0f, 1f );
sleeve = Math.Clamp( sleeve, 0f, 1f );
// Capped well short of 1: a neckline at the hem is a garment with no garment left, and the
// fit would refuse with "covers nothing" rather than explain itself.
neckline = Math.Clamp( neckline, 0f, 0.8f );
var r = new GarmentRecipe { Name = Names[Math.Clamp( index, 0, Names.Length - 1 )] };
void Arms( float s )
{
if ( s <= 0f )
return;
// The shoulder drop, shared with the torso. Halved because the upper arm's span runs
// shoulder to elbow and the torso's runs neck to waist - the same fraction would drop
// the sleeve twice as far down the arm as the neckline dropped down the chest.
var from = Math.Min( 0.6f, neckline * 0.5f );
r.Spans.Add( (BodyRegion.UpperArm, from, Math.Max( from, Math.Min( 1f, s * 2f ) ), sleeveSide) );
if ( s > 0.5f )
r.Spans.Add( (BodyRegion.LowerArm, 0f, (s - 0.5f) * 2f, sleeveSide) );
}
void Legs( float s )
{
r.Spans.Add( (BodyRegion.UpperLeg, 0f, Math.Min( 1f, Math.Max( 0.05f, s ) * 2f ), 0) );
if ( s > 0.5f )
r.Spans.Add( (BodyRegion.LowerLeg, 0f, (s - 0.5f) * 2f, 0) );
}
switch ( r.Name )
{
case "T-shirt":
case "Long sleeve":
case "Tank top":
r.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );
r.Spans.Add( (BodyRegion.Hips, 0f, length, 0) );
Arms( r.Name == "Tank top" ? 0f : sleeve );
break;
case "Trousers":
case "Shorts":
r.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );
Legs( length );
break;
case "Beanie":
r.Spans.Add( (BodyRegion.Head, 1f - Math.Max( 0.1f, length ), 1.01f, 0) );
break;
case "Gloves":
r.Spans.Add( (BodyRegion.Hand, 0f, 1.01f, sleeveSide) );
r.Spans.Add( (BodyRegion.LowerArm, 1f - length * 0.5f, 1.01f, sleeveSide) );
break;
case "Socks":
r.Spans.Add( (BodyRegion.Foot, 0f, 1.01f, 0) );
r.Spans.Add( (BodyRegion.LowerLeg, 1f - length * 0.6f, 1.01f, 0) );
break;
case "Bodysuit":
r.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );
r.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );
Arms( sleeve );
Legs( length );
break;
}
return r;
}
/// <summary>The defaults a recipe opens with: (length, sleeve).</summary>
public static (float Length, float Sleeve) Defaults( int index ) => Names[Math.Clamp( index, 0, Names.Length - 1 )] switch
{
"T-shirt" => (0.6f, 0.3f),
"Long sleeve" => (0.7f, 1f),
"Tank top" => (0.6f, 0f),
"Trousers" => (1f, 0f),
"Shorts" => (0.4f, 0f),
"Beanie" => (0.55f, 0f),
"Gloves" => (0.3f, 0f),
"Socks" => (0.4f, 0f),
_ => (1f, 1f),
};
/// <param name="side">Which side of the body the face is on: +1 left, -1 right. A span tagged 0
/// covers both, which is what every symmetric garment is made of.</param>
public bool Covers( BodyRegion region, float t, int side = 0 ) =>
Spans.Any( s => s.Region == region && t >= s.From - 1e-4f && t <= s.To + 1e-4f
&& (s.Side == 0 || s.Side == side) );
}
/// <summary>
/// Classify a skeleton's bones into regions by name. Covers Citizen's names (<c>arm_upper_R</c>,
/// <c>spine_2</c>), Unreal/Mixamo style (<c>upperarm_r</c>, <c>thigh_l</c>, <c>mixamorig:LeftForeArm</c>)
/// and plain English ones people type into the rig panel.
/// </summary>
public static class BodyRegions
{
public static BodyRegion Classify( string boneName )
{
var n = (boneName ?? "").ToLowerInvariant();
if ( n.Contains( "twist" ) )
n = n.Replace( "twist", "" );
bool Any( params string[] keys ) => keys.Any( n.Contains );
if ( Any( "finger", "thumb", "index", "middle", "ring_", "pinky", "hand" ) ) return BodyRegion.Hand;
if ( Any( "toe", "foot", "ball_" ) ) return BodyRegion.Foot;
if ( Any( "head", "eye", "jaw", "face" ) ) return BodyRegion.Head;
if ( Any( "neck" ) ) return BodyRegion.Neck;
if ( Any( "arm_lower", "forearm", "lowerarm", "lower_arm", "elbow" ) ) return BodyRegion.LowerArm;
if ( Any( "arm_upper", "upperarm", "upper_arm" ) ) return BodyRegion.UpperArm;
if ( Any( "leg_lower", "calf", "shin", "lowerleg", "lower_leg", "knee" ) ) return BodyRegion.LowerLeg;
if ( Any( "leg_upper", "thigh", "upperleg", "upper_leg" ) ) return BodyRegion.UpperLeg;
if ( Any( "pelvis", "hip" ) ) return BodyRegion.Hips;
if ( Any( "spine", "chest", "clavicle", "torso", "abdomen", "belly", "collar", "shoulder" ) ) return BodyRegion.Torso;
if ( Any( "arm" ) ) return BodyRegion.UpperArm;
if ( Any( "leg" ) ) return BodyRegion.UpperLeg;
return BodyRegion.None;
}
/// <summary>
/// The region nearest a point, and how far along that region's bone it sits.
///
/// "How far along" is measured on the nearest bone of the region, and a region with a chain of
/// bones (a spine, a twist chain) is measured along the whole chain, so 0.5 is halfway down the
/// upper arm whether that arm is one bone or three.
/// </summary>
public sealed class Map
{
readonly List<(BodyRegion Region, Vec3 A, Vec3 B)> _segments = new();
readonly List<int> _segmentBones = new();
public int Count => _segments.Count;
/// <summary>Every region bone as a segment, for fitting capsules to.</summary>
public IReadOnlyList<(BodyRegion Region, Vec3 A, Vec3 B)> Segments => _segments;
/// <summary>The skeleton bone each of <see cref="Segments"/> came from.</summary>
public IReadOnlyList<int> SegmentBones => _segmentBones;
public Map( Skeleton skeleton )
{
for ( var i = 0; i < skeleton.Count; i++ )
{
var region = Classify( skeleton.Bones[i].Name );
if ( region == BodyRegion.None )
continue;
var a = skeleton.HeadWorld( i );
var b = skeleton.TailWorld( i );
if ( (b - a).LengthSquared < 1e-8f )
continue;
_segments.Add( (region, a, b) );
_segmentBones.Add( i );
}
}
/// <summary>Nearest region to <paramref name="p"/>, and t along it (0 at the joint nearest
/// the body's centre, 1 at the far end).</summary>
public (BodyRegion Region, float T) Locate( Vec3 p ) => Locate( p, BodyRegion.None );
/// <summary>As <see cref="Locate(Vec3)"/>, but only among <paramref name="only"/>'s bones -
/// for a surface that already knows which region it belongs to, such as an envelope hull.
/// None means any region.</summary>
public (BodyRegion Region, float T) Locate( Vec3 p, BodyRegion only )
{
var best = float.MaxValue;
var region = BodyRegion.None;
var chainT = 0f;
foreach ( var (r, a, b ) in _segments )
{
if ( only != BodyRegion.None && r != only )
continue;
var ab = b - a;
var t = Math.Clamp( Vec3.Dot( p - a, ab ) / ab.LengthSquared, 0f, 1f );
var d = (a + ab * t - p).LengthSquared;
if ( d < best )
{
best = d;
region = r;
chainT = t;
}
}
if ( region == BodyRegion.None )
return (region, 0f);
// Chains: measure t over the whole region on this side, start to end, so a limb reads
// 0..1 shoulder to wrist whether it is one bone or a bone with twist bones laid over it
// (Citizen's are - summing their lengths would count the arm twice).
if ( Chain( region, Side( p ), out var from, out var to ) )
{
var axis = to - from;
chainT = Math.Clamp( Vec3.Dot( p - from, axis ) / axis.LengthSquared, 0f, 1f );
}
// The head is measured bottom-up (a beanie covers the TOP), the torso and hips top-down
// (a hem reads down from the collar).
if ( region == BodyRegion.Head )
chainT = 1f - chainT;
return (region, chainT);
}
/// <summary>
/// The line a region runs along, on one side of the body: from the end nearest the body's
/// centre (the top, for anything on the spine or a leg) to the far end. This is the axis t
/// is measured on and the axis a limb's UVs wrap around, so both agree about where a
/// sleeve starts. False for a region the skeleton does not have.
/// </summary>
public bool Chain( BodyRegion region, int side, out Vec3 from, out Vec3 to )
{
from = to = Vec3.Zero;
var ends = new List<Vec3>();
foreach ( var s in _segments )
{
if ( s.Region != region || (!IsCentral( region ) && Side( (s.A + s.B) * 0.5f ) != side) )
continue;
ends.Add( s.A );
ends.Add( s.B );
}
if ( ends.Count < 2 )
return false;
from = IsCentral( region ) || region is BodyRegion.UpperLeg or BodyRegion.LowerLeg or BodyRegion.Foot
? ends.OrderByDescending( e => e.z ).First()
: ends.OrderBy( e => MathF.Abs( e.y ) ).First();
var start = from;
to = ends.OrderByDescending( e => (e - start).LengthSquared ).First();
return (to - from).LengthSquared > 1e-8f;
}
public static bool IsCentral( BodyRegion r ) => r is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;
static int Side( Vec3 p ) => p.y > 0 ? 1 : -1;
}
}
/// <summary>
/// Make a garment that fits a particular body.
///
/// THE BODY DOES MOST OF THE WORK. The rig only says which faces a recipe covers; the garment is then
/// lifted off the body's own surface, so it starts with that body's proportions and topology - which
/// is also the topology the s&box quality bar asks clothing to follow. After that:
///
/// 1. every vertex is pushed out to the fit offset along its normal,
/// 2. collision: any vertex closer than the clearance to ANY body - the chest pressing into an arm, a
/// collar into a cog - is pushed back out along that surface's normal, and smoothed, a few times,
/// 3. the openings flare out if asked,
/// 4. the sheet is given thickness with <see cref="MeshSolidify"/>.
///
/// It does not drape. Folds come from the flare and from sculpting until editor cloth arrives.
/// </summary>
public static class GarmentFit
{
public sealed class Options
{
/// <summary>Round colliders for the drape instead of the bodies' triangles. See Capsule.
/// Null drapes against the meshes, as before.</summary>
public List<Capsule> Capsules;
/// <summary>How far the drape may move the cloth from where the fit put it. 0 takes the
/// default: a couple of inches, room to settle and no more.</summary>
public float Tether;
/// <summary>How far off the body the garment sits, in model units (inches on the Citizen).</summary>
public float Offset = 0.4f;
/// <summary>The closest any part of the garment may come to any body, after collision.</summary>
public float Clearance = 0.15f;
/// <summary>How far the openings (hems, cuffs) flare out beyond the offset.</summary>
public float Flare;
/// <summary>How far up from an opening the flare reaches.</summary>
public float FlareReach = 4f;
/// <summary>Cloth thickness; 0 leaves a single-sided sheet.</summary>
public float Thickness = 0.12f;
/// <summary>
/// Inflate the garment along its own normals, in inches. Puffed sleeves, a padded jacket,
/// a quilted vest.
///
/// Marvelous Designer calls this Pressure and Simply Cloth calls it the same; it is on both
/// tools' front page because it is the cheapest way to turn a fitted shape into a different
/// GARMENT rather than the same garment at a different size. Looseness moves the whole
/// surface away from the body uniformly, which reads as a bigger shirt. This pushes along
/// the local normal, which reads as stuffing.
/// </summary>
public float Puff;
/// <summary>
/// Pull the openings in towards their own centres, 0 none to 1 closed. A waistband, elastic
/// cuffs, a gathered ankle on a jogger.
///
/// The hems are the only part of a garment that has nothing holding it in, so they are the
/// part that reads as loose whatever the rest is doing - which is why "tighten the waistband"
/// is a thing people ask for and "make the shirt smaller" is not the same request.
/// </summary>
public float Cinch;
/// <summary>How far up from an opening the cinch reaches, in inches. A waistband is an inch;
/// a gathered ankle is three.</summary>
public float CinchReach = 1.5f;
/// <summary>
/// Creases along the surface, 0 smooth to 1 rumpled.
///
/// NOT A SUBSTITUTE FOR THE DRAPE. The solver makes the folds that gravity and collision
/// actually produce, and those are the ones that sit in the right places. This is the fine
/// noise on top - the crumple of cloth that has been worn - which a solver would need a much
/// finer mesh and much longer to find, for something nobody looks at closely enough to tell
/// apart from noise.
/// </summary>
public float Wrinkles;
/// <summary>Size of a wrinkle, in inches. Small is linen, large is a heavy coat.</summary>
public float WrinkleScale = 2.2f;
/// <summary>Collision passes. Each pass pushes out and relaxes once.</summary>
public int Passes = 4;
public int MaterialSlot;
/// <summary>Let the garment settle under gravity against the bodies before it is thickened.</summary>
public bool Drape = true;
/// <summary>
/// What the cloth is made of, as a set of solver settings.
///
/// THE FIRST THING EITHER OF THE BIG TOOLS ASKS. Marvelous Designer opens on a fabric
/// library and Simply Cloth ships presets, because the difference between cotton and leather
/// is most of the difference between two garments of the same shape - it decides whether the
/// folds are many and soft or few and standing. Stiffness alone could only say how much a
/// garment resists being stretched, which is the one property people do not think in.
///
/// A STARTING POINT, NOT A MEASUREMENT. These are artistic presets tuned to look right at
/// this scale, not the physical constants of real textiles; Stiffness still overrides the
/// stretch on top of whichever is chosen.
/// </summary>
public Fabric Fabric = Fabric.Cotton;
/// <summary>Simulation steps, at 1/60 s each. More settles further and costs more.</summary>
public int DrapeSteps = 40;
/// <summary>0 = stretchy jersey, 1 = stiff canvas. How hard the edges hold their fitted length.</summary>
public float Stiffness = 0.8f;
/// <summary>Pin the top fraction of the garment (by height) where it is, so trousers keep their
/// waistband. 0 pins nothing - a top rests on the shoulders by collision alone.</summary>
public float PinTop;
/// <summary>
/// Stop the garment passing through itself where it folds. OFF by default, and deliberately:
/// it is several times the cost of the whole rest of the drape, and this runs on every
/// rebuild while a slider moves. The fit pass has already pushed the garment out of the body,
/// so what this buys is folds resolving against each other - worth asking for on a finished
/// garment, not worth paying for on every drag.
/// </summary>
public bool SelfCollision;
}
public sealed class Result
{
public PolyMesh Mesh;
public int SourceFaces;
public int DroppedInside;
public bool Solidified;
public string SolidifyProblem;
}
/// <summary>
/// Build <paramref name="recipe"/> on <paramref name="bodies"/>, located with <paramref name="map"/>.
/// Returns null when the recipe covers no face at all.
/// </summary>
public static Result Fit( IReadOnlyList<PolyMesh> wearers, BodyRegions.Map map, GarmentRecipe recipe, Options o,
IReadOnlyList<PolyMesh> otherGarments = null, IReadOnlyList<BodyRegion> wearerRegions = null )
{
// Everything collides; only the wearers are cut from.
var bodies = wearers.Concat( otherGarments ?? Array.Empty<PolyMesh>() ).ToList();
var trees = bodies.Select( MeshBVH.Build ).ToList();
var closed = bodies.Select( b => MeshValidator.Validate( b ).IsClosed ).ToList();
var result = new Result { Mesh = new PolyMesh() };
var garment = result.Mesh;
var remap = new Dictionary<(int Body, int Vertex), int>();
var cutRemap = new Dictionary<(int Body, int A, int B, BodyRegion Region, int End), int>();
for ( var bi = 0; bi < wearers.Count; bi++ )
{
var body = bodies[bi];
// A wearer that already knows its region - an envelope hull - keeps it, and is only
// measured along it. Left to the nearest bone, the front of Camhead's torso hull was
// claimed by the arms hanging beside it and cut out of the shirt.
var only = wearerRegions is not null && bi < wearerRegions.Count ? wearerRegions[bi] : BodyRegion.None;
int Vertex( int vi )
{
var key = (bi, vi);
if ( !remap.TryGetValue( key, out var index ) )
{
index = garment.AddVertex( body.Positions[vi] );
remap[key] = index;
}
return index;
}
for ( var fi = 0; fi < body.FaceCount; fi++ )
{
var face = body.Faces[fi];
if ( face.Count < 3 )
continue;
var centroid = body.FaceCentroid( face );
var (region, t) = map.Locate( centroid, only );
// The side is the face's own position, not the bone's: +y is the body's left, the
// way Source measures it. Taken from the face rather than from the bone name so an
// imported skeleton with no _L / _R convention still makes a one-sleeved shirt.
var side = centroid.y > 0 ? 1 : -1;
var spans = recipe.Spans.Where( s => s.Region == region && (s.Side == 0 || s.Side == side) ).ToList();
if ( spans.Count == 0 )
continue;
static bool Within( (BodyRegion Region, float From, float To, int Side) s, float t ) =>
t >= s.From - 1e-4f && t <= s.To + 1e-4f;
// THE FACE IS CUT AT THE HEM, not kept or dropped whole. Whole faces gave every
// opening a sawtooth edge one face deep - the jagged cuffs and collars that said
// "generated" from across the room - and no amount of relaxing put a straight line
// back. The span's two ends are two planes across the bone (t is linear along it),
// and the polygon is clipped to each (Sutherland-Hodgman), so the edge is a clean
// line round the limb. An end at 0 or 1 is open - a sleeve runs into the shoulder,
// a glove past the fingertips - and is not cut. A face is in if any corner of it
// is, and it is the span round the centre, or failing that round a corner, that
// does the cutting.
var nodes = new List<CutNode>( face.Count + 2 );
var span = spans[0];
var chained = map.Chain( region, side, out var from, out var to );
if ( chained )
{
var axis = to - from;
var lengthSq = axis.LengthSquared;
for ( var c = 0; c < face.Count; c++ )
{
var vi = face.Indices[c];
var p = body.Positions[vi];
nodes.Add( new CutNode( vi, vi, -1, p, Vec3.Dot( p - from, axis ) / lengthSq ) );
}
var covering = spans.FirstOrDefault( s => Within( s, t ) );
if ( covering.Region != region )
covering = spans.FirstOrDefault( s => nodes.Any( n => Within( s, n.T ) ) );
if ( covering.Region != region )
continue;
span = covering;
}
else if ( !spans.Any( s => Within( s, t ) ) )
continue;
// A face buried inside another part - an arm cylinder's end inside the torso block,
// the way CAD characters are built - would be pushed out onto the surface as a crumpled
// flap. It is not skin; skip it.
if ( InsideOther( bodies, trees, closed, bi, centroid ) )
{
result.DroppedInside++;
continue;
}
if ( chained )
{
if ( span.From > 0f )
nodes = ClipSpan( nodes, span.From, keepAbove: true, end: 0 );
if ( span.To < 1f && nodes.Count >= 3 )
nodes = ClipSpan( nodes, span.To, keepAbove: false, end: 1 );
if ( nodes.Count < 3 )
continue;
}
else
{
for ( var c = 0; c < face.Count; c++ )
nodes.Add( new CutNode( face.Indices[c], face.Indices[c], -1, body.Positions[face.Indices[c]], 0f ) );
}
var indices = new int[nodes.Count];
for ( var c = 0; c < nodes.Count; c++ )
{
var node = nodes[c];
// An original corner; or a cut point on an original edge, which the face on the
// other side of that edge finds again by the same key, so the cut is watertight.
if ( node.End < 0 )
indices[c] = Vertex( node.A );
else if ( node.A >= 0 )
{
var (lo, hi) = node.A < node.B ? (node.A, node.B) : (node.B, node.A);
var key = (bi, lo, hi, region, node.End);
if ( !cutRemap.TryGetValue( key, out var index ) )
{
index = garment.AddVertex( node.P );
cutRemap[key] = index;
}
indices[c] = index;
}
else
indices[c] = garment.AddVertex( node.P );
}
garment.AddFace( indices, null, o.MaterialSlot );
result.SourceFaces++;
}
}
if ( garment.FaceCount == 0 )
return null;
var start = garment.Positions.ToArray();
var normals = garment.ComputeVertexNormals();
var boundary = BoundaryVertices( garment );
var flare = FlareWeights( garment, boundary, o.FlareReach );
// 1. Out to the fit offset, flaring toward the openings.
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] = start[i] + normals[i] * (o.Offset + o.Flare * flare[i]);
// 2. Collision against every body, with a relax between passes so a push does not leave a dent.
var neighbours = Neighbours( garment );
var reach = o.Offset + o.Flare + o.Clearance + 2f;
for ( var pass = 0; pass < Math.Max( 1, o.Passes ); pass++ )
{
PushOut( garment, bodies, trees, o.Clearance, reach );
Relax( garment, neighbours, boundary, 0.35f );
}
PushOut( garment, bodies, trees, o.Clearance, reach );
if ( o.Drape && o.DrapeSteps > 0 )
Drape( garment, bodies, o );
// SHAPING COMES AFTER THE DRAPE AND BEFORE THE THICKNESS, and the order is the whole
// correctness argument. After the drape, because these are deliberate style decisions and
// the solver would flatten a puff straight back out as if it were an error. Before the
// thickness, because Solidify offsets the surface it is given - shaping a solid would move
// its inner and outer shells apart by different amounts and split the garment open.
//
// Then pushed out again: a puff or a wrinkle can drive the cloth back into the body, and a
// garment that clips is worse than one that is not quite as puffy as asked.
if ( o.Puff > 0f )
Inflate( garment, o.Puff );
if ( o.Cinch > 0f )
Cinch( garment, o.Cinch, o.CinchReach );
if ( o.Wrinkles > 0f )
Wrinkle( garment, o.Wrinkles, o.WrinkleScale );
if ( o.Puff > 0f || o.Wrinkles > 0f )
PushOut( garment, bodies, trees, o.Clearance, reach );
LimbUVs( garment, map );
if ( o.Thickness > 0f )
{
try
{
garment = MeshSolidify.Solidify( garment, o.Thickness );
result.Solidified = true;
}
catch ( Exception e )
{
result.SolidifyProblem = e.Message;
}
}
result.Mesh = garment;
return result;
}
/// <summary>A corner of a face being cut: an original vertex (A == B, End -1), or a point on
/// the original edge A-B where the span's end (0 the start, 1 the finish) crosses it. A is -1
/// for a point on an edge the cut itself made, which no other face shares.</summary>
readonly record struct CutNode( int A, int B, int End, Vec3 P, float T );
/// <summary>One Sutherland-Hodgman pass: keep the side of t = limit the span is on.</summary>
static List<CutNode> ClipSpan( List<CutNode> nodes, float limit, bool keepAbove, int end )
{
var kept = new List<CutNode>( nodes.Count + 2 );
for ( var i = 0; i < nodes.Count; i++ )
{
var cur = nodes[i];
var nxt = nodes[(i + 1) % nodes.Count];
var inCur = keepAbove ? cur.T >= limit : cur.T <= limit;
var inNxt = keepAbove ? nxt.T >= limit : nxt.T <= limit;
if ( inCur )
kept.Add( cur );
if ( inCur == inNxt )
continue;
var s = Math.Clamp( (limit - cur.T) / (nxt.T - cur.T), 0f, 1f );
var (a, b) = EdgeUnder( cur, nxt );
kept.Add( new CutNode( a, b, end, cur.P + (nxt.P - cur.P) * s, limit ) );
}
return kept;
}
/// <summary>The original edge a segment between two nodes lies on, or (-1, -1) when it is a
/// line the first cut drew across the face and nothing else can share.</summary>
static (int, int) EdgeUnder( CutNode x, CutNode y )
{
static bool OnEdge( CutNode cut, int vertex ) => cut.A == vertex || cut.B == vertex;
if ( x.End < 0 && y.End < 0 )
return (x.A, y.A);
if ( x.End < 0 && y.A >= 0 && OnEdge( y, x.A ) )
return (y.A, y.B);
if ( y.End < 0 && x.A >= 0 && OnEdge( x, y.A ) )
return (x.A, x.B);
if ( x.End >= 0 && y.End >= 0 && x.A >= 0 && x.A == y.A && x.B == y.B )
return (x.A, x.B);
return (-1, -1);
}
/// <summary>
/// Push every vertex out along its own normal. Pressure, in Marvelous Designer's terms.
///
/// The normals are computed ONCE, from the shape as it arrives, rather than per vertex as it
/// moves. Recomputing as you go makes a bulge feed itself - the more a region inflates the more
/// outward its neighbours' normals become - and a padded jacket turns into a balloon.
/// </summary>
static void Inflate( PolyMesh garment, float amount )
{
var normals = garment.ComputeVertexNormals();
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] += normals[i] * amount;
}
/// <summary>
/// Pull the openings in towards their own centres: waistbands, elastic cuffs, gathered ankles.
///
/// The pull falls off with distance from the edge, so the garment is gathered at the hem and
/// untouched an inch above it - which is what elastic in a casing actually does, and what makes
/// this read as a waistband rather than as a garment that tapers.
/// </summary>
static void Cinch( PolyMesh garment, float amount, float reach )
{
var loops = GarmentTrim.OrderedBoundaryLoops( garment );
if ( loops.Count == 0 || reach <= 1e-4f )
return;
amount = Math.Clamp( amount, 0f, 1f );
// Every vertex moves towards the centre of the NEAREST opening, weighted by how close it is
// to that opening's edge. Nearest rather than "the opening it belongs to": a garment has no
// notion of which panel a vertex is on, and distance answers the question correctly anyway.
var centres = new List<Vec3>();
var rings = new List<List<Vec3>>();
foreach ( var loop in loops )
{
var points = loop.Select( v => garment.Positions[v] ).ToList();
var centre = Vec3.Zero;
foreach ( var p in points )
centre += p;
centres.Add( centre / points.Count );
rings.Add( points );
}
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i];
var best = float.MaxValue;
var which = -1;
for ( var li = 0; li < rings.Count; li++ )
{
foreach ( var q in rings[li] )
{
var d = (q - p).LengthSquared;
if ( d >= best )
continue;
best = d;
which = li;
}
}
if ( which < 0 )
continue;
var distance = MathF.Sqrt( best );
if ( distance >= reach )
continue;
// Smoothstep rather than linear, so the top of the band blends into the garment instead
// of leaving a crease exactly `reach` inches up.
var t = 1f - distance / reach;
var falloff = t * t * (3f - 2f * t);
var centre = centres[which];
// Along the opening's own plane only: pulling towards the centre POINT would drag the
// hem up the body as well as in, and shorten the garment as a side effect of tightening
// it. Only the part of the offset perpendicular to the ring's axis is wanted.
var toCentre = centre - p;
var axis = RingAxis( rings[which], centre );
toCentre -= axis * Vec3.Dot( toCentre, axis );
garment.Positions[i] = p + toCentre * (amount * falloff);
}
}
/// <summary>The direction an opening faces: the normal of the plane its vertices best lie in.
/// Found by summing the fan of cross products around the ring, which is the ring's own area
/// vector and needs no fitting.</summary>
static Vec3 RingAxis( List<Vec3> ring, Vec3 centre )
{
var area = Vec3.Zero;
for ( var i = 0; i < ring.Count; i++ )
area += Vec3.Cross( ring[i] - centre, ring[(i + 1) % ring.Count] - centre );
return area.LengthSquared < 1e-8f ? new Vec3( 0, 0, 1 ) : area.Normal;
}
/// <summary>
/// Fine creases along the surface. See Options.Wrinkles for what this is and is not.
///
/// Three octaves of a cheap value noise over the vertex's own position, displaced along the
/// normal. Position-based rather than UV-based on purpose: UVs are laid out per panel, so a
/// UV-space wrinkle would stop dead at every seam, and cloth does not.
/// </summary>
static void Wrinkle( PolyMesh garment, float amount, float scale )
{
if ( scale <= 1e-3f )
return;
var normals = garment.ComputeVertexNormals();
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i] / scale;
var n = Noise( p ) * 0.6f
+ Noise( p * 2.3f ) * 0.3f
+ Noise( p * 4.7f ) * 0.1f;
garment.Positions[i] += normals[i] * (n * amount * scale * 0.09f);
}
}
/// <summary>A cheap deterministic value noise in -1..1. Deterministic matters more than
/// quality: the same garment must rebuild to the same wrinkles, or every rebuild would be a
/// different model and undo would be a surprise.</summary>
static float Noise( Vec3 p )
{
var x = MathF.Sin( p.x * 12.9898f + p.y * 78.233f + p.z * 37.719f ) * 43758.5453f;
var y = MathF.Sin( p.x * 93.9898f + p.y * 67.345f + p.z * 24.123f ) * 28001.8384f;
return (x - MathF.Floor( x )) + (y - MathF.Floor( y )) - 1f;
}
static bool InsideOther( IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, List<bool> closed, int self, Vec3 p )
{
for ( var bi = 0; bi < bodies.Count; bi++ )
{
if ( bi == self || !closed[bi] )
continue;
if ( trees[bi].NearestSurface( bodies[bi], p, 1e6f ) is not { } hit )
continue;
if ( hit.Distance > 1e-4f && Vec3.Dot( p - hit.Point, hit.Normal ) < 0 )
return true;
}
return false;
}
internal static void PushOut( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, float clearance, float reach )
{
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i];
for ( var bi = 0; bi < bodies.Count; bi++ )
{
if ( trees[bi].NearestSurface( bodies[bi], p, reach ) is not { } hit )
continue;
var along = Vec3.Dot( p - hit.Point, hit.Normal );
if ( along < clearance )
p += hit.Normal * (clearance - along);
}
garment.Positions[i] = p;
}
}
static void Relax( PolyMesh mesh, List<int>[] neighbours, bool[] boundary, float amount )
{
var next = mesh.Positions.ToArray();
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var n = neighbours[i];
if ( n.Count == 0 )
continue;
var avg = Vec3.Zero;
foreach ( var j in n )
avg += mesh.Positions[j];
avg /= n.Count;
next[i] = Vec3.Lerp( mesh.Positions[i], avg, boundary[i] ? amount * 0.3f : amount );
}
for ( var i = 0; i < next.Length; i++ )
mesh.Positions[i] = next[i];
}
static List<int>[] Neighbours( PolyMesh mesh )
{
var sets = new HashSet<int>[mesh.VertexCount];
for ( var i = 0; i < sets.Length; i++ )
sets[i] = new HashSet<int>();
foreach ( var f in mesh.Faces )
{
for ( var c = 0; c < f.Count; c++ )
{
var a = f.Indices[c];
var b = f.Indices[(c + 1) % f.Count];
sets[a].Add( b );
sets[b].Add( a );
}
}
return sets.Select( s => s.ToList() ).ToArray();
}
/// <summary>
/// Let the fitted garment settle under gravity against the bodies, using the kernel's one cloth
/// solver. <see cref="Options.Stiffness"/> becomes XPBD stretch compliance — 1 is canvas that
/// holds its fitted length, 0 is jersey that gives — and <see cref="Options.PinTop"/> pins the
/// top slice of the mesh so a waistband stays where it was fitted.
/// </summary>
static void Drape( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, Options o )
{
// The fabric first, then the garment's own settings over the top. Order matters: the preset
// is what the cloth IS and Stiffness is the adjustment somebody made to it, so the
// adjustment has to win - the other way round, moving the slider would do nothing on any
// fabric that sets its own stretch.
var sim = ClothSim.WithFabric( garment, PinnedTop( garment, o.PinTop ), o.Fabric );
sim.Thickness = MathF.Max( o.Clearance, 1e-3f );
sim.SelfCollision = o.SelfCollision;
// Stiffness runs 0..1 and compliance runs rigid..slack over four decades, so the map is
// exponential: 1 -> 1e-7 (canvas), 0.5 -> 1e-5, 0 -> 1e-3 (jersey).
var stretch = MathF.Pow( 10f, -7f + 4f * (1f - Math.Clamp( o.Stiffness, 0f, 1f )) );
// Stretch is scaled rather than replaced, so a stretch fabric stays stretchier than denim at
// the same slider position instead of every fabric collapsing onto one curve.
sim.StretchCompliance = MathF.Max( sim.StretchCompliance, 1e-7f ) / 1e-5f * stretch;
sim.BendCompliance = MathF.Max( sim.BendCompliance, sim.StretchCompliance * 10f );
// The garment was fitted to lie where it should; the drape only settles it. Two inches, or
// four clearances on a loose fit, is room for a hem to fall and nowhere near room to drop
// through a body with gaps in it. See ClothSim.Tether.
sim.Tether = o.Tether > 0f ? o.Tether : MathF.Max( 2f, o.Clearance * 4f );
if ( o.Capsules is { Count: > 0 } )
sim.Capsules.AddRange( o.Capsules );
else
sim.SetColliders( bodies );
sim.Run( o.DrapeSteps / 60f );
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] = sim.Positions[i];
}
/// <summary>Vertices in the top <paramref name="fraction"/> of the mesh's height. Empty when the
/// fraction is 0, which pins nothing and lets the garment rest on the body by collision alone.</summary>
static IEnumerable<int> PinnedTop( PolyMesh mesh, float fraction )
{
fraction = Math.Clamp( fraction, 0f, 1f );
if ( fraction <= 0f || mesh.VertexCount == 0 )
yield break;
var top = mesh.Positions.Max( p => p.z );
var bottom = mesh.Positions.Min( p => p.z );
var cut = top - (top - bottom) * fraction;
for ( var i = 0; i < mesh.VertexCount; i++ )
if ( mesh.Positions[i].z >= cut )
yield return i;
}
public static bool[] BoundaryVertices( PolyMesh mesh )
{
var boundary = new bool[mesh.VertexCount];
foreach ( var (edge, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count == 1 )
{
boundary[edge.A] = true;
boundary[edge.B] = true;
}
}
return boundary;
}
/// <summary>1 at an opening, falling to 0 at <paramref name="reach"/> away from it.</summary>
public static float[] FlareWeights( PolyMesh mesh, bool[] boundary, float reach )
{
var weights = new float[mesh.VertexCount];
var edge = new List<Vec3>();
for ( var i = 0; i < boundary.Length; i++ )
{
if ( boundary[i] )
edge.Add( mesh.Positions[i] );
}
if ( edge.Count == 0 || reach <= 0f )
return weights;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
var best = float.MaxValue;
foreach ( var e in edge )
best = Math.Min( best, (e - p).LengthSquared );
var d = MathF.Sqrt( best );
var w = Math.Clamp( 1f - d / reach, 0f, 1f );
weights[i] = w * w;
}
return weights;
}
/// <summary>
/// UVs wrapped round each limb on its own bone, a foot to a UV unit everywhere.
///
/// ONE CYLINDER ROUND THE WHOLE BODY WAS WRONG FOR THE SLEEVES. CylinderUVs wraps everything
/// about the body's vertical axis, which is right for the torso and stretches a sleeve into a
/// smear: the arm is a tube lying across that projection, so its texture ran the wrong way and
/// was squeezed to a sliver on the top and bottom of the arm. Trouser legs the same, less so.
/// So each face is projected round the bone chain its region runs along - the torso's spine,
/// each arm, each leg - with the angle scaled by that limb's own girth, so a check the size of
/// a thumbnail on the chest is the size of a thumbnail on the cuff. The seam between a region
/// and the next is a UV seam, as it is on every real garment.
/// </summary>
public static void LimbUVs( PolyMesh mesh, BodyRegions.Map map )
{
const float UnitsPerUv = 12f;
// Every face's chain, and every chain's girth from the faces on it.
var chains = new Dictionary<(BodyRegion, int), (Vec3 From, Vec3 Axis, Vec3 E1, Vec3 E2, float Radius, int Count)>();
var faceChain = new (BodyRegion, int)[mesh.FaceCount];
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var centroid = mesh.FaceCentroid( mesh.Faces[fi] );
var (region, _) = map.Locate( centroid );
var side = BodyRegions.Map.IsCentral( region ) ? 0 : centroid.y > 0 ? 1 : -1;
var key = (region, side);
faceChain[fi] = key;
if ( !chains.TryGetValue( key, out var chain ) )
{
Vec3 from, to;
if ( region == BodyRegion.None || !map.Chain( region, side == 0 ? 1 : side, out from, out to ) )
{
// Nothing to hang it on: the old projection about the vertical.
from = Vec3.Zero;
to = new Vec3( 0f, 0f, -1f );
}
var axis = (to - from).Normal;
var reference = MathF.Abs( axis.z ) < 0.9f ? new Vec3( 0f, 0f, 1f ) : new Vec3( 1f, 0f, 0f );
var e1 = Vec3.Cross( reference, axis ).Normal;
var e2 = Vec3.Cross( axis, e1 );
chain = (from, axis, e1, e2, 0f, 0);
}
var q = centroid - chain.From;
var radial = q - chain.Axis * Vec3.Dot( q, chain.Axis );
chain.Radius += radial.Length;
chain.Count++;
chains[key] = chain;
}
foreach ( var fi in Enumerable.Range( 0, mesh.FaceCount ) )
{
var face = mesh.Faces[fi];
var chain = chains[faceChain[fi]];
var radius = MathF.Max( 1f, chain.Radius / Math.Max( 1, chain.Count ) );
var span = MathF.PI * 2f * radius / UnitsPerUv;
var uvs = new Vec2[face.Count];
for ( var c = 0; c < face.Count; c++ )
{
var q = mesh.Positions[face.Indices[c]] - chain.From;
var angle = MathF.Atan2( Vec3.Dot( q, chain.E2 ), Vec3.Dot( q, chain.E1 ) );
uvs[c] = new Vec2( angle * radius / UnitsPerUv, Vec3.Dot( q, chain.Axis ) / UnitsPerUv );
}
// A face straddling the seam at +-180 degrees would stretch across the whole texture.
var max = uvs.Max( u => u.x );
for ( var c = 0; c < uvs.Length; c++ )
if ( max - uvs[c].x > span * 0.5f )
uvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );
face.UVs = uvs;
}
}
/// <summary>
/// A wrap-around projection about the vertical axis, a foot to a UV unit. Fabric tiles and fur
/// noise both want something even; the body's own UVs are laid out for the body, and a CAD part
/// may have none. LimbUVs is what Fit uses now; this is kept for a mesh with no rig to go by.
/// </summary>
public static void CylinderUVs( PolyMesh mesh )
{
const float UnitsPerUv = 12f;
var centre = Vec3.Zero;
foreach ( var p in mesh.Positions )
centre += p;
centre /= Math.Max( 1, mesh.VertexCount );
foreach ( var face in mesh.Faces )
{
var uvs = new Vec2[face.Count];
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
var angle = MathF.Atan2( p.y - centre.y, p.x - centre.x );
uvs[c] = new Vec2( angle / (MathF.PI * 2f) * (MathF.PI * 2f * 8f) / UnitsPerUv, -p.z / UnitsPerUv );
}
// A face straddling the seam at +-180 degrees would stretch across the whole texture.
var span = MathF.PI * 2f * 8f / UnitsPerUv;
var max = uvs.Max( u => u.x );
for ( var c = 0; c < uvs.Length; c++ )
{
if ( max - uvs[c].x > span * 0.5f )
uvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );
}
face.UVs = uvs;
}
}
}
/// <summary>
/// What is wrong with a garment before you export it, said in the terms of the job.
///
/// WHY THIS IS NOT MeshValidator. That answers "is this a mesh" — corners, indices in range,
/// manifold edges — and a garment can pass every one of those checks and still be unwearable. The
/// questions that decide whether a piece of clothing is finished are different ones: does it clip
/// through the body when it is worn, can it be textured, will it move with the skeleton, is it
/// affordable. Structure is included because it matters too, but it is one line of five.
///
/// IT REPORTS, IT DOES NOT REFUSE. Every number here has a legitimate reason to be non-zero. A
/// garment SHOULD have openings — a T-shirt has four — and calling a neck hole an error would teach
/// people to fear the thing that makes it a shirt. Clipping is the one that is nearly always a
/// fault, and even that is a judgement about how deep and how much rather than a boolean. So this
/// hands back counts and depths for somebody to read, and says which of them usually mean trouble.
/// </summary>
public sealed class GarmentReport
{
public string Name = "";
public int Faces;
public int Triangles;
/// <summary>Vertices sitting INSIDE a body rather than on or outside it — the garment clipping
/// through whoever is wearing it. The number that most often means real trouble.</summary>
public int Clipping;
/// <summary>How far the worst of them is in, in inches. A hundredth of an inch is the solver
/// resting on the surface; a quarter of an inch is a shoulder through a sleeve.</summary>
public float DeepestClip;
/// <summary>Where they are, one point per clipping vertex, so a viewport can mark them.</summary>
public List<Vec3> ClipPoints = new();
/// <summary>How deep each of <see cref="ClipPoints"/> is, in inches, in the same order.</summary>
public List<float> ClipDepths = new();
/// <summary>Faces with no area — invisible, and a source of broken normals and bad lighting
/// downstream. Always a fault.</summary>
public int Degenerate;
/// <summary>Boundary loops: the holes a garment is MEANT to have. A T-shirt has four — neck,
/// hem, two cuffs. Reported so the number can be recognised, never as an error.</summary>
public int Openings;
public bool HasUVs;
public bool HasWeights;
/// <summary>Structural problems, straight from MeshValidator. These are faults.</summary>
public List<string> Structure = new();
/// <summary>Everything worth saying, in the order somebody reading it would want it — worst
/// first, then the merely notable, then the reassuring.</summary>
public IEnumerable<string> Lines()
{
if ( Clipping > 0 )
yield return $"{Clipping} {(Clipping == 1 ? "vertex clips" : "vertices clip")} through the body, "
+ $"deepest {DeepestClip:0.##} in. Raise Looseness or Clearance, or shrinkwrap it back out.";
if ( Degenerate > 0 )
yield return $"{Degenerate} face{(Degenerate == 1 ? " has" : "s have")} no area. "
+ "Merge by distance will usually take them out.";
foreach ( var problem in Structure )
yield return problem;
if ( !HasWeights )
yield return "No skin weights - it will not move with the body. Copy weights from the body you cut it from.";
if ( !HasUVs )
yield return "No UVs - it cannot be textured yet.";
yield return $"{Triangles} triangles, {Openings} opening{(Openings == 1 ? "" : "s")}"
+ $"{(HasUVs ? ", UVs" : "")}{(HasWeights ? ", weighted" : "")}.";
}
/// <summary>Nothing here is a fault. Openings and triangle count are not faults.</summary>
public bool Clean => Clipping == 0 && Degenerate == 0 && Structure.Count == 0 && HasUVs && HasWeights;
public override string ToString() => string.Join( " ", Lines() );
}
/// <summary>Check a garment against the bodies wearing it. See <see cref="GarmentReport"/>.</summary>
public static class GarmentCheck
{
/// <summary>How far inside a surface counts as clipping rather than as resting on it. The
/// drape solver settles vertices ON the body by design, and a tolerance smaller than its own
/// resting error would report every well-fitted garment as broken.</summary>
public const float ClipTolerance = 0.01f;
public static GarmentReport Run( PolyMesh garment, IReadOnlyList<PolyMesh> bodies )
{
var report = new GarmentReport();
if ( garment is null )
return report;
report.Faces = garment.FaceCount;
report.HasWeights = garment.IsRigged;
foreach ( var face in garment.Faces )
{
report.Triangles += Math.Max( 0, face.Count - 2 );
if ( face.UVs is { Length: > 0 } && face.UVs.Any( uv => uv.x != 0f || uv.y != 0f ) )
report.HasUVs = true;
if ( FaceArea( garment, face ) <= 1e-9f )
report.Degenerate++;
}
var validation = MeshValidator.Validate( garment );
report.Structure.AddRange( validation.Errors );
report.Openings = BoundaryLoops( garment );
// The same signed-distance test the fitter uses to push a garment out - see
// GarmentFit.PushOut. Reusing the idiom matters: a check that disagreed with the solver
// about which side of a surface a point is on would report faults the fitter cannot fix.
foreach ( var body in bodies ?? Array.Empty<PolyMesh>() )
{
if ( body is null || body.FaceCount == 0 || ReferenceEquals( body, garment ) )
continue;
var tree = MeshBVH.Build( body );
foreach ( var p in garment.Positions )
{
if ( tree.NearestSurface( body, p, 1e6f ) is not { } hit )
continue;
var depth = -Vec3.Dot( p - hit.Point, hit.Normal );
if ( depth <= ClipTolerance )
continue;
report.Clipping++;
report.DeepestClip = Math.Max( report.DeepestClip, depth );
report.ClipPoints.Add( p );
report.ClipDepths.Add( depth );
}
}
return report;
}
static float FaceArea( PolyMesh mesh, Face face )
{
if ( face.Count < 3 )
return 0f;
var area = Vec3.Zero;
var origin = mesh.Positions[face.Indices[0]];
for ( var i = 1; i + 1 < face.Count; i++ )
{
var a = mesh.Positions[face.Indices[i]] - origin;
var b = mesh.Positions[face.Indices[i + 1]] - origin;
area += Vec3.Cross( a, b );
}
return area.Length * 0.5f;
}
/// <summary>
/// How many separate holes the mesh has, by walking the boundary edges into loops.
///
/// LOOPS, NOT EDGES. MeshValidator already counts boundary edges, and that number is useless
/// here: a T-shirt's four openings are a few hundred edges, which says nothing a person can
/// check. Four is a number you can recognise as right.
/// </summary>
static int BoundaryLoops( PolyMesh mesh )
{
var next = new Dictionary<int, List<int>>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count != 1 )
continue;
if ( !next.TryGetValue( key.A, out var a ) )
next[key.A] = a = new List<int>();
if ( !next.TryGetValue( key.B, out var b ) )
next[key.B] = b = new List<int>();
a.Add( key.B );
b.Add( key.A );
}
var seen = new HashSet<int>();
var loops = 0;
foreach ( var start in next.Keys )
{
if ( !seen.Add( start ) )
continue;
loops++;
// Flood the connected run of boundary vertices. A figure-of-eight boundary counts once,
// which is the honest answer - it is one hole pinched, not two.
var stack = new Stack<int>();
stack.Push( start );
while ( stack.Count > 0 )
{
foreach ( var n in next[stack.Pop()] )
{
if ( seen.Add( n ) )
stack.Push( n );
}
}
}
return loops;
}
}
/// <summary>Where a trim goes, and what it is.</summary>
public enum TrimStyle
{
/// <summary>A gathered strip: longer than the edge it hangs from, so it waves. Frills, ruffles,
/// a gathered hem.</summary>
Frill,
/// <summary>A flat band following the edge. Ribbon, binding, a cuff band, a waistband.</summary>
Ribbon,
/// <summary>Folded rather than gathered - the wave is angular instead of round. Pleats, a
/// box-pleated hem, an accordion cuff.</summary>
Pleat,
/// <summary>Cut into separate hanging strips. Fringe, tassels, a shredded hem.</summary>
Fringe,
}
/// <summary>
/// Frills, ruffles, ribbons, pleats and fringe: the details that make a garment a piece of FASHION
/// rather than a fitted bag.
///
/// THEY HANG OFF THE OPENINGS, and that is the whole idea. A garment already has exactly the edges
/// trim belongs on - the hem, the collar, the cuffs - because they are its boundary loops, the holes
/// it is made to have. Nothing has to be selected, drawn or marked: the shape of the garment already
/// says where a frill could go, so asking for one is choosing which opening and how much.
///
/// LACE IS NOT HERE, DELIBERATELY. Lace is holes in cloth, and holes in cloth are an alpha-masked
/// material - that is how every shipped game does it, because modelling the mesh of a lace edging
/// costs thousands of triangles to render something a 512px texture renders better. Put lace on the
/// garment as a material with a cut-out; use a Frill for the shape the lace edging follows.
///
/// GATHERED, NOT SCALED. A real frill is a strip of cloth LONGER than the edge it is sewn to, forced
/// to fit by being bunched. That is why it waves, and why the waves get deeper further from the seam
/// while the seam itself stays smooth. So this displaces along the surface normal by an amount that
/// grows with the distance hung, rather than making a wavy ring and pulling it down - which looks
/// like a cake decoration and not like cloth.
/// </summary>
public static class GarmentTrim
{
public sealed class Options
{
public TrimStyle Style = TrimStyle.Frill;
/// <summary>How far the trim hangs from the edge, in inches.</summary>
public float Width = 1.2f;
/// <summary>How much longer than its edge the strip is, 0 flat to 1 heavily bunched. What
/// makes a frill a frill.</summary>
public float Gather = 0.5f;
/// <summary>How many waves around the whole opening. More waves, finer gathers.</summary>
public int Waves = 24;
/// <summary>Rows of quads from the seam to the free edge. One is enough for a ribbon; a deep
/// frill wants four or five so the wave has somewhere to develop.</summary>
public int Rows = 3;
/// <summary>For Fringe: what fraction of each strip's slot is cut away.</summary>
public float Gap = 0.4f;
public int MaterialSlot = 0;
}
public sealed class Result
{
public PolyMesh Mesh = new();
public int Openings;
public string Problem;
}
/// <summary>
/// Build trim along some of a mesh's boundary loops.
///
/// <paramref name="which"/> picks loops by index into the order OrderedBoundaryLoops returns
/// them in, which is by height, lowest first - so on a shirt 0 is the hem and the last is the
/// collar. Null or empty means every opening.
/// </summary>
public static Result Build( PolyMesh garment, IReadOnlyList<int> which, Options o )
{
var result = new Result();
if ( garment is null || garment.FaceCount == 0 )
{
result.Problem = "there is no garment to trim";
return result;
}
var loops = OrderedBoundaryLoops( garment );
if ( loops.Count == 0 )
{
result.Problem = "this garment has no openings - trim hangs from an edge, and a closed "
+ "shape has none";
return result;
}
var normals = garment.ComputeVertexNormals();
var rows = Math.Clamp( o.Rows, 1, 12 );
for ( var li = 0; li < loops.Count; li++ )
{
if ( which is { Count: > 0 } && !which.Contains( li ) )
continue;
if ( loops[li].Count < 3 )
continue;
AddStrip( result.Mesh, garment, loops[li], normals, rows, o );
result.Openings++;
}
if ( result.Openings == 0 )
result.Problem = $"none of this garment's {loops.Count} openings were chosen";
return result;
}
static void AddStrip( PolyMesh into, PolyMesh garment, List<int> ring, Vec3[] normals, int rows,
Options o )
{
var n = ring.Count;
// Arc length around the ring, so the wave count means the same thing whether the ring is a
// cuff or a hem and whether its vertices are evenly spaced or not. Spacing waves by INDEX
// instead puts every wave of a hem into the densest quarter of it.
var arc = new float[n + 1];
for ( var i = 0; i < n; i++ )
{
var a = garment.Positions[ring[i]];
var b = garment.Positions[ring[(i + 1) % n]];
arc[i + 1] = arc[i] + (b - a).Length;
}
var total = arc[n];
if ( total <= 1e-5f )
return;
var centre = Vec3.Zero;
foreach ( var v in ring )
centre += garment.Positions[v];
centre /= n;
// Which way the trim hangs: the direction the cloth would carry on in if the garment did not
// stop at its own hem. Taken from the edge rather than as "straight down", so a cuff's frill
// runs on down the arm and a collar's stands off the neck.
var away = new Vec3[n];
for ( var i = 0; i < n; i++ )
{
var p = garment.Positions[ring[i]];
var prev = garment.Positions[ring[(i - 1 + n) % n]];
var next = garment.Positions[ring[(i + 1) % n]];
var tangent = (next - prev).Normal;
var outward = Vec3.Cross( tangent, normals[ring[i]] );
if ( outward.LengthSquared < 1e-8f )
outward = p - centre;
outward = outward.Normal;
// The cross product's sign flips with the ring's winding, which is not something a
// caller should have to know about. Point it away from the ring's own centre.
if ( Vec3.Dot( outward, p - centre ) < 0f )
outward = -outward;
away[i] = outward;
}
var waves = Math.Max( 1, o.Waves );
var gather = Math.Clamp( o.Gather, 0f, 1f );
var width = Math.Max( 0f, o.Width );
// Row 0 is the seam: it sits exactly on the garment's own edge, unmoved, so the trim is
// attached rather than floating near it.
var grid = new int[rows + 1][];
for ( var r = 0; r <= rows; r++ )
{
grid[r] = new int[n];
var t = r / (float)rows;
for ( var i = 0; i < n; i++ )
{
var p = garment.Positions[ring[i]];
var s = arc[i] / total;
var swing = o.Style switch
{
TrimStyle.Frill => MathF.Sin( s * MathF.Tau * waves ),
TrimStyle.Pleat => Triangle( s * waves ),
_ => 0f,
};
// The gather grows with distance from the seam, which is what keeps the seam itself
// from rippling. A ribbon has no gather at all and stays a flat band.
var offset = away[i] * (width * t)
+ normals[ring[i]] * (swing * gather * width * 0.6f * t);
grid[r][i] = into.Positions.Count;
into.Positions.Add( p + offset );
}
}
var fringe = o.Style == TrimStyle.Fringe;
var keep = Math.Clamp( 1f - o.Gap, 0.05f, 1f );
for ( var r = 0; r < rows; r++ )
{
for ( var i = 0; i < n; i++ )
{
var j = (i + 1) % n;
// Fringe is the same strip with most of it cut away: whole slots are dropped rather
// than narrowed, so what is left hangs as separate tassels and not as a comb.
if ( fringe && r > 0 )
{
var phase = arc[i] / total * waves;
if ( phase - MathF.Floor( phase ) > keep )
continue;
}
into.Faces.Add( new Face( new[] { grid[r][i], grid[r][j], grid[r + 1][j], grid[r + 1][i] } )
{
Material = o.MaterialSlot,
} );
}
}
}
/// <summary>A 0..1 sawtooth as a -1..1 triangle wave: a pleat's fold, angular where a frill's
/// is round.</summary>
static float Triangle( float x )
{
var f = x - MathF.Floor( x );
return f < 0.5f ? f * 4f - 1f : 3f - f * 4f;
}
/// <summary>
/// Every boundary loop as an ORDERED ring of vertices, lowest first.
///
/// ORDERED IS THE WHOLE JOB. GarmentCheck only has to COUNT openings, so it floods them; trim
/// has to walk one in sequence to lay quads along it. Height ordering is what makes "opening 0"
/// mean the hem on every garment rather than whichever vertex happened to be found first.
/// </summary>
public static List<List<int>> OrderedBoundaryLoops( PolyMesh mesh )
{
var neighbours = new Dictionary<int, List<int>>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count != 1 )
continue;
if ( !neighbours.TryGetValue( key.A, out var a ) )
neighbours[key.A] = a = new List<int>();
if ( !neighbours.TryGetValue( key.B, out var b ) )
neighbours[key.B] = b = new List<int>();
a.Add( key.B );
b.Add( key.A );
}
var loops = new List<List<int>>();
var used = new HashSet<int>();
foreach ( var start in neighbours.Keys )
{
if ( used.Contains( start ) )
continue;
var loop = new List<int>();
var current = start;
var previous = -1;
// Walk the ring. A boundary vertex normally has exactly two boundary neighbours; where
// it has more the loop is pinched, and taking the first unused one keeps the walk going
// rather than abandoning the opening.
while ( current >= 0 && used.Add( current ) )
{
loop.Add( current );
var next = -1;
foreach ( var candidate in neighbours[current] )
{
if ( candidate == previous || used.Contains( candidate ) )
continue;
next = candidate;
break;
}
previous = current;
current = next;
}
if ( loop.Count >= 3 )
loops.Add( loop );
}
loops.Sort( ( a, b ) => Height( mesh, a ).CompareTo( Height( mesh, b ) ) );
return loops;
}
static float Height( PolyMesh mesh, List<int> loop )
{
var z = 0f;
foreach ( var v in loop )
z += mesh.Positions[v].z;
return z / Math.Max( 1, loop.Count );
}
}
/// <summary>
/// Turn a garment into an s&box `.clothing` definition: the record that makes a mesh into a
/// wearable item rather than a prop.
///
/// THE STEP THIS TOOL STOPPED ONE SHORT OF. Effigy could already compile a garment to a skinned
/// `.vmdl`, and a `.vmdl` is not clothing. s&box dresses a citizen from a Clothing resource,
/// which names the model and then answers three questions the mesh cannot: what KIND of garment it
/// is, which body slots it OCCUPIES so two hats do not both get worn, and which parts of the body it
/// HIDES so the chest does not poke through the shirt. Without it a finished garment cannot be put
/// on a character at all, and the modelling was the easy part of making clothes.
///
/// THE SLOTS COME FROM THE RECIPE, not from the garment's name. The recipe already says exactly
/// which regions of the body it covers - that is what it IS - so a long sleeve claims the wrists and
/// a T-shirt does not, and adjusting Sleeve moves the answer with it. Deriving them from the name
/// would be a second, parallel description of the same garment, and the two would drift apart the
/// first time somebody dragged a slider.
///
/// WRITTEN AS TEXT, IN THE KERNEL. A Clothing resource is JSON, so it needs no engine to produce and
/// can be tested headlessly - which matters more here than usual, because the failure mode of a
/// wrong slot name is an asset that compiles, loads, and silently does not work.
/// </summary>
public static class ClothingDefinition
{
/// <summary>What a garment covers, in s&box's own slot vocabulary. Anything not listed maps
/// to nothing, which is the right answer for Neck.</summary>
public static IEnumerable<string> SlotsFor( GarmentRecipe recipe )
{
var slots = new List<string>();
void Add( string slot )
{
if ( !slots.Contains( slot ) )
slots.Add( slot );
}
// A span tagged for one side claims only that side's slots. That is the whole reason the
// side is on the span: a one-sleeved top that claimed both arms would stop you wearing
// anything on the bare one.
void Sided( int side, string left, string right )
{
if ( side >= 0 )
Add( left );
if ( side <= 0 )
Add( right );
}
foreach ( var (region, from, to, side) in recipe.Spans )
{
switch ( region )
{
case BodyRegion.Torso:
Add( "Chest" );
break;
case BodyRegion.Hips:
Add( "Groin" );
Add( "Waist" );
break;
case BodyRegion.UpperArm:
Sided( side, "LeftArm", "RightArm" );
break;
case BodyRegion.LowerArm:
Sided( side, "LeftArm", "RightArm" );
// Only a sleeve that actually reaches the wrist claims it. A three-quarter
// sleeve stopping at the forearm must leave the wrist free for a watch.
if ( to > 0.9f )
Sided( side, "LeftWrist", "RightWrist" );
break;
case BodyRegion.Hand:
Sided( side, "LeftHand", "RightHand" );
break;
case BodyRegion.UpperLeg:
Sided( side, "LeftThigh", "RightThigh" );
if ( to > 0.9f )
Sided( side, "LeftKnee", "RightKnee" );
break;
case BodyRegion.LowerLeg:
Sided( side, "LeftKnee", "RightKnee" );
Sided( side, "LeftShin", "RightShin" );
break;
case BodyRegion.Foot:
Sided( side, "LeftFoot", "RightFoot" );
break;
case BodyRegion.Head:
Add( "HeadTop" );
// A hat that comes down past the ears covers the lower head too - the
// difference between a cap and a balaclava, and what stops both being wearable
// at once.
if ( from < 0.35f )
Add( "HeadBottom" );
break;
}
}
return slots;
}
/// <summary>
/// Which parts of the BODY to stop drawing underneath, in s&box's BodyGroups vocabulary.
///
/// NOT THE SAME QUESTION AS THE SLOTS, and conflating them is how clothing ends up with skin
/// poking through it. Slots are about what else can be worn; this is about the body being drawn
/// inside the cloth. It is deliberately conservative - only whole groups the garment substantially
/// covers - because hiding a limb a garment only half covers leaves a character with no forearm.
/// </summary>
public static IEnumerable<string> HideBodyFor( GarmentRecipe recipe )
{
var hide = new List<string>();
void Add( string group )
{
if ( !hide.Contains( group ) )
hide.Add( group );
}
foreach ( var (region, from, to, _) in recipe.Spans )
{
switch ( region )
{
case BodyRegion.Torso when to > 0.9f:
Add( "Chest" );
break;
// Legs come off only for something that reaches the ankle. Shorts hide nothing:
// the leg below them is the leg you are meant to see.
case BodyRegion.LowerLeg when to > 0.9f:
Add( "Legs" );
break;
case BodyRegion.Hand:
Add( "Hands" );
break;
case BodyRegion.Foot:
Add( "Feet" );
break;
}
}
return hide;
}
/// <summary>The s&box ClothingCategory a recipe belongs in. Named from the enum this engine
/// actually has — see the effigy_clothing_probe console command.</summary>
public static string CategoryFor( GarmentRecipe recipe ) => recipe?.Name switch
{
"T-shirt" => "TShirt",
"Long sleeve" => "Shirt",
"Tank top" => "Vest",
"Trousers" => "Trousers",
"Shorts" => "Shorts",
"Beanie" => "HatBeanie",
"Gloves" => "Gloves",
"Socks" => "Socks",
"Bodysuit" => "Fullbody",
_ => "Tops",
};
/// <summary>
/// The `.clothing` file's text.
///
/// Only the fields that mean something for a garment made here are written. The rest - human
/// skin overrides, alternate male and female models, tint gradients, a Steam item id - are
/// s&box's own defaults, and writing guesses at them would be claiming a garment supports
/// things it has never been tested with.
/// </summary>
/// <param name="modelPath">The compiled model, as an asset path: models/effigy/shirt.vmdl.</param>
public static string Write( GarmentRecipe recipe, string modelPath, string title,
string subtitle = null )
{
var slots = string.Join( ", ", SlotsFor( recipe ) );
var hide = string.Join( ", ", HideBodyFor( recipe ) );
var sb = new StringBuilder();
sb.Append( "{\n" );
sb.Append( $" \"Title\": {Json( title )},\n" );
sb.Append( $" \"Subtitle\": {Json( subtitle ?? "" )},\n" );
sb.Append( $" \"Category\": {Json( CategoryFor( recipe ) )},\n" );
sb.Append( $" \"Model\": {Json( modelPath )},\n" );
// An empty flags enum serialises as 0, not as "". s&box writes it that way itself - see any
// shipped .clothing with no SlotsOver - and a "" here is read as an unknown flag name.
sb.Append( $" \"SlotsUnder\": {(slots.Length > 0 ? Json( slots ) : "0")},\n" );
sb.Append( " \"SlotsOver\": 0,\n" );
sb.Append( $" \"HideBody\": {(hide.Length > 0 ? Json( hide ) : "0")},\n" );
sb.Append( " \"__references\": [],\n" );
sb.Append( " \"__version\": 0\n" );
sb.Append( "}\n" );
return sb.ToString();
}
/// <summary>
/// Several garments as the one recipe their shared model wears.
///
/// ONE MODEL, ONE DEFINITION. Compile .vmdl bakes every garment in the document into a single
/// model, so a shirt and trousers made together are one item and must claim both sets of slots -
/// a .clothing that only named the shirt's would let a second pair of trousers go on over the
/// first. The category is the first garment's: s&box has exactly one per item, and the first
/// thing added is the best guess at what the item is.
/// </summary>
public static GarmentRecipe Combine( IEnumerable<GarmentRecipe> recipes )
{
GarmentRecipe merged = null;
foreach ( var recipe in recipes )
{
if ( recipe is null )
continue;
merged ??= new GarmentRecipe { Name = recipe.Name };
merged.Spans.AddRange( recipe.Spans );
}
return merged;
}
/// <summary>A JSON string literal. Small by design - this writes four known fields, and pulling
/// in a serialiser for them would put an engine dependency in the kernel.</summary>
static string Json( string value )
{
if ( value is null )
return "null";
var sb = new StringBuilder( value.Length + 2 );
sb.Append( '"' );
foreach ( var c in value )
{
switch ( c )
{
case '"': sb.Append( "\\\"" ); break;
case '\\': sb.Append( "\\\\" ); break;
case '\n': sb.Append( "\\n" ); break;
case '\r': sb.Append( "\\r" ); break;
case '\t': sb.Append( "\\t" ); break;
default:
if ( c < 0x20 )
sb.Append( "\\u" ).Append( ((int)c).ToString( "x4" ) );
else
sb.Append( c );
break;
}
}
sb.Append( '"' );
return sb.ToString();
}
}
/// <summary>
/// A smooth stand-in body to cut a garment from, for a wearer that is not a skin.
///
/// WHY A GARMENT NEEDS ONE. GarmentFit lifts the cloth straight off the wearer's own surface,
/// which is exactly right for a skin like Citizen's and exactly wrong for a body built from
/// parts. Camhead is struts, plates and cables with air between them; lifted off that, a
/// T-shirt came out as a tight shell over every bolt, and the drape had nothing to hang across
/// the gaps. A shirt on a robot is a shirt on the robot's SILHOUETTE.
///
/// ONE CONVEX HULL PER REGION AND SIDE - the torso, the hips, each upper arm, each forearm - out
/// of the vertices nearest that region's bones. Convex, so it spans every gap; per region, so
/// the armpit and the crotch stay open instead of the whole body becoming one blob. The hulls
/// overlap at the joints, and Fit already drops faces buried inside another closed body, which
/// is what turns overlapping hulls into one clean outer surface.
///
/// SUBDIVIDED LINEARLY, not smoothed. Catmull-Clark would round the hull inward and let the
/// corners of the real parts poke through the cloth; midpoint splits keep the shape and only add
/// the vertices the drape needs to have something to bend.
/// </summary>
public static class GarmentEnvelope
{
/// <summary>How many separate pieces a mesh is made of. A skin is one or a handful (a head, a
/// body, two eyes); a kitbashed robot is hundreds.</summary>
public static int ShellCount( PolyMesh mesh )
{
var parent = new int[mesh.VertexCount];
for ( var i = 0; i < parent.Length; i++ )
parent[i] = i;
int Find( int x )
{
while ( parent[x] != x )
x = parent[x] = parent[parent[x]];
return x;
}
foreach ( var face in mesh.Faces )
for ( var c = 1; c < face.Count; c++ )
parent[Find( face.Indices[c] )] = Find( face.Indices[0] );
var roots = new HashSet<int>();
foreach ( var face in mesh.Faces )
if ( face.Count > 0 )
roots.Add( Find( face.Indices[0] ) );
return roots.Count;
}
/// <summary>Should a garment be cut from an envelope instead of the surface? More than this
/// many pieces is a body built from parts. Citizen is under ten.</summary>
public const int PartsThreshold = 24;
public static bool IsKitbashed( IEnumerable<PolyMesh> bodies ) =>
bodies.Sum( ShellCount ) > PartsThreshold;
/// <summary>The stand-in: one closed, subdivided hull per body region and side.</summary>
public static List<(BodyRegion Region, PolyMesh Mesh)> Build( IReadOnlyList<PolyMesh> bodies, BodyRegions.Map map, float edge = 1.5f )
{
var groups = new Dictionary<(BodyRegion, int), List<Vec3>>();
foreach ( var body in bodies )
{
foreach ( var p in body.Positions )
{
var (region, _) = map.Locate( p );
if ( region == BodyRegion.None )
continue;
var central = region is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;
var key = (region, central ? 0 : p.y > 0 ? 1 : -1);
if ( !groups.TryGetValue( key, out var list ) )
groups[key] = list = new List<Vec3>();
list.Add( p );
}
}
// THE TORSO HANGS. Cloth falls from the widest thing it rests on - shoulders, chest - and
// does not follow the body back in at the waist, which is the whole difference between a
// shirt and a wetsuit. So the torso's hull is dropped straight down to the bottom of the
// hips: a column as wide as the chest, and the drape has something to hang from rather
// than a waist to crumple into.
if ( groups.TryGetValue( (BodyRegion.Torso, 0), out var torso ) && groups.TryGetValue( (BodyRegion.Hips, 0), out var hips ) )
{
var floor = hips.Min( p => p.z );
// Only the chest between the arms. Dropped from the shoulder points too, the column is
// as wide as the shoulders all the way down, swallows an A-posed upper arm, and the
// sleeves - the faces of the arm hulls inside it - are cut away with it.
var inner = groups.Where( g => g.Key.Item1 == BodyRegion.UpperArm )
.SelectMany( g => g.Value ).Select( p => MathF.Abs( p.y ) ).DefaultIfEmpty( float.MaxValue ).Min();
var dropped = torso.Where( p => MathF.Abs( p.y ) < inner )
.Select( p => new Vec3( p.x, p.y, floor ) ).ToList();
torso.AddRange( dropped );
}
var hulls = new List<(BodyRegion, PolyMesh)>();
foreach ( var ((groupRegion, _), points) in groups )
{
if ( points.Count < 4 )
continue;
PolyMesh hull;
try
{
hull = ConvexHull.ToMesh( Extremes( points ), 1e-4f );
}
catch ( Exception )
{
// Flat or degenerate - a single plate on its own. Nothing to wrap.
continue;
}
if ( hull is null || hull.FaceCount == 0 )
continue;
hulls.Add( (groupRegion, Refine( hull, edge )) );
}
return hulls;
}
/// <summary>
/// The points furthest along a few hundred directions spread evenly over the sphere.
///
/// ConvexHull is incremental and sized for CAD bodies of a few hundred vertices; a robot's
/// torso is thousands, nearly all of them inside, and hulling them all took minutes. The
/// extremes are the hull's own corners to within a fraction of an inch, which is far closer
/// than cloth sitting a clearance off it can tell apart.
/// </summary>
static List<Vec3> Extremes( List<Vec3> points, int directions = 320 )
{
if ( points.Count <= directions )
return points;
var keep = new HashSet<int>();
var golden = MathF.PI * (3f - MathF.Sqrt( 5f ));
for ( var d = 0; d < directions; d++ )
{
var z = 1f - 2f * (d + 0.5f) / directions;
var r = MathF.Sqrt( MathF.Max( 0f, 1f - z * z ) );
var dir = new Vec3( r * MathF.Cos( golden * d ), r * MathF.Sin( golden * d ), z );
var best = 0;
var bestDot = float.MinValue;
for ( var i = 0; i < points.Count; i++ )
{
var dot = Vec3.Dot( points[i], dir );
if ( dot > bestDot )
{
bestDot = dot;
best = i;
}
}
keep.Add( best );
}
return keep.Select( i => points[i] ).ToList();
}
/// <summary>Triangulate, then split every triangle four ways until the longest edge is under
/// <paramref name="edge"/>. Uniform on purpose: every triangle splits every round, so shared
/// edges always split together and the hull stays closed - a split that followed each edge's
/// own length would leave T-junctions wherever a long face met a short one.</summary>
public static PolyMesh Refine( PolyMesh mesh, float edge, int maxTriangles = 12000 )
{
var positions = new List<Vec3>( mesh.Positions );
var tris = new List<(int A, int B, int C)>();
foreach ( var face in mesh.Faces )
for ( var c = 1; c + 1 < face.Count; c++ )
tris.Add( (face.Indices[0], face.Indices[c], face.Indices[c + 1]) );
var limit = MathF.Max( edge, 0.05f );
float Longest() => tris.Count == 0 ? 0f : tris.Max( t => MathF.Max( (positions[t.A] - positions[t.B]).Length,
MathF.Max( (positions[t.B] - positions[t.C]).Length, (positions[t.C] - positions[t.A]).Length ) ) );
while ( Longest() > limit && tris.Count * 4 <= maxTriangles )
{
var mids = new Dictionary<EdgeKey, int>();
var next = new List<(int, int, int)>( tris.Count * 4 );
int Mid( int a, int b )
{
var key = new EdgeKey( a, b );
if ( !mids.TryGetValue( key, out var m ) )
{
m = positions.Count;
positions.Add( (positions[a] + positions[b]) * 0.5f );
mids[key] = m;
}
return m;
}
foreach ( var (a, b, c) in tris )
{
var ab = Mid( a, b );
var bc = Mid( b, c );
var ca = Mid( c, a );
next.Add( (a, ab, ca) );
next.Add( (ab, b, bc) );
next.Add( (ca, bc, c) );
next.Add( (ab, bc, ca) );
}
tris = next;
}
var result = new PolyMesh();
foreach ( var p in positions )
result.AddVertex( p );
foreach ( var (a, b, c) in tris )
result.AddFace( new[] { a, b, c }, null, 0 );
return result;
}
}
Editor
library
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Keep the half of a mesh on one side of a plane, clipping faces that straddle it.
///
/// THIS IS A VIEW, NOT A FEATURE. Shell builds interiors nobody can look at; a cutaway hides the
/// near half while looking, without writing anything into the history. Faces that cross the plane
/// are clipped in place so the cut reads as a hard line rather than as missing polygons. There is
/// no cap — an open cut is the point of a section view, and capping would pretend the kernel had
/// booleaned a solid it has not.
/// </summary>
public static class MeshClip
{
/// <summary>
/// Everything on the positive side of the plane through <paramref name="origin"/> with
/// <paramref name="normal"/>, including the plane itself. Vertices on the negative side are
/// dropped; edges that cross emit a new vertex at the intersection.
/// </summary>
public static PolyMesh Keep( PolyMesh mesh, Vec3 origin, Vec3 normal, float tolerance = 1e-5f )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
var n = normal.Normal;
if ( n.LengthSquared < 0.5f )
return mesh.Clone();
var result = new PolyMesh();
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var clipped = ClipFace( mesh, face, origin, n, tolerance );
if ( clipped.Count < 3 )
continue;
var indices = new int[clipped.Count];
var uvs = new Vec2[clipped.Count];
for ( var i = 0; i < clipped.Count; i++ )
{
indices[i] = result.AddVertex( clipped[i].Position );
uvs[i] = clipped[i].Uv;
}
result.AddFace( indices, uvs, face.Material );
}
return result;
}
readonly struct ClipVert
{
public readonly Vec3 Position;
public readonly Vec2 Uv;
public ClipVert( Vec3 position, Vec2 uv )
{
Position = position;
Uv = uv;
}
}
static List<ClipVert> ClipFace( PolyMesh mesh, Face face, Vec3 origin, Vec3 normal, float tolerance )
{
var polygon = new List<ClipVert>( face.Count );
for ( var i = 0; i < face.Count; i++ )
{
var uv = face.UVs is not null && i < face.UVs.Length ? face.UVs[i] : default;
polygon.Add( new ClipVert( mesh.Positions[face.Indices[i]], uv ) );
}
// Sutherland–Hodgman against one plane: walk each edge, emit the intersection when the
// edge crosses, and keep the vertex when it sits on the kept side.
var output = new List<ClipVert>( polygon.Count + 2 );
for ( var i = 0; i < polygon.Count; i++ )
{
var current = polygon[i];
var previous = polygon[( i + polygon.Count - 1 ) % polygon.Count];
var currentDist = Vec3.Dot( current.Position - origin, normal );
var previousDist = Vec3.Dot( previous.Position - origin, normal );
var currentIn = currentDist >= -tolerance;
var previousIn = previousDist >= -tolerance;
if ( currentIn )
{
if ( !previousIn )
output.Add( Intersect( previous, current, previousDist, currentDist ) );
output.Add( current );
}
else if ( previousIn )
{
output.Add( Intersect( previous, current, previousDist, currentDist ) );
}
}
return output;
}
static ClipVert Intersect( ClipVert a, ClipVert b, float da, float db )
{
var denom = da - db;
// Both on the plane, or an edge so close the numbers vanish: pick B rather than divide.
var t = MathF.Abs( denom ) < 1e-12f ? 1f : da / denom;
t = Math.Clamp( t, 0f, 1f );
return new ClipVert( a.Position + ( b.Position - a.Position ) * t, a.Uv + ( b.Uv - a.Uv ) * t );
}
}
Editor
library
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Where a mesh meets a plane, as line segments.
///
/// This exists for one job: showing the FOOTPRINT another body leaves on a face. Effigy does not
/// union bodies, so an extrude standing on a slab is two separate solids that happen to overlap,
/// and the slab's top face is one uninterrupted rectangle with no idea anything is sitting on it.
/// Outlining only that rectangle makes a face with a block on it look completely clear.
///
/// Two cases, and both turn up constantly because they are the two ways bodies get stacked:
/// a solid that PASSES THROUGH the plane, whose faces cross it and produce a chord each; and one
/// that SITS ON it, whose bottom face lies in the plane and produces its own outline. Handling
/// only the first would miss every part built by sketching on a face, which is most of them.
/// </summary>
public static class MeshSection
{
/// <summary>
/// Every segment where <paramref name="mesh"/> meets the plane through
/// <paramref name="planeOrigin"/> with <paramref name="planeNormal"/>.
/// </summary>
public static List<(Vec3 A, Vec3 B)> CrossSection( PolyMesh mesh, Vec3 planeOrigin, Vec3 planeNormal,
float tolerance = 1e-4f )
{
var segments = new List<(Vec3, Vec3)>();
if ( mesh is null || mesh.Faces.Count == 0 )
return segments;
var normal = planeNormal.Normal;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var distances = new float[face.Count];
var above = false;
var below = false;
for ( var i = 0; i < face.Count; i++ )
{
distances[i] = Vec3.Dot( mesh.Positions[face.Indices[i]] - planeOrigin, normal );
if ( distances[i] > tolerance )
above = true;
else if ( distances[i] < -tolerance )
below = true;
}
var coplanar = !above && !below;
// Sitting flat ON the plane: the face's own edges ARE the footprint.
if ( coplanar )
{
for ( var i = 0; i < face.Count; i++ )
{
segments.Add( (mesh.Positions[face.Indices[i]],
mesh.Positions[face.Indices[(i + 1) % face.Count]]) );
}
continue;
}
// STRICTLY BOTH SIDES, or this face does not cross the plane - it only touches it.
// A block standing on a slab touches with all four of its side walls, each along its
// bottom edge, and every one of those edges is already an edge of the coplanar bottom
// face. Without this the whole footprint gets drawn twice.
if ( !above || !below )
continue;
// Passing through it: collect where the face's edges cross, which for a convex face is
// exactly two points and therefore one chord.
var crossings = new List<Vec3>( 2 );
for ( var i = 0; i < face.Count; i++ )
{
var a = mesh.Positions[face.Indices[i]];
var b = mesh.Positions[face.Indices[(i + 1) % face.Count]];
var da = distances[i];
var db = distances[(i + 1) % face.Count];
// A corner sitting on the plane is reported once, by the edge that arrives at it -
// counting it twice would leave a zero-length segment behind.
if ( MathF.Abs( da ) <= tolerance )
{
crossings.Add( a );
continue;
}
if ( MathF.Abs( db ) <= tolerance || da * db > 0f )
continue;
crossings.Add( a + (b - a) * (da / (da - db)) );
}
for ( var i = 0; i + 1 < crossings.Count; i += 2 )
{
if ( (crossings[i + 1] - crossings[i]).LengthSquared > tolerance * tolerance )
segments.Add( (crossings[i], crossings[i + 1]) );
}
}
return segments;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace Effigy;
/// <summary>
/// Wavefront OBJ export — the DEBUG and interchange format, not the export path.
///
/// THIS IS NO LONGER HOW MODELS REACH s&box. OBJ cannot carry bones or vertex weights, so it stops
/// being viable the moment a model is rigged, and rigging is in scope. SmdWriter is the export
/// path for both static and skinned models; see the note at the top of it for why one format
/// covers both.
///
/// What OBJ is still the best tool for, and why it stays:
///
/// It is the only format here that PRESERVES QUADS. SMD triangulates on the way out, so an OBJ is
/// the only way to look at the cage as the kernel actually holds it — which is exactly what you
/// need when checking whether a subdivision result is right. Open it in Blender and the topology
/// is there to read.
///
/// The test suite writes one per primitive for that reason, and round-trips them to prove the
/// writer emits something parseable with its topology intact.
///
/// Godot needs neither path — there the kernel hands vertices to ArrayMesh directly.
/// </summary>
public static class ObjWriter
{
/// <summary>Kept as an alias so existing callers do not have to know where this moved to.</summary>
public const float DefaultSmoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees;
/// <summary>What an unnamed slot is called. Shared with SmdWriter and DmxWriter so a model
/// exported three ways names its materials the same three times.</summary>
public static string DefaultMaterialName( int slot ) => $"material_{slot}";
public static void WriteFile( PolyMesh mesh, string path, string objectName = "model",
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
File.WriteAllText( path, Write( mesh, objectName, smoothingAngleDegrees, materialName ) );
}
/// <param name="materialName">What to call each material slot. Defaults to material_0, material_1
/// and so on — a name a person chose is the difference between binding by meaning and binding by
/// number in whatever the model lands in.</param>
public static string Write( PolyMesh mesh, string objectName = "model",
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
var sb = new StringBuilder();
sb.Append( "# generated by Effigy\n" );
sb.Append( $"# {mesh.VertexCount} vertices, {mesh.FaceCount} faces\n" );
var offsets = (Vertices: 0, UVs: 0, Normals: 0);
AppendPiece( sb, mesh, objectName, smoothingAngleDegrees, materialName, ref offsets );
return sb.ToString();
}
/// <summary>
/// Several named pieces in one file, each under its own <c>o</c> marker — which is what makes
/// it come back as several parts rather than one welded lump.
///
/// THE MERGE IS THE LOSSY STEP, not the format. A part-segmentation export exists precisely so
/// that a hand is a hand and a torso is a torso, and flattening fourteen of those into one mesh
/// on the way out throws away the only thing that made the file worth segmenting: everything
/// downstream that works per part — rigging a bone to a body, hiding one while you sculpt
/// another, a Remesh with a budget of its own — has nothing left to work per part ON.
///
/// OBJ's indices are file-global rather than per-object, which is the entire reason this cannot
/// be done by concatenating what <see cref="Write(PolyMesh, string, float, Func{int, string})"/>
/// returns: every piece after the first would reference the first one's vertices.
/// </summary>
public static void WriteFile( IReadOnlyList<ObjReader.ObjPiece> pieces, string path,
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
File.WriteAllText( path, Write( pieces, smoothingAngleDegrees, materialName ) );
}
/// <inheritdoc cref="WriteFile(IReadOnlyList{ObjReader.ObjPiece}, string, float, Func{int, string})"/>
public static string Write( IReadOnlyList<ObjReader.ObjPiece> pieces,
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
var sb = new StringBuilder();
var offsets = (Vertices: 0, UVs: 0, Normals: 0);
var vertices = 0;
var faces = 0;
foreach ( var piece in pieces )
{
vertices += piece.Mesh?.VertexCount ?? 0;
faces += piece.Mesh?.FaceCount ?? 0;
}
sb.Append( "# generated by Effigy\n" );
sb.Append( $"# {vertices} vertices, {faces} faces, {pieces.Count} objects\n" );
for ( var i = 0; i < pieces.Count; i++ )
{
var piece = pieces[i];
if ( piece.Mesh is null || piece.Mesh.FaceCount == 0 )
continue;
// An unnamed piece still needs a marker, or it merges into the one before it on the way
// back in. ReadPieces hands back "" for faces that appeared before any o/g line.
var name = string.IsNullOrWhiteSpace( piece.Name ) ? $"part_{i}" : piece.Name;
AppendPiece( sb, piece.Mesh, name, smoothingAngleDegrees, materialName, ref offsets );
}
return sb.ToString();
}
/// <summary>One object's worth of the file. The offsets go in as the counts written so far and
/// come back raised by this piece's own, because OBJ numbers v/vt/vn across the whole file.
/// </summary>
private static void AppendPiece( StringBuilder sb, PolyMesh mesh, string objectName,
float smoothingAngleDegrees, Func<int, string> materialName,
ref (int Vertices, int UVs, int Normals) offsets )
{
var c = CultureInfo.InvariantCulture;
sb.Append( $"o {objectName}\n" );
for ( var i = 0; i < mesh.Positions.Count; i++ )
{
var p = mesh.Positions[i];
sb.Append( string.Format( c, "v {0:0.######} {1:0.######} {2:0.######}", p.x, p.y, p.z ) );
// Vertex colour, when there is one. OBJ vertex colour is RGB, and "no paint" is WHITE —
// the consumer multiplies the material by the colour, so white means "unchanged" and
// coverage fades the vertex from white toward the paint colour.
if ( mesh.VertexColors is not null && i < mesh.VertexColors.Length )
{
var tint = mesh.VertexColors[i].Tint();
sb.Append( string.Format( c, " {0:0.######} {1:0.######} {2:0.######}",
tint.x, tint.y, tint.z ) );
}
sb.Append( '\n' );
}
// UVs are per corner, so the same value recurs constantly. Deduping keeps the file to a
// sane size without changing what it means.
var uvIndex = new Dictionary<(long, long), int>();
var faceUVRefs = new int[mesh.FaceCount][];
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var f = mesh.Faces[fi];
faceUVRefs[fi] = new int[f.Count];
for ( var i = 0; i < f.Count; i++ )
{
var uv = f.UVs[i];
// OBJ puts the UV origin at the bottom-left where Effigy's is top-left, so V is
// flipped on the way out — the same flip FbxWriter makes for the same reason, and the
// one DmxWriter avoids by leaving flipVCoordinates off and writing V as-is. Without it
// a compiled static model samples its texture upside down relative to the viewport.
var flippedV = 1f - uv.y;
var key = ((long)MathF.Round( uv.x * 1e5f ), (long)MathF.Round( flippedV * 1e5f ));
if ( !uvIndex.TryGetValue( key, out var idx ) )
{
idx = uvIndex.Count;
uvIndex[key] = idx;
sb.Append( string.Format( c, "vt {0:0.######} {1:0.######}\n", uv.x, flippedV ) );
}
faceUVRefs[fi][i] = idx;
}
}
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
foreach ( var n in normals )
sb.Append( string.Format( c, "vn {0:0.######} {1:0.######} {2:0.######}\n", n.x, n.y, n.z ) );
var currentMaterial = int.MinValue;
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var f = mesh.Faces[fi];
if ( f.Material != currentMaterial )
{
currentMaterial = f.Material;
sb.Append( $"usemtl {(materialName ?? DefaultMaterialName)( currentMaterial )}\n" );
}
sb.Append( 'f' );
for ( var i = 0; i < f.Count; i++ )
{
// OBJ indices are 1-based, and counted from the top of the FILE rather than from
// the top of this object — hence the offsets, which are zero for a one-piece write.
sb.Append( $" {f.Indices[i] + 1 + offsets.Vertices}"
+ $"/{faceUVRefs[fi][i] + 1 + offsets.UVs}"
+ $"/{cornerNormals[fi][i] + 1 + offsets.Normals}" );
}
sb.Append( '\n' );
}
offsets = (offsets.Vertices + mesh.VertexCount, offsets.UVs + uvIndex.Count,
offsets.Normals + normals.Count);
}
}
/// <summary>
/// Wavefront OBJ reader — the import path, and the round-trip for tests.
///
/// Understands what <see cref="ObjWriter"/> emits, plus the subset a Meshy or Blender export
/// actually uses: positions, optional vertex colour, UVs, faces (with or without vt/vn), and
/// <c>usemtl material_N</c>. Everything else on a line is ignored rather than refused, because
/// a real OBJ is full of <c>o</c> / <c>g</c> / <c>s</c> / <c>vn</c> / <c>mtllib</c> that do not
/// change the solid.
///
/// V IS FLIPPED, matching the writer. OBJ's UV origin is bottom-left; Effigy's is top-left.
/// Reading without the flip would import every textured mesh upside down, and a writer→reader
/// round trip would invert V twice and look correct by accident.
/// </summary>
public static class ObjReader
{
public static PolyMesh ReadFile( string path ) => Read( File.ReadAllText( path ) );
/// <summary>One named lump of an OBJ — an <c>o</c> object or a <c>g</c> group, with the faces
/// that followed it. See <see cref="ReadPieces"/>.</summary>
public readonly struct ObjPiece
{
public readonly string Name;
public readonly PolyMesh Mesh;
public ObjPiece( string name, PolyMesh mesh )
{
Name = name;
Mesh = mesh;
}
}
/// <summary>
/// The whole file as one mesh, objects and groups fused. What every caller wanted before
/// imports could be split, and what a writer's round-trip still compares against.
/// </summary>
public static PolyMesh Read( string text )
{
var parsed = Parse( text );
var mesh = new PolyMesh { Positions = parsed.Positions };
foreach ( var f in parsed.Faces )
mesh.AddFace( f.Indices, f.UVs, f.Material );
if ( parsed.SawColor )
mesh.VertexColors = parsed.Colors.ToArray();
return mesh;
}
/// <summary>
/// The file split at its <c>o</c> and <c>g</c> lines, one mesh per lump.
///
/// WHY THIS EXISTS: an OBJ's vertex indices are global to the FILE, not to the object, so an
/// exporter that carefully kept a character's eyelids, brows and hair as separate objects hands
/// us one flat pile of triangles with the boundaries recorded only in these marker lines.
/// Reading it with <see cref="Read"/> throws that structure away and produces one welded body —
/// which is exactly the body you cannot build a tidy feature tree out of, and cannot hide,
/// re-material or weight a piece of on its own.
///
/// Each piece gets its own vertex list, renumbered, holding only the vertices its faces
/// actually use. Faces before the first marker land in a piece with an empty name; the caller
/// decides what to call that, since only it knows what the file was called.
///
/// A file with no markers at all comes back as one unnamed piece, so a caller can always take
/// this path rather than branching on whether the exporter bothered.
/// </summary>
public static List<ObjPiece> ReadPieces( string text )
{
var parsed = Parse( text );
var pieces = new List<ObjPiece>();
// Grouped by index rather than by name so two objects sharing a name stay two pieces —
// a duplicated name is an exporter's business, not a reason to weld geometry together.
var byGroup = new Dictionary<int, List<ObjFace>>();
var order = new List<int>();
foreach ( var f in parsed.Faces )
{
if ( !byGroup.TryGetValue( f.Group, out var list ) )
{
byGroup[f.Group] = list = new List<ObjFace>();
order.Add( f.Group );
}
list.Add( f );
}
foreach ( var group in order )
{
var faces = byGroup[group];
var mesh = new PolyMesh();
var remap = new Dictionary<int, int>();
var colors = parsed.SawColor ? new List<Vec4>() : null;
foreach ( var f in faces )
{
var indices = new int[f.Indices.Length];
for ( var i = 0; i < f.Indices.Length; i++ )
{
var source = f.Indices[i];
if ( !remap.TryGetValue( source, out var local ) )
{
local = mesh.AddVertex( parsed.Positions[source] );
remap[source] = local;
colors?.Add( parsed.Colors[source] );
}
indices[i] = local;
}
mesh.AddFace( indices, f.UVs, f.Material );
}
if ( colors is not null )
mesh.VertexColors = colors.ToArray();
pieces.Add( new ObjPiece( group >= 0 ? parsed.GroupNames[group] : "", mesh ) );
}
return pieces;
}
readonly struct ObjFace
{
public readonly int[] Indices;
public readonly Vec2[] UVs;
public readonly int Material;
public readonly int Group;
public ObjFace( int[] indices, Vec2[] uvs, int material, int group )
{
Indices = indices;
UVs = uvs;
Material = material;
Group = group;
}
}
sealed class ObjData
{
public List<Vec3> Positions = new();
public List<Vec4> Colors = new();
public List<Vec2> UVs = new();
public List<ObjFace> Faces = new();
public List<string> GroupNames = new();
public bool SawColor;
}
/// <summary>
/// One pass over the text. Both readers above run on this, so a fused read and a split read
/// cannot disagree about what the file says — which is the whole reason it is factored out
/// rather than written twice.
/// </summary>
static ObjData Parse( string text )
{
var data = new ObjData();
var material = 0;
var group = -1;
// One pass over the string as spans. The string.Split / Trim version this replaced did
// three allocations a line on a dense file — the line, the token array, and every token
// itself — which on a million-face OBJ is tens of millions of objects for the GC to chew.
// Spans keep the whole parse allocation-free except for the data it is actually building.
var remaining = text.AsSpan();
while ( remaining.Length > 0 )
{
var newline = remaining.IndexOf( '\n' );
var line = newline < 0 ? remaining : remaining.Slice( 0, newline );
remaining = newline < 0 ? default : remaining.Slice( newline + 1 );
line = line.Trim();
if ( line.Length == 0 || line[0] == '#' )
continue;
var space = line.IndexOf( ' ' );
var head = space < 0 ? line : line.Slice( 0, space );
var rest = space < 0 ? default : line.Slice( space + 1 );
switch ( head[0] )
{
case 'v':
{
if ( head.Length == 2 )
{
// vt: writer flips V on the way out; this un-flips it. External OBJs
// (bottom-left origin) land in Effigy space the same way. vn (normals) is
// skipped, as it always was.
if ( head[1] == 't' )
data.UVs.Add( new Vec2( NextFloat( ref rest ), 1f - NextFloat( ref rest ) ) );
break;
}
data.Positions.Add( new Vec3(
NextFloat( ref rest ),
NextFloat( ref rest ),
NextFloat( ref rest ) ) );
if ( CountTokens( rest ) >= 3 )
{
data.SawColor = true;
data.Colors.Add( new Vec4(
NextFloat( ref rest ),
NextFloat( ref rest ),
NextFloat( ref rest ),
1f ) );
}
else
{
data.Colors.Add( new Vec4( 1f, 1f, 1f, 1f ) );
}
break;
}
case 'f':
{
var n = CountTokens( rest );
if ( n < 3 )
break;
var indices = new int[n];
var faceUVs = new Vec2[n];
for ( var i = 0; i < n; i++ )
{
NextToken( ref rest, out var corner );
var slash = corner.IndexOf( '/' );
indices[i] = ObjIndex( slash < 0 ? corner : corner.Slice( 0, slash ), data.Positions.Count );
if ( slash >= 0 )
{
var after = corner.Slice( slash + 1 );
var second = after.IndexOf( '/' );
var vt = second < 0 ? after : after.Slice( 0, second );
if ( vt.Length > 0 && data.UVs.Count > 0 )
faceUVs[i] = data.UVs[ObjIndex( vt, data.UVs.Count )];
}
}
data.Faces.Add( new ObjFace( indices, faceUVs, material, group ) );
break;
}
// BOTH MARKERS START A PIECE. Exporters disagree about which one means "object":
// Blender writes `o` per object and `g` for its vertex groups, other tools only
// ever write `g`. Treating either as a boundary is what makes the split work on
// files this tool did not write, which is the only kind it gets.
case 'o':
case 'g':
data.GroupNames.Add( rest.Length > 0 ? JoinTokens( rest ) : "" );
group = data.GroupNames.Count - 1;
break;
case 'u':
// usemtl — the only u-keyword the reader acts on.
if ( head.SequenceEqual( "usemtl".AsSpan() ) )
material = SlotFromMaterialName( NextToken( ref rest, out var name ) ? name.ToString() : "", material );
break;
}
}
return data;
}
/// <summary>The next whitespace-delimited token, advancing <paramref name="s"/> past it.</summary>
static bool NextToken( ref ReadOnlySpan<char> s, out ReadOnlySpan<char> token )
{
while ( s.Length > 0 && (s[0] == ' ' || s[0] == '\t') )
s = s.Slice( 1 );
if ( s.Length == 0 )
{
token = default;
return false;
}
var end = 0;
while ( end < s.Length && s[end] != ' ' && s[end] != '\t' )
end++;
token = s.Slice( 0, end );
s = s.Slice( end );
return true;
}
/// <summary>The next token as a float.</summary>
static float NextFloat( ref ReadOnlySpan<char> s )
{
NextToken( ref s, out var token );
return ParseFloat( token );
}
/// <summary>
/// A decimal float with no culture and no allocation.
///
/// The reader used <c>float.Parse</c>, which on a dense OBJ is the whole of the load time — a
/// million vertices is a few million culture-aware, correctly-rounding parses into a general
/// number machinery. OBJ numbers are plain decimals, so the digits are accumulated into an
/// exact integer and scaled by a power of ten, which matches <c>float.Parse</c> on every value a
/// Wavefront file actually contains and runs many times faster.
/// </summary>
static float ParseFloat( ReadOnlySpan<char> s )
{
var i = 0;
var len = s.Length;
var negative = false;
if ( i < len && (s[i] == '-' || s[i] == '+') )
{
negative = s[i] == '-';
i++;
}
// Integer digits, then fraction digits, accumulated into one integer. Stopped at 15 digits:
// below 2^53 the double conversion is exact, and 15 significant figures is already more than
// a float can carry, so nothing is lost. Digits past that only move the decimal point.
const long Cap = 1_000_000_000_000_000L;
long mantissa = 0;
var digits = 0;
var intDigits = 0;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
if ( mantissa < Cap )
{
mantissa = mantissa * 10 + (s[i] - '0');
digits++;
}
intDigits++;
i++;
}
if ( i < len && s[i] == '.' )
{
i++;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
if ( mantissa < Cap )
{
mantissa = mantissa * 10 + (s[i] - '0');
digits++;
}
i++;
}
}
// Where the decimal point sits relative to the accumulated digits.
var exponent = intDigits - digits;
if ( i < len && (s[i] == 'e' || s[i] == 'E') )
{
i++;
var expNegative = false;
if ( i < len && (s[i] == '-' || s[i] == '+') )
{
expNegative = s[i] == '-';
i++;
}
var e = 0;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
e = e * 10 + (s[i] - '0');
i++;
}
exponent += expNegative ? -e : e;
}
var value = (double)mantissa * Pow10( exponent );
return (float)(negative ? -value : value);
}
static readonly double[] Pow10Positive = BuildPow10();
static double[] BuildPow10()
{
var table = new double[39];
table[0] = 1.0;
for ( var i = 1; i < table.Length; i++ )
table[i] = table[i - 1] * 10.0;
return table;
}
/// <summary>10 to the power of <paramref name="e"/>, from a table for the exponents a mesh
/// coordinate uses and <see cref="Math.Pow"/> for the rest.</summary>
static double Pow10( int e )
{
if ( e >= 0 && e < Pow10Positive.Length )
return Pow10Positive[e];
if ( e < 0 && -e < Pow10Positive.Length )
return 1.0 / Pow10Positive[-e];
return Math.Pow( 10.0, e );
}
/// <summary>How many whitespace-delimited tokens remain.</summary>
static int CountTokens( ReadOnlySpan<char> s )
{
var count = 0;
while ( NextToken( ref s, out _ ) )
count++;
return count;
}
/// <summary>The remaining tokens rejoined with single spaces — what string.Join over the split
/// produced before.</summary>
static string JoinTokens( ReadOnlySpan<char> s )
{
var builder = new StringBuilder();
while ( NextToken( ref s, out var token ) )
{
if ( builder.Length > 0 )
builder.Append( ' ' );
builder.Append( token );
}
return builder.ToString();
}
/// <summary>Wavefront indices are 1-based, and negative counts back from the last element.</summary>
static int ObjIndex( ReadOnlySpan<char> token, int count )
{
var n = ParseInt( token );
return n > 0 ? n - 1 : count + n;
}
/// <summary>A plain base-10 integer, no culture, no allocation. Face indices only, so small.</summary>
static int ParseInt( ReadOnlySpan<char> s )
{
var i = 0;
var negative = false;
if ( i < s.Length && (s[i] == '-' || s[i] == '+') )
{
negative = s[i] == '-';
i++;
}
var value = 0;
while ( i < s.Length )
{
value = value * 10 + (s[i] - '0');
i++;
}
return negative ? -value : value;
}
/// <summary><see cref="ObjWriter.DefaultMaterialName"/> is <c>material_N</c>; anything else
/// leaves the current slot alone rather than inventing one.</summary>
static int SlotFromMaterialName( string name, int current )
{
const string prefix = "material_";
if ( name.StartsWith( prefix, StringComparison.OrdinalIgnoreCase )
&& int.TryParse( name.AsSpan( prefix.Length ), NumberStyles.Integer, CultureInfo.InvariantCulture, out var slot )
&& slot >= 0 )
return slot;
return current;
}
}
Editor
library
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// One point on a paint stroke's path: where the brush touched, and which way the surface faced
/// there.
///
/// WHY THE NORMAL IS STORED BESIDE THE POSITION. A paint dab is a sphere in object space, and a
/// sphere at a point on a thin wall reaches both sides of the wall — paint it from one side and the
/// far face gets painted too, which reads as paint on the wrong surface rather than as thin
/// geometry. Recording the normal lets the replay reject faces that point away from the brush, the
/// same guard NormalBake gives the bake with its MaxDistance, and the same failure when it is
/// skipped.
/// </summary>
public readonly struct PaintStrokePoint
{
public readonly Vec3 Position;
public readonly Vec3 Normal;
public PaintStrokePoint( Vec3 position, Vec3 normal )
{
Position = position;
Normal = normal;
}
}
/// <summary>
/// One stroke of paint, stored as a PATH rather than as a list of dabs.
///
/// WHY A PATH. A dab list at ten floats a dab grows without bound and bloats the hand-written text
/// format; a stroke stores its settings once and a handful of path points, then regenerates its dabs
/// deterministically along the path on replay, by the same spacing rule SculptSession already uses
/// to fill in a fast drag. That smallness is what lets paint live in the .effigy text beside the
/// feature tree instead of in a binary side-car — and what makes it diff, which for somebody's model
/// matters more than it sounds.
/// </summary>
public sealed class PaintStroke
{
/// <summary>Brush colour, straight RGBA in 0..1.</summary>
public float R = 1f;
public float G = 1f;
public float B = 1f;
public float A = 1f;
/// <summary>Brush radius, in world units.</summary>
public float Radius = 0.1f;
/// <summary>How hard the stroke presses, 0..1.</summary>
public float Strength = 1f;
/// <summary>Reuses <see cref="BrushFalloff"/> — Smooth / Linear / Sharp / Constant. Deliberately
/// not a second enum: the replay weights dabs through <see cref="Brush.Falloff"/> and two copies
/// of the same list is the thing that drifts.</summary>
public BrushFalloff Falloff = BrushFalloff.Smooth;
/// <summary>Distance between dabs along the path, as a fraction of <see cref="Radius"/>.</summary>
public float Spacing = 0.5f;
/// <summary>
/// Whether this stroke TAKES paint away rather than laying it down.
///
/// AN ERASE IS A STROKE, NOT AN EDIT TO THE CANVAS. The canvas is a derived artifact replayed
/// from this list on every rebuild, so an erase that scrubbed texels directly would be undone by
/// the next rebuild — the paint would come back on its own, which is worse than having no eraser
/// at all. Recorded here it sits in the log in the order the hand made it, and colour blending
/// does not commute, so that order is the whole point.
///
/// THE COLOUR FIELDS ARE STILL CARRIED AND STILL MEAN NOTHING HERE. An erase subtracts its
/// coverage from the canvas's alpha and never reads R/G/B. They stay rather than moving to a
/// second type so that one writer and one reader serve both kinds — see StudioDocument, where an
/// erase is a different LINE KIND rather than a ninth header field, because appending a field
/// would shift the path every older document parses from.
/// </summary>
public bool Erase;
/// <summary>The path, in the order it was painted, each point carrying a position and the
/// surface normal there. Order is the whole point: strokes are a log and colour blending does
/// not commute.</summary>
public readonly List<PaintStrokePoint> Path = new();
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Move a skinned mesh from one skeleton onto another.
///
/// WHAT THIS IS FOR. A playermodel is a mesh bound to CITIZEN'S skeleton, in citizen's bind pose,
/// because that is what citizen's animations were authored against and they bind by bone name. A
/// model rigged in Effigy - or imported from anywhere else - has its own bones with its own names
/// in its own pose. Renaming the bones is the obvious move and it is wrong: the animation supplies
/// a rotation relative to the bind pose it was authored against, so feeding it a different bind
/// pose bends every joint by the difference. Citizen stands in an A-pose with its arms ~40 degrees
/// below horizontal; hand a T-posed rig citizen's walk cycle and the arms go through the floor.
///
/// TWO WAYS TO MAKE THE TWO AGREE, and they are not interchangeable. <see cref="To"/> moves the
/// MESH onto the target's skeleton, so the model takes on the target's proportions.
/// <see cref="Fit"/> moves the target's SKELETON into the mesh, so the model keeps its own. Which
/// one a model wants depends on whether it is meant to look like itself.
///
/// For <see cref="To"/>: for each source bone this builds the transform that takes its bind pose
/// to the target bone's bind pose, and applies that to the vertices it influences, blended by the
/// same weights that will skin it afterwards. The result is the same mesh standing in the target's
/// pose, bound to the target's bones - which is exactly the situation the target's animations
/// expect. It is the operation a DCC tool calls "bind to a new skeleton", written out.
///
/// LINEAR BLEND, AND ITS ONE KNOWN FAILURE. A vertex influenced by several bones is transformed by
/// each and averaged. That is the same maths skinning itself uses, so the result is consistent
/// with how the model will actually deform - and it inherits the same candy-wrapper pinching where
/// two bones differ by a large twist. The joints that move most here are shoulders and elbows,
/// which is where it will show first if it shows at all.
///
/// TWO KINDS OF CORRESPONDENCE, AND CONFLATING THEM TEARS THE MESH. Mapping `upperarm_L` onto
/// `arm_upper_L` means "these are the same bone": the source bone's bind position SNAPS onto the
/// target's, which is how the model takes on the target's proportions, and is the whole point.
/// Mapping a brow bone onto `head` means something else entirely - citizen has no brow, and the
/// geometry should simply ride along with the head where it already is. Snapping it would drag the
/// brow into the middle of the skull. Same for a fifth finger onto a four-fingered hand.
///
/// So a source bone can be named as RIDE-ALONG: its weight is rebound to the target bone, but its
/// geometry moves by the nearest true correspondence above it instead of by its own. The cost of
/// getting this wrong is not subtle - it was long spikes radiating out of the Gearhead's face and
/// fingers stretched onto the wrong knuckles.
///
/// THE MAP IS THE CALLER'S PROBLEM, deliberately. Guessing that `upperarm_L` means `arm_upper_L`
/// is a heuristic that will be wrong on somebody's rig, silently, in a way that looks like bad
/// weights rather than a bad guess. So the correspondence is supplied and this reports exactly
/// what it could not place.
/// </summary>
public static class SkeletonRetarget
{
/// <summary>What a retarget produced, and everything that did not survive it cleanly.</summary>
public sealed class Result
{
/// <summary>The mesh, skinned to the target's bones - moved into the target's bind pose by
/// <see cref="To"/>, left exactly where it was by <see cref="Fit"/>.</summary>
public PolyMesh Mesh;
/// <summary>The skeleton <see cref="Mesh"/> is bound to: the target itself after
/// <see cref="To"/>, the target fitted into the mesh after <see cref="Fit"/>. Export this,
/// not the target you passed in.</summary>
public Skeleton Skeleton;
/// <summary>Source bone names with no entry in the map, or an entry naming a bone the
/// target does not have. Their weight had nowhere to go.</summary>
public List<string> Unmapped = new();
/// <summary>Vertices that lost some weight because of <see cref="Unmapped"/>, and were
/// renormalised over whatever was left.</summary>
public int VerticesRenormalised;
/// <summary>Vertices with no mapped influence at all. These could not be moved and were
/// left where they were - the one failure that shows up as geometry in the wrong
/// place.</summary>
public int VerticesStranded;
/// <summary>Target bones nothing was weighted onto. Expected rather than alarming: citizen
/// carries twists, helpers and IK targets that the anim graph drives and no mesh is bound
/// to.</summary>
public int TargetBonesUnused;
}
/// <summary>
/// Retarget <paramref name="mesh"/> from <paramref name="from"/> onto <paramref name="to"/>.
///
/// <paramref name="map"/> is keyed by SOURCE bone name and names a target bone. Several source
/// bones may name the same target - a five-fingered hand onto citizen's four is exactly that -
/// and their weights are summed on the way through.
///
/// <paramref name="rideAlong"/> names source bones that have no true counterpart and should
/// keep their place rather than snapping onto the bone they are bound to - see the note above.
///
/// <paramref name="adoptRoll"/> takes the target bone's frame whole instead of keeping the
/// source's roll - see <see cref="Land"/> for why that is not the default. Kept as a switch
/// because the two only differ once something animates, and the only way to know which a given
/// pair of skeletons wants is to bake both and look.
///
/// The mesh is not modified; the result carries a clone.
/// </summary>
public static Result To( PolyMesh mesh, Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map,
IReadOnlyCollection<string> rideAlong = null, bool adoptRoll = false )
{
ArgumentNullException.ThrowIfNull( mesh );
ArgumentNullException.ThrowIfNull( from );
ArgumentNullException.ThrowIfNull( to );
ArgumentNullException.ThrowIfNull( map );
if ( !mesh.IsRigged )
throw new ArgumentException( "The mesh has no skin weights - there is nothing to retarget.", nameof( mesh ) );
var result = new Result { Mesh = mesh.Clone(), Skeleton = to };
// Per source bone: where it maps to, and the transform from its bind pose to that one.
var target = Resolve( from, to, map, result );
var delta = new Xform[from.Count];
for ( var b = 0; b < from.Count; b++ )
{
var t = target[b];
if ( t < 0 )
continue;
// Source bind pose to target bind pose, both in model space. Undo where the source
// bone stands, then stand where the target one does - but with the source's own roll
// carried across rather than the target's. See Land.
delta[b] = (adoptRoll ? to.WorldBind( t )
: Land( from.WorldBind( b ), from.BoneDirection( b ), to.WorldBind( t ), to.BoneDirection( t ) ))
* from.WorldBind( b ).Inverse;
}
// Ride-along bones keep the weight they were given but borrow their MOVEMENT from the
// nearest ancestor that is a real correspondence, so their geometry stays where it sits
// relative to that bone instead of collapsing onto the one it is now bound to. Walked
// child-last so a chain of them (a whole pinky) all reach the same real ancestor.
if ( rideAlong is { Count: > 0 } )
{
// Its own set, case-insensitively, because the map is matched that way and a caller
// handing this in as IReadOnlyCollection would otherwise get LINQ's ordinal Contains
// no matter what comparer their set was built with - a silent miss that looks exactly
// like a missing entry.
var passengers = new HashSet<string>( rideAlong, StringComparer.OrdinalIgnoreCase );
var passenger = new bool[from.Count];
for ( var b = 0; b < from.Count; b++ )
passenger[b] = target[b] >= 0 && passengers.Contains( from.Bones[b].Name );
for ( var b = 0; b < from.Count; b++ )
{
if ( !passenger[b] )
continue;
var anchor = from.Bones[b].Parent;
while ( anchor >= 0 && (passenger[anchor] || target[anchor] < 0) )
anchor = from.Bones[anchor].Parent;
if ( anchor >= 0 )
delta[b] = delta[anchor];
else
result.Unmapped.Add( $"{from.Bones[b].Name} rides along but has no anchored ancestor" );
}
}
Rebind( result, from.Count, to.Count, target, delta );
return result;
}
/// <summary>
/// The other way round from <see cref="To"/>: move the TARGET skeleton into the mesh and leave
/// the mesh exactly where it is.
///
/// WHY THIS EXISTS. <see cref="To"/> snaps every joint of the mesh onto the target's joints, so
/// the model takes on the target's proportions. On a body that is a different shape that is a
/// mangle: each bone's geometry moves rigidly to where the target's bone starts - citizen's
/// upper arm is 10in against the Gearhead's 7.7 - and the skin bridges every gap by stretching.
/// The Gearhead came out long-limbed and pinched at the joints with nothing wrong in its
/// weights.
///
/// So here the bones move instead. Every target bone with a source counterpart stands on that
/// bone's head; the ones with none - twists, helpers, IK - ride their parent, turned and
/// stretched with it. Each bone keeps the TARGET's frame convention, swung by the shortest arc
/// onto the direction the source limb actually runs: citizen's +X still runs down the bone and
/// its roll is still citizen's. That is what lets citizen's clips drive it. A clip is local
/// rotations in citizen's convention, so a bone whose axes mean what citizen's mean ends up
/// pointing where citizen's would, and the mesh hanging off it follows. A T-posed arm on an
/// A-posed skeleton is just a larger swing at the shoulder.
///
/// WHICH WAY A BONE RUNS is named by <paramref name="aims"/> - target bone name to the target
/// bones it points at, averaged - not guessed. "Furthest child" is wrong on citizen twice over:
/// spine_2's children include both clavicles, and the hand's furthest child is an IK rule bone
/// forty inches away. A bone with no aim, or none that landed anywhere, keeps its parent's
/// swing, which is right for a head, a fingertip, or anything rigid on its parent.
///
/// THE CLIPS STILL CARRY CITIZEN'S LENGTHS. Local translation is part of every clip, and a
/// fitted bone played at citizen's translations is pulled straight back to citizen's
/// proportions. The model that wears this must mark those bones ignore_Translation - see
/// <see cref="CitizenBoneMap.KeepsOwnLength"/>.
///
/// Weights are rebound by the same map as <see cref="To"/>. Ride-along bones place a target
/// bone only where no true correspondence does - the Gearhead's lids hinge where its own eyelid
/// bones are because nothing else claims citizen's.
/// </summary>
public static Result Fit( PolyMesh mesh, Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map,
IReadOnlyCollection<string> rideAlong, IReadOnlyDictionary<string, string[]> aims )
{
ArgumentNullException.ThrowIfNull( mesh );
ArgumentNullException.ThrowIfNull( from );
ArgumentNullException.ThrowIfNull( to );
ArgumentNullException.ThrowIfNull( map );
ArgumentNullException.ThrowIfNull( aims );
if ( !mesh.IsRigged )
throw new ArgumentException( "The mesh has no skin weights - there is nothing to retarget.", nameof( mesh ) );
var result = new Result { Mesh = mesh.Clone() };
var target = Resolve( from, to, map, result );
var passengers = new HashSet<string>( rideAlong ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase );
// Which source bone's head each target bone stands on. True correspondences first, then
// passengers for whatever is still free; within each, the bone nearest the root wins, so
// `chest` and `spine_03` both naming spine_2 cannot fight over it.
var placedBy = new int[to.Count];
Array.Fill( placedBy, -1 );
foreach ( var passes in new[] { false, true } )
{
for ( var b = 0; b < from.Count; b++ )
{
var t = target[b];
if ( t >= 0 && placedBy[t] < 0 && passengers.Contains( from.Bones[b].Name ) == passes )
placedBy[t] = b;
}
}
var bind = new Xform[to.Count];
var at = new Vec3[to.Count];
for ( var t = 0; t < to.Count; t++ )
{
bind[t] = to.WorldBind( t );
if ( placedBy[t] >= 0 )
at[t] = from.WorldBind( placedBy[t] ).Origin;
}
// Parents before children, which the skeleton guarantees: an unplaced bone needs its
// parent's final swing and stretch, and an aim only ever reads bones placed up front.
var swing = new Xform[to.Count];
var stretch = new float[to.Count];
var fitted = new Xform[to.Count];
for ( var t = 0; t < to.Count; t++ )
{
var parent = to.Bones[t].Parent;
swing[t] = parent < 0 ? Xform.Identity : swing[parent];
stretch[t] = parent < 0 ? 1f : stretch[parent];
// Nothing stands here: carry the target's own offset from the parent, turned and
// scaled the way the parent was, so a twist halfway down a limb stays halfway down it.
if ( placedBy[t] < 0 )
{
at[t] = parent < 0 ? bind[t].Origin
: at[parent] + swing[parent].TransformDirection( bind[t].Origin - bind[parent].Origin ) * stretch[parent];
}
if ( aims.TryGetValue( to.Bones[t].Name, out var aimNames ) )
{
var landed = aimNames.Select( to.IndexOf ).Where( a => a >= 0 && placedBy[a] >= 0 ).ToList();
if ( landed.Count > 0 )
{
var was = Centroid( landed.Select( a => bind[a].Origin ) ) - bind[t].Origin;
var now = Centroid( landed.Select( a => at[a] ) ) - at[t];
if ( was.Length > 1e-4f && now.Length > 1e-4f )
{
swing[t] = Arc( was.Normal, now.Normal );
stretch[t] = now.Length / was.Length;
}
}
}
var turned = swing[t] * new Xform( bind[t].X, bind[t].Y, bind[t].Z, Vec3.Zero );
fitted[t] = new Xform( turned.X, turned.Y, turned.Z, at[t] );
}
var skeleton = to.Clone();
for ( var t = 0; t < to.Count; t++ )
{
var parent = to.Bones[t].Parent;
skeleton.Bones[t].Local = parent < 0 ? fitted[t] : fitted[parent].Inverse * fitted[t];
}
result.Skeleton = skeleton;
Rebind( result, from.Count, to.Count, target, delta: null );
return result;
}
/// <summary>Which target bone each source bone's weight goes to, or -1, with every miss
/// recorded on <paramref name="result"/>.</summary>
static int[] Resolve( Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map, Result result )
{
var target = new int[from.Count];
for ( var b = 0; b < from.Count; b++ )
{
var name = from.Bones[b].Name;
if ( !map.TryGetValue( name, out var wanted ) || string.IsNullOrEmpty( wanted ) )
{
target[b] = -1;
result.Unmapped.Add( name );
continue;
}
var t = to.IndexOf( wanted );
if ( t < 0 )
{
target[b] = -1;
result.Unmapped.Add( $"{name} -> {wanted} (the target has no such bone)" );
continue;
}
target[b] = t;
}
return target;
}
/// <summary>
/// Put every vertex's weight onto the target bones, and move it by <paramref name="delta"/>
/// if there is one - <see cref="To"/> moves the mesh, <see cref="Fit"/> passes null and only
/// renames what each vertex hangs off.
/// </summary>
static void Rebind( Result result, int fromCount, int toCount, int[] target, Xform[] delta )
{
var used = new bool[toCount];
var skin = result.Mesh.Skin;
for ( var v = 0; v < result.Mesh.VertexCount; v++ )
{
var weights = skin[v];
var kept = 0f;
foreach ( var w in weights )
{
if ( w.Bone >= 0 && w.Bone < fromCount && target[w.Bone] >= 0 )
kept += w.Weight;
}
if ( kept <= 1e-6f )
{
// Nothing this vertex is attached to went anywhere. Moving it by an arbitrary bone
// would be a guess; leaving it is at least honest, and the count says how bad it is.
result.VerticesStranded++;
skin[v] = Array.Empty<BoneWeight>();
continue;
}
if ( kept < 0.999f )
result.VerticesRenormalised++;
// The vertex goes where its bones take it, averaged - the same blend that will skin it.
var p = result.Mesh.Positions[v];
var moved = Vec3.Zero;
var merged = new Dictionary<int, float>();
foreach ( var w in weights )
{
if ( w.Bone < 0 || w.Bone >= fromCount || target[w.Bone] < 0 )
continue;
var share = w.Weight / kept;
if ( delta is not null )
moved += delta[w.Bone].TransformPoint( p ) * share;
var t = target[w.Bone];
merged[t] = merged.TryGetValue( t, out var had ) ? had + share : share;
used[t] = true;
}
if ( delta is not null )
result.Mesh.Positions[v] = moved;
skin[v] = merged.Select( kv => new BoneWeight( kv.Key, kv.Value ) )
.OrderByDescending( w => w.Weight )
.ToArray();
}
result.TargetBonesUnused = used.Count( u => !u );
result.Unmapped = result.Unmapped.Distinct().ToList();
}
static Vec3 Centroid( IEnumerable<Vec3> points )
{
var sum = Vec3.Zero;
var n = 0;
foreach ( var p in points )
{
sum += p;
n++;
}
return n == 0 ? sum : sum / n;
}
/// <summary>The shortest rotation taking unit <paramref name="from"/> onto unit
/// <paramref name="to"/>. Antiparallel has no shortest one, so it turns about any fixed
/// perpendicular - stable, and no worse than every other choice.</summary>
static Xform Arc( Vec3 from, Vec3 to )
{
var axis = Vec3.Cross( from, to );
var sin = axis.Length;
var cos = Vec3.Dot( from, to );
if ( sin >= 1e-6f )
return Xform.Rotate( axis, MathF.Atan2( sin, cos ) );
if ( cos > 0f )
return Xform.Identity;
var seed = MathF.Abs( from.x ) < 0.9f ? new Vec3( 1, 0, 0 ) : new Vec3( 0, 1, 0 );
return Xform.Rotate( Vec3.Cross( from, seed ), MathF.PI );
}
/// <summary>
/// Where a source bone should stand on the target skeleton: the target bone's position and
/// direction, and the SOURCE bone's roll about that direction.
///
/// WHY NOT JUST TAKE THE TARGET'S FRAME. Aiming a bone head to tail pins two of its axes and
/// leaves it free to spin about its own length, so every rig picks that roll by a convention
/// of its own - citizen's came out of its fbx, a bone built by <see cref="Skeleton.AddBone"/>
/// from head and tail points gets whatever <c>PerpendicularAxes</c> seeded. Two skeletons
/// agreeing perfectly on where every joint is can still disagree about roll on every single
/// bone, by any angle up to 180 degrees. Adopting the target's roll then spins each limb's
/// geometry about its own length by a different arbitrary amount, which does not look like a
/// convention mismatch - it looks like the mesh has been through a shredder.
///
/// So the twist is dropped and only the swing is kept: the shortest rotation that takes the
/// source bone's direction onto the target's, applied to the source's own cross-axes. Nothing
/// is lost by it. The skin binds against the target's real bind pose either way, and at rest
/// pose equals bind, so the vertex stays exactly where this puts it - the delta is ours to
/// choose, and the choice that moves geometry least is the right one.
///
/// DIRECTION COMES FROM GEOMETRY, NOT THE FRAME. <paramref name="sourceDir"/> and
/// <paramref name="targetDir"/> are the bones' head-to-child directions, not their +Y axes -
/// a frame's +Y is only the bone direction for an Effigy-built skeleton, and citizen is
/// +X-down-the-bone. Swinging .Y onto .Y across that convention boundary spins the mesh a
/// quarter turn about every limb.
/// </summary>
static Xform Land( Xform source, Vec3 sourceDir, Xform target, Vec3 targetDir )
{
var from = sourceDir.Normal;
var to = targetDir.Normal;
var axis = Vec3.Cross( from, to );
var sin = axis.Length;
var cos = Vec3.Dot( from, to );
// Antiparallel has no shortest rotation - every half turn about a perpendicular axis is
// equally short - so take the one about the source's own X, which is perpendicular by
// construction and keeps the choice stable.
var swing = sin < 1e-6f
? (cos > 0f ? Xform.Identity : Xform.Rotate( source.X, MathF.PI ))
: Xform.Rotate( axis, MathF.Atan2( sin, cos ) );
// Swing the WHOLE source frame, not just its cross-axes with the target direction bolted
// into the +Y slot. Slotting the direction into +Y assumed the target's bone direction IS
// its +Y, which is true of an Effigy skeleton and false of citizen's +X-down-the-bone - on
// the identity retarget that single-slot form stopped being the source frame and moved the
// mesh. Applying the swing to every axis keeps the source's roll and collapses to the
// source frame (and so an identity delta) exactly when the two directions already agree.
return new Xform(
swing.TransformDirection( source.X.Normal ),
swing.TransformDirection( source.Y.Normal ),
swing.TransformDirection( source.Z.Normal ),
target.Origin );
}
}
Editor
library
using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using Effigy.Render; namespace Effigy; /// <summary> /// A build script: what to add to the studio, in order, in one text file that runs headlessly /// and in the editor alike. /// /// # a comment /// add Primitive name=Torso shape=Box sizex=20 sizey=12 sizez=30 position=@ground+0,0,15 /// add Part name=Buckle part=Buckle size=6 length=4 [email protected]+1,0,-6 /// add Spline name=Cable [email protected];-10,4,20;-14,0,8 radius=0.8 /// add CurveDeform bodies=Tail /// set Torso sizez=32 /// fix Torso all /// render out/torso.png view=sheet /// describe /// match front reference.png out/match.png /// /// `add TYPE` takes a feature type (Primitive, Spline, Part, Profile, Transform, Mirror, /// Subdivide…) and `key=value` pairs naming its parameters by label or field. Anywhere a point /// goes, `@landmark` or `@landmark+dx,dy,dz` names a place on the model — a body's side, a /// bone, the ground — resolved by <see cref="ModelMeasure.TryLandmark"/> against the model AS /// BUILT SO FAR, so a part can sit on the body the line before it made. /// /// Every command runs a rebuild when it changes the model, and a feature that fails stops the /// script with the feature's own diagnostic — its cause and its remedies — so what comes back /// is what to change, not a stack trace. /// </summary> public static class BuildScript { public sealed class Result { public bool Ok = true; public List<string> Log = new(); public List<string> Written = new(); public string Error; public int ErrorLine; /// <summary>Where each body turns or stands, by body name, set with `pivot`. Export moves the /// body so this point is its model origin, and the layout says where that point was.</summary> public Dictionary<string, Vec3> Pivots = new( StringComparer.OrdinalIgnoreCase ); public override string ToString() { var sb = new StringBuilder(); foreach ( var line in Log ) sb.AppendLine( line ); if ( Error is not null ) sb.AppendLine( $"ERROR line {ErrorLine}: {Error}" ); return sb.ToString().TrimEnd(); } } /// <summary>Run <paramref name="text"/> against <paramref name="studio"/>. Files a `render`, /// `match` or `save` writes go under <paramref name="baseDir"/> when their path is relative.</summary> public static Result Run( PartStudio studio, string text, string baseDir = null ) { var result = new Result(); baseDir ??= Directory.GetCurrentDirectory(); if ( studio is null ) { result.Ok = false; result.Error = "No studio."; return result; } var lines = (text ?? "").Replace( "\r\n", "\n" ).Split( '\n' ); for ( var i = 0; i < lines.Length; i++ ) { var line = lines[i].Trim(); if ( line.Length == 0 || line.StartsWith( '#' ) ) continue; try { if ( !Command( studio, Tokenise( line ), baseDir, result ) ) { result.Ok = false; result.ErrorLine = i + 1; return result; } } catch ( Exception e ) { result.Ok = false; result.ErrorLine = i + 1; result.Error = e.Message; return result; } } return result; } static bool Command( PartStudio studio, List<string> tokens, string baseDir, Result r ) { if ( tokens.Count == 0 ) return true; var verb = tokens[0].ToLowerInvariant(); var args = tokens.Skip( 1 ).ToList(); switch ( verb ) { case "add": { if ( args.Count == 0 ) return Fail( r, "add needs a feature type: add Primitive shape=Box …" ); var feature = Create( args[0] ); if ( feature is null ) return Fail( r, $"No feature type '{args[0]}'. Types: {string.Join( ", ", FeatureTypeNames() )}." ); var pairs = Pairs( args.Skip( 1 ) ); if ( !Assign( studio, feature, pairs, r ) ) return false; studio.Add( feature ); return RebuildAndCheck( studio, feature, r, $"added {feature.TypeName} '{feature.Name ?? feature.Id}'" ); } case "set": { if ( args.Count < 2 ) return Fail( r, "set needs a feature name and key=value pairs." ); var feature = FindFeature( studio, args[0] ); if ( feature is null ) return Fail( r, $"No feature called '{args[0]}'. Features: {string.Join( ", ", studio.Features.Select( f => f.Name ?? f.TypeName ) )}." ); if ( !Assign( studio, feature, Pairs( args.Skip( 1 ) ), r ) ) return false; studio.MarkDirty( feature ); return RebuildAndCheck( studio, feature, r, $"set {feature.Name ?? feature.TypeName}" ); } case "remove": { if ( args.Count < 1 ) return Fail( r, "remove needs a feature name." ); var feature = FindFeature( studio, args[0] ); if ( feature is null ) return Fail( r, $"No feature called '{args[0]}'." ); studio.Remove( feature ); studio.Rebuild(); r.Log.Add( $"removed {feature.Name ?? feature.TypeName}" ); return true; } case "rebuild": studio.Rebuild(); r.Log.Add( "rebuilt" ); return true; case "fix": { if ( args.Count < 2 ) return Fail( r, "fix needs a body (or all) and the fixes: fix Torso loose holes normals" ); var targets = args[0].Equals( "all", StringComparison.OrdinalIgnoreCase ) ? studio.Bodies.ToList() : new List<Body> { ModelMeasure.FindBody( studio, args[0] ) }; if ( targets.Count == 0 || targets[0] is null ) return Fail( r, $"No body called '{args[0]}'. Bodies: {string.Join( ", ", studio.Bodies.Select( b => b.Name ?? b.Id ) )}." ); foreach ( var body in targets ) foreach ( var line in MeshFix.Apply( body.Mesh, args.Skip( 1 ) ) ) r.Log.Add( $"{body.Name ?? body.Id} {line}" ); return true; } case "render": { if ( args.Count < 1 ) return Fail( r, "render needs an output path: render out/model.png view=sheet" ); var path = Resolve( baseDir, args[0] ); var opts = Pairs( args.Skip( 1 ) ); var viewName = opts.GetValueOrDefault( "view", "sheet" ); var size = (int)Number( opts.GetValueOrDefault( "size", "512" ) ); var options = new RenderOptions { Wireframe = Truthy( opts.GetValueOrDefault( "wire", "1" ) ), Bones = Truthy( opts.GetValueOrDefault( "bones", "0" ) ), Labels = Truthy( opts.GetValueOrDefault( "labels", "1" ) ), Grid = Truthy( opts.GetValueOrDefault( "grid", "1" ) ), FocusBodyId = opts.TryGetValue( "focus", out var focus ) ? ModelMeasure.FindBody( studio, focus )?.Id : null, }; Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? "." ); ModelRender.Named( studio.Bodies, viewName, size, options, studio.Rig ).SavePng( path ); r.Written.Add( path ); r.Log.Add( $"rendered {viewName} to {path}" ); return true; } case "describe": r.Log.Add( ModelMeasure.Describe( studio ) ); return true; case "measure": case "at": { if ( args.Count < 1 ) return Fail( r, "measure needs a landmark or a point: measure Torso.top" ); if ( !Point( studio, args[0], out var p, out var why ) ) return Fail( r, why ); r.Log.Add( ModelMeasure.At( studio, p ) ); return true; } case "distance": { if ( args.Count < 2 ) return Fail( r, "distance needs two landmarks: distance Hand_L Hand_R" ); if ( !Point( studio, args[0], out var a, out var whyA ) ) return Fail( r, whyA ); if ( !Point( studio, args[1], out var b, out var whyB ) ) return Fail( r, whyB ); var d = b - a; r.Log.Add( $"{args[0]} to {args[1]}: {d.Length:0.###} (dx {d.x:0.###}, dy {d.y:0.###}, dz {d.z:0.###})" ); return true; } case "symmetry": { foreach ( var body in args.Count > 0 ? new[] { ModelMeasure.FindBody( studio, args[0] ) } : studio.Bodies.ToArray() ) { if ( body?.Mesh is null ) continue; r.Log.Add( $"{body.Name ?? body.Id}: left-right symmetry error {ModelMeasure.SymmetryError( body.Mesh ) * 100f:0.#}% of width" ); } return true; } case "match": { if ( args.Count < 2 ) return Fail( r, "match needs a view and a reference PNG: match front ref.png [out.png]" ); var view = RenderView.Named( args[0] ); if ( view is null ) return Fail( r, $"No view called '{args[0]}'." ); var refPath = Resolve( baseDir, args[1] ); if ( !File.Exists( refPath ) ) return Fail( r, $"No reference image at {refPath}." ); const int size = 256; var (w, h, rgb, alpha) = Raster.LoadPng( refPath ); var reference = ReferenceMatch.ReferenceSilhouette( w, h, rgb, alpha, size, out var aspect ); var match = ReferenceMatch.Compare( studio.Bodies, view, reference, aspect, size ); r.Log.Add( match.Report ); if ( args.Count > 2 ) { var outPath = Resolve( baseDir, args[2] ); Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( outPath ) ) ?? "." ); match.Diff.SavePng( outPath ); r.Written.Add( outPath ); } return true; } case "save": { if ( args.Count < 1 ) return Fail( r, "save needs a path." ); var path = Resolve( baseDir, args[0] ); Directory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? "." ); StudioDocument.WriteFile( studio, path ); r.Written.Add( path ); r.Log.Add( $"saved {path}" ); return true; } case "echo": r.Log.Add( string.Join( " ", args ) ); return true; case "material": { // material 1 materials/alley/brass.vmat — what slot 1 is exported as. if ( args.Count < 2 || !int.TryParse( args[0], out var slot ) ) return Fail( r, "material needs a slot number and a .vmat path: material 1 materials/alley/brass.vmat" ); studio.MaterialNames[slot] = args[1]; studio.MarkDirty( 0 ); r.Log.Add( $"slot {slot} is {args[1]}" ); return true; } case "pivot": { // pivot Bob3 @Beam.bottom — the point this part turns about, which export makes its origin. if ( args.Count < 2 ) return Fail( r, "pivot needs a body and a point: pivot Arm @Hub.centre" ); if ( ModelMeasure.FindBody( studio, args[0] ) is null ) return Fail( r, $"No body called '{args[0]}'." ); if ( !Point( studio, args[1], out var p, out var why ) ) return Fail( r, why ); r.Pivots[args[0]] = p; return true; } case "export": { // export models/civic_art/orrery split=1 assets=C:/proj/Assets name=orrery if ( args.Count < 1 ) return Fail( r, "export needs a folder under the assets root: export models/civic_art/orrery [split=1] [assets=root] [name=x]" ); var folder = args[0].Replace( '\\', '/' ).Trim( '/' ); var opts = Pairs( args.Skip( 1 ) ); var root = opts.TryGetValue( "assets", out var assets ) ? assets : baseDir; var split = Truthy( opts.GetValueOrDefault( "split", "0" ) ); var name = opts.GetValueOrDefault( "name", folder.Contains( '/' ) ? folder[(folder.LastIndexOf( '/' ) + 1)..] : folder ); var dir = Path.Combine( root, folder ); Directory.CreateDirectory( dir ); var layout = new StringBuilder(); layout.AppendLine( "# part\tmodel\tpivot x\ty\tz\tsize x\ty\tz" ); var bodies = studio.Bodies.Where( b => b?.Mesh is not null && b.Mesh.Faces.Count > 0 && b.Visible ).ToList(); if ( bodies.Count == 0 ) return Fail( r, "Nothing to export: no visible bodies." ); if ( split ) { foreach ( var body in bodies ) { var partName = Safe( body.Name ?? body.Id ); var mesh = body.Mesh.Clone(); var (min, max) = ModelMeasure.Bounds( mesh ); var pivot = r.Pivots.TryGetValue( body.Name ?? "", out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z ); MeshTransform.Apply( mesh, Xform.Translate( -pivot ) ); WriteModel( studio, mesh, dir, folder, partName ); layout.AppendLine( $"{body.Name ?? body.Id}\t{folder}/{partName}.vmdl\t{N( pivot.x )}\t{N( pivot.y )}\t{N( pivot.z )}\t{N( max.x - min.x )}\t{N( max.y - min.y )}\t{N( max.z - min.z )}" ); } } else { var mesh = new PolyMesh(); foreach ( var body in bodies ) MeshTransform.Append( mesh, body.Mesh ); var (min, max) = ModelMeasure.Bounds( mesh ); var pivot = r.Pivots.TryGetValue( name, out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z ); MeshTransform.Apply( mesh, Xform.Translate( -pivot ) ); WriteModel( studio, mesh, dir, folder, Safe( name ) ); layout.AppendLine( $"{name}\t{folder}/{Safe( name )}.vmdl\t{N( pivot.x )}\t{N( pivot.y )}\t{N( pivot.z )}\t{N( max.x - min.x )}\t{N( max.y - min.y )}\t{N( max.z - min.z )}" ); } var layoutPath = Path.Combine( dir, "layout.txt" ); File.WriteAllText( layoutPath, layout.ToString() ); r.Written.Add( layoutPath ); r.Log.Add( $"exported {(split ? bodies.Count : 1)} model(s) to {dir}; layout in layout.txt" ); return true; } default: return Fail( r, $"Unknown command '{verb}'. Commands: add, set, remove, rebuild, fix, render, describe, measure, distance, symmetry, match, material, pivot, export, save, echo." ); } } /// <summary>One .obj and the .vmdl that loads it, materials bound from the studio's slots.</summary> static void WriteModel( PartStudio studio, PolyMesh mesh, string dir, string folder, string name ) { var objPath = Path.Combine( dir, name + ".obj" ); ObjWriter.WriteFile( mesh, objPath, name, materialName: studio.NameForSlot ); var materials = VmdlMaterials.GroupList( studio, mesh ); File.WriteAllText( Path.Combine( dir, name + ".vmdl" ), VmdlDocument.Static( $"{folder}/{name}.obj", "", materials ) ); } static string Safe( string name ) { var sb = new StringBuilder(); foreach ( var c in (name ?? "part").Trim().ToLowerInvariant() ) sb.Append( char.IsLetterOrDigit( c ) ? c : '_' ); return sb.Length == 0 ? "part" : sb.ToString(); } static string N( float v ) => v.ToString( "0.###", CultureInfo.InvariantCulture ); static bool RebuildAndCheck( PartStudio studio, Feature feature, Result r, string did ) { studio.Rebuild(); if ( feature.Error is not null ) { var d = feature.Diagnostic; var sb = new StringBuilder(); sb.Append( $"{feature.TypeName} '{feature.Name ?? feature.Id}' failed: {feature.Error}" ); if ( d is not null ) { if ( !string.IsNullOrEmpty( d.Cause ) ) sb.Append( $" Because: {d.Cause}" ); foreach ( var remedy in d.Remedies ) sb.Append( $" Try: {remedy}." ); } return Fail( r, sb.ToString() ); } r.Log.Add( did + (feature.Warning is not null ? $" (note: {feature.Warning})" : "" ) ); return true; } static bool Fail( Result r, string message ) { r.Error = message; return false; } // --- assigning parameters --------------------------------------------------------------------- static bool Assign( PartStudio studio, Feature feature, Dictionary<string, string> pairs, Result r ) { foreach ( var (key, value) in pairs ) { if ( key == "name" ) { feature.Name = value; continue; } var target = FindParam( feature, key, out var field ); if ( target is null && field is null ) return Fail( r, $"{feature.TypeName} has no parameter '{key}'. It has: {string.Join( ", ", ParamNames( feature ) )}." ); try { if ( target is not null && !AssignParam( studio, target, value, out var why ) ) return Fail( r, $"{key}: {why}" ); if ( target is null && !AssignField( studio, feature, field, value, out var whyField ) ) return Fail( r, $"{key}: {whyField}" ); } catch ( Exception e ) { return Fail( r, $"{key}: {e.Message}" ); } } return true; } static bool AssignParam( PartStudio studio, IParam param, string value, out string why ) { why = null; switch ( param ) { case FloatParam f: if ( !TryNumber( value, out var fv ) ) { why = $"'{value}' is not a number."; return false; } f.Value = fv; f.Expr = null; return true; case IntParam i: if ( !TryNumber( value, out var iv ) ) { why = $"'{value}' is not a number."; return false; } i.Value = (int)MathF.Round( iv ); return true; case BoolParam b: b.Value = Truthy( value ); return true; case ChoiceParam c: { var index = Array.FindIndex( c.Options, o => string.Equals( o, value, StringComparison.OrdinalIgnoreCase ) || string.Equals( o.Replace( ' ', '_' ), value, StringComparison.OrdinalIgnoreCase ) ); // "Left" for "Left (y)", "box" for "Box": the start of an option, then anywhere in it. if ( index < 0 ) index = Array.FindIndex( c.Options, o => o.StartsWith( value, StringComparison.OrdinalIgnoreCase ) ); if ( index < 0 ) index = Array.FindIndex( c.Options, o => o.Contains( value, StringComparison.OrdinalIgnoreCase ) ); if ( index < 0 && int.TryParse( value, out var n ) && n >= 0 && n < c.Options.Length ) index = n; if ( index < 0 ) { why = $"'{value}' is not one of {string.Join( ", ", c.Options )}."; return false; } c.Index = index; return true; } case Vec3Param v: if ( !Point( studio, value, out var p, out why ) ) return false; v.Value = p; return true; case StringParam s: s.Value = value; return true; case BodySelectionParam bodies: { bodies.BodyIds.Clear(); foreach ( var name in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) ) { var body = ModelMeasure.FindBody( studio, name.Trim() ); if ( body is null ) { why = $"No body called '{name.Trim()}'. Bodies: {string.Join( ", ", studio.Bodies.Select( b => b.Name ?? b.Id ) )}."; return false; } bodies.BodyIds.Add( body.Id ); } return true; } } why = $"a {param.GetType().Name} cannot be set from a script."; return false; } static bool AssignField( PartStudio studio, Feature feature, FieldInfo field, string value, out string why ) { why = null; var current = field.GetValue( feature ); switch ( current ) { case List<Vec3> points: { points.Clear(); foreach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) ) { if ( !Point( studio, part.Trim(), out var p, out why ) ) return false; points.Add( p ); } return true; } case List<Vec2> pairs: { pairs.Clear(); foreach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) ) { var xy = part.Split( ',' ); if ( xy.Length != 2 || !TryNumber( xy[0], out var x ) || !TryNumber( xy[1], out var y ) ) { why = $"'{part}' is not a pair a,b."; return false; } pairs.Add( new Vec2( x, y ) ); } return true; } case string: { // A field holding another feature's id — a path sketch, a spline — takes its name. var other = FindFeature( studio, value ); field.SetValue( feature, other?.Id ?? value ); return true; } case List<string> texts: { texts.Clear(); foreach ( var part in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) ) texts.Add( FindFeature( studio, part.Trim() )?.Id ?? part.Trim() ); return true; } } why = $"a {field.FieldType.Name} cannot be set from a script."; return false; } static IParam FindParam( Feature feature, string key, out FieldInfo field ) { field = null; var wanted = Normalise( key ); foreach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ) ) if ( Normalise( param.Label ) == wanted ) return param; foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) { if ( Normalise( f.Name ) != wanted ) continue; if ( f.GetValue( feature ) is IParam p ) return p; field = f; return null; } return null; } static IEnumerable<string> ParamNames( Feature feature ) { foreach ( var p in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() ) yield return p.Label; foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) if ( f.GetValue( feature ) is not IParam && f.Name is not ("Id" or "Name" or "Suppressed" or "Visible") ) yield return f.Name; } static string Normalise( string s ) => (s ?? "").ToLowerInvariant().Replace( " ", "" ).Replace( "_", "" ).Replace( "'", "" ); // --- points and landmarks --------------------------------------------------------------------- /// <summary>`x,y,z`, `@landmark`, or `@landmark+dx,dy,dz` / `@landmark-dx,dy,dz`.</summary> public static bool Point( PartStudio studio, string text, out Vec3 point, out string why ) { why = null; point = Vec3.Zero; text = (text ?? "").Trim(); if ( !text.StartsWith( '@' ) ) { if ( ModelMeasure.TryParseVec3( text, out point ) ) return true; // A bare name is a landmark too. return ModelMeasure.TryLandmark( studio, text, out point, out why ); } var body = text[1..]; var offset = Vec3.Zero; var plus = body.IndexOfAny( new[] { '+', '-' }, 1 ); if ( plus > 0 ) { var sign = body[plus] == '-' ? -1f : 1f; var offsetText = body[(plus + 1)..]; body = body[..plus]; if ( !ModelMeasure.TryParseVec3( offsetText, out offset ) ) { why = $"'{offsetText}' after the landmark is not an offset dx,dy,dz."; return false; } offset *= sign; } if ( !ModelMeasure.TryLandmark( studio, body, out point, out why ) ) return false; point += offset; return true; } // --- features by type and name ---------------------------------------------------------------- static Feature Create( string typeName ) { var wanted = Normalise( typeName ); foreach ( var type in FeatureTypes() ) { var name = Normalise( type.Name ); var bare = name.EndsWith( "feature" ) ? name[..^7] : name; if ( name == wanted || bare == wanted ) return (Feature)Activator.CreateInstance( type ); } return null; } static IEnumerable<Type> FeatureTypes() => typeof( Feature ).Assembly.GetTypes() .Where( t => t.IsSubclassOf( typeof( Feature ) ) && !t.IsAbstract ) .OrderBy( t => t.Name, StringComparer.Ordinal ); /// <summary>A feature type's parameters, one per line, with what each takes — the help a script /// author reads before writing `add`.</summary> public static string Describe( string typeName ) { var feature = Create( typeName ); if ( feature is null ) return $"No feature type '{typeName}'. Types: {string.Join( ", ", FeatureTypeNames() )}."; var sb = new StringBuilder(); sb.AppendLine( $"{feature.TypeName} (add {typeName})" ); foreach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() ) { var key = Normalise( param.Label ); var kind = param switch { FloatParam f => $"number, default {f.Value}" + (f.Min > float.MinValue ? $", min {f.Min}" : "") + (f.Max < float.MaxValue ? $", max {f.Max}" : ""), IntParam i => $"whole number, default {i.Value}, {i.Min}..{i.Max}", BoolParam b => $"on/off, default {(b.Value ? "on" : "off")}", ChoiceParam c => $"one of {string.Join( "|", c.Options )}, default {c.Value}", Vec3Param v => $"point x,y,z or @landmark, default {v.Value.x},{v.Value.y},{v.Value.z}", StringParam => "text", BodySelectionParam => "body names, comma-separated (empty = every body)", _ => param.GetType().Name, }; sb.AppendLine( $" {key} = {kind}" ); } foreach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) ) { if ( f.GetValue( feature ) is IParam || f.Name is "Id" or "Name" or "Suppressed" or "Visible" ) continue; var kind = f.FieldType == typeof( List<Vec3> ) ? "points x,y,z;x,y,z;… (landmarks allowed)" : f.FieldType == typeof( List<Vec2> ) ? "pairs a,b;a,b;…" : f.FieldType == typeof( string ) ? "a feature name" : f.FieldType == typeof( List<string> ) ? "feature names, comma-separated" : null; if ( kind is not null ) sb.AppendLine( $" {Normalise( f.Name )} = {kind}" ); } sb.AppendLine( " name = what to call it" ); return sb.ToString().TrimEnd(); } public static IEnumerable<string> FeatureTypeNames() => FeatureTypes().Select( t => t.Name.EndsWith( "Feature" ) ? t.Name[..^7] : t.Name ); static Feature FindFeature( PartStudio studio, string name ) { if ( string.IsNullOrWhiteSpace( name ) ) return null; if ( name.StartsWith( '#' ) && int.TryParse( name[1..], out var index ) && index >= 1 && index <= studio.Features.Count ) return studio.Features[index - 1]; return studio.Features.FirstOrDefault( f => f.Id == name ) ?? studio.Features.LastOrDefault( f => string.Equals( f.Name, name, StringComparison.OrdinalIgnoreCase ) ) ?? studio.Features.LastOrDefault( f => string.Equals( f.Name?.Replace( ' ', '_' ), name, StringComparison.OrdinalIgnoreCase ) ) ?? studio.Features.LastOrDefault( f => string.Equals( f.TypeName, name, StringComparison.OrdinalIgnoreCase ) ); } // --- lexing ------------------------------------------------------------------------------------- static List<string> Tokenise( string line ) { var tokens = new List<string>(); var sb = new StringBuilder(); var quoted = false; foreach ( var c in line ) { if ( c == '"' ) { quoted = !quoted; continue; } if ( char.IsWhiteSpace( c ) && !quoted ) { if ( sb.Length > 0 ) { tokens.Add( sb.ToString() ); sb.Clear(); } continue; } sb.Append( c ); } if ( sb.Length > 0 ) tokens.Add( sb.ToString() ); return tokens; } static Dictionary<string, string> Pairs( IEnumerable<string> tokens ) { var pairs = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase ); foreach ( var token in tokens ) { var eq = token.IndexOf( '=' ); if ( eq <= 0 ) throw new ArgumentException( $"'{token}' is not key=value." ); pairs[token[..eq].Trim().ToLowerInvariant()] = token[(eq + 1)..].Trim(); } return pairs; } static bool TryNumber( string text, out float value ) => float.TryParse( (text ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value ); static float Number( string text ) => TryNumber( text, out var v ) ? v : throw new ArgumentException( $"'{text}' is not a number." ); static bool Truthy( string text ) => (text ?? "").Trim().ToLowerInvariant() is "1" or "true" or "on" or "yes"; static string Resolve( string baseDir, string path ) => Path.IsPathRooted( path ) ? path : Path.Combine( baseDir, path ); }
Editor
library
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Geometry the sketch does not own, projected into its plane so it can be seen and snapped to.
///
/// WHAT THIS IS FOR. A sketch on the face of an existing part is nearly always ABOUT that face —
/// a boss centred on it, a pocket set in from one of its corners, a rib running along one of its
/// edges. Until this existed the face went blank the moment the sketcher opened: the plane was
/// derived from it and then nothing about it was drawn or snappable, so lining a new rectangle up
/// with the edge directly underneath it was done by eye against the shaded solid. That is exactly
/// the kind of "close enough" that turns into a 0.03-unit sliver after an extrude.
///
/// EVERY OTHER CAD PACKAGE CALLS THIS PROJECTED REFERENCE GEOMETRY and makes you ask for it a
/// curve at a time — Onshape's Use tool, SolidWorks' Convert Entities. Effigy shows the whole
/// face's boundary automatically instead, because the face was CHOSEN as the plane a moment ago,
/// which is a much stronger statement of intent than clicking one edge of it.
///
/// IT IS NOT PART OF THE SKETCH. Nothing here is in Sketch.Points or Sketch.Curves, so it never
/// reaches ProfileFinder, never extrudes, and is never saved. It is rebuilt from the model every
/// time the sketch is opened, which is what keeps it honest when the face underneath changes —
/// the same reason FaceRef stores geometry rather than "Face6".
/// </summary>
public sealed class SketchReference
{
/// <summary>Corners of the referenced geometry, in sketch-plane coordinates.</summary>
public readonly List<Vec2> Points = new();
/// <summary>Edges, as index pairs into <see cref="Points"/>.</summary>
public readonly List<(int A, int B)> Edges = new();
public bool IsEmpty => Points.Count == 0;
/// <summary>An edge as the two positions it runs between. Bounds-checked to a zero-length
/// segment rather than throwing: this is drawn and hit-tested every frame from an index that a
/// rebuild underneath could have invalidated, and a viewport that throws once per frame is
/// worse than one that briefly draws nothing.</summary>
public (Vec2 A, Vec2 B) Segment( int index )
{
if ( index < 0 || index >= Edges.Count )
return (Vec2.Zero, Vec2.Zero);
var (a, b) = Edges[index];
if ( a < 0 || a >= Points.Count || b < 0 || b >= Points.Count )
return (Vec2.Zero, Vec2.Zero);
return (Points[a], Points[b]);
}
/// <summary>
/// Copy one reference edge into the sketch as a real line — Onshape's Use, one edge at a time.
///
/// WHY A COPY AND NOT A LIVE LINK. Onshape's projected curves stay attached to what they were
/// taken from and move when it moves. That is the better behaviour and it is also a whole
/// feature: the sketch would need a second class of curve that the user cannot drag or delete,
/// that ProfileFinder unions in, and that is rebuilt rather than saved. A copy is what the tool
/// does here, and it is honest about it — the line becomes ordinary sketch geometry, yours to
/// trim and drag, and it does NOT follow the face afterwards.
///
/// Points are reused through SketchSnapper.PointIndex, so an edge copied in welds onto whatever
/// is already at its ends rather than laying a second point on top of the first. That is what
/// makes "use all four edges, then draw a line across" close into two regions instead of into
/// nothing at all.
/// </summary>
/// <returns>The line added, or null when the edge is degenerate or the sketch already has it.</returns>
public SketchLine UseEdge( Sketch sketch, int edgeIndex )
{
if ( sketch is null || edgeIndex < 0 || edgeIndex >= Edges.Count )
return null;
var (a, b) = Segment( edgeIndex );
var start = SketchSnapper.PointIndex( sketch, a );
var end = SketchSnapper.PointIndex( sketch, b );
// A zero-length line is not geometry - ProfileFinder links it into the adjacency map twice
// at one point and calls the sketch branching. The line tool has the same guard.
if ( start == end )
return null;
// USING THE SAME EDGE TWICE MUST NOT LAY A SECOND LINE ON THE FIRST. Two curves between the
// same pair of points is exactly the branching case ProfileFinder refuses, so clicking an
// edge you already used would quietly destroy the profile you were building.
foreach ( var curve in sketch.Curves )
{
var (from, to) = curve.Endpoints;
if ( (from == start && to == end) || (from == end && to == start) )
return null;
}
return sketch.Add( new SketchLine( start, end ) );
}
/// <summary>Copy every reference edge in, which is the common case: the whole face outline, so a
/// single line drawn across it closes two regions. Returns how many were added — edges already
/// in the sketch are skipped, so running it twice is harmless.</summary>
public int UseAll( Sketch sketch )
{
var added = 0;
for ( var i = 0; i < Edges.Count; i++ )
{
if ( UseEdge( sketch, i ) is not null )
added++;
}
return added;
}
/// <summary>
/// The boundary of the face a sketch is attached to, in that sketch's plane.
///
/// THE BOUNDARY OF THE SURFACE, NOT OF ONE n-GON. A face that has been through a boolean is
/// usually several faces sharing a plane, and outlining each of them separately draws the seams
/// where they were split — lines that are not edges of anything, sitting in the middle of what
/// looks like one flat surface, and snapping to them is snapping to an artefact of how the mesh
/// happens to be cut up. FaceSurface is what decides where the surface stops, and it is the
/// same answer the viewport highlights and the edge picker offers: three things that used to
/// each work it out for themselves and disagree on screen at the same time.
///
/// PROJECTED, NOT INTERSECTED, so a sketch with an offset still gets the face's outline —
/// directly below where it will be drawn, which is what makes the offset useful for a boss
/// standing clear of the surface it grows from.
/// </summary>
public static SketchReference FromFace( IEnumerable<Body> bodies, FaceRef reference, SketchPlane plane )
{
var result = new SketchReference();
if ( plane is null || !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )
return result;
var mesh = body.Mesh;
// Scaled to the part, for the same reason every other tolerance in the sketcher is: a
// constant that is generous on a 100-unit block silently merges every vertex of a 0.1-unit
// one. See SketchSnapper's header for what fixed tolerances did to this sketcher.
var tolerance = MathF.Max( mesh.BoundsDiagonal * 1e-4f, 1e-5f );
var surface = FaceSurface.FromFace( mesh, faceIndex );
var mapped = new Dictionary<int, int>();
foreach ( var (from, to) in surface.Boundary )
{
var a = Map( result, mapped, mesh, plane, tolerance, from );
var b = Map( result, mapped, mesh, plane, tolerance, to );
if ( a != b )
result.Edges.Add( (a, b) );
}
return result;
}
/// <summary>Mesh vertex to reference point, projected and de-duplicated. Two mesh vertices at
/// the same position — which a boolean leaves behind routinely — must become ONE snap target,
/// or the corner of the face has two dots on it and the cursor picks between them at
/// random.</summary>
static int Map( SketchReference result, Dictionary<int, int> mapped, PolyMesh mesh,
SketchPlane plane, float tolerance, int vertex )
{
if ( mapped.TryGetValue( vertex, out var existing ) )
return existing;
var p = plane.ToPlane( mesh.Positions[vertex] );
for ( var i = 0; i < result.Points.Count; i++ )
{
if ( (result.Points[i] - p).LengthSquared > tolerance * tolerance )
continue;
mapped[vertex] = i;
return i;
}
result.Points.Add( p );
mapped[vertex] = result.Points.Count - 1;
return result.Points.Count - 1;
}
}
Editor
library
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// A body from a skeleton — Blender's Skin modifier, aimed at a rig instead of a wire. Every
/// joint becomes a cube the size of its radius, every bone a tube between the two cubes it joins,
/// and a couple of levels of Catmull-Clark turn the result into a smooth, all-quad blob that is
/// already a closed solid with a clean flow along each limb. The block-out a character starts
/// from, in one click, on the bones it will be rigged to.
///
/// SIX BONES PER JOINT AT MOST: each takes one face of the joint's cube. A joint with more is
/// how a real skeleton never looks, so the seventh bone is refused rather than fudged.
/// </summary>
public static class SkinBlockout
{
/// <summary>
/// Build the body. <paramref name="radiusOf"/> gives each bone's thickness, or null for a
/// fifth of its length; <paramref name="smoothLevels"/> is how many times to subdivide the
/// blocky skin (0 keeps the cubes and tubes, which is what to edit by hand).
/// </summary>
public static PolyMesh Build( Skeleton skeleton, Func<int, float> radiusOf = null, int smoothLevels = 2 )
{
if ( skeleton is null || skeleton.Count == 0 )
throw new InvalidOperationException( "There are no bones to build a body on. Draw a rig first." );
radiusOf ??= i => MathF.Max( skeleton.Bones[i].Length * 0.2f, 0.01f );
// Joints: heads and tails, merged where a child's head sits on its parent's tail.
var nodes = new List<Vec3>();
var nodeRadius = new List<float>();
int NodeAt( Vec3 p, float r )
{
for ( var i = 0; i < nodes.Count; i++ )
{
if ( (nodes[i] - p).LengthSquared < 1e-4f )
{
nodeRadius[i] = MathF.Max( nodeRadius[i], r );
return i;
}
}
nodes.Add( p );
nodeRadius.Add( r );
return nodes.Count - 1;
}
var bones = new List<(int head, int tail)>();
for ( var b = 0; b < skeleton.Count; b++ )
{
var head = skeleton.HeadWorld( b );
var tail = skeleton.TailWorld( b );
if ( (tail - head).LengthSquared < 1e-6f )
continue;
var r = MathF.Max( radiusOf( b ), 0.01f );
bones.Add( (NodeAt( head, r ), NodeAt( tail, r )) );
}
if ( bones.Count == 0 )
throw new InvalidOperationException( "Every bone has no length, so there is nothing to build along." );
// A cube per joint: eight corners, six faces, each face free until a bone claims it.
var mesh = new PolyMesh();
var cubeCorner = new int[nodes.Count][];
var faceTaken = new bool[nodes.Count][];
for ( var n = 0; n < nodes.Count; n++ )
{
var c = nodes[n];
var r = nodeRadius[n];
cubeCorner[n] = new int[8];
for ( var k = 0; k < 8; k++ )
{
cubeCorner[n][k] = mesh.Positions.Count;
mesh.Positions.Add( c + new Vec3( (k & 1) == 0 ? -r : r, (k & 2) == 0 ? -r : r, (k & 4) == 0 ? -r : r ) );
}
faceTaken[n] = new bool[6];
}
// The six faces of a cube, wound outward, and the way each looks.
var faceNormals = new[] { new Vec3( 1, 0, 0 ), new Vec3( -1, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 0, -1, 0 ), new Vec3( 0, 0, 1 ), new Vec3( 0, 0, -1 ) };
var faceCorners = new[]
{
new[] { 1, 3, 7, 5 }, // +x
new[] { 0, 4, 6, 2 }, // -x
new[] { 2, 6, 7, 3 }, // +y
new[] { 0, 1, 5, 4 }, // -y
new[] { 4, 5, 7, 6 }, // +z
new[] { 0, 2, 3, 1 }, // -z
};
int Claim( int node, Vec3 direction )
{
var best = -1;
var bestDot = float.MinValue;
for ( var f = 0; f < 6; f++ )
{
if ( faceTaken[node][f] )
continue;
var d = Vec3.Dot( faceNormals[f], direction );
if ( d > bestDot )
{
bestDot = d;
best = f;
}
}
if ( best < 0 )
throw new InvalidOperationException( "A joint has more than six bones meeting at it, which is more than a body can be built round." );
faceTaken[node][best] = true;
return best;
}
// Tubes: each bone claims a face at each end and bridges them.
foreach ( var (head, tail) in bones )
{
var axis = (nodes[tail] - nodes[head]).Normal;
var fa = Claim( head, axis );
var fb = Claim( tail, axis * -1f );
var a = new int[4];
var b = new int[4];
for ( var i = 0; i < 4; i++ )
{
a[i] = cubeCorner[head][faceCorners[fa][i]];
b[i] = cubeCorner[tail][faceCorners[fb][3 - i]];
}
// Turn the far ring so its first corner is the one nearest the near ring's first,
// measured across the axis, so the tube does not twist.
var start = 0;
var bestD = float.MaxValue;
var pa = mesh.Positions[a[0]] - axis * Vec3.Dot( mesh.Positions[a[0]] - nodes[head], axis );
for ( var j = 0; j < 4; j++ )
{
var pb = mesh.Positions[b[j]] - axis * Vec3.Dot( mesh.Positions[b[j]] - nodes[head], axis );
var d = (pb - pa).LengthSquared;
if ( d < bestD )
{
bestD = d;
start = j;
}
}
for ( var i = 0; i < 4; i++ )
{
var i2 = (i + 1) % 4;
var j = (start + i) % 4;
var j2 = (start + i + 1) % 4;
mesh.AddFace( new[] { a[i], a[i2], b[j2], b[j] } );
}
}
// Whatever faces no bone claimed stay: they cap the ends of limbs and close the joints.
for ( var n = 0; n < nodes.Count; n++ )
{
for ( var f = 0; f < 6; f++ )
{
if ( faceTaken[n][f] )
continue;
var idx = new int[4];
for ( var i = 0; i < 4; i++ )
idx[i] = cubeCorner[n][faceCorners[f][i]];
mesh.AddFace( idx );
}
}
return smoothLevels > 0 ? CatmullClark.Subdivide( mesh, smoothLevels ) : mesh;
}
}
Editor
library
using Editor;
using System.Collections.Generic;
using Sandbox;
using System;
namespace Marionette.EditorTools;
/// <summary>Which drawing a feature-tool button paints. One per creation tool.</summary>
internal enum EffigyIcon
{
Sketch,
Plane,
Primitive,
Extrude,
Revolve,
Sweep,
Loft,
Chamfer,
Fillet,
Shell,
Subdivide,
// Subdivide's opposite number, and drawn as it: the same square, with the fine grid it is
// losing shown faint underneath the coarse one it keeps.
Remesh,
Mirror,
LinearPattern,
CircularPattern,
Transform,
UVProject,
FaceMaterial,
Boolean,
// --- sketch tools -------------------------------------------------------------------------
// These were Material Icon NAMES until now, and generic ones: show_chart (a zigzag line chart)
// for Line, cached (two refresh arrows) for Arc, crop_square for Rectangle. They said nothing
// about the operation, and half of them said something actively misleading.
SelectTool,
LineTool,
LineMidpointTool,
RectangleTool,
RectangleCentreTool,
CircleTool,
CircleThreePointTool,
ArcTool,
ArcThreePointTool,
PolygonTool,
PolygonCircumscribedTool,
SlotTool,
PointTool,
ConstructionTool,
ProfileInspectorTool,
FinishSketchTool,
// --- sculpt tools -------------------------------------------------------------------------
// The brushes are drawn as what they DO to a surface rather than as tool shapes: a row of six
// identical brush heads distinguished by a tiny badge is six ways to pick the wrong one. Every
// glyph here is a surface line and what the brush does to it.
Sculpt,
SculptDraw,
SculptSmooth,
SculptInflate,
SculptGrab,
SculptFlatten,
SculptPinch,
SculptMask,
SculptLevelDown,
SculptLevelUp,
SculptBake,
// --- paint tools ---------------------------------------------------------------------------
Paint,
PaintBrush,
PaintEraser,
// --- solid tools that act on picked faces ---------------------------------------------------
Draft,
MoveFace,
Hole,
// --- the six sketch tools whose kernel half was finished first ------------------------------
EllipseTool,
SplineTool,
TrimTool,
ExtendTool,
SketchFilletTool,
OffsetTool,
MirrorTool,
// --- taking the face's own outline into the sketch -------------------------------------------
UseTool,
UseAllTool,
// --- the one sketch tool driven by a drag ----------------------------------------------------
CutTool,
// --- grease pencil: annotation, not geometry -------------------------------------------------
// Both are drawn as the real-world objects rather than as marks, because that is the one thing
// that says "this is not a modelling operation" before the tooltip gets a chance to. Every other
// glyph in the bar is a shape being changed; these are stationery.
NoteTool,
NoteEraseTool,
// --- lighting: viewport scenery, not geometry ------------------------------------------------
// Drawn as lamps and as what a lamp DOES to a shape, for the same reason the notes above are
// drawn as stationery: nothing on this stage changes the model, and the glyphs should say so
// before the tooltip gets a chance to. The three rigs are the same sphere lit three ways, which
// is the only honest way to draw the difference between them.
LightFullBright,
LightPoint,
LightSpot,
LightSun,
LightRigThreePoint,
LightRigRim,
LightRigTop,
LightRigKey,
LightClear,
// --- rig ---
Bone,
BoneBind,
BoneSoft,
SoftPreview,
SoftRest,
// --- Model > Edit: direct polygon editing ------------------------------------------------------
// Drawn as a small quad cage with the thing being picked or changed lit, so the three pick
// modes read as "which part of the same cage" and every operation reads as what it does to it.
MeshEdit,
SelectVertex,
SelectEdge,
SelectFace,
Inset,
LoopCut,
Merge,
Dissolve,
DeleteGeometry,
Shrinkwrap,
Solidify,
Drape,
Fabric,
// --- UV: where the texture is cut and how it is laid out ------------------------------------
Seam,
Unwrap,
// --- Clothing: trim hung off a garment's edge ------------------------------------------------
// A scalloped edge: the wave a gathered strip takes. Drawn as the EDGE rather than as a frill in
// the round, because what the tool does is decorate a boundary, and the boundary is the part a
// person is choosing.
Frill,
// --- Clothing: the body being dressed -------------------------------------------------------
// A dressmaker's dummy, not a person and not a skeleton. The two things it has to say at a
// glance are "this is a whole body" and "the body is not the thing you are making" - a stand
// says both, which neither a figure nor a bone does.
Wearer,
}
/// <summary>
/// Painted icons for the feature-creation strip, drawn rather than looked up in a font.
///
/// Same reasoning as RigIconButton (see Editor/RigControlEditor): s&box ships CLASSIC Material
/// Icons, not the newer Material Symbols, so a name from the Symbols set silently renders as
/// nothing — and the strip was leaning on generic names like "square", "flip" and "call_made"
/// that, where they resolved at all, said nothing about the CAD operation behind them. A drawn
/// glyph can show the actual operation: Chamfer cuts a corner off a square, Shell puts a wall
/// inside one, Mirror reflects a solid shape into an outlined one.
///
/// Every icon is drawn around <c>center</c> inside a nominal 18x18 box, so they all read at the
/// same weight, then scaled up as one by the <c>scale</c> argument to fit the button drawing it.
/// </summary>
internal static class EffigyIcons
{
/// <summary>Stroke width every outline uses, so no icon looks heavier than its neighbours.</summary>
private const float Stroke = 1.6f;
// --- the pencil's own colours -------------------------------------------------------------
//
// The ONLY icon that does not draw entirely in the colour it is handed. Sketch is the tool
// every part starts with and the only button in the strip carrying a text label, so it is the
// one worth making findable at a glance rather than another grey glyph in a row of grey
// glyphs. A yellow #2 is about as legible as a small object gets.
//
// Chosen against a dark viewport: the graphite is a mid grey rather than near-black, because a
// true graphite point disappears into the background exactly where the icon needs to read.
private static readonly Color PencilBody = new( 0.96f, 0.76f, 0.15f );
private static readonly Color PencilFerrule = new( 0.74f, 0.77f, 0.80f );
private static readonly Color PencilEraser = new( 0.91f, 0.56f, 0.58f );
private static readonly Color PencilWood = new( 0.87f, 0.68f, 0.44f );
private static readonly Color PencilGraphite = new( 0.45f, 0.47f, 0.50f );
/// <summary>Multiplier applied to every coordinate, radius and pen width for the icon being
/// drawn right now. Every glyph is authored against the nominal 18x18 box, so one factor set
/// here at the top of Draw is enough to resize all of them together — the strip's buttons grew
/// past the size the glyphs were drawn for and a fixed-size glyph in a big button reads as a
/// mistake. Painting only ever happens on the editor UI thread, so a plain static is safe.</summary>
private static float _scale = 1f;
public static void Draw( EffigyIcon icon, Vector2 center, Color color, float scale = 1f )
{
Editor.Paint.Antialiasing = true;
_scale = scale;
switch ( icon )
{
case EffigyIcon.Sketch: PaintSketch( center, color ); return;
case EffigyIcon.Primitive: PaintPrimitive( center, color ); return;
case EffigyIcon.Extrude: PaintExtrude( center, color ); return;
case EffigyIcon.Revolve: PaintRevolve( center, color ); return;
case EffigyIcon.Sweep: PaintSweep( center, color ); return;
case EffigyIcon.Loft: PaintLoft( center, color ); return;
case EffigyIcon.Chamfer: PaintChamfer( center, color ); return;
case EffigyIcon.Fillet: PaintFillet( center, color ); return;
case EffigyIcon.Shell: PaintShell( center, color ); return;
case EffigyIcon.Subdivide: PaintSubdivide( center, color ); return;
case EffigyIcon.Remesh: PaintRemesh( center, color ); return;
case EffigyIcon.Mirror: PaintMirror( center, color ); return;
case EffigyIcon.LinearPattern: PaintLinearPattern( center, color ); return;
case EffigyIcon.CircularPattern: PaintCircularPattern( center, color ); return;
case EffigyIcon.Transform: PaintTransform( center, color ); return;
case EffigyIcon.UVProject: PaintUVProject( center, color ); return;
case EffigyIcon.FaceMaterial: PaintFaceMaterial( center, color ); return;
case EffigyIcon.Boolean: PaintBoolean( center, color ); return;
case EffigyIcon.Plane: PaintPlane( center, color ); return;
case EffigyIcon.SelectTool: PaintSelectTool( center, color ); return;
case EffigyIcon.LineTool: PaintLineTool( center, color ); return;
case EffigyIcon.LineMidpointTool: PaintLineMidpointTool( center, color ); return;
case EffigyIcon.RectangleTool: PaintRectangleTool( center, color ); return;
case EffigyIcon.RectangleCentreTool: PaintRectangleCentreTool( center, color ); return;
case EffigyIcon.CircleTool: PaintCircleTool( center, color ); return;
case EffigyIcon.CircleThreePointTool: PaintCircleThreePointTool( center, color ); return;
case EffigyIcon.ArcTool: PaintArcTool( center, color ); return;
case EffigyIcon.ArcThreePointTool: PaintArcThreePointTool( center, color ); return;
case EffigyIcon.PolygonTool: PaintPolygonTool( center, color ); return;
case EffigyIcon.PolygonCircumscribedTool: PaintPolygonCircumscribedTool( center, color ); return;
case EffigyIcon.SlotTool: PaintSlotTool( center, color ); return;
case EffigyIcon.PointTool: PaintPointTool( center, color ); return;
case EffigyIcon.ConstructionTool: PaintConstructionTool( center, color ); return;
case EffigyIcon.ProfileInspectorTool: PaintProfileInspectorTool( center, color ); return;
case EffigyIcon.FinishSketchTool: PaintFinishSketchTool( center, color ); return;
case EffigyIcon.Sculpt: PaintSculpt( center, color ); return;
case EffigyIcon.SculptDraw: PaintSculptDraw( center, color ); return;
case EffigyIcon.SculptSmooth: PaintSculptSmooth( center, color ); return;
case EffigyIcon.SculptInflate: PaintSculptInflate( center, color ); return;
case EffigyIcon.SculptGrab: PaintSculptGrab( center, color ); return;
case EffigyIcon.SculptFlatten: PaintSculptFlatten( center, color ); return;
case EffigyIcon.SculptPinch: PaintSculptPinch( center, color ); return;
case EffigyIcon.SculptMask: PaintSculptMask( center, color ); return;
case EffigyIcon.SculptLevelDown: PaintSculptLevelDown( center, color ); return;
case EffigyIcon.SculptLevelUp: PaintSculptLevelUp( center, color ); return;
case EffigyIcon.SculptBake: PaintSculptBake( center, color ); return;
case EffigyIcon.Paint: PaintPaint( center, color ); return;
case EffigyIcon.PaintBrush: PaintPaintBrush( center, color ); return;
case EffigyIcon.PaintEraser: PaintPaintEraser( center, color ); return;
case EffigyIcon.Draft: PaintDraft( center, color ); return;
case EffigyIcon.MoveFace: PaintMoveFace( center, color ); return;
case EffigyIcon.Hole: PaintHole( center, color ); return;
case EffigyIcon.NoteTool: PaintNoteTool( center, color ); return;
case EffigyIcon.NoteEraseTool: PaintNoteEraseTool( center, color ); return;
case EffigyIcon.LightFullBright: PaintLightFullBright( center, color ); return;
case EffigyIcon.LightPoint: PaintLightPoint( center, color ); return;
case EffigyIcon.LightSpot: PaintLightSpot( center, color ); return;
case EffigyIcon.LightSun: PaintLightSun( center, color ); return;
case EffigyIcon.LightRigThreePoint: PaintLightRigThreePoint( center, color ); return;
case EffigyIcon.LightRigRim: PaintLightRigRim( center, color ); return;
case EffigyIcon.LightRigTop: PaintLightRigTop( center, color ); return;
case EffigyIcon.LightRigKey: PaintLightRigKey( center, color ); return;
case EffigyIcon.LightClear: PaintLightClear( center, color ); return;
case EffigyIcon.EllipseTool: PaintEllipseTool( center, color ); return;
case EffigyIcon.SplineTool: PaintSplineTool( center, color ); return;
case EffigyIcon.TrimTool: PaintTrimTool( center, color ); return;
case EffigyIcon.ExtendTool: PaintExtendTool( center, color ); return;
case EffigyIcon.SketchFilletTool: PaintSketchFilletTool( center, color ); return;
case EffigyIcon.OffsetTool: PaintOffsetTool( center, color ); return;
case EffigyIcon.MirrorTool: PaintMirrorTool( center, color ); return;
case EffigyIcon.UseTool: PaintUseTool( center, color ); return;
case EffigyIcon.UseAllTool: PaintUseAllTool( center, color ); return;
case EffigyIcon.MeshEdit: PaintMeshEdit( center, color ); return;
case EffigyIcon.SelectVertex: PaintCage( center, color, lit: 1 ); return;
case EffigyIcon.SelectEdge: PaintCage( center, color, lit: 2 ); return;
case EffigyIcon.SelectFace: PaintCage( center, color, lit: 3 ); return;
case EffigyIcon.Inset: PaintInset( center, color ); return;
case EffigyIcon.LoopCut: PaintLoopCut( center, color ); return;
case EffigyIcon.Merge: PaintMerge( center, color ); return;
case EffigyIcon.Dissolve: PaintDissolve( center, color ); return;
case EffigyIcon.DeleteGeometry: PaintDeleteGeometry( center, color ); return;
case EffigyIcon.Shrinkwrap: PaintShrinkwrap( center, color ); return;
case EffigyIcon.Solidify: PaintSolidify( center, color ); return;
case EffigyIcon.Drape: PaintDrape( center, color ); return;
case EffigyIcon.Fabric: PaintFabric( center, color ); return;
case EffigyIcon.Seam: PaintSeam( center, color ); return;
case EffigyIcon.Unwrap: PaintUnwrap( center, color ); return;
case EffigyIcon.Wearer: PaintWearer( center, color ); return;
case EffigyIcon.Frill: PaintFrill( center, color ); return;
case EffigyIcon.CutTool: PaintCutTool( center, color ); return;
case EffigyIcon.Bone: PaintBone( center, color ); return;
case EffigyIcon.BoneBind: PaintBoneBind( center, color ); return;
case EffigyIcon.BoneSoft: PaintBoneSoft( center, color ); return;
case EffigyIcon.SoftPreview: PaintSoftPreview( center, color ); return;
case EffigyIcon.SoftRest: PaintSoftRest( center, color ); return;
}
}
// --- drawing helpers --------------------------------------------------------------------
private static void Stroked( Color color, float width = Stroke )
{
Editor.Paint.ClearBrush();
Editor.Paint.SetPen( color, width * _scale );
}
private static void Filled( Color color )
{
Editor.Paint.ClearPen();
Editor.Paint.SetBrush( color );
}
/// <summary>Closed outline through the given points — DrawPolygon fills, so an outlined shape
/// has to be walked as lines.</summary>
private static void Outline( params Vector2[] points )
{
for ( var i = 0; i < points.Length; i++ )
Editor.Paint.DrawLine( points[i], points[(i + 1) % points.Length] );
}
/// <summary>An arc as a polyline. There is no arc primitive in Paint, and approximating with
/// segments is exact enough at icon size.</summary>
private static void Arc( Vector2 center, float radius, float fromDegrees, float toDegrees, int segments = 14 )
{
var previous = Vector2.Zero;
for ( var i = 0; i <= segments; i++ )
{
var t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);
var radians = t * MathF.PI / 180f;
var point = center + new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale;
if ( i > 0 )
Editor.Paint.DrawLine( previous, point );
previous = point;
}
}
/// <summary>An elliptical arc as a polyline. Arc() draws a circle and cannot say "this circle
/// is lying flat"; that foreshortening is the entire difference between a glyph that reads as
/// a rotation about an axis and one that reads as a spiral.</summary>
private static void EllipseArc( Vector2 center, float radiusX, float radiusY,
float fromDegrees, float toDegrees, int segments = 24 )
{
var previous = Vector2.Zero;
for ( var i = 0; i <= segments; i++ )
{
var t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);
var radians = t * MathF.PI / 180f;
var point = center + new Vector2( MathF.Cos( radians ) * radiusX, MathF.Sin( radians ) * radiusY ) * _scale;
if ( i > 0 )
Editor.Paint.DrawLine( previous, point );
previous = point;
}
}
/// <summary>A solid triangular arrow head, pointing along <paramref name="direction"/>.</summary>
private static void ArrowHead( Vector2 tip, Vector2 direction, Color color, float size = 3.4f )
{
var d = direction.Normal;
var side = new Vector2( -d.y, d.x );
size *= _scale;
Filled( color );
Editor.Paint.DrawPolygon(
tip,
tip - d * size + side * size * 0.62f,
tip - d * size - side * size * 0.62f );
}
private static Vector2 At( Vector2 center, float x, float y ) => center + new Vector2( x, y ) * _scale;
/// <summary>A rect in the same nominal icon space At() uses, for the glyphs that need
/// DrawRect/DrawCircle rather than a walked outline.</summary>
private static Rect Box( Vector2 center, float x, float y, float width, float height )
=> new Rect( center.x + x * _scale, center.y + y * _scale, width * _scale, height * _scale );
// --- the icons --------------------------------------------------------------------------
/// <summary>
/// A pencil drawing on a sheet, its point resting ON the paper's top edge.
///
/// The pencil used to be a plain parallelogram - blunt at both ends, with one of its corners
/// landing on the paper line. A pencil reads as a pencil because of the cone at the end, and
/// the mark reads as DRAWING because that cone touches the paper rather than hovering above
/// it or crossing through it. So: a solid tapered point that ends exactly on the line, an
/// outlined barrel behind it, and a band where the ferrule would be.
///
/// Every coordinate is derived from the tip and the pencil's axis, laid out along a 45 degree
/// diagonal, so the point cannot drift off the paper if the proportions are adjusted.
///
/// The paper keeps the colour it is handed; the pencil does not (see PencilBody and friends).
/// </summary>
private static void PaintSketch( Vector2 c, Color color )
{
// Paper: a single flat horizontal line, matching the compact reference glyph. Everything
// else is placed against PaperY.
const float PaperY = 4.8f;
// How thick the pencil is, across the barrel.
const float BarrelWidth = 1.7f;
Stroked( color, Stroke );
Editor.Paint.DrawLine( At( c, -6.6f, PaperY ), At( c, 6.4f, PaperY ) );
// The sharpened cone, sitting on the paper. Solid, because at 27px a hollow cone is a
// smudge - the filled wedge is what makes it read as sharpened. Its base is exactly as
// wide as the barrel's stroke, so the two meet without a step.
Filled( PencilWood );
Editor.Paint.DrawPolygon(
At( c, -4.4f, PaperY ),
At( c, -1.678f, 3.28f ),
At( c, -2.88f, 2.078f ) );
// The exposed lead, the outer 40% of that cone. Drawn over the wood rather than beside it,
// so the two always agree about where the point is.
Filled( PencilGraphite );
Editor.Paint.DrawPolygon(
At( c, -4.4f, PaperY ),
At( c, -3.257f, 4.161f ),
At( c, -3.761f, 3.657f ) );
// Barrel: ONE STROKED LINE, not an outlined shape. A pencil this slim has a body 1.7 units
// across, and two outline strokes inside that merge into a blob - the line IS the barrel,
// and it is the only way to get a thin pencil that still reads at icon size. The ferrule
// and eraser are further stretches of the same line, which is also why they cannot drift
// out of alignment with it.
Stroked( PencilBody, BarrelWidth );
Editor.Paint.DrawLine( At( c, -2.279f, 2.679f ), At( c, 4.156f, -3.756f ) );
Stroked( PencilFerrule, BarrelWidth );
Editor.Paint.DrawLine( At( c, 4.156f, -3.756f ), At( c, 4.948f, -4.548f ) );
Stroked( PencilEraser, BarrelWidth );
Editor.Paint.DrawLine( At( c, 4.948f, -4.548f ), At( c, 5.853f, -5.453f ) );
}
/// <summary>An isometric cube — the generic "a solid body" mark.</summary>
private static void PaintPrimitive( Vector2 c, Color color )
{
Stroked( color );
Outline(
At( c, 0, -7.5f ), At( c, 7, -3.6f ), At( c, 7, 3.6f ),
At( c, 0, 7.5f ), At( c, -7, 3.6f ), At( c, -7, -3.6f ) );
// The three edges meeting at the near corner are what make it read as a cube rather than
// a hexagon.
Editor.Paint.DrawLine( At( c, 0, 0 ), At( c, 0, 7.5f ) );
Editor.Paint.DrawLine( At( c, 0, 0 ), At( c, 7, -3.6f ) );
Editor.Paint.DrawLine( At( c, 0, 0 ), At( c, -7, -3.6f ) );
}
/// <summary>
/// A profile lying flat, and the solid pulled UP off it.
///
/// The old glyph had the profile on top with the arrow pointing down, which reads as something
/// falling rather than as something being drawn out — and at toolbar size the whole thing came
/// out looking like a plumb bob. Arrow and profile now agree about which way an extrude goes.
/// </summary>
private static void PaintExtrude( Vector2 c, Color color )
{
// The sketch, in plan, dimmed: it is what the operation starts FROM, not what it makes.
Stroked( color.WithAlpha( 0.55f ), 1.5f );
Outline( At( c, -7.5f, 6.5f ), At( c, 0, 9.4f ), At( c, 7.5f, 6.5f ), At( c, 0, 3.6f ) );
Stroked( color, 2.6f );
Editor.Paint.DrawLine( At( c, 0, 6.5f ), At( c, 0, -4.2f ) );
ArrowHead( At( c, 0, -8.6f ), new Vector2( 0, -1 ), color, 4.2f );
}
/// <summary>
/// A sketch sitting on an axis, and the spin that turns it into a solid.
///
/// Extrude is a straight arrow off a profile. This is the same grammar bent into a C — axis,
/// profile, curved arrow — which is what every CAD tool draws for Revolve and what the last
/// version threw out. That version drew a vase in section and hoped the silhouette would
/// carry it; at toolbar size it was a lumpy outline with a dashed line through its face.
/// Fill the profile (same weight as Chamfer and Shell) and let the arrow be the operation.
/// </summary>
private static void PaintRevolve( Vector2 c, Color color )
{
// ONSHAPE'S OWN REVOLVE ICON: a disc with a wedge taken out of it. po pointed at it, and it
// is better than either drawing that came before.
//
// The first attempt stacked a dashed axis, a filled profile rectangle and a circular arrow
// on top of each other; the arc swept straight through the rectangle it was meant to be
// spinning and the three merged into a tall dark blob that read as the letter D. The
// second replaced it with a lathe - profile beside an axis, ellipse sweeping round it -
// which was legible but was three small shapes doing the work of one, and small shapes are
// what stop reading first when the strip is the only chrome on a 3D viewport.
//
// A disc with a mouth is ONE shape. It shows the result rather than the mechanism, which
// is the idiom the rest of this strip already uses - Chamfer cuts a corner off a square,
// Shell puts a wall inside one. The mouth is what makes it a revolve rather than a circle:
// the two cut faces meeting at the centre are the start and end of the sweep, and they say
// "this was swept through an angle" without needing an arrow to explain it. It is also
// square and solid, so it holds its weight next to Extrude and Loft instead of being the
// one thin glyph on the row.
const float Radius = 8.6f;
// How much of the disc is missing. Wide enough to read as a deliberate mouth at 32px
// rather than as a nick in the outline, narrow enough that the shape is still a disc.
const float MouthHalfAngle = 38f;
// The rim, from one cut face round to the other, then in to the centre. Closing the loop
// draws the second cut face, so the whole silhouette is one walk.
var rim = ArcPoints( c, Radius, MouthHalfAngle, 360f - MouthHalfAngle, 30 );
rim.Add( c );
var silhouette = rim.ToArray();
Filled( color.WithAlpha( 0.22f ) );
Editor.Paint.DrawPolygon( silhouette );
Stroked( color, 1.6f );
Outline( silhouette );
}
/// <summary>
/// A profile carried along a path, with the path drawn as the thing that shapes it.
///
/// Sweep and Extrude are the same sentence with a different verb — a profile, and where it goes
/// — so they are drawn with the same grammar: the starting profile dim because it is what the
/// operation begins FROM rather than what it makes, and an arrow for the operation itself. The
/// difference between them is the whole point, so here the path is a curve and the solid
/// follows it instead of standing straight up.
/// </summary>
private static void PaintSweep( Vector2 c, Color color )
{
// Hub, radius and extent of the path. Everything else is derived from these, so the glyph
// stays consistent with itself if the arc is retuned.
const float HubX = -7f;
const float HubY = -7.5f;
const float Radius = 12f;
// Half the profile's width, so the band either side of the path IS the solid.
const float Half = 2.6f;
const float From = 0f;
const float To = 90f;
const float ArrowAt = To + 10f;
var hub = At( c, HubX, HubY );
// The swept solid: the path offset either side of itself. Faint rather than outlined at
// full weight, so the path stays the strongest line in the glyph.
Stroked( color.WithAlpha( 0.32f ), 2f );
Arc( hub, Radius - Half, From, To, 18 );
Arc( hub, Radius + Half, From, To, 18 );
Stroked( color, 1.7f );
Arc( hub, Radius, From, ArrowAt, 20 );
// Where it starts, and where it arrives.
SweepStation( hub, Radius, Half, From, color.WithAlpha( 0.55f ), 1.4f );
SweepStation( hub, Radius, Half, To, color, 1.7f );
var end = ArrowAt * MathF.PI / 180f;
var tip = hub + new Vector2( MathF.Cos( end ), MathF.Sin( end ) ) * Radius * _scale;
ArrowHead( tip, new Vector2( -MathF.Sin( end ), MathF.Cos( end ) ), color, 3.6f );
}
/// <summary>The profile at one station of a sweep: a diamond spanning the swept band, drawn
/// ACROSS the path rather than lying flat, because a sweep takes its profile perpendicular to
/// where it is going — see SweepFeature.</summary>
private static void SweepStation( Vector2 hub, float radius, float half, float degrees, Color color, float width )
{
var angle = degrees * MathF.PI / 180f;
var radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
var tangent = new Vector2( -radial.y, radial.x );
var centre = hub + radial * radius * _scale;
Stroked( color, width );
Outline(
centre + radial * half * _scale,
centre + tangent * half * 0.62f * _scale,
centre - radial * half * _scale,
centre - tangent * half * 0.62f * _scale );
}
/// <summary>
/// Two sections, and the skin ruled between them.
///
/// The sections are drawn as flat diamonds — the same plan-view profile Extrude and Sweep use,
/// so a closed sketch reads the same way everywhere on this strip — one small and one large, so
/// what lies between them has to be a loft rather than an extrusion. The sides are STRAIGHT,
/// which is what the kernel actually does: neighbouring sections joined by a ruled surface, not
/// a spline smoothly through them.
/// </summary>
private static void PaintLoft( Vector2 c, Color color )
{
const float TopY = -7f;
const float TopHalf = 3.4f;
const float BottomY = 6.6f;
const float BottomHalf = 7.6f;
// The skin, as a tint between the two sections — same weight as Chamfer and Shell, so it
// reads as material rather than as two more lines.
Filled( color.WithAlpha( 0.22f ) );
Editor.Paint.DrawPolygon(
At( c, -TopHalf, TopY ), At( c, TopHalf, TopY ),
At( c, BottomHalf, BottomY ), At( c, -BottomHalf, BottomY ) );
Stroked( color, 1.7f );
Editor.Paint.DrawLine( At( c, -TopHalf, TopY ), At( c, -BottomHalf, BottomY ) );
Editor.Paint.DrawLine( At( c, TopHalf, TopY ), At( c, BottomHalf, BottomY ) );
LoftSection( c, TopY, TopHalf, TopHalf * 0.46f, color );
LoftSection( c, BottomY, BottomHalf, BottomHalf * 0.32f, color );
}
/// <summary>One loft section, in plan: a diamond as wide as the section and shallow enough to
/// read as lying flat, rather than as the top and bottom edges of a trapezium.</summary>
private static void LoftSection( Vector2 c, float y, float half, float depth, Color color )
{
Stroked( color, 1.5f );
Outline( At( c, -half, y ), At( c, 0, y - depth ), At( c, half, y ), At( c, 0, y + depth ) );
}
/// <summary>
/// A solid block with its corner cut away, the cut face called out.
///
/// The old glyph was an outlined square with a small nick in one corner, which at toolbar size
/// is a page icon and nothing else. Two changes fix it: fill the body, so it reads as a solid
/// rather than as a sheet, and cut deep enough that the chamfer is a face rather than a nick.
/// The faint lines show the corner that was removed.
/// </summary>
private static void PaintChamfer( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.22f ) );
Editor.Paint.DrawPolygon(
At( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );
Stroked( color, 1.5f );
Outline( At( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );
// The cut face, in the same amber the sketch pencil draws with — the accent on this strip
// means "the thing this operation did".
Stroked( ClickColor, 2.8f );
Editor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -1.5f, -7 ) );
Stroked( color.WithAlpha( 0.3f ), 1f );
Editor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );
Editor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );
}
/// <summary>
/// The chamfer's twin, and deliberately so: the same solid, the same corner gone, the same
/// ghost of the corner that was removed — the ONLY difference is that the accent is an arc
/// instead of a straight line.
///
/// That is the whole point. These two sit next to each other on the strip and the thing a
/// person needs to tell apart at 40px is round versus flat, which a shared body makes obvious
/// and two unrelated drawings would bury.
/// </summary>
private static void PaintFillet( Vector2 c, Color color )
{
// The arc's centre is the inner corner of the cut, so it runs from (-7,-1.5) to (-1.5,-7)
// exactly where the chamfer's straight cut does.
var arc = ArcPoints( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );
var body = new List<Vector2>( arc );
body.Add( At( c, 7, -7 ) );
body.Add( At( c, 7, 7 ) );
body.Add( At( c, -7, 7 ) );
Filled( color.WithAlpha( 0.22f ) );
Editor.Paint.DrawPolygon( body.ToArray() );
Stroked( color, 1.5f );
Outline( body.ToArray() );
Stroked( ClickColor, 2.8f );
Arc( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );
Stroked( color.WithAlpha( 0.3f ), 1f );
Editor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );
Editor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );
}
/// <summary>The points Arc walks, for a glyph that needs the arc as part of a filled outline
/// rather than as a stroke. Same maths, so the fill and the stroke cannot drift apart.</summary>
private static List<Vector2> ArcPoints( Vector2 center, float radius,
float fromDegrees, float toDegrees, int segments )
{
var points = new List<Vector2>( segments + 1 );
for ( var i = 0; i <= segments; i++ )
{
var t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);
var radians = t * MathF.PI / 180f;
points.Add( center + new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale );
}
return points;
}
/// <summary>
/// A hollowed solid in section: material on three sides, opening at the top.
///
/// A square inside a square is a frame, a border, a picture — it was never going to say
/// "hollowed to a wall thickness". THE WALL IS THE OBJECT, so the wall is what gets filled and
/// the void is what gets left out, which is how a section drawing says it.
/// </summary>
/// <summary>
/// Two overlapping squares with the shared region solid — a boolean, drawn as what it operates
/// on rather than as which of the three it is.
///
/// ONE GLYPH FOR ALL THREE OPERATIONS, because the button carries a dropdown and the variant
/// chosen is named on it. A Venn lens is the universal mark for this and reads at twenty-four
/// pixels; three near-identical lenses differing only in which part is filled do not, and the
/// one thing worse than an icon you have to think about is three you have to tell apart.
///
/// SQUARES RATHER THAN CIRCLES so the overlap is exact: the intersection of two axis-aligned
/// rectangles is a rectangle, which DrawRect can fill honestly. Two circles would need the lens
/// approximated by a polygon, and at this size the approximation is what you would see.
/// </summary>
private static void PaintBoolean( Vector2 c, Color color )
{
// The shared volume first, so the outlines drawn over it keep their edges crisp.
Filled( color.WithAlpha( 0.9f ) );
Editor.Paint.DrawRect( Box( c, -2f, -3f, 4f, 6f ), 0.8f * _scale );
Stroked( color.WithAlpha( 0.85f ), 1.2f );
Editor.Paint.DrawRect( Box( c, -7f, -6f, 9f, 9f ), 1.4f * _scale );
Editor.Paint.DrawRect( Box( c, -2f, -3f, 9f, 9f ), 1.4f * _scale );
}
private static void PaintShell( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.9f ) );
Editor.Paint.DrawPolygon(
At( c, -7.8f, -7f ), At( c, -3.6f, -7f ), At( c, -3.6f, 3.2f ),
At( c, 3.6f, 3.2f ), At( c, 3.6f, -7f ), At( c, 7.8f, -7f ),
At( c, 7.8f, 7.4f ), At( c, -7.8f, 7.4f ) );
// The opening, as a faint lid line, so the U reads as a container rather than as a letter.
Stroked( color.WithAlpha( 0.45f ), 1.1f );
Editor.Paint.DrawLine( At( c, -3.6f, -7f ), At( c, 3.6f, -7f ) );
}
/// <summary>
/// A quad split into four, with one of those four split again — subdivision, drawn literally.
///
/// The old glyph was a rounded square with a cross and a dot in the middle, which is the
/// universal "add" icon and was read as one. Showing one quadrant DENSER than its neighbours is
/// what the operation actually does, and the density is carried by a tint as well as by lines so
/// it survives being small — at twenty-four pixels a 4x4 of hairlines is a grey smear.
/// </summary>
private static void PaintSubdivide( Vector2 c, Color color )
{
Stroked( color, 1.6f );
Outline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );
Filled( color.WithAlpha( 0.3f ) );
Editor.Paint.DrawPolygon( At( c, -8, -8 ), At( c, 0, -8 ), At( c, 0, 0 ), At( c, -8, 0 ) );
Stroked( color.WithAlpha( 0.9f ), 1.5f );
Editor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 8 ) );
Editor.Paint.DrawLine( At( c, -8, 0 ), At( c, 8, 0 ) );
Stroked( color.WithAlpha( 0.85f ), 1.2f );
Editor.Paint.DrawLine( At( c, -4, -8 ), At( c, -4, 0 ) );
Editor.Paint.DrawLine( At( c, -8, -4 ), At( c, 0, -4 ) );
}
/// <summary>
/// The Subdivide glyph read backwards: a faint fine grid, and a bold coarse one over it.
///
/// DELIBERATELY THE SAME SQUARE. The two operations are the same axis in opposite directions,
/// and a button that looked unrelated to Subdivide would hide that — they sit next to each other
/// on two bars. What tells them apart is which grid is emphasised: Subdivide draws the fine
/// lines solid because those are the ones it is adding, and this draws them faint because those
/// are the ones going away.
/// </summary>
private static void PaintRemesh( Vector2 c, Color color )
{
Stroked( color, 1.6f );
Outline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );
// What is being lost.
Stroked( color.WithAlpha( 0.28f ), 1f );
for ( var i = -4f; i < 8f; i += 4f )
{
Editor.Paint.DrawLine( At( c, i, -8 ), At( c, i, 8 ) );
Editor.Paint.DrawLine( At( c, -8, i ), At( c, 8, i ) );
}
// What is left: one diagonal across the whole square, so it reads as two big triangles
// rather than as a coarser grid of the same thing.
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -8, 8 ), At( c, 8, -8 ) );
}
/// <summary>A solid shape and its reflection across a dashed mirror line.</summary>
private static void PaintMirror( Vector2 c, Color color )
{
// Mirror plane, dashed.
Stroked( color.WithAlpha( 0.5f ), 1.2f );
for ( var y = -8f; y < 8f; y += 3.6f )
Editor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 2.1f ) );
// Source: solid.
Filled( color );
Editor.Paint.DrawPolygon( At( c, -2.4f, -5.6f ), At( c, -8, 0 ), At( c, -2.4f, 5.6f ) );
// Reflection: outlined, so the two are not mistaken for a pattern.
Stroked( color );
Outline( At( c, 2.4f, -5.6f ), At( c, 8, 0 ), At( c, 2.4f, 5.6f ) );
}
/// <summary>
/// A plane placed clear of the one it came from: a faint parallelogram below, a solid one above,
/// and the gap between them called out.
///
/// TWO OF THEM, NOT ONE. A single parallelogram is what "face" or "surface" looks like in every
/// icon set there is, and this tool is not about a plane — it is about a plane's RELATIONSHIP to
/// something else. The pair says offset without a caption, and the arrow between them says which
/// of the two is the new one.
/// </summary>
private static void PaintPlane( Vector2 c, Color color )
{
// The reference: faint, because it is what you already had.
Stroked( color.WithAlpha( 0.4f ) );
Outline( Parallelogram( c, 3.9f ) );
// The new plane, filled just enough to read as a surface rather than as a wire loop.
Filled( color.WithAlpha( 0.18f ) );
Editor.Paint.DrawPolygon( Parallelogram( c, -3.9f ) );
Stroked( color );
Outline( Parallelogram( c, -3.9f ) );
// The offset itself, pointing at the plane that was made rather than the one it came from.
Stroked( color.WithAlpha( 0.75f ), 1.2f );
Editor.Paint.DrawLine( At( c, 0, 3.2f ), At( c, 0, -2.2f ) );
ArrowHead( At( c, 0, -3.2f ), new Vector2( 0, -1 ), color.WithAlpha( 0.75f ), 2.6f );
}
/// <summary>A plane in three-quarter view, centred on <paramref name="y"/>. Shared by the two
/// halves of the Plane glyph so they cannot drift into different shapes.</summary>
private static Vector2[] Parallelogram( Vector2 c, float y )
{
const float Half = 5.4f;
const float SkewX = 2.5f;
const float SkewY = 2.2f;
return new[]
{
At( c, -Half + SkewX, y - SkewY ),
At( c, Half + SkewX, y - SkewY ),
At( c, Half - SkewX, y + SkewY ),
At( c, -Half - SkewX, y + SkewY ),
};
}
/// <summary>One body copied along a direction — first solid, copies outlined and fading.</summary>
private static void PaintLinearPattern( Vector2 c, Color color )
{
// TWO FAULTS, AND THE SECOND IS THE ONE THAT MATTERED. Three 6-unit squares starting at
// -8.4 ran to +12, so the glyph was 20.4 units wide in a box of 18 - and, worse, its centre
// of mass sat 1.8 units RIGHT of the button's centre, because the run was never balanced
// about it. An off-centre glyph in a row of centred ones is visible long before an
// oversized one is, and neither is visible while looking at this icon on its own.
//
// Three 5.2 squares with 1.2 between them is 18 exactly, laid out symmetrically about c.
const float Square = 5.2f;
const float Gap = 1.2f;
const float First = -9f;
Filled( color );
Editor.Paint.DrawRect( Box( c, First, -Square / 2f, Square, Square ), 1.2f * _scale );
Stroked( color.WithAlpha( 0.8f ) );
Editor.Paint.DrawRect( Box( c, First + Square + Gap, -Square / 2f, Square, Square ), 1.2f * _scale );
Stroked( color.WithAlpha( 0.45f ) );
Editor.Paint.DrawRect( Box( c, First + 2f * (Square + Gap), -Square / 2f, Square, Square ), 1.2f * _scale );
}
/// <summary>
/// Copies stepped around an axis, on a ring that can actually be seen.
///
/// The old glyph drew the ring as twelve dashes, and at toolbar size twelve dashes are a faint
/// smudge — which left three small squares floating with nothing to explain them. A solid thin
/// ring and a dot at the centre cost less ink and say more, and the copies are smaller than they
/// were so they sit ON the ring instead of swallowing it.
/// </summary>
private static void PaintCircularPattern( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.7f ), 1.4f );
Arc( c, 6.6f, 0f, 360f, 40 );
Filled( color.WithAlpha( 0.8f ) );
Editor.Paint.DrawRect( Box( c, -1.3f, -1.3f, 2.6f, 2.6f ), 1.3f * _scale );
var angles = new[] { -90f, 30f, 150f };
for ( var i = 0; i < angles.Length; i++ )
{
var radians = angles[i] * MathF.PI / 180f;
var box = Box( c,
MathF.Cos( radians ) * 6.6f - 2.5f,
MathF.Sin( radians ) * 6.6f - 2.5f, 5f, 5f );
// One filled, the rest outlined — the same "this is the original, these are the copies"
// grammar the linear pattern uses, so the pair read as a family.
if ( i == 0 )
{
Filled( color );
Editor.Paint.DrawRect( box, 1f * _scale );
}
else
{
Stroked( color.WithAlpha( 0.9f ), 1.5f );
Editor.Paint.DrawRect( box, 1f * _scale );
}
}
}
/// <summary>Move/rotate/scale — a body with four-way translation arrows through it.</summary>
private static void PaintTransform( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.6f ) );
Outline( At( c, -4, -4 ), At( c, 4, -4 ), At( c, 4, 4 ), At( c, -4, 4 ) );
Stroked( color, 1.4f );
Editor.Paint.DrawLine( At( c, 0, -5.4f ), At( c, 0, 5.4f ) );
Editor.Paint.DrawLine( At( c, -5.4f, 0 ), At( c, 5.4f, 0 ) );
ArrowHead( At( c, 0, -8.2f ), new Vector2( 0, -1 ), color, 3f );
ArrowHead( At( c, 0, 8.2f ), new Vector2( 0, 1 ), color, 3f );
ArrowHead( At( c, -8.2f, 0 ), new Vector2( -1, 0 ), color, 3f );
ArrowHead( At( c, 8.2f, 0 ), new Vector2( 1, 0 ), color, 3f );
}
/// <summary>A UV grid with one texel lit, and the projection arriving from off-surface.</summary>
private static void PaintUVProject( Vector2 c, Color color )
{
Stroked( color );
Outline( At( c, -7, -2 ), At( c, 7, -2 ), At( c, 7, 8 ), At( c, -7, 8 ) );
Stroked( color.WithAlpha( 0.6f ), 1.1f );
Editor.Paint.DrawLine( At( c, -2.4f, -2 ), At( c, -2.4f, 8 ) );
Editor.Paint.DrawLine( At( c, 2.4f, -2 ), At( c, 2.4f, 8 ) );
Editor.Paint.DrawLine( At( c, -7, 3 ), At( c, 7, 3 ) );
// One lit texel, so the grid reads as a texture rather than a wireframe.
Filled( color.WithAlpha( 0.8f ) );
Editor.Paint.DrawRect( Box( c, -6.3f, -1.3f, 3.8f, 3.6f ), 0.6f * _scale );
// The projection coming down onto it.
Stroked( color, 1.4f );
Editor.Paint.DrawLine( At( c, 0, -8.4f ), At( c, 0, -4.4f ) );
ArrowHead( At( c, 0, -2.6f ), new Vector2( 0, 1 ), color, 3f );
}
/// <summary>A cube with ONE of its three visible faces filled — the operation is "this face,
/// not that one", so what the glyph has to show is faces being told apart. A paint pot or a
/// swatch would say "material" without saying "per face", which is the whole distinction.</summary>
private static void PaintFaceMaterial( Vector2 c, Color color )
{
// Isometric cube: top rhombus, then the two visible side quads.
var top = At( c, 0, -8 );
var right = At( c, 8, -3.5f );
var bottom = At( c, 0, 1 );
var left = At( c, -8, -3.5f );
// The lit face, filled. DrawPolygon fills, which is exactly what is wanted here and is why
// the other faces are walked as lines instead.
Filled( color.WithAlpha( 0.85f ) );
Editor.Paint.DrawPolygon( top, right, bottom, left );
Stroked( color );
Outline( top, right, bottom, left );
// The two side faces, left plain so the filled top reads as the odd one out.
var lowLeft = At( c, -8, 5.5f );
var lowMid = At( c, 0, 10 );
var lowRight = At( c, 8, 5.5f );
Outline( left, bottom, lowMid, lowLeft );
Outline( bottom, right, lowRight, lowMid );
}
// --- sketch tools ---------------------------------------------------------------------------
//
// One rule for the whole row: SHOW THE SHAPE THE TOOL MAKES, AND SHOW HOW IT IS PLACED.
//
// The second half is what earns its keep. Every family behind a chevron draws the identical
// shape and differs only in which points you click — a corner rectangle and a centre rectangle
// are the same rectangle — so the shape alone cannot tell them apart. The shape is the body of
// the glyph and the click points are accent dots on it, which makes the pair legible side by
// side without either needing a label.
//
// The dots are annotation and must never outweigh the shape. They were half again this size to
// begin with, which looked right on a large preview and swallowed the geometry at the size these
// are actually seen at.
/// <summary>The colour of a click point. Deliberately the one warm accent in a monochrome row,
/// so "this is where you press" reads before anything else does.</summary>
private static readonly Color ClickColor = new( 1f, 0.77f, 0.24f, 1f );
/// <summary>An end that does not join up. Warm rather than red — this is information, not an
/// error, and a sketch mid-draw is full of them.</summary>
private static readonly Color LooseEndColor = new( 1f, 0.48f, 0.36f, 1f );
/// <summary>A guide line — a radius, a diagonal, a centre line. Something the tool uses to place
/// the shape rather than part of the shape itself.</summary>
private static Color GuideColor( Color color ) => color.WithAlpha( 0.35f );
/// <summary>A filled dot in the nominal icon space. DrawRect with a corner radius of half its
/// own size, since Paint has no circle of its own and this is exact.</summary>
private static void Dot( Vector2 center, float radius, Color color )
{
Filled( color );
Editor.Paint.DrawRect( Box( center, -radius, -radius, radius * 2f, radius * 2f ), radius * _scale );
}
private static void ClickDot( Vector2 p, float radius = 1.8f ) => Dot( p, radius, ClickColor );
/// <summary>The corners of a regular hexagon, for the two polygon tools.</summary>
private static Vector2[] Hexagon( Vector2 c, float radius, float rotationDegrees )
{
var points = new Vector2[6];
for ( var i = 0; i < 6; i++ )
{
var a = (rotationDegrees + i * 60f) * MathF.PI / 180f;
points[i] = At( c, MathF.Cos( a ) * radius, MathF.Sin( a ) * radius );
}
return points;
}
/// <summary>A cursor with a point caught under it. Select drags sketch POINTS, which is what the
/// dot says and a bare arrow would not.</summary>
private static void PaintSelectTool( Vector2 c, Color color )
{
Filled( color );
Editor.Paint.DrawPolygon(
At( c, -4, -8 ), At( c, -4, 4 ), At( c, -1, 1 ), At( c, 1.5f, 6 ),
At( c, 4, 5 ), At( c, 1.5f, 0.2f ), At( c, 5, -0.5f ) );
ClickDot( At( c, 5, 5 ), 2.2f );
}
private static void PaintLineTool( Vector2 c, Color color )
{
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );
ClickDot( At( c, -6.5f, 6 ) );
ClickDot( At( c, 6.5f, -6 ) );
}
/// <summary>The same line, marked at its MIDDLE - with the tick mark that means midpoint in every
/// CAD package there is. One click dot, because the second click is an end and the far one comes
/// for free.</summary>
private static void PaintLineMidpointTool( Vector2 c, Color color )
{
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );
// Across the line rather than along it, so it reads as a mark ON the line instead of a second
// shorter line beside it.
Stroked( GuideColor( color ), 1f );
Editor.Paint.DrawLine( At( c, -2.1f, -2.3f ), At( c, 2.1f, 2.3f ) );
ClickDot( c, 1.9f );
ClickDot( At( c, 6.5f, -6 ) );
}
/// <summary>Two opposite corners marked: click one, then the other.</summary>
private static void PaintRectangleTool( Vector2 c, Color color )
{
Stroked( color );
Outline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );
ClickDot( At( c, -6.5f, -5 ) );
ClickDot( At( c, 6.5f, 5 ) );
}
/// <summary>The same rectangle, marked at its CENTRE instead — with the half-diagonal it is
/// dragged out along.</summary>
private static void PaintRectangleCentreTool( Vector2 c, Color color )
{
Stroked( color );
Outline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );
Stroked( GuideColor( color ), 1f );
Editor.Paint.DrawLine( c, At( c, 6.5f, 5 ) );
ClickDot( c, 1.9f );
}
private static void PaintCircleTool( Vector2 c, Color color )
{
Stroked( color );
Arc( c, 6.2f, 0, 360, 28 );
Stroked( GuideColor( color ), 1f );
Editor.Paint.DrawLine( c, At( c, 6.2f, 0 ) );
ClickDot( c, 1.9f );
}
/// <summary>The same circle with three points ON the rim and no centre — which is precisely the
/// difference between the two ways of placing it.</summary>
private static void PaintCircleThreePointTool( Vector2 c, Color color )
{
Stroked( color );
Arc( c, 6.2f, 0, 360, 28 );
foreach ( var degrees in new[] { -90f, 30f, 150f } )
{
var a = degrees * MathF.PI / 180f;
ClickDot( At( c, MathF.Cos( a ) * 6.2f, MathF.Sin( a ) * 6.2f ) );
}
}
/// <summary>
/// An arc standing on its centre, with both radii drawn.
///
/// It was drawn small and off to one side first, and at the size these are actually used it read
/// as a tick mark rather than a curve. An arc has to span the box to look like an arc.
/// </summary>
private static void PaintArcTool( Vector2 c, Color color )
{
// RADIUS 9, NOT 10.5, AND THE HUB SITS AT 4.2 RATHER THAN 5.5. A radius of 10.5 put the
// guide rails 21 units apart inside a box this file says is 18, which made this the second
// widest glyph on the strip - and nothing about one icon in isolation shows that. Nine
// fills the width exactly, and dropping the hub centres the drawing's own height in the
// button instead of hanging it below the middle.
var hub = At( c, 0, 4.2f );
Stroked( color, 1.9f );
Arc( hub, 9f, 180, 360, 20 );
Stroked( GuideColor( color ), 1f );
Editor.Paint.DrawLine( hub, At( c, -9f, 4.2f ) );
Editor.Paint.DrawLine( hub, At( c, 9f, 4.2f ) );
ClickDot( hub, 1.9f );
}
/// <summary>The same arc with no centre at all, marked instead at both ends and the point it
/// passes through.</summary>
private static void PaintArcThreePointTool( Vector2 c, Color color )
{
// A SMALLER ARC THAN PaintArcTool's, ON PURPOSE. This was the widest glyph on the strip by
// a distance - 24.6 units against a nominal 18 - and the arc was not what made it so: the
// three dots are the whole point of the tool, they sit ON the arc's ends, and a 1.8 dot at
// x = 10.5 reaches 12.3. The dots are part of the drawing, so the arc gives up the room for
// them rather than the pair of icons disagreeing about how wide an arc glyph is: both now
// fill exactly 18 units, which is the measurement the eye actually compares.
var hub = At( c, 0, 4.2f );
Stroked( color, 1.9f );
Arc( hub, 7.2f, 180, 360, 20 );
ClickDot( At( c, -7.2f, 4.2f ) );
ClickDot( At( c, 0, -3f ) );
ClickDot( At( c, 7.2f, 4.2f ) );
}
/// <summary>
/// A polygon with its corners ON the circle.
///
/// The circle is drawn brighter than a guide normally would be, because where it sits relative
/// to the polygon IS the whole difference between this and the circumscribed version. Draw it at
/// guide strength and the two glyphs become the same hexagon.
/// </summary>
private static void PaintPolygonTool( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.6f ), 1.2f );
Arc( c, 7f, 0, 360, 28 );
Stroked( color, 1.7f );
Outline( Hexagon( c, 7f, -90f ) );
ClickDot( c, 1.7f );
}
/// <summary>Edges on the circle instead, so it sits visibly inside the polygon — the apothem is
/// 0.866 of the radius, a gap wide enough to read small.</summary>
private static void PaintPolygonCircumscribedTool( Vector2 c, Color color )
{
Stroked( color, 1.7f );
Outline( Hexagon( c, 7.6f, -90f ) );
Stroked( color.WithAlpha( 0.6f ), 1.2f );
Arc( c, 6.6f, 0, 360, 28 );
ClickDot( c, 1.7f );
}
/// <summary>A slot, with the centre line you actually click marked at both ends.</summary>
private static void PaintSlotTool( Vector2 c, Color color )
{
const float r = 4.6f;
Stroked( color );
Editor.Paint.DrawLine( At( c, -3, -r ), At( c, 3, -r ) );
Editor.Paint.DrawLine( At( c, -3, r ), At( c, 3, r ) );
Arc( At( c, 3, 0 ), r, -90, 90, 12 );
Arc( At( c, -3, 0 ), r, 90, 270, 12 );
Stroked( GuideColor( color ), 1f );
Editor.Paint.DrawLine( At( c, -3, 0 ), At( c, 3, 0 ) );
ClickDot( At( c, -3, 0 ) );
ClickDot( At( c, 3, 0 ) );
}
/// <summary>Crosshairs around a point. The gap at the centre is what stops it reading as a plus
/// sign.</summary>
private static void PaintPointTool( Vector2 c, Color color )
{
Stroked( color, 1.4f );
Editor.Paint.DrawLine( At( c, -7, 0 ), At( c, -2.5f, 0 ) );
Editor.Paint.DrawLine( At( c, 2.5f, 0 ), At( c, 7, 0 ) );
Editor.Paint.DrawLine( At( c, 0, -7 ), At( c, 0, -2.5f ) );
Editor.Paint.DrawLine( At( c, 0, 2.5f ), At( c, 0, 7 ) );
ClickDot( c, 2.2f );
}
/// <summary>A dashed line: geometry that guides and never becomes part of a profile. Dashed
/// because that is how construction geometry is drawn in the viewport, so the button and the
/// thing it makes look like each other.</summary>
private static void PaintConstructionTool( Vector2 c, Color color )
{
Stroked( color, 1.7f );
foreach ( var (from, to) in new[] { (0f, 0.22f), (0.39f, 0.61f), (0.78f, 1f) } )
{
Editor.Paint.DrawLine(
At( c, -7 + 14 * from, 6 - 12 * from ),
At( c, -7 + 14 * to, 6 - 12 * to ) );
}
}
/// <summary>
/// A shaded region with a gap in its outline, and the two loose ends called out.
///
/// This is exactly what the inspector shows: which regions closed, and where a chain did not.
/// Drawn first as a stub with a dot on it, which at the size these are seen reads as a box with
/// a speck in the corner and says nothing. The fill has to be solid enough to read as shading and
/// the gap has to be a real hole in the outline.
/// </summary>
private static void PaintProfileInspectorTool( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.41f ) );
Editor.Paint.DrawPolygon( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );
// Walked as an open polyline rather than an outline, because the gap is the point.
Stroked( color, 1.7f );
Editor.Paint.DrawLine( At( c, 6.5f, -1.6f ), At( c, 6.5f, -5 ) );
Editor.Paint.DrawLine( At( c, 6.5f, -5 ), At( c, -6.5f, -5 ) );
Editor.Paint.DrawLine( At( c, -6.5f, -5 ), At( c, -6.5f, 5 ) );
Editor.Paint.DrawLine( At( c, -6.5f, 5 ), At( c, 6.5f, 5 ) );
Editor.Paint.DrawLine( At( c, 6.5f, 5 ), At( c, 6.5f, 1.6f ) );
Dot( At( c, 6.5f, -1.6f ), 1.9f, LooseEndColor );
Dot( At( c, 6.5f, 1.6f ), 1.9f, LooseEndColor );
}
/// <summary>A plain tick. The one glyph in the row that must not be clever: it ends the mode,
/// and the confirm colour it is painted in already carries the meaning.</summary>
private static void PaintFinishSketchTool( Vector2 c, Color color )
{
Stroked( color, 2.2f );
Editor.Paint.DrawLine( At( c, -6, 0.5f ), At( c, -1.5f, 5 ) );
Editor.Paint.DrawLine( At( c, -1.5f, 5 ), At( c, 6.5f, -5 ) );
}
// --- sculpt tools ---------------------------------------------------------------------------
//
// EVERY ONE OF THESE IS A SURFACE AND WHAT HAPPENS TO IT. The obvious way to draw six brushes is
// six brush heads with a small badge each, which at 27px is six identical blobs. Drawing the
// EFFECT instead means the row can be read at a glance without learning it: a bump rising, a
// ripple flattening, a peak dragged sideways.
//
// The surface runs across the lower half so every glyph shares a baseline and the row reads as
// one family.
/// <summary>A surface with a bump pushed up out of it, and the brush's ring resting on the
/// bump. The feature-strip glyph, so it says "sculpting" rather than any one brush.</summary>
private static void PaintSculpt( Vector2 c, Color color )
{
Stroked( color, 1.7f );
SurfaceWithBump( c, 4.5f );
// The brush ring, seen at a slight angle so it reads as sitting ON the surface.
Stroked( color.WithAlpha( 0.75f ), 1.3f );
Arc( At( c, 0, -3.4f ), 5.6f, 0f, 360f, 20 );
}
/// <summary>A bump, and an arrow pushing outward from it: draw adds material along the normal.</summary>
private static void PaintSculptDraw( Vector2 c, Color color )
{
Stroked( color, 1.7f );
SurfaceWithBump( c, 4f );
Stroked( color, 1.4f );
Editor.Paint.DrawLine( At( c, 0, -1.5f ), At( c, 0, -7f ) );
ArrowHead( At( c, 0, -8f ), new Vector2( 0, -1 ), color );
}
/// <summary>A ripple above, the same surface calmed below. Smooth is the one brush whose whole
/// meaning is the difference between two lines.</summary>
private static void PaintSculptSmooth( Vector2 c, Color color )
{
// Rippled.
Stroked( color.WithAlpha( 0.85f ), 1.5f );
var previous = At( c, -8.5f, -4f );
for ( var i = 1; i <= 24; i++ )
{
var t = i / 24f;
var x = -8.5f + 17f * t;
var y = -4f + MathF.Sin( t * MathF.PI * 3f ) * 2.6f;
var point = At( c, x, y );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
// Calmed.
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, 8.5f, 5f ) );
Stroked( color.WithAlpha( 0.6f ), 1.2f );
Editor.Paint.DrawLine( At( c, 0, -0.5f ), At( c, 0, 2.2f ) );
ArrowHead( At( c, 0, 3.4f ), new Vector2( 0, 1 ), color.WithAlpha( 0.6f ), 2.8f );
}
/// <summary>A closed shape with arrows pushing out all round it — inflate acts everywhere at
/// once, which is what tells it apart from draw.</summary>
private static void PaintSculptInflate( Vector2 c, Color color )
{
Stroked( color, 1.7f );
Arc( c, 4.6f, 0f, 360f, 20 );
for ( var i = 0; i < 4; i++ )
{
var radians = (45f + i * 90f) * MathF.PI / 180f;
var dir = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );
Stroked( color.WithAlpha( 0.9f ), 1.3f );
Editor.Paint.DrawLine( c + dir * 5.8f * _scale, c + dir * 8f * _scale );
ArrowHead( c + dir * 9.2f * _scale, dir, color, 2.8f );
}
}
/// <summary>A surface dragged sideways into a lean, with the pull shown as an arrow. Grab moves
/// what it holds rather than adding to it, so nothing here points along the normal.</summary>
private static void PaintSculptGrab( Vector2 c, Color color )
{
Stroked( color, 1.7f );
// A peak that leans right, rather than a symmetric bump.
Editor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, -2.5f, 5f ) );
Editor.Paint.DrawLine( At( c, -2.5f, 5f ), At( c, 2.5f, -3f ) );
Editor.Paint.DrawLine( At( c, 2.5f, -3f ), At( c, 5.5f, 5f ) );
Editor.Paint.DrawLine( At( c, 5.5f, 5f ), At( c, 8.5f, 5f ) );
Stroked( color.WithAlpha( 0.9f ), 1.4f );
Editor.Paint.DrawLine( At( c, -2f, -6f ), At( c, 3.5f, -6f ) );
ArrowHead( At( c, 5f, -6f ), new Vector2( 1, 0 ), color );
}
/// <summary>A bump with a straight edge laid across it — flatten is a plane meeting a surface.
/// </summary>
private static void PaintSculptFlatten( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.65f ), 1.5f );
SurfaceWithBump( c, 5.5f );
// The plane it is being cut back to.
Stroked( color, 2f );
Editor.Paint.DrawLine( At( c, -8.5f, -2.5f ), At( c, 8.5f, -2.5f ) );
}
/// <summary>Two arrows squeezing towards one ridge. Pinch gathers a surface rather than moving
/// it, so both arrows point inward at the same line.</summary>
private static void PaintSculptPinch( Vector2 c, Color color )
{
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, 0, -7.5f ), At( c, 0, 7.5f ) );
Stroked( color.WithAlpha( 0.9f ), 1.4f );
Editor.Paint.DrawLine( At( c, -8f, 0 ), At( c, -3.5f, 0 ) );
ArrowHead( At( c, -2.2f, 0 ), new Vector2( 1, 0 ), color );
Editor.Paint.DrawLine( At( c, 8f, 0 ), At( c, 3.5f, 0 ) );
ArrowHead( At( c, 2.2f, 0 ), new Vector2( -1, 0 ), color );
}
/// <summary>A patch of the surface hatched off. Masking protects rather than shapes, so this is
/// the one sculpt glyph that is not a deformation.</summary>
private static void PaintSculptMask( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.85f ), 1.5f );
Outline( At( c, -8, -6.5f ), At( c, 8, -6.5f ), At( c, 8, 6.5f ), At( c, -8, 6.5f ) );
// Hatching, the universal "held back" texture.
Stroked( color.WithAlpha( 0.7f ), 1.2f );
for ( var x = -6f; x <= 8f; x += 3.4f )
{
var top = MathF.Max( x - 13f, -8f );
var bottom = MathF.Min( x, 8f );
Editor.Paint.DrawLine( At( c, bottom, -6.5f ), At( c, top, 6.5f ) );
}
}
/// <summary>A coarse grid with a chevron down: fewer, bigger faces.</summary>
private static void PaintSculptLevelDown( Vector2 c, Color color ) => PaintSculptLevel( c, color, 2, down: true );
/// <summary>A fine grid with a chevron up: four times the faces, which is the whole cost.
/// </summary>
private static void PaintSculptLevelUp( Vector2 c, Color color ) => PaintSculptLevel( c, color, 4, down: false );
private static void PaintSculptLevel( Vector2 c, Color color, int divisions, bool down )
{
const float Half = 6.5f;
Stroked( color.WithAlpha( 0.9f ), 1.4f );
Outline( At( c, -Half, -Half - 1.5f ), At( c, Half, -Half - 1.5f ),
At( c, Half, Half - 1.5f ), At( c, -Half, Half - 1.5f ) );
Stroked( color.WithAlpha( 0.65f ), 1f );
for ( var i = 1; i < divisions; i++ )
{
var t = -Half + i * (Half * 2f / divisions);
Editor.Paint.DrawLine( At( c, t, -Half - 1.5f ), At( c, t, Half - 1.5f ) );
Editor.Paint.DrawLine( At( c, -Half, t - 1.5f ), At( c, Half, t - 1.5f ) );
}
// The chevron, below the grid so the two never overlap at strip size.
Stroked( color, 1.8f );
if ( down )
{
Editor.Paint.DrawLine( At( c, -3.5f, 6f ), At( c, 0, 9f ) );
Editor.Paint.DrawLine( At( c, 0, 9f ), At( c, 3.5f, 6f ) );
}
else
{
Editor.Paint.DrawLine( At( c, -3.5f, 9f ), At( c, 0, 6f ) );
Editor.Paint.DrawLine( At( c, 0, 6f ), At( c, 3.5f, 9f ) );
}
}
/// <summary>A dense surface collapsing into a flat square: the sculpt becoming a texture, which
/// is the whole point of the pipeline and the one operation here that produces a file.</summary>
/// <summary>
/// A grease pencil laid over a wavy scribble.
///
/// NOT THE SKETCH PENCIL, which this sits two buttons away from and must not be mistaken for.
/// That one is a sharp #2 drawing a straight line and it makes geometry; this is a fat blunt
/// marker over a loose squiggle, and the squiggle is doing the work — a scribble is what
/// handwriting looks like at 18px, and nothing that produces a solid in this bar is drawn
/// scribbly.
/// </summary>
private static void PaintNoteTool( Vector2 c, Color color )
{
// The scribble first, so the marker sits on top of it the way a pen sits on its own line.
Stroked( color.WithAlpha( 0.75f ), 1.5f );
var previous = At( c, -8f, 5.5f );
for ( var i = 1; i <= 20; i++ )
{
var t = i / 20f;
var point = At( c, -8f + 13f * t, 5.5f + MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
// The barrel, drawn as a slab on the diagonal rather than the sketch pencil's thin shaft.
var tip = At( c, -5.5f, 1.5f );
var along = (At( c, 7f, -7.5f ) - tip).Normal;
var across = new Vector2( -along.y, along.x ) * (2.6f * _scale);
Stroked( color, 1.5f );
Outline(
tip + across * 0.35f,
At( c, 7f, -7.5f ) + across,
At( c, 8.5f, -8.5f ) + across,
At( c, 8.5f, -8.5f ) - across,
At( c, 7f, -7.5f ) - across,
tip - across * 0.35f );
// The nib, filled: the one part of a marker that is a different colour from the barrel, and
// what makes the shape read as pointing at the scribble rather than away from it.
Filled( color );
Editor.Paint.DrawPolygon( tip, At( c, -3.2f, -0.4f ) + across * 0.75f, At( c, -3.2f, -0.4f ) - across * 0.75f );
}
/// <summary>An eraser on the same scribble, taking a bite out of it. The gap in the line is the
/// whole glyph — an eraser drawn hovering over an intact scribble is just a second block
/// shape.</summary>
private static void PaintNoteEraseTool( Vector2 c, Color color )
{
// Left half of the scribble survives; the right half is where the eraser has been.
Stroked( color.WithAlpha( 0.75f ), 1.5f );
var previous = At( c, -8.5f, 5.5f );
for ( var i = 1; i <= 10; i++ )
{
var t = i / 20f;
var point = At( c, -8.5f + 13f * t, 5.5f + MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
// The block, tilted so it reads as being pushed along the line rather than parked on it.
var down = new Vector2( 0.34f, 0.94f );
var side = new Vector2( -down.y, down.x );
var centre = At( c, 3f, -1f );
var half = 4.6f * _scale;
var length = 5.6f * _scale;
Stroked( color, 1.5f );
Outline(
centre - down * length + side * half,
centre + down * length + side * half,
centre + down * length - side * half,
centre - down * length - side * half );
// The ferrule line across it, which is what separates an eraser from a plain rectangle.
Editor.Paint.DrawLine( centre + side * half, centre - side * half );
}
private static void PaintSculptBake( Vector2 c, Color color )
{
// The sculpted surface, up top.
Stroked( color.WithAlpha( 0.85f ), 1.5f );
var previous = At( c, -8.5f, -5f );
for ( var i = 1; i <= 20; i++ )
{
var t = i / 20f;
var x = -8.5f + 17f * t;
var y = -5f + MathF.Sin( t * MathF.PI * 2f ) * 2.2f;
var point = At( c, x, y );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
// Into the map.
Stroked( color.WithAlpha( 0.7f ), 1.2f );
Editor.Paint.DrawLine( At( c, 0, -1f ), At( c, 0, 1.6f ) );
ArrowHead( At( c, 0, 2.8f ), new Vector2( 0, 1 ), color.WithAlpha( 0.7f ), 2.8f );
Stroked( color, 1.6f );
Outline( At( c, -7, 4f ), At( c, 7, 4f ), At( c, 7, 9f ), At( c, -7, 9f ) );
Filled( color.WithAlpha( 0.3f ) );
Editor.Paint.DrawRect( Box( c, -7, 4f, 14f, 5f ) );
}
/// <summary>A paint brush: a handle, a ferrule, and a bristle tip dragging a stroke of colour
/// behind it. The stroke is the point — this is a tool that leaves marks, not one that changes
/// the surface's shape.</summary>
private static void PaintPaint( Vector2 c, Color color )
{
// The stroke it leaves, drawn first so it sits behind the brush.
Stroked( color.WithAlpha( 0.7f ), 2.4f );
Editor.Paint.DrawLine( At( c, 4.5f, 5f ), At( c, -1f, -6f ) );
// The handle, tilted.
Stroked( color, 2f );
Editor.Paint.DrawLine( At( c, -3f, 8f ), At( c, -1.5f, 3.5f ) );
// The ferrule.
Editor.Paint.DrawLine( At( c, -2f, 3.5f ), At( c, 2.5f, 1.2f ) );
// The bristle tip.
Filled( color );
Outline( At( c, 1.6f, 1f ), At( c, 3.4f, -2f ), At( c, 4.5f, -3.6f ), At( c, 6.4f, -1.8f ) );
}
/// <summary>The same brush as PaintPaint, with the ferrule separated by a hair so the stroke
/// reads as a wide band rather than a line.</summary>
private static void PaintPaintBrush( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.7f ), 3.2f );
Editor.Paint.DrawLine( At( c, 5f, 4f ), At( c, -1f, -7f ) );
Stroked( color, 2f );
Editor.Paint.DrawLine( At( c, -3f, 8f ), At( c, -1.5f, 3.5f ) );
Editor.Paint.DrawLine( At( c, -2f, 3.5f ), At( c, 2.5f, 1.2f ) );
Filled( color );
Outline( At( c, 1.6f, 1f ), At( c, 3.4f, -2f ), At( c, 4.5f, -3.6f ), At( c, 6.4f, -1.8f ) );
}
/// <summary>An eraser block, worn at one corner, with its band cut away. Drawn as stationery
/// rather than as a mark, for the same reason the notes are: it removes rather than adds.</summary>
private static void PaintPaintEraser( Vector2 c, Color color )
{
var half = 4.5f;
var length = 7f;
Stroked( color.WithAlpha( 0.7f ), 1.8f );
Editor.Paint.DrawLine( At( c, -6f, 3.5f ), At( c, 3f, -6f ) );
Stroked( color, 1.8f );
var topLeft = At( c, -half, -length );
var topRight = At( c, half, -length );
var bottomLeft = At( c, -half, length );
// Worn corner: the bottom-right corner is cut off.
Outline( topLeft, topRight, At( c, half, 2f ), At( c, 2f, length ), bottomLeft );
// The band.
Editor.Paint.DrawLine( At( c, -half, -3f ), At( c, half, -3f ) );
}
/// <summary>The shared baseline: a flat surface with one smooth bump in the middle of it. Every
/// brush glyph starts from this so the row reads as one family acting on one thing.</summary>
private static void SurfaceWithBump( Vector2 c, float height )
{
var previous = At( c, -8.5f, 5f );
for ( var i = 1; i <= 24; i++ )
{
var t = i / 24f;
var x = -8.5f + 17f * t;
// A raised cosine, flat at both ends so it meets the surface without a corner.
var bump = 0.5f * (1f + MathF.Cos( MathF.Max( MathF.Min( x / 5.5f, 1f ), -1f ) * MathF.PI ));
var point = At( c, x, 5f - bump * height );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
}
/// <summary>
/// A wall leaning off vertical, with the vertical it leans from left dashed beside it.
///
/// The angle IS the operation, so the glyph is the angle. Drawing a moulded part instead would
/// say "moulding" and leave you guessing which of the six tools on the strip does the leaning.
/// </summary>
/// <summary>
/// A face lifting off the solid it belongs to: the body drawn faintly where it was, the face
/// itself solid at its new height, and an arrow between them.
///
/// THE GHOST IS THE WHOLE GLYPH. Without it this is an arrow over a rectangle, which is what
/// Transform looks like; with it, the picture is of one face of a part having moved and the rest
/// having stayed — which is exactly what the tool does and what tells it apart from Draft
/// standing next to it.
/// </summary>
private static void PaintMoveFace( Vector2 c, Color color )
{
// Where the face was, and the walls that stretched to follow it.
Stroked( color.WithAlpha( 0.35f ), 1.1f );
Outline( At( c, -7f, 1f ), At( c, 7f, 1f ), At( c, 7f, 8f ), At( c, -7f, 8f ) );
// The face, at its new height.
Stroked( color, 1.7f );
Editor.Paint.DrawLine( At( c, -7f, -5f ), At( c, 7f, -5f ) );
// The sides it dragged up with it.
Stroked( color.WithAlpha( 0.75f ), 1.2f );
Editor.Paint.DrawLine( At( c, -7f, -5f ), At( c, -7f, 1f ) );
Editor.Paint.DrawLine( At( c, 7f, -5f ), At( c, 7f, 1f ) );
// Which way it went.
Stroked( color, 1.6f );
Editor.Paint.DrawLine( At( c, 0f, -1f ), At( c, 0f, -8f ) );
Editor.Paint.DrawLine( At( c, -3f, -5f ), At( c, 0f, -8f ) );
Editor.Paint.DrawLine( At( c, 3f, -5f ), At( c, 0f, -8f ) );
}
private static void PaintDraft( Vector2 c, Color color )
{
// The parting line the taper is measured from.
Stroked( color.WithAlpha( 0.45f ), 1.1f );
for ( var x = -9f; x < 9f; x += 3.4f )
Editor.Paint.DrawLine( At( c, x, 0 ), At( c, x + 2f, 0 ) );
// The tapered wall: narrow at the top, wide at the bottom, closed as an outline.
Stroked( color, 1.7f );
Outline( At( c, -3.4f, -8 ), At( c, 3.4f, -8 ), At( c, 6.6f, 8 ), At( c, -6.6f, 8 ) );
// The vertical it is leaning away from, so the lean reads as deliberate rather than as a
// wonky rectangle.
Stroked( color.WithAlpha( 0.5f ), 1f );
Editor.Paint.DrawLine( At( c, 3.4f, -8 ), At( c, 3.4f, 8 ) );
}
/// <summary>
/// A counterbore in section: a wide mouth stepping down to a narrow shaft, through a plate.
///
/// Drawn as a SECTION rather than as a circle on a surface, because a circle is what every other
/// round thing on this strip already looks like from above - and the step is the whole reason
/// this is a feature rather than a sketched circle.
/// </summary>
private static void PaintHole( Vector2 c, Color color )
{
// The plate, in section.
Stroked( color.WithAlpha( 0.8f ), 1.5f );
Editor.Paint.DrawLine( At( c, -9, -6 ), At( c, -3.2f, -6 ) );
Editor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 9, -6 ) );
Editor.Paint.DrawLine( At( c, -9, 7 ), At( c, 9, 7 ) );
Editor.Paint.DrawLine( At( c, -9, -6 ), At( c, -9, 7 ) );
Editor.Paint.DrawLine( At( c, 9, -6 ), At( c, 9, 7 ) );
// The bore: wide at the mouth, stepping in to the shaft.
Stroked( color, 1.7f );
Editor.Paint.DrawLine( At( c, -3.2f, -6 ), At( c, -3.2f, -1 ) );
Editor.Paint.DrawLine( At( c, -3.2f, -1 ), At( c, -1.4f, -1 ) );
Editor.Paint.DrawLine( At( c, -1.4f, -1 ), At( c, -1.4f, 7 ) );
Editor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 3.2f, -1 ) );
Editor.Paint.DrawLine( At( c, 3.2f, -1 ), At( c, 1.4f, -1 ) );
Editor.Paint.DrawLine( At( c, 1.4f, -1 ), At( c, 1.4f, 7 ) );
// The void itself, so the shape reads as absence rather than as a post.
Filled( color.WithAlpha( 0.18f ) );
Editor.Paint.DrawPolygon(
At( c, -3.2f, -6 ), At( c, 3.2f, -6 ), At( c, 3.2f, -1 ), At( c, 1.4f, -1 ),
At( c, 1.4f, 7 ), At( c, -1.4f, 7 ), At( c, -1.4f, -1 ), At( c, -3.2f, -1 ) );
}
// --- the six sketch tools -------------------------------------------------------------------
//
// The four EDIT tools all show the same thing: a curve, and what the tool does to it, with the
// part being removed or added drawn faintly. A trim that showed only the result would be
// indistinguishable from a plain line at 27 pixels.
/// <summary>An ellipse, with its long axis marked so it is not mistaken for a circle.</summary>
private static void PaintEllipseTool( Vector2 c, Color color )
{
Stroked( color, 1.7f );
var previous = Vector2.Zero;
for ( var i = 0; i <= 40; i++ )
{
var a = i / 40f * MathF.PI * 2f;
var point = At( c, MathF.Cos( a ) * 8.6f, MathF.Sin( a ) * 5f );
if ( i > 0 )
Editor.Paint.DrawLine( previous, point );
previous = point;
}
Stroked( color.WithAlpha( 0.5f ), 1.1f );
Editor.Paint.DrawLine( At( c, -8.6f, 0 ), At( c, 8.6f, 0 ) );
}
/// <summary>A curve through its control points, which is what a spline IS - the points are the
/// thing you place and the curve is what follows.</summary>
private static void PaintSplineTool( Vector2 c, Color color )
{
Stroked( color, 1.7f );
var previous = Vector2.Zero;
// A cubic-ish wiggle through the three dots below.
for ( var i = 0; i <= 32; i++ )
{
var t = i / 32f;
var x = -7.5f + 15f * t;
var y = MathF.Sin( t * MathF.PI * 1.6f + 0.4f ) * 5.2f - 1f;
var point = At( c, x, y );
if ( i > 0 )
Editor.Paint.DrawLine( previous, point );
previous = point;
}
Filled( color );
// 15 UNITS OF CURVE, NOT 17. The end dots are centred on the curve's own ends and are 3
// units across, so a curve spanning 17 drew a glyph spanning 20 in a box of 18. Fifteen
// plus the two half-dots is exactly 18. The dot positions are recomputed from the same
// expression as the curve rather than being written out, so the two cannot drift apart.
foreach ( var t in new[] { 0f, 0.5f, 1f } )
{
var x = -7.5f + 15f * t;
var y = MathF.Sin( t * MathF.PI * 1.6f + 0.4f ) * 5.2f - 1f;
Editor.Paint.DrawRect( Box( c, x - 1.5f, y - 1.5f, 3f, 3f ), 1.5f * _scale );
}
}
/// <summary>Two crossing lines with the stub past the crossing drawn faintly - the piece that
/// goes. Trim is defined by what it removes, so that is what the glyph shows.</summary>
private static void PaintTrimTool( Vector2 c, Color color )
{
// The cutting line.
Stroked( color.WithAlpha( 0.55f ), 1.3f );
Editor.Paint.DrawLine( At( c, 2.5f, -8.5f ), At( c, 2.5f, 8.5f ) );
// The part that stays.
Stroked( color, 1.9f );
Editor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, 2.5f, 0f ) );
// The part that goes, dashed.
Stroked( color.WithAlpha( 0.35f ), 1.5f );
for ( var t = 0f; t < 1f; t += 0.28f )
{
var a = At( c, 2.5f + 6f * t, -1.6f * t );
var b = At( c, 2.5f + 6f * (t + 0.16f), -1.6f * (t + 0.16f) );
Editor.Paint.DrawLine( a, b );
}
}
/// <summary>A line reaching a boundary, with the new length drawn faintly and an arrow head -
/// the mirror of Trim, and drawn as its mirror so the pair reads as a pair.</summary>
private static void PaintExtendTool( Vector2 c, Color color )
{
// The boundary it reaches to.
Stroked( color.WithAlpha( 0.55f ), 1.3f );
Editor.Paint.DrawLine( At( c, 6.5f, -8.5f ), At( c, 6.5f, 8.5f ) );
// What is there now.
Stroked( color, 1.9f );
Editor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, -1f, 1f ) );
// Where it is going.
Stroked( color.WithAlpha( 0.4f ), 1.4f );
Editor.Paint.DrawLine( At( c, -1f, 1f ), At( c, 5f, -0.6f ) );
ArrowHead( At( c, 6.4f, -1f ), new Vector2( 1f, -0.26f ), color.WithAlpha( 0.75f ), 3f );
}
/// <summary>A rounded corner with the square one it replaces dashed behind it.</summary>
private static void PaintSketchFilletTool( Vector2 c, Color color )
{
// The corner that was.
Stroked( color.WithAlpha( 0.35f ), 1.3f );
Editor.Paint.DrawLine( At( c, -8, -7 ), At( c, 7, -7 ) );
Editor.Paint.DrawLine( At( c, 7, -7 ), At( c, 7, 8 ) );
// The corner that is.
Stroked( color, 1.9f );
Editor.Paint.DrawLine( At( c, -8, -7 ), At( c, 0f, -7 ) );
Arc( At( c, 0f, 0f ), 7f, -90f, 0f, 12 );
Editor.Paint.DrawLine( At( c, 7, 0f ), At( c, 7, 8 ) );
}
/// <summary>A shape and a second one running parallel outside it, which is the whole of what an
/// offset is - the same curve, held away at a distance.</summary>
private static void PaintOffsetTool( Vector2 c, Color color )
{
// The original.
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -6, 6 ), At( c, -6, -2 ) );
Arc( At( c, -1.5f, -2f ), 4.5f, 180f, 270f, 10 );
Editor.Paint.DrawLine( At( c, -1.5f, -6.5f ), At( c, 5, -6.5f ) );
// Its offset, outside and parallel.
Stroked( color.WithAlpha( 0.55f ), 1.4f );
Editor.Paint.DrawLine( At( c, -9.5f, 6 ), At( c, -9.5f, -2 ) );
Arc( At( c, -1.5f, -2f ), 8f, 180f, 270f, 12 );
Editor.Paint.DrawLine( At( c, -1.5f, -10f ), At( c, 5, -10f ) );
}
/// <summary>An open L and its reflection across a dashed line you click. Distinct from the
/// solid-feature Mirror glyph, which is a filled triangle: this one is a sketch polyline, and
/// the dot on the axis is what says the line is the thing you pick.</summary>
private static void PaintMirrorTool( Vector2 c, Color color )
{
// The axis, dashed, copied from PaintMirror so the two glyphs share a language.
Stroked( color.WithAlpha( 0.5f ), 1.2f );
for ( var y = -8f; y < 8f; y += 3.6f )
Editor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 2.1f ) );
// Source: an open L, solid.
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, -6.5f, 6.5f ), At( c, -6.5f, -6.5f ) );
Editor.Paint.DrawLine( At( c, -6.5f, -6.5f ), At( c, -1.4f, -6.5f ) );
// Reflection, fainter, so it reads as a copy rather than as a second original.
Stroked( color.WithAlpha( 0.5f ), 1.6f );
Editor.Paint.DrawLine( At( c, 6.5f, 6.5f ), At( c, 6.5f, -6.5f ) );
Editor.Paint.DrawLine( At( c, 6.5f, -6.5f ), At( c, 1.4f, -6.5f ) );
ClickDot( At( c, 0, 0 ) );
}
/// <summary>The face's outline drawn faint, with ONE of its edges taken - solid, and in the
/// green the viewport paints reference geometry, so the button and the thing it acts on are
/// obviously the same thing.</summary>
private static void PaintUseTool( Vector2 c, Color color )
{
Faint( color );
Outline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );
// The one edge that has been taken.
Stroked( ReferenceColor, 2.6f );
Editor.Paint.DrawLine( At( c, -7, 7 ), At( c, 7, 7 ) );
ClickDot( At( c, 0, 7 ) );
}
/// <summary>The same square with every edge taken, which is what the button does in one press.
/// </summary>
private static void PaintUseAllTool( Vector2 c, Color color )
{
Faint( color );
Editor.Paint.DrawLine( At( c, -7, 0 ), At( c, 7, 0 ) );
Stroked( ReferenceColor, 2.6f );
Outline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );
}
/// <summary>
/// A freehand stroke swept across three lines, with the pieces it went through dashed away.
///
/// The STROKE is the subject and is drawn in the red the viewport draws it in, because that is
/// the thing the button is offering to let you do. Trim's glyph shows one clean crossing; this
/// one shows a wobble through several, which is the difference between the two tools.
/// </summary>
private static void PaintCutTool( Vector2 c, Color color )
{
// Three uprights, each with the piece the stroke went through faded out. The gap is what the
// tool does; the two solid ends are what it leaves.
for ( var i = 0; i < 3; i++ )
{
var x = -6f + i * 6f;
Stroked( color, 1.8f );
Editor.Paint.DrawLine( At( c, x, -8.5f ), At( c, x, -2.5f ) );
Editor.Paint.DrawLine( At( c, x, 3.5f ), At( c, x, 8.5f ) );
Stroked( color.WithAlpha( 0.28f ), 1.4f );
Editor.Paint.DrawLine( At( c, x, -2.5f ), At( c, x, 3.5f ) );
}
// The stroke itself, sagging so that it passes through all three gaps rather than running
// straight - straight would be Trim's glyph with two more lines in it.
Stroked( CutColor, 2.1f );
var previous = At( c, -9f, -2f );
for ( var i = 1; i <= 12; i++ )
{
var t = i / 12f;
var point = At( c, -9f + 18f * t, -2f + MathF.Sin( t * MathF.PI ) * 3.5f );
Editor.Paint.DrawLine( previous, point );
previous = point;
}
}
/// <summary>The part of a Use glyph that is still only scenery.</summary>
private static void Faint( Color color ) => Stroked( color.WithAlpha( 0.4f ), 1.3f );
/// <summary>The green the sketcher paints a face's outline in - see SketchReferenceColor in
/// EffigyViewport.Sketching.cs. Kept in step by eye rather than shared, because that one carries
/// an alpha for drawing in the world and this one has to read on a small dark button.</summary>
private static readonly Color ReferenceColor = new( 0.45f, 1f, 0.6f, 1f );
/// <summary>The red a cut stroke is drawn in - see SketchCutColor in
/// EffigyViewport.SketchTools.cs. Kept in step by eye, the same as ReferenceColor above: that
/// one carries an alpha for drawing in the world and this one has to read on a small dark
/// button.</summary>
private static readonly Color CutColor = new( 1f, 0.45f, 0.35f, 1f );
// --- lighting ------------------------------------------------------------------------------
//
// THE THREE RIGS ARE THE SAME SPHERE, LIT THREE WAYS. Three-point, rim and top-down differ only
// in where the light comes from, so drawing them as three different objects would invent a
// distinction that is not there. Each is a circle with the lit part filled and the rest left as
// outline, which is the difference itself rather than a symbol standing in for it.
/// <summary>The lit sphere the rig glyphs share: an outlined circle with a filled crescent on
/// whichever side the light is on. <paramref name="from"/> is the direction the light arrives
/// from, in icon space.</summary>
private static void LitSphere( Vector2 c, Color color, Vector2 from, float radius = 5.2f )
{
var d = from.Normal;
// The terminator is perpendicular to the light, so the lit cap runs 180 degrees centred on
// the light's own bearing. Drawn as a filled polygon fan rather than an arc, because an
// outline would read as a second circle rather than as brightness.
var bearing = MathF.Atan2( d.y, d.x ) * 180f / MathF.PI;
var points = new List<Vector2>();
for ( var i = 0; i <= 18; i++ )
{
var t = (bearing - 90f) + 180f * (i / 18f);
var radians = t * MathF.PI / 180f;
points.Add( At( c, MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) );
}
Filled( color.WithAlpha( 0.85f ) );
Editor.Paint.DrawPolygon( points.ToArray() );
Stroked( color, 1.4f );
Arc( c, radius, 0f, 360f, 28 );
}
/// <summary>A short ray coming in toward the sphere, so the glyph says which side the lamp is
/// on as well as which side is bright.</summary>
private static void LightRay( Vector2 c, Color color, Vector2 from, float outer = 8.6f, float inner = 6.4f )
{
var d = from.Normal;
Stroked( color.WithAlpha( 0.9f ), 1.3f );
Editor.Paint.DrawLine( At( c, d.x * outer, d.y * outer ), At( c, d.x * inner, d.y * inner ) );
}
/// <summary>Even light from every side: a circle with rays all the way round, none of them
/// longer than the others. Full bright has no key direction and the glyph must not imply
/// one — that is the entire difference between this and the sun below.</summary>
private static void PaintLightFullBright( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.9f ) );
Editor.Paint.DrawCircle( c, 4.1f * _scale );
Stroked( color, 1.3f );
for ( var i = 0; i < 8; i++ )
{
var radians = i * 45f * MathF.PI / 180f;
var d = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );
Editor.Paint.DrawLine( At( c, d.x * 6f, d.y * 6f ), At( c, d.x * 8.4f, d.y * 8.4f ) );
}
}
/// <summary>A bulb: glass, a screw base, and three rays. The one lamp with no direction, drawn
/// as the object rather than as its effect.</summary>
private static void PaintLightPoint( Vector2 c, Color color )
{
Stroked( color, 1.5f );
Arc( c, 4.2f, 0f, 360f, 24 );
// The base, under the glass.
Editor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, 2.2f, 5.2f ) );
Editor.Paint.DrawLine( At( c, -1.6f, 7f ), At( c, 1.6f, 7f ) );
Editor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, -1.6f, 7f ) );
Editor.Paint.DrawLine( At( c, 2.2f, 5.2f ), At( c, 1.6f, 7f ) );
Stroked( color.WithAlpha( 0.8f ), 1.2f );
Editor.Paint.DrawLine( At( c, -7.4f, -4.6f ), At( c, -5.6f, -3.4f ) );
Editor.Paint.DrawLine( At( c, 0f, -8.2f ), At( c, 0f, -6.2f ) );
Editor.Paint.DrawLine( At( c, 7.4f, -4.6f ), At( c, 5.6f, -3.4f ) );
}
/// <summary>A cone of light widening downward, with the pool it lands in. Aimed, which is the
/// property that separates it from the bulb.</summary>
private static void PaintLightSpot( Vector2 c, Color color )
{
// The housing.
Stroked( color, 1.5f );
Outline(
At( c, -3f, -7.6f ),
At( c, 3f, -7.6f ),
At( c, 2.2f, -4.4f ),
At( c, -2.2f, -4.4f ) );
// The cone, dimmer than the lamp that casts it.
Stroked( color.WithAlpha( 0.75f ), 1.3f );
Editor.Paint.DrawLine( At( c, -2.2f, -4.4f ), At( c, -6.4f, 5.2f ) );
Editor.Paint.DrawLine( At( c, 2.2f, -4.4f ), At( c, 6.4f, 5.2f ) );
// The pool on the floor, flattened so it reads as ground rather than as a ball.
EllipseArc( At( c, 0f, 5.2f ), 6.4f, 1.9f, 0f, 360f, 26 );
}
/// <summary>Parallel rays at an angle: a directional light has no position, only a bearing, and
/// parallel is the one thing that says "these rays never converge".</summary>
private static void PaintLightSun( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.9f ) );
Editor.Paint.DrawCircle( At( c, -3.4f, -3.4f ), 3.2f * _scale );
Stroked( color.WithAlpha( 0.85f ), 1.3f );
// Three rays on the same bearing, spaced across the diagonal. Arrow heads because a sun is
// the only lamp here whose glyph would otherwise be three plain lines.
for ( var i = -1; i <= 1; i++ )
{
var offset = new Vector2( i * 4.6f, -i * 4.6f );
var tail = At( c, 1.4f + offset.x, 1.4f + offset.y );
var tip = At( c, 6.6f + offset.x, 6.6f + offset.y );
Editor.Paint.DrawLine( tail, tip );
ArrowHead( tip, new Vector2( 1f, 1f ), color.WithAlpha( 0.85f ), 2.8f );
Stroked( color.WithAlpha( 0.85f ), 1.3f );
}
}
/// <summary>Key, fill and rim: the sphere lit from the upper right, with the two weaker lamps
/// marked as rays on the sides they come from.</summary>
private static void PaintLightRigThreePoint( Vector2 c, Color color )
{
LitSphere( c, color, new Vector2( 1f, -1f ) );
LightRay( c, color, new Vector2( 1f, -1f ) );
LightRay( c, color.WithAlpha( 0.5f ), new Vector2( -1f, -0.2f ) );
LightRay( c, color.WithAlpha( 0.65f ), new Vector2( -0.3f, 1f ) );
}
/// <summary>Lit from behind: the far edge glows and the face is dark. The one rig whose whole
/// point is the outline rather than the surface.</summary>
private static void PaintLightRigRim( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.55f ), 1.4f );
Arc( c, 5.2f, 0f, 360f, 28 );
// Only the far crescent, drawn heavy. No fill at all, because a rim light leaves the body
// of the shape unlit and filling it would be the opposite of what this means.
Stroked( color, 2.4f );
Arc( c, 5.2f, -155f, -25f, 20 );
LightRay( c, color, new Vector2( 0.2f, -1f ) );
}
/// <summary>Lit from straight above: the lamp is drawn, and the top of the sphere is bright.</summary>
private static void PaintLightRigTop( Vector2 c, Color color )
{
Stroked( color, 1.4f );
Editor.Paint.DrawLine( At( c, -4.2f, -8f ), At( c, 4.2f, -8f ) );
Stroked( color.WithAlpha( 0.8f ), 1.2f );
Editor.Paint.DrawLine( At( c, -2.6f, -7.2f ), At( c, -3.6f, -5f ) );
Editor.Paint.DrawLine( At( c, 0f, -7.2f ), At( c, 0f, -5f ) );
Editor.Paint.DrawLine( At( c, 2.6f, -7.2f ), At( c, 3.6f, -5f ) );
LitSphere( At( c, 0f, 1.6f ), color, new Vector2( 0f, -1f ), 4.6f );
}
/// <summary>One lamp and nothing else: the sphere lit hard from one side, the other side left
/// empty. No fill ray anywhere, which is the difference from three-point.</summary>
private static void PaintLightRigKey( Vector2 c, Color color )
{
LitSphere( c, color, new Vector2( 1f, -1f ) );
LightRay( c, color, new Vector2( 1f, -1f ) );
}
/// <summary>A bulb with a stroke through it. Clear is the only button on the stage that takes
/// light away, and a struck-through glyph is the one shape that reads as removal without
/// needing a colour to say so.</summary>
private static void PaintLightClear( Vector2 c, Color color )
{
var dim = color.WithAlpha( 0.55f );
Stroked( dim, 1.5f );
Arc( At( c, 0f, -1f ), 4.2f, 0f, 360f, 24 );
Editor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, 2.2f, 4.2f ) );
Editor.Paint.DrawLine( At( c, -1.6f, 6f ), At( c, 1.6f, 6f ) );
Editor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, -1.6f, 6f ) );
Editor.Paint.DrawLine( At( c, 2.2f, 4.2f ), At( c, 1.6f, 6f ) );
Stroked( color, 1.9f );
Editor.Paint.DrawLine( At( c, -7f, 7f ), At( c, 7f, -7f ) );
}
// --- rig ----------------------------------------------------------------------------------
/// <summary>
/// The dog bone the viewport already draws, flattened to two dimensions.
///
/// SAME SHAPE THE TOOL COMMITS, for the same reason EffigyViewport.Rig.cs previews with the
/// real DrawDogBone rather than a placeholder dot: the button and the thing it makes should be
/// recognisably one object. A knob at each end and a tapered shaft between them is the whole
/// silhouette - it is Blender's octahedral bone seen side-on, which is the form anyone arriving
/// from a rigging tool already reads as "bone" without being told.
/// </summary>
private static void PaintBone( Vector2 c, Color color )
{
// Head at the top, tail at the bottom - bones hang downward in every tree view this
// editor draws, so a vertical glyph matches the direction the rig panel lists them in.
var head = At( c, 0f, -7f );
var tail = At( c, 0f, 7f );
// The shaft, widest a third of the way down from the head. That is where DogBone puts its
// cross-section, and a diamond with its waist at the midpoint reads as a kite instead.
Filled( color.WithAlpha( 0.28f ) );
Editor.Paint.DrawPolygon( head, At( c, -3.4f, -2.4f ), tail, At( c, 3.4f, -2.4f ) );
Stroked( color );
Outline( head, At( c, -3.4f, -2.4f ), tail, At( c, 3.4f, -2.4f ) );
// The joints. Solid, because they are the part you click and drag.
Filled( color );
Editor.Paint.DrawCircle( head, 2.6f * _scale );
Editor.Paint.DrawCircle( tail, 2f * _scale );
}
/// <summary>
/// A bone with a body pinned to it - the bone at the left, a solid at the right, a tie between.
///
/// The bone half is deliberately the SAME silhouette as PaintBone rather than a fresh drawing,
/// only smaller and turned: Add Bone and Assign Body sit on adjacent stages of the rig bar, and
/// two unrelated glyphs would hide that they are about the same object.
/// </summary>
private static void PaintBoneBind( Vector2 c, Color color )
{
var head = At( c, -8f, 3f );
var tail = At( c, -1.5f, -3f );
Stroked( color );
Outline( head, At( c, -6.6f, -1.6f ), tail, At( c, -2.9f, 1.6f ) );
Filled( color );
Editor.Paint.DrawCircle( head, 2f * _scale );
Editor.Paint.DrawCircle( tail, 1.5f * _scale );
// The body it is being pinned to: outlined, the same way PaintMirror outlines the copy, so
// the bone stays the solid thing and the body stays the thing being attached to it.
Stroked( color, 1.4f );
Editor.Paint.DrawRect( Box( c, 2f, -1f, 7f, 7f ), 1f );
// The tie. Dashed, because the assignment is a named link rather than geometry.
Stroked( color.WithAlpha( 0.6f ), 1.2f );
for ( var x = -1.2f; x < 2f; x += 1.6f )
Editor.Paint.DrawLine( At( c, x, -1.6f ), At( c, x + 0.9f, -1.6f ) );
}
/// <summary>
/// A bone that has swung off its pose, with the pose it left behind still showing.
///
/// THE GHOST IS THE WHOLE ICON. A bone drawn bent means nothing on its own - bones are bent.
/// What says "soft" is the pair: where the animation put it, dashed, and where the spring
/// actually took it, solid, with the gap between them being exactly the lag the four numbers in
/// the inspector control. Same trick PaintMirror uses, where the outlined copy is only
/// meaningful next to the solid original.
/// </summary>
private static void PaintBoneSoft( Vector2 c, Color color )
{
var head = At( c, -2f, -7f );
// The pose: where the bone would be if it were rigid. Dashed and faint, because it is the
// thing that is NOT happening.
Stroked( color.WithAlpha( 0.4f ), 1.2f );
for ( var t = 0f; t < 1f; t += 0.28f )
{
var a = At( c, -2f, -7f + 14f * t );
var b = At( c, -2f, -7f + 14f * (t + 0.16f) );
Editor.Paint.DrawLine( a, b );
}
// The bone as the spring left it: swung out and trailing behind. Same knob-shaft-knob
// silhouette as PaintBone so the two read as the same object.
var tail = At( c, 6f, 5.5f );
Stroked( color );
Outline( head, At( c, -1f, -1f ), tail, At( c, 3.6f, -2.6f ) );
Filled( color );
Editor.Paint.DrawCircle( head, 2.4f * _scale );
Editor.Paint.DrawCircle( tail, 1.9f * _scale );
}
/// <summary>A play triangle with the swing drawn behind it — the ordinary "run this" shape,
/// made specific to what it runs so it is not mistaken for a generic play button on a bar that
/// has no other one.</summary>
private static void PaintSoftPreview( Vector2 c, Color color )
{
// Motion trails, fading backwards, the way the bone actually arrives at where it is going.
Stroked( color.WithAlpha( 0.30f ), 1.3f );
Arc( At( c, -9f, 0f ), 7.5f, -52f, 52f, 10 );
Stroked( color.WithAlpha( 0.55f ), 1.3f );
Arc( At( c, -9f, 0f ), 11f, -40f, 40f, 10 );
Filled( color );
Editor.Paint.DrawPolygon( At( c, 1.5f, -6.5f ), At( c, 8.5f, 0f ), At( c, 1.5f, 6.5f ) );
}
/// <summary>
/// A bone dropped back onto its line, with the swing it gave up drawn as a fading arc.
///
/// NOT A REFRESH ARROW, which is the obvious choice and the wrong one: a circular arrow means
/// "do it again" everywhere else in an editor, and this does the opposite - it stops the thing
/// that is happening and puts it back where it started.
/// </summary>
private static void PaintSoftRest( Vector2 c, Color color )
{
// What is being given up: the arc the tail was swinging through, fading out as it goes.
Stroked( color.WithAlpha( 0.28f ), 1.2f );
Arc( At( c, 0f, -7f ), 13f, 62f, 90f, 8 );
// The bone, back on its pose - straight down, the direction PaintBone draws it.
var head = At( c, 0f, -7f );
var tail = At( c, 0f, 6f );
Stroked( color );
Outline( head, At( c, -3f, -2.2f ), tail, At( c, 3f, -2.2f ) );
Filled( color );
Editor.Paint.DrawCircle( head, 2.4f * _scale );
Editor.Paint.DrawCircle( tail, 1.8f * _scale );
// The line it came to rest on, so "rest" is a place rather than only a state.
Stroked( color.WithAlpha( 0.5f ), 1.4f );
Editor.Paint.DrawLine( At( c, -6f, 8.6f ), At( c, 6f, 8.6f ) );
}
// --- Model > Edit ---------------------------------------------------------------------------
/// <summary>A cube's cage with one corner grabbed and pulled — editing the mesh itself.</summary>
private static void PaintMeshEdit( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.55f ), 1.2f );
Outline( At( c, -7, -3 ), At( c, 3, -3 ), At( c, 3, 7 ), At( c, -7, 7 ) );
Editor.Paint.DrawLine( At( c, -7, -3 ), At( c, -3, -7 ) );
Editor.Paint.DrawLine( At( c, -3, -7 ), At( c, 7, -8 ) );
Editor.Paint.DrawLine( At( c, 7, -8 ), At( c, 3, -3 ) );
Editor.Paint.DrawLine( At( c, 7, -8 ), At( c, 7, 3 ) );
Editor.Paint.DrawLine( At( c, 7, 3 ), At( c, 3, 7 ) );
Dot( At( c, 7, -8 ), 2.4f, color );
}
/// <summary>A 2x2 quad cage. lit: 1 a vertex, 2 an edge, 3 a face — the pick mode, shown as the
/// part of the same cage it picks.</summary>
private static void PaintCage( Vector2 c, Color color, int lit )
{
if ( lit == 3 )
{
Filled( color.WithAlpha( 0.55f ) );
Editor.Paint.DrawPolygon( At( c, 0, -8 ), At( c, 8, -8 ), At( c, 8, 0 ), At( c, 0, 0 ) );
}
Stroked( color.WithAlpha( 0.45f ), 1.1f );
Outline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );
Editor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 8 ) );
Editor.Paint.DrawLine( At( c, -8, 0 ), At( c, 8, 0 ) );
if ( lit == 2 )
{
Stroked( color, 2.6f );
Editor.Paint.DrawLine( At( c, -8, 0 ), At( c, 0, 0 ) );
}
if ( lit == 1 )
Dot( At( c, 0, 0 ), 3f, color );
}
/// <summary>A face with a smaller face inside it and the ring between them.</summary>
private static void PaintInset( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.6f ), 1.3f );
Outline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );
Editor.Paint.DrawLine( At( c, -8, -8 ), At( c, -4, -4 ) );
Editor.Paint.DrawLine( At( c, 8, -8 ), At( c, 4, -4 ) );
Editor.Paint.DrawLine( At( c, 8, 8 ), At( c, 4, 4 ) );
Editor.Paint.DrawLine( At( c, -8, 8 ), At( c, -4, 4 ) );
Filled( color.WithAlpha( 0.8f ) );
Editor.Paint.DrawPolygon( At( c, -4, -4 ), At( c, 4, -4 ), At( c, 4, 4 ), At( c, -4, 4 ) );
}
/// <summary>A strip of quads with a new loop running across it, dashed where it is being added.</summary>
private static void PaintLoopCut( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.6f ), 1.3f );
Outline( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );
Stroked( color, 2.2f );
for ( var y = -6f; y < 6f; y += 3f )
Editor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 1.8f ) );
}
/// <summary>Three vertices pulled together into one.</summary>
private static void PaintMerge( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.6f ), 1.3f );
Editor.Paint.DrawLine( At( c, -7, -6 ), At( c, 0, 3 ) );
Editor.Paint.DrawLine( At( c, 7, -6 ), At( c, 0, 3 ) );
Editor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 3 ) );
Dot( At( c, -7, -6 ), 1.6f, color.WithAlpha( 0.6f ) );
Dot( At( c, 7, -6 ), 1.6f, color.WithAlpha( 0.6f ) );
Dot( At( c, 0, -8 ), 1.6f, color.WithAlpha( 0.6f ) );
Dot( At( c, 0, 4 ), 3f, color );
}
/// <summary>Two faces whose shared edge is fading out — they become one.</summary>
private static void PaintDissolve( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.3f ) );
Editor.Paint.DrawPolygon( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );
Stroked( color, 1.5f );
Outline( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );
Stroked( color.WithAlpha( 0.35f ), 1.2f );
for ( var y = -6f; y < 6f; y += 3f )
Editor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 1f ) );
}
/// <summary>A cage with one face knocked out, leaving a hole.</summary>
private static void PaintDeleteGeometry( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.45f ) );
Editor.Paint.DrawPolygon( At( c, -8, -8 ), At( c, 0, -8 ), At( c, 0, 8 ), At( c, -8, 8 ) );
Editor.Paint.DrawPolygon( At( c, 0, 0 ), At( c, 8, 0 ), At( c, 8, 8 ), At( c, 0, 8 ) );
Stroked( color, 1.4f );
Outline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );
Stroked( new Color( 1f, 0.45f, 0.4f ), 1.8f );
Editor.Paint.DrawLine( At( c, 2, -6 ), At( c, 6, -2 ) );
Editor.Paint.DrawLine( At( c, 6, -6 ), At( c, 2, -2 ) );
}
/// <summary>A body with a dashed sheet pulled tight around it.</summary>
private static void PaintShrinkwrap( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.35f ) );
Editor.Paint.DrawCircle( At( c, 0, 1 ), 5.5f * _scale );
Stroked( color, 1.6f );
var prev = At( c, -8, 1 );
for ( var i = 1; i <= 12; i++ )
{
var a = MathF.PI + i / 12f * MathF.PI;
var p = At( c, MathF.Cos( a ) * 8f, 1f + MathF.Sin( a ) * 8f );
if ( i % 2 == 1 )
Editor.Paint.DrawLine( prev, p );
prev = p;
}
Editor.Paint.DrawLine( At( c, -8, 1 ), At( c, -5.5f, 1 ) );
Editor.Paint.DrawLine( At( c, 8, 1 ), At( c, 5.5f, 1 ) );
}
/// <summary>A thin sheet and the same sheet given a thickness.</summary>
private static void PaintSolidify( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.5f ), 1.3f );
Editor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );
Filled( color.WithAlpha( 0.85f ) );
Editor.Paint.DrawPolygon( At( c, -8, 1 ), At( c, 8, 1 ), At( c, 8, 7 ), At( c, -8, 7 ) );
}
/// <summary>Cloth pinned along the top and sagging under its own weight: two pins, a rail, and
/// the catenary the fabric hangs in.</summary>
/// <summary>
/// A dressmaker's dummy: torso on a stand.
///
/// DELIBERATELY NOT A PERSON AND NOT A BONE. The button loads somebody else's finished model,
/// and the two things the glyph has to carry are that it is a whole body and that the body is
/// not what you are making. A figure says the first and denies the second; the rig glyphs next
/// door say neither. A stand says both, and it is what the thing is actually for.
/// </summary>
/// <summary>A scalloped edge - the hem the tool makes, seen side on.</summary>
private static void PaintFrill( Vector2 c, Color color )
{
// The cloth the trim is sewn to: a plain band, so the scallop below reads as an addition to
// an edge rather than as a shape in its own right.
Stroked( color.WithAlpha( 0.5f ), 1.5f );
Editor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );
Editor.Paint.DrawLine( At( c, -8, -6 ), At( c, -8, -1 ) );
Editor.Paint.DrawLine( At( c, 8, -6 ), At( c, 8, -1 ) );
// The seam it hangs from.
Stroked( color, 1.6f );
Editor.Paint.DrawLine( At( c, -8, -1 ), At( c, 8, -1 ) );
// Four scallops. Cosine off the seam, deepening as it falls - the same shape the generator
// makes, for the same reason: the gather grows with the distance from the seam.
Stroked( color, 1.7f );
var prev = At( c, -8, -1 );
for ( var i = 1; i <= 32; i++ )
{
var t = i / 32f;
var x = -8f + t * 16f;
var drop = 4f + 2.6f * MathF.Cos( t * MathF.PI * 8f );
var p = At( c, x, -1f + drop );
Editor.Paint.DrawLine( prev, p );
prev = p;
}
}
private static void PaintWearer( Vector2 c, Color color )
{
// The torso: shoulders wide, waist in, hips out again. Drawn as a filled silhouette because
// it is a solid object, unlike the wireframe operations either side of it on the bar.
Filled( color );
var torso = new List<Vector2>();
for ( var i = 0; i <= 16; i++ )
{
var t = i / 16f;
var y = -8f + t * 11f;
// Shoulder 5.2 -> waist 3.0 -> hip 4.4, as one smooth sweep rather than three segments.
var w = 5.2f - 2.2f * MathF.Sin( t * MathF.PI * 0.72f ) + 2.4f * t * t;
torso.Add( At( c, w, y ) );
}
for ( var i = 16; i >= 0; i-- )
{
var t = i / 16f;
var y = -8f + t * 11f;
var w = 5.2f - 2.2f * MathF.Sin( t * MathF.PI * 0.72f ) + 2.4f * t * t;
torso.Add( At( c, -w, y ) );
}
Editor.Paint.DrawPolygon( torso );
// The neck stub, so the shoulders read as shoulders and not as the top of a vase.
Editor.Paint.DrawRect( new Rect( At( c, -1.4f, -9.6f ), new Vector2( 2.8f * _scale, 2f * _scale ) ) );
// The stand. Stroked rather than filled - it is furniture holding the subject up, and
// drawing it as lightly as the pins in PaintDrape keeps the body the thing you see first.
Stroked( color, 1.6f );
Editor.Paint.DrawLine( At( c, 0, 3 ), At( c, 0, 7.5f ) );
Editor.Paint.DrawLine( At( c, -4, 8 ), At( c, 4, 8 ) );
}
private static void PaintDrape( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.5f ), 1.4f );
Editor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );
// The pins that hold it up - the selection, in the tool's own terms.
Filled( color );
Editor.Paint.DrawCircle( At( c, -6, -6 ), 1.8f * _scale );
Editor.Paint.DrawCircle( At( c, 6, -6 ), 1.8f * _scale );
// cosh, sampled: the shape a hanging sheet actually takes.
Stroked( color, 1.7f );
var prev = At( c, -6, -6 );
for ( var i = 1; i <= 14; i++ )
{
var t = -1f + i / 7f;
var p = At( c, t * 6f, -6f + (MathF.Cosh( 1.6f ) - MathF.Cosh( t * 1.6f )) * 4.6f );
Editor.Paint.DrawLine( prev, p );
prev = p;
}
}
/// <summary>A swatch of woven cloth: the weave is what the fabric setting picks.</summary>
private static void PaintFabric( Vector2 c, Color color )
{
Stroked( color.WithAlpha( 0.8f ), 1.5f );
Editor.Paint.DrawLine( At( c, -7, -7 ), At( c, 7, -7 ) );
Editor.Paint.DrawLine( At( c, -7, 7 ), At( c, 7, 7 ) );
Editor.Paint.DrawLine( At( c, -7, -7 ), At( c, -7, 7 ) );
Editor.Paint.DrawLine( At( c, 7, -7 ), At( c, 7, 7 ) );
Stroked( color.WithAlpha( 0.55f ), 1.2f );
for ( var i = 1; i <= 2; i++ )
{
var o = -7f + i * 14f / 3f;
Editor.Paint.DrawLine( At( c, o, -7 ), At( c, o, 7 ) );
Editor.Paint.DrawLine( At( c, -7, o ), At( c, 7, o ) );
}
}
/// <summary>A surface cut open along a line: the two halves parting, and the cut itself in the
/// red every tool uses for a seam.</summary>
private static void PaintSeam( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.35f ) );
Editor.Paint.DrawPolygon( At( c, -8, -7 ), At( c, -2, -7 ), At( c, -2, 7 ), At( c, -8, 7 ) );
Editor.Paint.DrawPolygon( At( c, 2, -7 ), At( c, 8, -7 ), At( c, 8, 7 ), At( c, 2, 7 ) );
// The cut. Red, because that is what a marked seam is drawn as in the viewport.
Stroked( new Color( 0.93f, 0.26f, 0.21f ), 2f );
Editor.Paint.DrawLine( At( c, -2, -7 ), At( c, -2, 7 ) );
Editor.Paint.DrawLine( At( c, 2, -7 ), At( c, 2, 7 ) );
}
/// <summary>A box opened out into its net - the thing unwrapping does, in one picture.</summary>
private static void PaintUnwrap( Vector2 c, Color color )
{
Filled( color.WithAlpha( 0.75f ) );
Editor.Paint.DrawPolygon( At( c, -2.5f, -2.5f ), At( c, 2.5f, -2.5f ), At( c, 2.5f, 2.5f ), At( c, -2.5f, 2.5f ) );
// The four flaps folded out around it.
Filled( color.WithAlpha( 0.35f ) );
Editor.Paint.DrawPolygon( At( c, -2.5f, -8 ), At( c, 2.5f, -8 ), At( c, 2.5f, -3.5f ), At( c, -2.5f, -3.5f ) );
Editor.Paint.DrawPolygon( At( c, -2.5f, 3.5f ), At( c, 2.5f, 3.5f ), At( c, 2.5f, 8 ), At( c, -2.5f, 8 ) );
Editor.Paint.DrawPolygon( At( c, -8, -2.5f ), At( c, -3.5f, -2.5f ), At( c, -3.5f, 2.5f ), At( c, -8, 2.5f ) );
Editor.Paint.DrawPolygon( At( c, 3.5f, -2.5f ), At( c, 8, -2.5f ), At( c, 8, 2.5f ), At( c, 3.5f, 2.5f ) );
}
}
Editor
library
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Turns the Part Studio's in-memory PolyMesh straight into a runtime Model, so the viewport can
/// show the result of a feature the moment it is added.
///
/// The alternative — the path Export/Compile still take — is OBJ on disk, a generated .vmdl, and
/// a call into the asset compiler. That takes hundreds of milliseconds and writes files, which is
/// fine for producing a placeable prop and hopeless as the response to dragging a slider. This
/// path never touches the disk.
///
/// It is deliberately NOT a second geometry pipeline: it shares MeshNormals with ObjWriter, so
/// what you see here and what the compiler bakes are smoothed by the same rule.
///
/// MATERIALS RENDER HERE NOW. A face carries a slot number and PartStudio.MaterialNames binds the
/// slot to a vmat, so the preview groups faces by the material they resolve to and builds one
/// submesh per material — the same grouping the exporters express as usemtl runs. Faces on slot 0,
/// on a slot nothing is bound to, or on a slot whose vmat will not load fall back to the flat
/// placeholder rather than rendering as nothing. Pass no resolver — the sculpt preview does — and
/// the whole model is the placeholder, which is the old behaviour.
/// </summary>
internal static class EffigyPreview
{
/// <summary>
/// FLAT grey, with no pattern on it at all. The fallback for a face whose slot names no
/// material, or names one that will not load.
///
/// It used to be dev/reflectivity_30, and that material actively lies about scale: its texture
/// is a grid with the number "30" printed in every tile - the material's reflectivity, nothing
/// to do with size - and caps take plane coordinates straight through as UVs, so it tiles once
/// per sketch unit. A 30x30 face therefore came out covered in thirty-odd squares each labelled
/// "30", which reads as a part 900 units across. gray_50's texture is a single flat colour.
/// </summary>
private const string PreviewMaterial = "materials/dev/gray_50.vmat";
/// <summary>
/// Build a Model from the mesh, optionally resolving each face's material slot to a real vmat.
/// </summary>
/// <param name="materialForSlot">Slot number to bound material path, or null / empty for an
/// unbound slot. Pass null to render everything on the placeholder.</param>
public static Model Build( PolyMesh mesh, Func<int, string> materialForSlot = null,
float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )
{
if ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 )
return null;
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
var placeholder = Material.Load( PreviewMaterial );
var bounds = BoundsOf( mesh );
// Faces bucketed by the material they render with. One drop of brushed steel onto three
// faces is one bucket and one submesh; a part nobody assigned is one bucket on the
// placeholder. The slot is resolved once and cached, not once per face.
var buckets = new Dictionary<Material, List<int>>();
var slotCache = new Dictionary<int, Material>();
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
if ( mesh.Faces[fi].Count < 3 )
continue;
var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );
if ( !buckets.TryGetValue( material, out var faces ) )
buckets[material] = faces = new List<int>();
faces.Add( fi );
}
var builder = Model.Builder;
var any = false;
foreach ( var (material, faces) in buckets )
{
if ( BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds ) is { } sub )
{
builder.AddMesh( sub );
any = true;
}
}
return any ? builder.Create() : null;
}
/// <summary>
/// Build a Model from the mesh wearing ONE material on every face, slot resolution ignored.
///
/// THIS IS THE PAINT-PREVIEW PATH. While painting, the whole part wears the painted atlas — a
/// <see cref="Material.CreateCopy"/> with the live canvas texture bound — so the slot bucketing
/// above is the wrong answer there. One material, one submesh, the same smoothing as the ordinary
/// preview, and the caller owns the material's lifetime across rebuilds.
/// </summary>
public static Model Build( PolyMesh mesh, Material material,
float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )
{
if ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 || material is null )
return null;
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
var bounds = BoundsOf( mesh );
var faces = new List<int>( mesh.FaceCount );
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
if ( mesh.Faces[fi].Count >= 3 )
faces.Add( fi );
}
var sub = BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds );
if ( sub is null )
return null;
var builder = Model.Builder;
builder.AddMesh( sub );
return builder.Create();
}
/// <summary>
/// A preview Model that can be rewritten in place when only vertex positions change — a body
/// being dragged by the Transform handle. Rebuilding the Model, its buffers and its GameObject
/// every frame is what made dragging a dense import sluggish, so this retains the meshes and
/// overwrites their vertex buffers instead. Buckets, triangulation and buffer allocation are
/// done once; a drag only recomputes normals and re-uploads the vertex data.
/// </summary>
internal sealed class LivePreview
{
public Model Model { get; private set; }
private readonly List<Mesh> _meshes = new();
private readonly List<List<SimpleVertex>> _vertices = new();
private readonly Dictionary<Material, int> _bucketIndex = new();
private int[] _faceMesh;
private int[] _faceCorners;
private int _faceCount;
// The corner normals the vertex buffers were last built with. Kept so a position-only update
// (a sculpt stroke, which moves vertices without changing topology) can reuse them instead of
// re-running the O(vertices) fan pass every dab. The normals snap correct on the next full
// update.
private int[][] _cornerNormals;
private List<Vec3> _normals;
private LivePreview() { }
public static LivePreview Build( PolyMesh mesh, Func<int, string> materialForSlot,
float smoothingAngleDegrees )
{
var live = new LivePreview { _faceCount = mesh.FaceCount };
live.Rebuild( mesh, materialForSlot, smoothingAngleDegrees );
return live;
}
/// <summary>
/// Rewrite the vertex buffers from a mesh whose topology matches the one this was built
/// from. Returns false — the caller must rebuild — when the face count, material layout or
/// corner count changed, because any of those moves the bucketing and the index buffers.
/// </summary>
public bool TryUpdate( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )
{
if ( Model is null || mesh.FaceCount != _faceCount || mesh.HasVertexColors )
return false;
var placeholder = Material.Load( PreviewMaterial );
var slotCache = new Dictionary<int, Material>();
var faceLists = new List<List<int>>( _meshes.Count );
for ( var i = 0; i < _meshes.Count; i++ )
faceLists.Add( new List<int>() );
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
// The index buffers are laid out corner by corner, so two faces swapping corner
// counts keeps the per-bucket vertex total the same while scrambling the triangles.
if ( mesh.Faces[fi].Count != _faceCorners[fi] )
return false;
if ( mesh.Faces[fi].Count < 3 )
continue;
var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );
// A face on a bucket that was not there at build time means the material layout
// changed, which an in-place update cannot express.
if ( !_bucketIndex.TryGetValue( material, out var bi ) || bi != _faceMesh[fi] )
return false;
faceLists[bi].Add( fi );
}
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
_cornerNormals = cornerNormals;
_normals = normals;
var bounds = BoundsOf( mesh );
for ( var i = 0; i < _meshes.Count; i++ )
{
var vertices = BuildVertices( mesh, faceLists[i], cornerNormals, normals );
if ( vertices.Count != _vertices[i].Count )
return false;
_meshes[i].SetVertexBufferData( vertices, 0 );
_meshes[i].Bounds = bounds;
}
return true;
}
/// <summary>
/// Rewrite positions only, reusing the normals and UVs the buffers were last built with. A
/// sculpt stroke moves vertices without changing topology, and this skips the O(vertices)
/// corner-normal fan pass that a full update re-runs every dab — the normals snap correct on
/// the next full update (stroke end). Returns false when the face layout has changed, which
/// means a full rebuild instead.
/// </summary>
public bool TryUpdatePositions( PolyMesh mesh )
{
if ( Model is null || mesh.FaceCount != _faceCount || _cornerNormals is null || mesh.HasVertexColors )
return false;
var faceLists = new List<List<int>>( _meshes.Count );
for ( var i = 0; i < _meshes.Count; i++ )
faceLists.Add( new List<int>() );
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
if ( mesh.Faces[fi].Count != _faceCorners[fi] )
return false;
if ( mesh.Faces[fi].Count < 3 )
continue;
faceLists[_faceMesh[fi]].Add( fi );
}
for ( var i = 0; i < _meshes.Count; i++ )
{
var vertices = BuildVertices( mesh, faceLists[i], _cornerNormals, _normals );
if ( vertices.Count != _vertices[i].Count )
return false;
_meshes[i].SetVertexBufferData( vertices, 0 );
_vertices[i] = vertices;
}
return true;
}
void Rebuild( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )
{
_meshes.Clear();
_vertices.Clear();
_bucketIndex.Clear();
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
_cornerNormals = cornerNormals;
_normals = normals;
var bounds = BoundsOf( mesh );
var placeholder = Material.Load( PreviewMaterial );
var slotCache = new Dictionary<int, Material>();
var buckets = new List<(Material Material, List<int> Faces)>();
_faceMesh = new int[mesh.FaceCount];
_faceCorners = new int[mesh.FaceCount];
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
_faceCorners[fi] = mesh.Faces[fi].Count;
if ( mesh.Faces[fi].Count < 3 )
continue;
var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );
if ( !_bucketIndex.TryGetValue( material, out var bi ) )
{
bi = buckets.Count;
_bucketIndex[material] = bi;
buckets.Add( (material, new List<int>()) );
}
buckets[bi].Faces.Add( fi );
_faceMesh[fi] = bi;
}
var builder = Model.Builder;
foreach ( var (material, faces) in buckets )
{
var vertices = BuildVertices( mesh, faces, cornerNormals, normals );
if ( vertices.Count == 0 )
continue;
var indices = TriangulateIndices( mesh, faces );
var sbMesh = new Mesh( material );
if ( mesh.HasVertexColors )
{
var coloured = BuildColouredVertices( mesh, faces, cornerNormals, normals );
sbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );
}
else
sbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );
sbMesh.CreateIndexBuffer( indices.Count, indices );
sbMesh.Bounds = bounds;
builder.AddMesh( sbMesh );
_meshes.Add( sbMesh );
_vertices.Add( vertices );
}
Model = _meshes.Count > 0 ? builder.Create() : null;
}
}
/// <summary>
/// The material a slot renders with: the vmat bound to it, or the flat placeholder.
///
/// Slot 0 is the slot every face starts on and never carries a material, so it is the
/// placeholder without a lookup. A named slot whose vmat will not load — a path into a package
/// that is not installed, a material deleted since it was assigned — also falls back rather
/// than rendering the part as nothing, which is the failure the single-material preview used
/// to avoid wholesale.
/// </summary>
private static Material ResolveMaterial( int slot, Func<int, string> materialForSlot,
Material placeholder, Dictionary<int, Material> cache )
{
if ( slot <= 0 || materialForSlot is null )
return placeholder;
if ( cache.TryGetValue( slot, out var cached ) )
return cached;
var name = materialForSlot( slot );
var material = string.IsNullOrWhiteSpace( name ) ? null : Material.Load( name );
// A named slot whose vmat would not load is the one case the fallback hides — the face
// renders as placeholder grey and nothing says why. Say why, once per slot per build.
if ( material is null && !string.IsNullOrWhiteSpace( name ) )
Log.Warning( $"[effigy-preview] slot {slot}: material '{name}' failed to load — using placeholder" );
return cache[slot] = material ?? placeholder;
}
/// <summary>
/// One Mesh over a subset of the faces, all sharing one material.
///
/// ONE VERTEX FORMAT, since paint stopped being vertex colour. There was a second path here that
/// built the richer <c>Vertex</c> so the engine could multiply a material by a per-vertex colour;
/// it was chosen by <c>PolyMesh.HasVertexColors</c>, which nothing sets any more, so it rendered
/// nothing that this path does not. Paint reaches the viewport as the painted body's material
/// now, the same way a dropped material does.
/// </summary>
private static Mesh BuildSubmesh( PolyMesh mesh, List<int> faceIndices, int[][] cornerNormals,
List<Vec3> normals, Material material, BBox bounds )
{
var vertices = BuildVertices( mesh, faceIndices, cornerNormals, normals );
var indices = TriangulateIndices( mesh, faceIndices );
if ( indices.Count == 0 )
return null;
var sbMesh = new Mesh( material );
if ( mesh.HasVertexColors )
{
var coloured = BuildColouredVertices( mesh, faceIndices, cornerNormals, normals );
sbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );
}
else
sbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );
sbMesh.CreateIndexBuffer( indices.Count, indices );
sbMesh.Bounds = bounds;
return sbMesh;
}
/// <summary>
/// The same corners as <see cref="BuildVertices"/>, carrying the mesh's vertex colour.
///
/// BACK FOR FUR. The vertex-colour path was retired when paint became a texture; it returns
/// because s&box's fur.shader reads the mesh COLOR stream - red is each shell's height - and
/// without it every shell reads as the root layer and the fur renders as a solid lump. Every other
/// shader ignores the stream, so the rest of a furred part looks exactly as it did.
/// </summary>
private static List<Vertex> BuildColouredVertices( PolyMesh mesh, List<int> faceIndices,
int[][] cornerNormals, List<Vec3> normals )
{
var colours = mesh.VertexColors;
var vertices = new List<Vertex>( faceIndices.Count * 4 );
foreach ( var fi in faceIndices )
{
var face = mesh.Faces[fi];
var corners = cornerNormals[fi];
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
var n = normals[corners[c]];
var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;
var tint = colours[face.Indices[c]].Tint();
var normal = new Vector3( n.x, n.y, n.z );
var tangent = MathF.Abs( normal.z ) < 0.9f
? Vector3.Cross( Vector3.Up, normal ).Normal
: Vector3.Cross( Vector3.Forward, normal ).Normal;
vertices.Add( new Vertex
{
Position = new Vector3( p.x, p.y, p.z ),
Normal = normal,
Tangent = new Vector4( tangent, 1f ),
TexCoord0 = new Vector2( uv.x, uv.y ),
Color = new Color32(
(byte)MathF.Round( Math.Clamp( tint.x, 0f, 1f ) * 255f ),
(byte)MathF.Round( Math.Clamp( tint.y, 0f, 1f ) * 255f ),
(byte)MathF.Round( Math.Clamp( tint.z, 0f, 1f ) * 255f ),
255 ),
} );
}
}
return vertices;
}
/// <summary>
/// One vertex per face corner rather than per position, in the same order
/// <see cref="TriangulateIndices"/> indexes them. Corner normals are the whole point of
/// MeshNormals - sharing a vertex between two faces that disagree about the normal is exactly
/// what rounds off a box's edges.
/// </summary>
private static List<SimpleVertex> BuildVertices( PolyMesh mesh, List<int> faceIndices,
int[][] cornerNormals, List<Vec3> normals )
{
var vertices = new List<SimpleVertex>( faceIndices.Count * 4 );
foreach ( var fi in faceIndices )
{
var face = mesh.Faces[fi];
var corners = cornerNormals[fi];
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
var n = normals[corners[c]];
var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;
var position = new Vector3( p.x, p.y, p.z );
var normal = new Vector3( n.x, n.y, n.z );
vertices.Add( new SimpleVertex( position, normal, Vector3.Forward, new Vector2( uv.x, uv.y ) ) );
}
}
return vertices;
}
/// <summary>
/// Ear-clipping, not a fan. This used to fan from corner 0 on the grounds that every face the
/// kernel produces is convex. Extrude caps are not: they are whatever closed region was drawn,
/// and fanning a concave one fills its notches in — draw a dart and the solid came back as a
/// quadrilateral with the concave corner swallowed.
///
/// A TRIANGLE BYPASSES THE CLIPPER. An import is overwhelmingly triangles, and ear clipping a
/// 3-corner face is still a Newell normal, a flatten, a signed-area and several allocations per
/// face — millions of them on a dense import. A triangle is always itself, so it is emitted
/// straight through.
/// </summary>
private static List<int> TriangulateIndices( PolyMesh mesh, List<int> faceIndices )
{
var indices = new List<int>( faceIndices.Count * 6 );
var first = 0;
foreach ( var fi in faceIndices )
{
var face = mesh.Faces[fi];
if ( face.Count == 3 )
{
indices.Add( first );
indices.Add( first + 1 );
indices.Add( first + 2 );
}
else
{
var polygon = new List<Vec3>( face.Count );
for ( var k = 0; k < face.Count; k++ )
polygon.Add( mesh.Positions[face.Indices[k]] );
foreach ( var (a, b, cc) in Triangulate.Face( polygon ) )
{
indices.Add( first + a );
indices.Add( first + b );
indices.Add( first + cc );
}
}
first += face.Count;
}
return indices;
}
private static BBox BoundsOf( PolyMesh mesh )
{
var min = new Vector3( float.MaxValue );
var max = new Vector3( float.MinValue );
foreach ( var p in mesh.Positions )
{
var v = new Vector3( p.x, p.y, p.z );
min = Vector3.Min( min, v );
max = Vector3.Max( max, v );
}
return new BBox( min, max );
}
}
Editor
library
using Editor;
using System;
namespace Marionette.EditorTools;
/// <summary>
/// The grid switch and its spacing, floating in the corner of the viewport while a sketch is open.
///
/// WHY IT IS NOT ONLY IN SETTINGS. Both controls already live in Edit > Settings, and that is the
/// right home for them when you are setting up how the tool behaves. It is the wrong home for them
/// while you are drawing: the grid is the paper, changing paper is part of drawing, and a dialog
/// three menus away is not somewhere you go mid-sketch. Every sketcher worth using puts these two
/// on the canvas.
///
/// NOT A SECOND COPY OF THE VALUE. This reads and writes the viewport's own properties - the same
/// two the settings window sets - and re-reads them every time it is shown, so the two controls can
/// never disagree about what is true. The spacing list itself is EffigySettingsWindow's, for the
/// same reason: two lists drift, and a value that exists on one dropdown and not the other reads as
/// the setting having been lost.
///
/// AT THE END OF THE TOOL ROW, not floating on the model. It belongs to the mode you are in, the
/// way the Line and Rectangle buttons beside it do, and chrome is where a mode's controls go - a
/// panel sitting on the part is one more thing between you and the thing you are drawing. Flush
/// right, so the tools growing and shrinking as you change stage never move it.
/// </summary>
internal sealed class EffigySketchGridBar : Widget
{
/// <summary>The tool buttons' own height, so the two read as one row of controls rather than
/// as a panel parked next to them.</summary>
public const float BarHeight = EffigyToolChrome.ButtonHeight;
public const float BarWidth = 210f;
private readonly Button _toggle;
private readonly ComboBox _spacing;
/// <summary>Set while Refresh writes the controls. Both of them fire their change callbacks on
/// assignment exactly as a click does, and without this a refresh would turn straight round and
/// write the value it had only just read - harmless for the toggle, and for the dropdown a way
/// to reset the spacing to whatever happened to be first.</summary>
private bool _syncing;
/// <summary>Raised after either control has changed the viewport, so the window can save the
/// setting the same way the settings dialog's own callback does. The viewport is already
/// updated by the time this fires.</summary>
public Action Changed { get; set; }
private readonly EffigyViewport _viewport;
public EffigySketchGridBar( Widget parent, EffigyViewport viewport ) : base( parent )
{
_viewport = viewport;
// The same two flags every floating widget in this tool sets — a plain Widget paints the
// system background, which is a white slab on the 3D view.
TranslucentBackground = true;
NoSystemBackground = true;
Visible = false;
FixedHeight = BarHeight;
FixedWidth = BarWidth;
Layout = Layout.Row();
Layout.Spacing = 6;
Layout.Margin = new Sandbox.UI.Margin( 6, 2, 6, 2 );
_toggle = new Button( "Grid", "grid_on", this )
{
ToolTip = "Draw the grid on the face you are sketching on. The lines are the intervals "
+ "the cursor snaps to.",
FixedWidth = 74f,
};
_toggle.Clicked = ToggleGrid;
Layout.Add( _toggle );
_spacing = new ComboBox( this )
{
ToolTip = "How far apart the grid lines sit, in sketch units. Automatic fits the step "
+ "to the face you are on and to how close the camera is.",
};
foreach ( var value in EffigySettingsWindow.Spacings )
{
var step = value;
_spacing.AddItem( EffigySettingsWindow.Describe( step ), onSelected: () => SetSpacing( step ) );
}
Layout.Add( _spacing, 1 );
Refresh();
}
/// <summary>The tool row's background, so the gaps between the two controls disappear into the
/// bar rather than showing as a panel laid on it. A widget cannot simply decline to paint: a
/// rect it leaves alone keeps whatever the previous frame put there.</summary>
public Color GapColor { get; set; } = Theme.ControlBackground;
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( GapColor );
Paint.DrawRect( LocalRect );
}
/// <summary>Point both controls at what the viewport actually holds. Called on every show, and
/// again whenever the settings window has been the one to change it.</summary>
public void Refresh()
{
if ( !_viewport.IsValid )
return;
_syncing = true;
try
{
var on = _viewport.ShowPlaneGrid;
_toggle.Icon = on ? "grid_on" : "grid_off";
// Tinted rather than labelled on and off. The button says "Grid" either way, so the
// only thing that has to change is whether it reads as engaged - and a label that
// alternates between two words is a control you have to read to use.
_toggle.Tint = on ? Theme.Blue : Theme.TextControl.WithAlpha( 0.5f );
var spacing = _viewport.GridSpacing;
var index = 0;
for ( var i = 0; i < EffigySettingsWindow.Spacings.Length; i++ )
{
if ( MathF.Abs( EffigySettingsWindow.Spacings[i] - spacing ) < 0.0001f )
{
index = i;
break;
}
}
_spacing.CurrentIndex = index;
}
finally
{
_syncing = false;
}
}
private void ToggleGrid()
{
if ( _syncing || !_viewport.IsValid )
return;
_viewport.ShowPlaneGrid = !_viewport.ShowPlaneGrid;
Refresh();
Changed?.Invoke();
}
private void SetSpacing( float step )
{
if ( _syncing || !_viewport.IsValid )
return;
_viewport.GridSpacing = step;
// Choosing a spacing while the grid is off is asking to see it. Turning it on is what was
// meant, and leaving it off would make the dropdown look broken.
if ( !_viewport.ShowPlaneGrid )
_viewport.ShowPlaneGrid = true;
Refresh();
Changed?.Invoke();
}
}
Editor
library
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Brushing an existing material onto faces.
///
/// THE SAME GESTURE AS THE COLOUR BRUSH AND A DIFFERENT RESULT. The ring, the hover, the
/// press-drag-release are all copied from <see cref="PaintFrame"/> deliberately: switching between
/// the two should feel like changing what the brush is loaded with, not like learning a tool. What
/// differs is what a dab does — colour blends into vertices here and now, a material dab is an
/// edit to the feature history, so it has to go out to the window, change the document and come
/// back as a rebuild.
///
/// WHICH IS WHY IT REPORTS RATHER THAN APPLIES. The viewport owns no studio and must not: it is
/// handed a mesh to look at. So a dab raises <see cref="MaterialDabbed"/> with the faces it
/// covered and the window does the assignment through MaterialDrop.Brush. That keeps the undo
/// story ordinary — a material dab is the same kind of edit as dropping a material, and undoes the
/// same way — instead of inventing a second undo path that only the brush knows about.
///
/// A DAB PER FRAME, AN UNDO STEP PER STROKE. The faces under the ring change as it moves, and each
/// batch has to reach the document while the drag is still happening or the model would not follow
/// the brush — so dabs are frequent. What a person expects to take back is the GESTURE, though, so
/// undo is recorded once, at the press, by <see cref="MaterialStrokeStarted"/>. Recording it per
/// dab instead would turn one sweep into fifty Ctrl+Z presses.
/// </summary>
internal sealed partial class EffigyViewport
{
public MaterialBrushSession MaterialBrush { get; private set; }
public bool IsMaterialBrushing => MaterialBrush is not null;
/// <summary>Raised with the faces one dab covered. The window assigns them and rebuilds; the
/// list is reused between dabs, so it must be consumed rather than kept.</summary>
public Action<IReadOnlyList<int>> MaterialDabbed { get; set; }
/// <summary>
/// Raised once when a drag begins, BEFORE its first dab, so the window can record undo.
///
/// ONCE PER STROKE AND NOT PER DAB, which is the whole reason it is a separate signal. A dab
/// fires every frame the brush moves, so recording undo there would make one sweep across a
/// part into fifty Ctrl+Z presses; recording it nowhere - which is what this did at first -
/// leaves the stroke unundoable and sends Ctrl+Z back past it to whatever came before. The
/// gesture is the unit a person would expect to take back, so the gesture is what is recorded.
/// </summary>
public Action MaterialStrokeStarted { get; set; }
/// <summary>
/// Whether a material is actually chosen. The window sets it from the Materials browser.
///
/// THE RING HAS TO SAY SO. Without this the brush draws its ring, outlines the faces under it
/// and then does nothing at all when dragged, because there is no material to lay down - which
/// looks exactly like a broken tool rather than an unloaded one. Unloaded, the ring goes grey
/// and stops outlining faces it is not going to take.
/// </summary>
public bool MaterialBrushLoaded { get; set; }
private MeshHit? _materialCursor;
private bool _materialStroking;
public void BeginMaterialBrush( MaterialBrushSession session )
{
MaterialBrush = session ?? throw new ArgumentNullException( nameof( session ) );
_materialStroking = false;
}
public void EndMaterialBrush()
{
MaterialBrush = null;
_materialCursor = null;
_materialStroking = false;
}
private void MaterialBrushFrame()
{
if ( MaterialBrush is null )
return;
_materialCursor = null;
// Leaving the canvas does not end the drag, the same rule paint and sculpt keep: dragging
// off the model and back on 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 );
_materialCursor = MaterialBrush.Hover( origin, direction );
// Before the first dab, so the snapshot is the state the stroke is about to change.
if ( Gizmo.WasLeftMousePressed && !_materialStroking )
{
_materialStroking = true;
MaterialStrokeStarted?.Invoke();
}
if ( _materialStroking && Gizmo.IsLeftMouseDown && _materialCursor is { } hit )
{
var faces = MaterialBrush.FacesAt( hit );
if ( faces.Count > 0 )
MaterialDabbed?.Invoke( faces );
}
}
if ( _materialStroking && !Gizmo.IsLeftMouseDown )
_materialStroking = false;
DrawMaterialCursor();
}
/// <summary>
/// The ring, plus the faces it is about to take.
///
/// HIGHLIGHTING THE FACES IS THE WHOLE POINT HERE, in a way it is not for colour. A material dab
/// covers whole faces, so what the ring encloses and what the dab takes are not the same shape —
/// on a coarse box a small ring still paints an entire side. Showing the faces means the tool
/// says what it is about to do rather than letting the first click explain it.
/// </summary>
private void DrawMaterialCursor()
{
if ( _materialCursor is not { } hit )
return;
var mesh = MaterialBrush.Mesh;
var colour = MaterialBrushLoaded ? MaterialCursorColor : UnloadedCursorColor;
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = colour.WithAlpha( 0.5f );
Gizmo.Draw.LineThickness = 1.5f;
// Only when there is something to lay down: outlining faces the brush will not touch is a
// promise it cannot keep.
foreach ( var faceIndex in MaterialBrushLoaded ? MaterialBrush.FacesAt( hit ) : NoFaces )
{
if ( faceIndex < 0 || faceIndex >= mesh.Faces.Count )
continue;
var face = mesh.Faces[faceIndex];
for ( var c = 0; c < face.Count; c++ )
{
var a = mesh.Positions[face.Indices[c]];
var b = mesh.Positions[face.Indices[(c + 1) % face.Count]];
Gizmo.Draw.Line( new Vector3( a.x, a.y, a.z ), new Vector3( b.x, b.y, b.z ) );
}
}
var normal = new Vector3( hit.Normal.x, hit.Normal.y, hit.Normal.z ).Normal;
var centre = new Vector3( hit.Point.x, hit.Point.y, hit.Point.z );
var reference = MathF.Abs( normal.z ) > 0.9f ? new Vector3( 1f, 0f, 0f ) : new Vector3( 0f, 0f, 1f );
var right = Vector3.Cross( normal, reference ).Normal;
var up = Vector3.Cross( normal, right ).Normal;
var radius = MaterialBrush.Radius;
Gizmo.Draw.Color = colour;
var lift = normal * (radius * 0.01f);
const int Segments = 40;
var previous = centre + right * radius + lift;
for ( var i = 1; i <= Segments; i++ )
{
var angle = i / (float)Segments * MathF.PI * 2f;
var point = centre + (right * MathF.Cos( angle ) + up * MathF.Sin( angle )) * radius + lift;
Gizmo.Draw.Line( previous, point );
previous = point;
}
}
/// <summary>Amber, so it is neither sculpt's blue nor paint's pink — the three brushes are told
/// apart by the colour of their ring before anything else.</summary>
private static readonly Color MaterialCursorColor = new( 1f, 0.75f, 0.25f, 0.9f );
/// <summary>Grey, for a brush with nothing loaded - the ring still tracks the surface so the
/// tool is visibly alive, it just does not claim it is about to paint anything.</summary>
private static readonly Color UnloadedCursorColor = new( 0.6f, 0.6f, 0.6f, 0.7f );
private static readonly IReadOnlyList<int> NoFaces = new List<int>();
}
Editor
library
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
/// <summary>
/// Idle geometry selection — click a face or a part, then use a tool on it.
///
/// Separate from FacePickMode / BodyPickMode. Those are a dialog asking a question; this is the
/// selection that exists when nobody is asking. Onshape works the same way: you point at a face,
/// it lights up, you click it, then Draft/Hole/Sketch consume what you already picked instead of
/// making you pick it again inside a dialog.
/// </summary>
internal sealed partial class EffigyViewport
{
/// <summary>Faces currently selected while idle. Empty when the last click was a whole part
/// in the Parts list, or nothing.</summary>
public IReadOnlyList<FaceRef> IdleFaces => _idleFaces;
/// <summary>Edges currently selected while idle. Empty unless the last click landed near a
/// rim rather than in the middle of a face.</summary>
public IReadOnlyList<EdgeRef> IdleEdges => _idleEdges;
/// <summary>Bodies currently selected while idle. A face click names its owning body so the
/// Parts list can highlight the same row; a Parts-list click names the body and no faces.
/// </summary>
public IReadOnlyList<string> IdleBodyIds => _idleBodyIds;
/// <summary>Committed sketch currently selected while idle, or null. Extrude/Revolve read
/// this instead of waiting for a second click.</summary>
public string IdleSketchFeatureId => _idleSketchFeatureId;
/// <summary>Region seeds on <see cref="IdleSketchFeatureId"/>. Empty means the whole sketch.
/// </summary>
public IReadOnlyList<Vec2> IdleRegionSeeds => _idleRegionSeeds;
public bool HasIdleSelection =>
_idleFaces.Count > 0 || _idleEdges.Count > 0 || _idleBodyIds.Count > 0
|| _idleSketchFeatureId is not null;
/// <summary>Raised after a click in the viewport changes the idle selection, so the Parts
/// list can stay in step. Not raised when the list itself drove the change.</summary>
public Action IdleSelectionChanged { get; set; }
private readonly List<FaceRef> _idleFaces = new();
private readonly List<EdgeRef> _idleEdges = new();
private readonly List<string> _idleBodyIds = new();
private string _idleSketchFeatureId;
private readonly List<Vec2> _idleRegionSeeds = new();
/// <summary>How close a click must be to a rim, in screen pixels, before it counts as an
/// edge rather than the face. Wide enough to grab, tight enough that a click in the middle
/// of a face stays a face.</summary>
private const float EdgePickPixels = 10f;
/// <summary>Whether a left click on a face is a selection rather than an answer to a dialog.
/// Anything else with a click of its own owns the mouse while it is armed.</summary>
private bool IdlePickingAllowed =>
!IsSketching && !IsSplineEditing && !IsSculpting && !IsMeshEditing && !IsPainting && !IsMaterialBrushing && !IsNoting && !IsWeightPainting
&& !PlanePickMode && !SketchPickMode && !FacePickMode && !EdgePickMode && !BodyPickMode
&& !BoneToolActive
&& !_draggingOrigin && !_draggingLight && !_draggingFace;
/// <summary>
/// Replace the idle selection with whole bodies, clearing any face picks.
///
/// This is what the Parts list does: clicking a row selects that part, not one of its faces.
/// Fillet, shell, transform then act on the part; Sketch/Draft still need a face, so they
/// open asking for one.
/// </summary>
public void SelectBodies( IReadOnlyList<string> bodyIds )
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
if ( bodyIds is not null )
{
foreach ( var id in bodyIds )
{
if ( !string.IsNullOrEmpty( id ) && !_idleBodyIds.Contains( id ) )
_idleBodyIds.Add( id );
}
}
IdleSelectionChanged?.Invoke();
}
public void ClearIdleSelection()
{
if ( !HasIdleSelection )
return;
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
IdleSelectionChanged?.Invoke();
}
void ClearIdleSketch()
{
_idleSketchFeatureId = null;
_idleRegionSeeds.Clear();
}
/// <summary>Select a committed sketch, optionally a single closed region of it.</summary>
public void SelectIdleSketch( string featureId, Vec2? seed, bool add = false )
{
if ( string.IsNullOrEmpty( featureId ) )
{
if ( _idleSketchFeatureId is null )
return;
ClearIdleSketch();
IdleSelectionChanged?.Invoke();
return;
}
if ( add && _idleSketchFeatureId == featureId && seed is { } extra )
{
for ( var i = 0; i < _idleRegionSeeds.Count; i++ )
{
var existing = _idleRegionSeeds[i];
if ( MathF.Abs( existing.x - extra.x ) > 1e-4f || MathF.Abs( existing.y - extra.y ) > 1e-4f )
continue;
_idleRegionSeeds.RemoveAt( i );
if ( _idleRegionSeeds.Count == 0 )
_idleSketchFeatureId = null;
IdleSelectionChanged?.Invoke();
return;
}
_idleRegionSeeds.Add( extra );
IdleSelectionChanged?.Invoke();
return;
}
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
_idleSketchFeatureId = featureId;
_idleRegionSeeds.Clear();
if ( seed is { } one )
_idleRegionSeeds.Add( one );
IdleSelectionChanged?.Invoke();
}
/// <summary>Drop faces and parts whose bodies no longer exist after a rebuild. Called from
/// SetDisplayBodies so a deleted part cannot stay selected against nothing.</summary>
private void PruneIdleSelection()
{
if ( !HasIdleSelection )
return;
var ids = new HashSet<string>();
foreach ( var body in _displayBodies )
{
if ( body?.Id is { } id )
ids.Add( id );
}
var changed = _idleFaces.RemoveAll( f => !ids.Contains( f.BodyId ) ) > 0
| _idleEdges.RemoveAll( e => !ids.Contains( e.BodyId ) ) > 0
| _idleBodyIds.RemoveAll( id => !ids.Contains( id ) ) > 0;
if ( _idleSketchFeatureId is not null )
{
var found = false;
foreach ( var pickable in _pickableSketches )
{
if ( pickable.FeatureId == _idleSketchFeatureId )
{
found = true;
break;
}
}
if ( !found )
{
ClearIdleSketch();
changed = true;
}
}
if ( changed )
IdleSelectionChanged?.Invoke();
}
/// <summary>
/// Hover the face under the cursor, keep already-picked faces and parts lit, and take a click
/// as a selection when nothing else owns the mouse.
///
/// Runs after the origin handle so a click on the origin is not also a click on the face that
/// happens to sit behind it. Gizmo.HasHovered covers the origin, the plane-corner handles and
/// anything else that registered a hitbox this frame.
/// </summary>
private void IdleSelectionFrame()
{
DrawIdleSelection();
if ( !IdlePickingAllowed )
return;
if ( !_canvasHasCursor )
return;
// A bone under the cursor is a bone click, even when the mesh is in front of it.
// Taking the part instead is why clicking a 3D bone did nothing and Assign stayed grey.
if ( RigMode && TryPickBoneUnderCursor( out _ ) )
return;
// Sketches are a CAD question. In the rig workspace a click names a part (or a bone),
// and a profile lighting up under the cursor would steal the body you meant to pin.
if ( !RigMode
&& TryResolveSketchHover( out var sketchId, out var sketchSeed, out var sketchDistance )
&& SketchBeatsFace( sketchDistance ) )
{
_hoveredFaceBodyId = sketchId;
DrawSketchPickHighlight( sketchId, sketchSeed );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleSketch( sketchId, sketchSeed, Gizmo.IsShiftPressed );
}
return;
}
if ( TryPickFaceUnderCursor( out var hit ) )
{
_hoveredFaceBodyId = hit.Body.Id;
// Rigging assigns WHOLE parts. A face or an edge under the cursor is still the part,
// and lighting a single face would look like CAD selection in a workspace that has
// no face tools.
if ( RigMode )
{
DrawBodyHighlight( hit.Body, BodyPickHoverColor );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
SelectIdleBody( hit.Body.Id, Gizmo.IsShiftPressed );
return;
}
if ( TryIdleEdge( hit, out var edge, out var key ) )
{
DrawEdge( hit.Body, key, FaceHighlightColor );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleEdge( hit.Body, edge, Gizmo.IsShiftPressed );
}
return;
}
DrawHoveredFace( hit.Body, hit.FaceIndex );
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
if ( OriginSelected )
DeselectOrigin();
SelectIdleFace( hit, Gizmo.IsShiftPressed );
}
return;
}
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && !Gizmo.IsHovered )
ClearIdleSelection();
}
/// <summary>
/// Chosen idle geometry, in the same amber the dialog uses for a committed pick.
///
/// Skipped when a dialog is already drawing its own SelectedFaces / SelectedBodyIds, so the
/// two never paint the same face twice in slightly different states.
/// </summary>
/// <summary>A sketch on a face sits at the same depth as that face. Prefer the sketch when
/// they tie; prefer a solid that is genuinely in front of the sketch plane.</summary>
private bool SketchBeatsFace( float sketchDistance )
{
if ( !TryPickFaceUnderCursor( out var hit ) )
return true;
var faceDistance = (hit.Reference.Point - _cursorRayOrigin).Length;
return sketchDistance <= faceDistance + 0.05f;
}
private void DrawIdleSelection()
{
// A BRUSH WANTS THE SURFACE, NOT THE SELECTION. An amber wash and wireframe over the part
// you are painting hides the very colour you are laying down. The selection survives — it
// comes back when the brush is put away — only the drawing of it stops. The shell is cleared
// too: it is a cached scene object, and a frame that simply stops drawing leaves it lit.
if ( IsPainting || IsSculpting || IsMeshEditing || IsMaterialBrushing || IsWeightPainting )
{
SyncBodyHighlight( null, BodySelectedColor );
return;
}
if ( (SelectedSketchFeatureId is null || SelectedSketchFeatureId.Length == 0)
&& _idleSketchFeatureId is not null )
{
var seed = _idleRegionSeeds.Count == 1 ? _idleRegionSeeds[0] : (Vec2?)null;
if ( _idleRegionSeeds.Count <= 1 )
DrawSketchPickHighlight( _idleSketchFeatureId, seed );
else
{
foreach ( var region in _idleRegionSeeds )
DrawSketchPickHighlight( _idleSketchFeatureId, region );
}
}
var drawFaces = SelectedFaces is null || SelectedFaces.Count == 0;
var drawEdges = SelectedEdges is null || SelectedEdges.Count == 0;
var drawBodies = SelectedBodyIds is null || SelectedBodyIds.Count == 0;
if ( drawEdges && _idleEdges.Count > 0 )
{
foreach ( var edge in _idleEdges )
{
if ( FacePlane.TryResolveEdge( _displayBodies, edge, out var body, out var key ) )
DrawEdge( body, key, FaceSelectedColor );
}
}
if ( drawFaces && _idleFaces.Count > 0 )
{
foreach ( var face in _idleFaces )
{
if ( FacePlane.TryResolveFace( _displayBodies, face, out var body, out var index ) )
DrawFace( body, index, FaceSelectedColor );
}
// A face pick names its owning body, so _idleBodyIds is not empty here — but the thing
// selected is the face, and shelling the whole part around it would bury the pick.
SyncBodyHighlight( null, BodySelectedColor );
return;
}
// A DIALOG OWNS THE SHELL WHILE ONE IS PICKING BODIES. DrawChosenBodies drives the same
// cache from the other side, and clearing it here — which is what "no idle selection" would
// otherwise mean — would blank the picker's own highlight every frame.
if ( !drawBodies )
return;
// EVERY OTHER PATH OUT OF HERE ANSWERS THIS, including the ones with nothing to light. The
// shell is a cached scene object rather than lines drawn this frame (see
// EffigyViewport.Highlight), so a frame that simply stops drawing it leaves it on screen.
_highlightBodies.Clear();
foreach ( var body in _displayBodies )
{
if ( body?.Id is not { } id || !_idleBodyIds.Contains( id ) )
continue;
// THE SAME DRAW A VIEWPORT CLICK USES, face for face.
//
// Not a similar one, not a body-shaped variant of one — DrawFace with FaceSelectedColor,
// which is literally the call that lights a face up when you click the solid. Clicking a
// row in the Parts list means "that whole part", so it runs over every face of the body
// and the part comes up in the amber you already know. Every attempt at this that drew
// something of its own instead — the wash-and-outline body highlight, the cached shell —
// failed in a way that looked from the outside like the click doing nothing at all.
//
// The shell stays for the case that earns it: an import too dense to draw a face at a
// time, where this loop is hundreds of thousands of gizmo calls a frame.
// ONE PASS, NOT ONE PER FACE. Calling DrawFace in a loop was the obvious way to say "the
// same amber a face pick gives you" and it is quadratic: DrawFace resolves the COPLANAR
// SURFACE the face belongs to and draws every face in it, so a surface of forty faces is
// drawn forty times, and a part with a few thousand faces spends the frame redrawing
// itself. DrawBodyHighlight paints each face exactly once in the colour it is handed,
// which is the same picture for a fraction of the work.
if ( (body.Mesh?.FaceCount ?? 0) <= HoverHighlightFaceBudget )
DrawBodyHighlight( body, FaceSelectedColor );
else
_highlightBodies.Add( body );
}
SyncBodyHighlight( _highlightBodies, BodySelectedColor );
}
/// <summary>Scratch for the line above — reused rather than allocated, since it is rebuilt every
/// frame and thrown away on almost all of them.</summary>
private readonly List<Body> _highlightBodies = new();
/// <summary>
/// Select one face outright, from something other than a click — the right-click menu, which
/// has already resolved the face under the cursor and now wants a tool pointed at it.
///
/// Replaces the selection rather than adding to it: the face you just right-clicked is the face
/// you meant, and quietly bundling it with whatever was lit a moment ago would hand the tool
/// more than you asked for.
/// </summary>
public void SelectFace( EffigyFaceHit hit ) => SelectIdleFace( hit, add: false );
/// <summary>Click a part in the rig workspace: the whole body, never one of its faces.</summary>
private void SelectIdleBody( string bodyId, bool add )
{
if ( string.IsNullOrEmpty( bodyId ) )
return;
_idleFaces.Clear();
_idleEdges.Clear();
ClearIdleSketch();
if ( add )
{
if ( !_idleBodyIds.Remove( bodyId ) )
_idleBodyIds.Add( bodyId );
}
else
{
_idleBodyIds.Clear();
_idleBodyIds.Add( bodyId );
}
IdleSelectionChanged?.Invoke();
}
/// <summary>Click a face: replace the selection, or Shift-click to toggle it in.</summary>
private void SelectIdleFace( EffigyFaceHit hit, bool add )
{
var face = hit.Reference;
if ( add )
{
for ( var i = 0; i < _idleFaces.Count; i++ )
{
if ( !SameIdleFace( _idleFaces[i], face ) )
continue;
_idleFaces.RemoveAt( i );
RebuildIdleBodyIdsFromFaces();
IdleSelectionChanged?.Invoke();
return;
}
_idleEdges.Clear();
ClearIdleSketch();
_idleFaces.Add( face );
if ( !_idleBodyIds.Contains( hit.Body.Id ) )
_idleBodyIds.Add( hit.Body.Id );
}
else
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
_idleFaces.Add( face );
_idleBodyIds.Add( hit.Body.Id );
}
IdleSelectionChanged?.Invoke();
}
private void SelectIdleEdge( Body body, EdgeRef edge, bool add )
{
if ( add )
{
for ( var i = 0; i < _idleEdges.Count; i++ )
{
if ( !SameIdleEdge( _idleEdges[i], edge ) )
continue;
_idleEdges.RemoveAt( i );
RebuildIdleBodyIdsFromFaces();
IdleSelectionChanged?.Invoke();
return;
}
_idleFaces.Clear();
ClearIdleSketch();
_idleEdges.Add( edge );
if ( !_idleBodyIds.Contains( body.Id ) )
_idleBodyIds.Add( body.Id );
}
else
{
_idleFaces.Clear();
_idleEdges.Clear();
_idleBodyIds.Clear();
ClearIdleSketch();
_idleEdges.Add( edge );
_idleBodyIds.Add( body.Id );
}
IdleSelectionChanged?.Invoke();
}
private bool TryIdleEdge( EffigyFaceHit hit, out EdgeRef edge, out EdgeKey key )
{
edge = default;
key = default;
if ( !TryClosestEdge( hit.Body.Mesh, hit.FaceIndex, hit.Reference.Point, out key, out var distance ) )
return false;
var point = hit.Reference.Point;
var threshold = WorldRadiusAt( new Vector3( point.x, point.y, point.z ), EdgePickPixels );
if ( distance > threshold )
return false;
edge = FacePlane.Capture( hit.Body, key );
return true;
}
private void RebuildIdleBodyIdsFromFaces()
{
_idleBodyIds.Clear();
foreach ( var face in _idleFaces )
{
if ( !_idleBodyIds.Contains( face.BodyId ) )
_idleBodyIds.Add( face.BodyId );
}
foreach ( var edge in _idleEdges )
{
if ( !_idleBodyIds.Contains( edge.BodyId ) )
_idleBodyIds.Add( edge.BodyId );
}
}
private bool SameIdleEdge( EdgeRef a, EdgeRef b )
{
if ( !FacePlane.TryResolveEdge( _displayBodies, a, out var bodyA, out var keyA )
|| !FacePlane.TryResolveEdge( _displayBodies, b, out var bodyB, out var keyB ) )
return false;
return bodyA.Id == bodyB.Id && keyA.Equals( keyB );
}
/// <summary>Match by resolved face, not by stored FaceRef equality — two clicks on the same
/// face produce two references with slightly different hit points.</summary>
private bool SameIdleFace( FaceRef a, FaceRef b )
{
if ( !FacePlane.TryResolveFace( _displayBodies, a, out var bodyA, out var indexA )
|| !FacePlane.TryResolveFace( _displayBodies, b, out var bodyB, out var indexB ) )
return false;
return bodyA.Id == bodyB.Id && indexA == indexB;
}
}
Editor
library
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// Drawing a spline: click to drop points, drag a point to move it, and the curve follows.
///
/// A CLICK LANDS ON THE MODEL WHEN THERE IS ONE UNDER IT, and on a plane through the last point
/// facing the camera otherwise — so a cable can be clicked straight onto a body's surface, and
/// the run between two bodies is clicked out in the air at the height you were already at. The
/// first point with nothing under it lands on the ground plane, which is where a path usually
/// starts. A drag moves the point in the plane facing the camera through it: the point goes
/// where the mouse goes, and depth is left alone.
///
/// The feature owns the points; this only edits them and says so. The curve drawn here is
/// sampled from the feature directly, so it follows the mouse without waiting on a rebuild,
/// and the rebuild that follows an edit is what turns it into the tube.
/// </summary>
internal sealed partial class EffigyViewport
{
public SplineFeature SplineFeature { get; private set; }
public bool IsSplineEditing => SplineFeature is not null;
/// <summary>A point was added, moved or removed. The window marks the feature dirty and rebuilds.</summary>
public Action SplineEdited { get; set; }
/// <summary>Enter, or a double-click: the drawing is done.</summary>
public Action SplineFinishRequested { get; set; }
private int _splineHover = -1;
private int _splineDrag = -1;
private bool _splineDragMoved;
private Vector3 _splineDragNormal;
private Vec3 _splineDragOrigin;
private static readonly Color SplineColor = new( 0.94f, 0.63f, 0.23f );
private static readonly Color SplinePointColor = new( 0.96f, 0.96f, 0.98f );
private static readonly Color SplineHoverColor = new( 0.36f, 0.58f, 1f );
public void BeginSplineEdit( SplineFeature feature )
{
SplineFeature = feature;
_splineHover = -1;
_splineDrag = -1;
Update();
}
public void EndSplineEdit()
{
SplineFeature = null;
_splineHover = -1;
_splineDrag = -1;
Update();
}
/// <summary>Remove the last point — Backspace while drawing.</summary>
public bool RemoveLastSplinePoint()
{
if ( SplineFeature is not { } feature || feature.Points.Count == 0 )
return false;
feature.Points.RemoveAt( feature.Points.Count - 1 );
_splineHover = -1;
SplineEdited?.Invoke();
return true;
}
private void SplineFrame()
{
if ( SplineFeature is not { } feature )
return;
var points = feature.Points;
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 );
var cameraForward = Gizmo.CameraTransform.Rotation.Forward;
// --- the drag, first, so a held button keeps its point even off the handle ---
if ( _splineDrag >= 0 )
{
if ( Gizmo.IsLeftMouseDown && _splineDrag < points.Count )
{
if ( RayPlane( origin, direction, _splineDragOrigin, new Vec3( _splineDragNormal.x, _splineDragNormal.y, _splineDragNormal.z ), out var moved ) )
{
if ( (moved - points[_splineDrag]).LengthSquared > 1e-10f )
{
points[_splineDrag] = moved;
_splineDragMoved = true;
}
}
}
else
{
var moved = _splineDragMoved;
_splineDrag = -1;
_splineDragMoved = false;
if ( moved )
SplineEdited?.Invoke();
}
}
// --- hover: the nearest point within a handle's width of the cursor ray ---
_splineHover = -1;
if ( _canvasHasCursor && _splineDrag < 0 )
{
var best = float.MaxValue;
for ( var i = 0; i < points.Count; i++ )
{
var toPoint = points[i] - origin;
var along = Vec3.Dot( toPoint, direction );
if ( along <= 0f )
continue;
var perpendicular = (toPoint - direction * along).Length;
var handle = HandleRadius( along ) * 1.6f;
if ( perpendicular < handle && along < best )
{
best = along;
_splineHover = i;
}
}
}
// --- where a click would land ---
Vec3? landing = null;
if ( _canvasHasCursor && _splineHover < 0 && _splineDrag < 0 )
landing = SplineLanding( origin, direction, points, cameraForward );
// --- input ---
if ( _canvasHasCursor && Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && _splineDrag < 0 )
{
if ( _splineHover >= 0 )
{
_splineDrag = _splineHover;
_splineDragMoved = false;
_splineDragNormal = cameraForward;
_splineDragOrigin = points[_splineHover];
}
else if ( landing is { } at )
{
points.Add( at );
SplineEdited?.Invoke();
}
}
// --- drawing ---
var spline = feature.ToSpline();
var path = spline.Sample( 10 );
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = SplineColor;
Gizmo.Draw.LineThickness = 2.5f;
for ( var i = 0; i + 1 < path.Count; i++ )
Gizmo.Draw.Line( ToWorldDir( path[i] ), ToWorldDir( path[i + 1] ) );
if ( spline.Closed && path.Count > 2 )
Gizmo.Draw.Line( ToWorldDir( path[^1] ), ToWorldDir( path[0] ) );
// The straight polyline between control points, faint, so the curve's relationship to
// the clicks stays readable on a tight bend.
if ( spline.Smooth && points.Count > 2 )
{
Gizmo.Draw.Color = SplineColor.WithAlpha( 0.25f );
Gizmo.Draw.LineThickness = 1f;
for ( var i = 0; i + 1 < points.Count; i++ )
Gizmo.Draw.Line( ToWorldDir( points[i] ), ToWorldDir( points[i + 1] ) );
}
for ( var i = 0; i < points.Count; i++ )
{
var world = ToWorldDir( points[i] );
var distance = (points[i] - origin).Length;
var radius = HandleRadius( distance );
var lit = i == _splineHover || i == _splineDrag;
Gizmo.Draw.Color = lit ? SplineHoverColor : i == points.Count - 1 ? SplineColor : SplinePointColor;
Gizmo.Draw.SolidSphere( world, lit ? radius * 1.3f : radius, 8, 8 );
}
if ( landing is { } ghost )
{
var world = ToWorldDir( ghost );
var radius = HandleRadius( (ghost - origin).Length );
Gizmo.Draw.Color = SplineColor.WithAlpha( 0.55f );
Gizmo.Draw.LineSphere( new Sphere( world, radius ), 8 );
if ( points.Count > 0 )
{
Gizmo.Draw.Color = SplineColor.WithAlpha( 0.35f );
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.Line( ToWorldDir( points[^1] ), world );
}
}
Gizmo.Draw.LineThickness = 1f;
Gizmo.Draw.IgnoreDepth = false;
}
/// <summary>A handle that stays the same size on screen: a fraction of its distance from the eye.</summary>
private static float HandleRadius( float distance ) => MathF.Max( distance * 0.008f, 0.02f );
/// <summary>
/// Where a click lands: the model's surface under the cursor, else a plane facing the camera
/// through the last point, else the ground.
/// </summary>
private Vec3? SplineLanding( Vec3 origin, Vec3 direction, List<Vec3> points, Vector3 cameraForward )
{
var visible = _displayBodies.Where( b => b?.Mesh is not null && b.Visible ).ToList();
if ( visible.Count > 0 && MeshRaycast.Raycast( visible, origin, direction, PickTreeFor ) is { } hit )
return hit.Hit.Point;
if ( points.Count > 0 )
{
var normal = new Vec3( cameraForward.x, cameraForward.y, cameraForward.z );
if ( RayPlane( origin, direction, points[^1], normal, out var onView ) )
return onView;
}
if ( RayPlane( origin, direction, Vec3.Zero, new Vec3( 0, 0, 1 ), out var onGround ) )
return onGround;
return null;
}
private static bool RayPlane( Vec3 origin, Vec3 direction, Vec3 planePoint, Vec3 planeNormal, out Vec3 hit )
{
hit = default;
var denominator = Vec3.Dot( direction, planeNormal );
if ( MathF.Abs( denominator ) < 1e-6f )
return false;
var t = Vec3.Dot( planePoint - origin, planeNormal ) / denominator;
if ( t <= 0f )
return false;
hit = origin + direction * t;
return true;
}
}
Editor
library
using Editor;
using Sandbox;
namespace Marionette.Tools;
/// <summary>
/// The "Documentation" style collapsible note used throughout the stock Inspector (a chevron, a
/// circled ? badge, a bold title, a blue accent line under the whole block) - reused here so each
/// tab opens with the same look instead of a hand-rolled one-off.
/// </summary>
internal static class RigHelpBox
{
/// <summary>One labelled chunk of the note - "Model", "IK Constraint", whatever the reader
/// needs to look up on its own rather than hunt for inside one wall of text.</summary>
public readonly record struct Section( string Heading, string Body );
public static Section S( string heading, string body ) => new( heading, body );
/// <summary>
/// intro: a plain, unlabelled paragraph at the top - what this tab is, in one or two
/// sentences. sections: each gets its own bold heading and paragraph below it, so a reader
/// can scan headings instead of parsing a block of text to find the one field they care about.
///
/// COLLAPSED BY DEFAULT. Expanded, three tabs of reference text is the first thing a new user
/// sees, before they've done anything for it to be about - which reads as homework and gets
/// skipped wholesale, taking the useful parts with it. The status bar's tutorial handles
/// "what do I do now" one step at a time; this is the reference you open once you have a
/// specific question, and the header line says which question it answers.
/// </summary>
public static Widget Create( Widget parent, string intro, Section[] sections, bool startExpanded = false )
{
var expanded = startExpanded;
var box = new Widget( parent ) { Layout = Layout.Column() };
box.Layout.Margin = 0;
box.OnPaintOverride = () =>
{
// Accent line along the bottom, the one clearly-visible border the reference has.
Paint.ClearPen();
Paint.SetBrush( Theme.Blue );
Paint.DrawRect( new Rect( 0, box.Height - 2, box.Width, 2 ) );
return false;
};
var header = new HeaderWidget( box ) { FixedHeight = 28 };
box.Layout.Add( header );
var body = new Widget( box ) { Layout = Layout.Column() };
body.Layout.Margin = new Sandbox.UI.Margin( 32, 0, 12, 12 );
body.Layout.Spacing = 10;
if ( !string.IsNullOrWhiteSpace( intro ) )
body.Layout.Add( new Editor.Label( intro ) { WordWrap = true } );
foreach ( var section in sections )
{
var block = body.Layout.AddColumn();
block.Spacing = 2;
var heading = new Editor.Label( section.Heading ) { Color = Theme.Blue };
heading.SetStyles( "font-weight: 600;" );
block.Add( heading );
block.Add( new Editor.Label( section.Body ) { WordWrap = true } );
}
box.Layout.Add( body );
void UpdateState()
{
header.Expanded = expanded;
body.Visible = expanded;
}
UpdateState();
header.Clicked = () =>
{
expanded = !expanded;
UpdateState();
};
return box;
}
/// <summary>Chevron + circled "?" + bold "Documentation" title, hand-painted so it actually
/// matches the reference instead of approximating it with stock Label/Button widgets.</summary>
private sealed class HeaderWidget : Widget
{
public bool Expanded { get; set; } = true;
public System.Action Clicked { get; set; }
public HeaderWidget( Widget parent ) : base( parent )
{
Cursor = CursorShape.Finger;
}
protected override void OnPaint()
{
var rect = LocalRect;
Paint.Antialiasing = true;
// Chevron
Paint.ClearBrush();
Paint.SetPen( Theme.Blue );
Paint.DrawIcon( new Rect( 6, 0, 16, rect.Height ), Expanded ? "expand_more" : "chevron_right", 16, TextFlag.Center );
// Circled "?" badge - DrawCircle takes a Rect (confirmed from existing usage
// elsewhere in this project), not a center+radius pair.
var badgeCenter = new Vector2( 34, rect.Center.y );
Paint.ClearPen();
Paint.SetBrush( Theme.Blue );
Paint.DrawCircle( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ) );
Paint.SetPen( Theme.WindowBackground );
Paint.SetDefaultFont( 8, 700 );
Paint.DrawText( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ), "?", TextFlag.Center );
// Title
Paint.SetPen( Theme.Blue );
Paint.SetDefaultFont( 8, 600 );
Paint.DrawText( new Rect( 50, 0, rect.Width - 58, rect.Height ), "Documentation", TextFlag.LeftCenter );
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
Clicked?.Invoke();
}
}
}
Game
library
using System;
using System.Collections.Generic;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Deriving a bone from the shape of the body it will move.
///
/// The theme here is that the answers must be STABLE, not merely plausible. A derived bone that is
/// a little off is easy to nudge; a derived bone that comes out differently on two identical
/// fingers, or flips end-for-end when the model is rebuilt, is worse than no feature at all,
/// because the modeller has to check every one by eye before trusting any of them. So most of what
/// follows measures repeatability and orientation rather than precision.
/// </summary>
public static class BoneFromBodyTests
{
public static void Run()
{
Section( "a bone spans the body's longest axis" );
TestLongestAxis();
Section( "roll follows the widest direction across the bone" );
TestRoll();
Section( "an anchor decides which end is the head" );
TestAnchor();
Section( "shapes with no answer are refused rather than guessed" );
TestDegenerate();
Section( "the same shape always derives the same bone" );
TestRepeatable();
Section( "a derived bone feeds Skeleton unchanged" );
TestFeedsSkeleton();
}
/// <summary>The eight corners of an axis-aligned box from the origin to (sx, sy, sz).</summary>
static List<Vec3> Box( float sx, float sy, float sz )
{
var points = new List<Vec3>();
for ( var i = 0; i < 8; i++ )
{
points.Add( new Vec3(
(i & 1) == 0 ? 0 : sx,
(i & 2) == 0 ? 0 : sy,
(i & 4) == 0 ? 0 : sz ) );
}
return points;
}
static void TestLongestAxis()
{
// Four long, two wide, one thick — a finger, near enough, and unambiguous about which axis
// is which.
var ok = BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out _ );
Check( "a box derives a bone", ok );
Check( "the bone aims down the long axis",
Near( (tail - head).Normal, new Vec3( 1, 0, 0 ) ), (tail - head).Normal.ToString() );
Check( "it spans the full extent", Near( (tail - head).Length, 4f ), (tail - head).Length.ToString() );
Check( "it sits on the body's centre line", Near( head, new Vec3( 0, 0.5f, 1 ) ), head.ToString() );
// Rotated onto a diagonal: nothing about the method is axis-aligned, and a body built on a
// sketch plane that is not one of the three standard ones is the normal case, not the
// exotic one.
var diagonal = new List<Vec3>();
var along = new Vec3( 1, 1, 0 ).Normal;
foreach ( var p in Box( 4, 1, 2 ) )
{
// Re-express the box about the diagonal: x runs along it, y across it in plane.
diagonal.Add( along * p.x + new Vec3( -along.y, along.x, 0 ) * p.y + new Vec3( 0, 0, 1 ) * p.z );
}
BoneFromBody.TryDerive( diagonal, out var dHead, out var dTail, out _ );
Check( "a diagonal body aims down its own long axis",
Near( (dTail - dHead).Normal, along ), (dTail - dHead).Normal.ToString() );
Check( "and still spans four units", Near( (dTail - dHead).Length, 4f ),
(dTail - dHead).Length.ToString() );
}
static void TestRoll()
{
// Long in x, thin in y, wide in z. The roll should name z, because that is the direction
// the section actually has width in — it is what an animator would call the flat of it.
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out _, out var up );
Check( "roll takes the wide direction, not the thin one",
Near( up, new Vec3( 0, 0, 1 ) ) || Near( up, new Vec3( 0, 0, -1 ) ), up.ToString() );
// Swap which of the two cross directions is wider and the roll has to follow. This is the
// check that would fail if the axes were simply being read off in a fixed order.
BoneFromBody.TryDerive( Box( 4, 2, 1 ), out _, out _, out var swapped );
Check( "widening the other way rolls the bone",
Near( swapped, new Vec3( 0, 1, 0 ) ) || Near( swapped, new Vec3( 0, -1, 0 ) ),
swapped.ToString() );
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out var tail, out var perpendicular );
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out _, out _ );
Check( "the hint is already perpendicular to the aim",
MathF.Abs( Vec3.Dot( perpendicular, (tail - head).Normal ) ) < 1e-4f,
Vec3.Dot( perpendicular, (tail - head).Normal ).ToString() );
Check( "and is unit length", Near( perpendicular.Length, 1f ), perpendicular.Length.ToString() );
}
static void TestAnchor()
{
var box = Box( 4, 1, 2 );
// No anchor: canonical, and the canon is +x, so the head is the low end.
BoneFromBody.TryDerive( box, out var head, out var tail, out _ );
Check( "unanchored, the head is the canonical end", head.x < tail.x, $"{head} -> {tail}" );
// Anchored past the far end, the bone has to turn around — that end is where it joins the
// rest of the model, and a bone growing back toward its own parent is the bug this stops.
BoneFromBody.TryDerive( box, out var flipped, out var flippedTail, out _, new Vec3( 9, 0, 0 ) );
Check( "an anchor at the far end swaps the ends", flipped.x > flippedTail.x,
$"{flipped} -> {flippedTail}" );
Check( "swapping moves neither point", Near( flipped, tail ) && Near( flippedTail, head ),
$"{flipped} / {flippedTail}" );
// An anchor on the side it already pointed at must change nothing at all.
BoneFromBody.TryDerive( box, out var kept, out var keptTail, out _, new Vec3( -9, 0, 0 ) );
Check( "an anchor at the near end leaves it alone",
Near( kept, head ) && Near( keptTail, tail ), $"{kept} -> {keptTail}" );
}
static void TestDegenerate()
{
Check( "no points is refused",
!BoneFromBody.TryDerive( new List<Vec3>(), out _, out _, out _ ) );
Check( "one point is refused",
!BoneFromBody.TryDerive( new List<Vec3> { Vec3.Zero }, out _, out _, out _ ) );
Check( "a null list is refused",
!BoneFromBody.TryDerive( (IReadOnlyList<Vec3>)null, out _, out _, out _ ) );
Check( "a null mesh is refused",
!BoneFromBody.TryDerive( (PolyMesh)null, out _, out _, out _ ) );
// Every point in the same place has no axis and no length. Refusing beats returning a
// zero-length bone, which Skeleton would throw on anyway, further from the cause.
var stacked = new List<Vec3> { new( 1, 1, 1 ), new( 1, 1, 1 ), new( 1, 1, 1 ) };
Check( "a body with no extent is refused",
!BoneFromBody.TryDerive( stacked, out _, out _, out _ ) );
// A CUBE HAS NO LONGEST AXIS and that is not an error — every direction is a legitimate
// principal axis, so there is nothing to be right about. It must still produce a usable
// bone rather than a NaN or a refusal.
var cube = BoneFromBody.TryDerive( Box( 2, 2, 2 ), out var head, out var tail, out var up );
Check( "a cube still derives a bone", cube );
Check( "the cube's bone has real length",
cube && (tail - head).Length > 1e-3f && !float.IsNaN( (tail - head).Length ),
cube ? (tail - head).Length.ToString() : "refused" );
Check( "and a real up-hint", cube && Near( up.Length, 1f ), up.Length.ToString() );
}
static void TestRepeatable()
{
// The property the whole feature leans on: a patterned row of eight fingers has to come out
// as eight bones pointing the same way. Same shape in, same bone out, every time.
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headA, out var tailA, out var upA );
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headB, out var tailB, out var upB );
Check( "two identical bodies derive identical bones",
Near( headA, headB ) && Near( tailA, tailB ) && Near( upA, upB ) );
// Order of the vertices is not part of the shape, and a rebuild is free to change it.
var shuffled = Box( 4, 1, 2 );
shuffled.Reverse();
BoneFromBody.TryDerive( shuffled, out var headC, out var tailC, out var upC );
Check( "vertex order does not change the answer",
Near( headA, headC ) && Near( tailA, tailC ) && Near( upA, upC ),
$"{headC} -> {tailC}, up {upC}" );
// Translated bodies are the same shape somewhere else, so the bone must simply move with
// them — a pattern is a translation, and this is that case.
var moved = new List<Vec3>();
var shift = new Vec3( 10, -3, 7 );
foreach ( var p in Box( 4, 1, 2 ) )
moved.Add( p + shift );
BoneFromBody.TryDerive( moved, out var headD, out var tailD, out var upD );
Check( "a translated body derives a translated bone",
Near( headD, headA + shift ) && Near( tailD, tailA + shift ) && Near( upD, upA ),
$"{headD} -> {tailD}" );
}
static void TestFeedsSkeleton()
{
// The output exists to be handed straight to AddBoneFromPoints, up-hint and all, so check
// the bone that actually lands rather than only the numbers on the way in.
BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out var up );
var skeleton = new Skeleton();
skeleton.AddBoneFromPoints( "finger", -1, head, tail, up );
var world = skeleton.WorldBind( 0 );
Check( "the bone's +Y runs down the body", Near( world.Y, new Vec3( 1, 0, 0 ) ), world.Y.ToString() );
Check( "the bone's +Z took the derived roll", Near( world.Z, up ), world.Z.ToString() );
Check( "the bone starts at the derived head", Near( world.Origin, head ), world.Origin.ToString() );
Check( "its length is the body's extent", Near( skeleton.Bones[0].Length, 4f ),
skeleton.Bones[0].Length.ToString() );
}
static bool Near( Vec3 a, Vec3 b, float tolerance = 1e-4f ) => (a - b).Length < tolerance;
static bool Near( float a, float b, float tolerance = 1e-4f ) => MathF.Abs( a - b ) < tolerance;
static void Section( string title ) => Report.Section( title );
static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}
Debug: View Raw JSON Response
{
"TotalCount": 483,
"Files": [
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/DmxAnimWriter.cs",
"FileName": "DmxAnimWriter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.IO;\n\nnamespace Effigy;\n\n/// <summary>\n/// DMX export \u2014 the animation path. A skeleton and a set of per-bone channels, which is what\n/// ModelDoc's `AnimFile` node wants pointed at it, and therefore what puts a hand-authored clip\n/// inside a compiled model where AnimGraph can reach it.\n///\n/// WHY THIS IS NOT SMD, WHICH IS THE OBVIOUS ANSWER AND THE WRONG ONE. A sequence SMD is the\n/// classic way to hand-write animation for a Source model, SmdWriter already emits the exact\n/// `skeleton` / `time N` / bone-row block a sequence needs, and extending it looks like an\n/// afternoon's work. ModelDoc does not read SMD at all \u2014 see DmxWriter's header for the loader\n/// string that says so in the compiler's own words. The mesh path learned that the expensive way;\n/// this file exists so the animation path does not learn it again.\n///\n/// COPIED, NOT GUESSED \u2014 and there is a command that produces the thing to copy. The engine ships\n/// `bin/win64/fbx2dmx.exe`, whose <b>-a</b> flag converts animation rather than geometry, and\n/// every element, attribute and spelling below was read off its output for a shipping clip:\n///\n/// fbx2dmx.exe -a -i addons/citizen/Assets/models/citizen/animations/face/Citizen@Eyes_Blink.fbx -o ref.dmx\n///\n/// That reference is the only evidence there is about this format, and regenerating it beats\n/// reasoning from this comment if anything here ever stops working. What it settled, none of which\n/// is inferable from the element names alone:\n///\n/// - `animationList` hangs off the ROOT DmElement, beside `skeleton` and `model` \u2014 not inside\n/// the DmeModel, which is where it looks like it should go;\n/// - a channel targets the bone's DmeTransform, not its DmeJoint, and does it by id;\n/// - each bone needs TWO channels, suffixed `_p` and `_o`, writing `position` and `orientation`\n/// respectively. One channel per bone carrying both does not exist;\n/// - `mode` is 3 on every channel fbx2dmx writes;\n/// - the log layer's `curvetypes` array is present and EMPTY, which is what \"no per-key curve\n/// override\" looks like. Omitting the array is a different statement.\n///\n/// WHAT IS DELIBERATELY LEFT OUT. The reference also carries a `compressed` binary blob on each\n/// log layer \u2014 empty in every layer of it. An empty blob says nothing its absence does not, and\n/// KeyValues2's binary literal is a multi-line quoted form with no second example to check a guess\n/// against, so this writes no `compressed` attribute rather than an invented one.\n/// `dmxconvert.exe -i clip.dmx -o check.dmx` is the one-second check that this was the right call;\n/// DmxAnimTests runs the same parse with no engine involved.\n/// </summary>\npublic static class DmxAnimWriter\n{\n\tpublic static void WriteFile( string path, Skeleton skeleton, AnimClip clip, string modelName = null )\n\t{\n\t\tFile.WriteAllText( path, Write( skeleton, clip, modelName ) );\n\t}\n\n\t/// <summary>\n\t/// The animation as a DMX document.\n\t///\n\t/// The skeleton written here has to be the SAME skeleton the mesh was exported with. ModelDoc\n\t/// matches a clip's channels to a model's bones by name, and a bone the clip poses that the\n\t/// model does not have is dropped silently \u2014 so a rig edited between the two exports gives you\n\t/// a clip that compiles, loads, and moves less of the model than it used to.\n\t/// </summary>\n\tpublic static string Write( Skeleton skeleton, AnimClip clip, string modelName = null )\n\t{\n\t\tif ( skeleton is null )\n\t\t\tthrow new ArgumentNullException( nameof( skeleton ) );\n\n\t\tif ( clip is null )\n\t\t\tthrow new ArgumentNullException( nameof( clip ) );\n\n\t\tif ( clip.Validate( skeleton ) is { } problem )\n\t\t\tthrow new InvalidOperationException( $\"Animation clip does not fit its skeleton: {problem}\" );\n\n\t\tmodelName ??= \"effigy_model\";\n\n\t\tvar w = new DmxText();\n\n\t\t// Same id discipline as the mesh writer: counted, not random, so two exports of the same\n\t\t// clip are byte-identical and a diff shows what actually changed.\n\t\tvar idRoot = w.NextId();\n\t\tvar idModel = w.NextId();\n\t\tvar idModelTransform = w.NextId();\n\n\t\tvar boneDagIds = new string[skeleton.Count];\n\t\tvar boneTransformIds = new string[skeleton.Count];\n\t\tvar bindTransformIds = new string[skeleton.Count];\n\n\t\tfor ( var i = 0; i < skeleton.Count; i++ )\n\t\t{\n\t\t\tboneDagIds[i] = w.NextId();\n\t\t\tboneTransformIds[i] = w.NextId();\n\t\t\tbindTransformIds[i] = w.NextId();\n\t\t}\n\n\t\tvar idAnimList = w.NextId();\n\t\tvar idClip = w.NextId();\n\t\tvar idTimeFrame = w.NextId();\n\n\t\tw.Raw( $\"<!-- dmx encoding keyvalues2 1 format model {DmxWriter.ModelFormatVersion} -->\" );\n\t\tw.Raw( \"\" );\n\n\t\tw.OpenElement( \"DmElement\", idRoot, \"root\" );\n\n\t\t// No mesh child: an animation DMX carries the rig and the motion, and the geometry lives in\n\t\t// the model file this clip gets compiled into.\n\t\tDmxWriter.WriteSkeletonModel( w, skeleton, modelName, idModel, idModelTransform,\n\t\t\tboneDagIds, boneTransformIds, bindTransformIds, null );\n\n\t\tw.Attribute( \"model\", \"element\", idModel );\n\n\t\tWriteAnimationList( w, skeleton, clip, idAnimList, idClip, idTimeFrame, boneTransformIds );\n\n\t\tw.CloseElement();\n\n\t\treturn w.ToString();\n\t}\n\n\t// --- pieces -------------------------------------------------------------------------------\n\n\tstatic void WriteAnimationList( DmxText w, Skeleton skeleton, AnimClip clip,\n\t\tstring idAnimList, string idClip, string idTimeFrame, string[] boneTransformIds )\n\t{\n\t\tw.OpenAttributeElement( \"animationList\", \"DmeAnimationList\", idAnimList, \"anim\" );\n\t\tw.OpenArray( \"animations\", \"element_array\" );\n\n\t\tw.OpenArrayElement( \"DmeChannelsClip\", idClip, clip.Name );\n\t\t{\n\t\t\tw.OpenAttributeElement( \"timeFrame\", \"DmeTimeFrame\", idTimeFrame, \"timeFrame\" );\n\t\t\tw.Attribute( \"start\", \"time\", DmxText.Time( 0f ) );\n\t\t\tw.Attribute( \"duration\", \"time\", DmxText.Time( clip.Duration ) );\n\t\t\tw.Attribute( \"offset\", \"time\", DmxText.Time( 0f ) );\n\t\t\tw.Attribute( \"scale\", \"float\", \"1\" );\n\t\t\tw.CloseElement();\n\n\t\t\tw.OpenArray( \"channels\", \"element_array\" );\n\n\t\t\t// Two channels per bone, position then orientation, in skeleton order. The order is not\n\t\t\t// load-bearing \u2014 channels name their own target \u2014 but keeping it stable is what makes\n\t\t\t// two exports of the same clip diffable.\n\t\t\tfor ( var i = 0; i < skeleton.Count; i++ )\n\t\t\t{\n\t\t\t\tWritePositionChannel( w, skeleton, clip, i, boneTransformIds[i] );\n\t\t\t\tWriteOrientationChannel( w, skeleton, clip, i, boneTransformIds[i] );\n\t\t\t}\n\n\t\t\tw.CloseArray();\n\t\t}\n\t\tw.CloseElement();\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t}\n\n\tstatic void WritePositionChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )\n\t{\n\t\tw.OpenArrayElement( \"DmeChannel\", w.NextId(), $\"{skeleton.Bones[bone].Name}_p\" );\n\t\tWriteChannelTarget( w, targetId, \"position\" );\n\n\t\tw.OpenAttributeElement( \"log\", \"DmeVector3Log\", w.NextId(), \"vector3 log\" );\n\t\tw.OpenArray( \"layers\", \"element_array\" );\n\t\tw.OpenArrayElement( \"DmeVector3LogLayer\", w.NextId(), \"vector3 log\" );\n\n\t\tWriteTimes( w, clip );\n\n\t\tw.OpenArray( \"values\", \"vector3_array\" );\n\n\t\tfor ( var f = 0; f < clip.FrameCount; f++ )\n\t\t\tw.ArrayValue( DmxText.Vector3( clip.Frames[f][bone].Origin ) );\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t\tw.CloseArray();\n\n\t\tw.Attribute( \"usedefaultvalue\", \"bool\", \"0\" );\n\t\tw.Attribute( \"defaultvalue\", \"vector3\", \"0 0 0\" );\n\t\tw.CloseElement();\n\n\t\tw.CloseElement();\n\t}\n\n\tstatic void WriteOrientationChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )\n\t{\n\t\tw.OpenArrayElement( \"DmeChannel\", w.NextId(), $\"{skeleton.Bones[bone].Name}_o\" );\n\t\tWriteChannelTarget( w, targetId, \"orientation\" );\n\n\t\tw.OpenAttributeElement( \"log\", \"DmeQuaternionLog\", w.NextId(), \"quaternion log\" );\n\t\tw.OpenArray( \"layers\", \"element_array\" );\n\t\tw.OpenArrayElement( \"DmeQuaternionLogLayer\", w.NextId(), \"quaternion log\" );\n\n\t\tWriteTimes( w, clip );\n\n\t\tw.OpenArray( \"values\", \"quaternion_array\" );\n\n\t\tfor ( var f = 0; f < clip.FrameCount; f++ )\n\t\t\tw.ArrayValue( DmxText.Quaternion( clip.Frames[f][bone] ) );\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t\tw.CloseArray();\n\n\t\tw.Attribute( \"usedefaultvalue\", \"bool\", \"0\" );\n\t\tw.Attribute( \"defaultvalue\", \"quaternion\", \"0 0 0 1\" );\n\t\tw.CloseElement();\n\n\t\tw.CloseElement();\n\t}\n\n\t/// <summary>\n\t/// The half of a channel that says where its values go.\n\t///\n\t/// `fromElement` and `fromAttribute` are empty because nothing drives this channel \u2014 it is a\n\t/// stored curve, not a connection between two live elements, which is the other thing a\n\t/// DmeChannel gets used for. `mode` 3 is what fbx2dmx writes on every channel of an exported\n\t/// clip; the enum it comes from is not in anything readable here, so this is copied rather than\n\t/// named.\n\t/// </summary>\n\tstatic void WriteChannelTarget( DmxText w, string targetId, string attribute )\n\t{\n\t\tw.Attribute( \"fromElement\", \"element\", \"\" );\n\t\tw.Attribute( \"fromAttribute\", \"string\", \"\" );\n\t\tw.Attribute( \"fromIndex\", \"int\", \"0\" );\n\t\tw.Attribute( \"toElement\", \"element\", targetId );\n\t\tw.Attribute( \"toAttribute\", \"string\", attribute );\n\t\tw.Attribute( \"toIndex\", \"int\", \"0\" );\n\t\tw.Attribute( \"mode\", \"int\", \"3\" );\n\t}\n\n\t/// <summary>\n\t/// The sample times, plus the empty curvetypes array that goes with them.\n\t///\n\t/// Written once for both channel kinds because the two have to agree exactly: a position log\n\t/// and an orientation log with different time arrays is a bone whose translation and rotation\n\t/// drift apart, which reads as a rigging fault rather than an export one.\n\t/// </summary>\n\tstatic void WriteTimes( DmxText w, AnimClip clip )\n\t{\n\t\tw.OpenArray( \"times\", \"time_array\" );\n\n\t\tfor ( var f = 0; f < clip.FrameCount; f++ )\n\t\t\tw.ArrayValue( DmxText.Time( clip.TimeOf( f ) ) );\n\n\t\tw.CloseArray();\n\n\t\t// Present and empty \u2014 see the header. This is \"no per-key curve override\", not \"no curves\".\n\t\tw.OpenArray( \"curvetypes\", \"int_array\" );\n\t\tw.CloseArray();\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/Features/BasicFeatures.cs",
"FileName": "BasicFeatures.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// <summary>\n/// Creates a primitive solid. Onshape's own Primitives feature works this way \u2014 one dropdown, and\n/// the dialog shows only the fields that shape actually has.\n///\n/// Parameters deliberately changes with Shape rather than showing every field greyed out. That is\n/// the behaviour being copied: a box dialog asks for three lengths and nothing else.\n/// </summary>\npublic sealed class PrimitiveFeature : Feature\n{\n\tpublic override string TypeName => Shape.Value;\n\n\tpublic readonly ChoiceParam Shape = new( \"Shape\",\n\t\tnew[] { \"Box\", \"Cylinder\", \"Sphere\", \"Wedge\", \"Tube\", \"Plane\" } );\n\n\tpublic readonly FloatParam SizeX = new( \"Width\", 1f, 0.0001f, unit: \"u\" );\n\tpublic readonly FloatParam SizeY = new( \"Depth\", 1f, 0.0001f, unit: \"u\" );\n\tpublic readonly FloatParam SizeZ = new( \"Height\", 1f, 0.0001f, unit: \"u\" );\n\tpublic readonly FloatParam Radius = new( \"Radius\", 0.5f, 0.0001f, unit: \"u\" );\n\tpublic readonly FloatParam InnerRadius = new( \"Inner radius\", 0.3f, 0f, unit: \"u\" );\n\tpublic readonly IntParam Segments = new( \"Segments\", 16, 3, 512 );\n\tpublic readonly IntParam Divisions = new( \"Divisions\", 4, 1, 64 );\n\tpublic readonly Vec3Param Position = new( \"Position\", Vec3.Zero );\n\n\t/// <summary>\n\t/// Per-axis scale, applied about the primitive's own origin before it is moved into place.\n\t///\n\t/// NOT THE SAME THING AS THE SIZE PARAMETERS, and worth having alongside them. Width/Depth/\n\t/// Height build the shape at a size; this stretches whatever was built, which is the only way\n\t/// to get an ellipsoid out of a sphere or an oval tube out of a round one \u2014 those are defined\n\t/// by a radius and have no per-axis size to set.\n\t/// </summary>\n\tpublic readonly Vec3Param Scale = new( \"Scale\", Vec3.One );\n\n\t/// <summary>\n\t/// Whether the dialog keeps the three scale axes equal as you edit one of them.\n\t///\n\t/// A UI CONVENIENCE THAT IS PERSISTED, not something the kernel enforces. Scale stays the\n\t/// single truth about the shape \u2014 Execute reads all three axes and never consults this \u2014 so a\n\t/// document always builds exactly what its three numbers say. This only rides along so the\n\t/// dialog can remember that you were editing that primitive uniformly.\n\t/// </summary>\n\tpublic readonly BoolParam UniformScale = new( \"Uniform scale\", false );\n\n\tpublic readonly IntParam Material = new( \"Material slot\", 0, 0, 63 ) { Slider = false };\n\n\t/// <summary>A turn about an axis through the primitive's own origin, applied before Position \u2014\n\t/// a cylinder lying along x is an axle, a wedge on its side is a roof. Folded away: most\n\t/// primitives stand as they come.</summary>\n\tpublic readonly Vec3Param RotationAxis = new( \"Rotation axis\", new Vec3( 0, 0, 1 ) );\n\tpublic readonly FloatParam RotationAngle = new( \"Rotation\", 0f, unit: \"deg\" );\n\n\t/// <summary>The slot, folded away with the other features' \u2014 a primitive is placed and sized,\n\t/// and painted later from the Materials panel.</summary>\n\tpublic override IReadOnlyList<IParam> AdvancedParameters => new IParam[] { RotationAxis, RotationAngle, Material };\n\n\tpublic override IReadOnlyList<IParam> Parameters => Shape.Value switch\n\t{\n\t\t\"Box\" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t\"Cylinder\" => new IParam[] { Shape, Radius, SizeZ, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t\"Sphere\" => new IParam[] { Shape, Radius, Divisions, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t\"Wedge\" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t\"Tube\" => new IParam[] { Shape, Radius, InnerRadius, SizeZ, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t\"Plane\" => new IParam[] { Shape, SizeX, SizeY, Segments, Position, Scale, UniformScale, RotationAxis, RotationAngle, Material },\n\t\t_ => new IParam[] { Shape }\n\t};\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar mesh = Shape.Value switch\n\t\t{\n\t\t\t\"Box\" => Primitives.Box( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),\n\t\t\t\"Cylinder\" => Primitives.Cylinder( Radius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped ),\n\t\t\t\"Sphere\" => Primitives.QuadSphere( Radius.Clamped, Divisions.Clamped, Material.Clamped ),\n\t\t\t\"Wedge\" => Primitives.Wedge( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),\n\t\t\t\"Tube\" => BuildTube(),\n\t\t\t\"Plane\" => Primitives.Plane( SizeX.Clamped, SizeY.Clamped, Segments.Clamped, Segments.Clamped, Material.Clamped ),\n\t\t\t_ => throw new FeatureException( new FeatureDiagnostic(\n\t\t\t\tDiagnosticSeverity.Error,\n\t\t\t\t$\"unknown shape '{Shape.Value}'\",\n\t\t\t\t\"The shape dropdown has a value this feature does not build.\",\n\t\t\t\t\"Shape\",\n\t\t\t\tremedies: new[] { \"Pick Box, Cylinder, Sphere, Wedge, Tube or Plane\" } ) )\n\t\t};\n\n\t\tvar scale = Scale.Value;\n\n\t\tif ( scale.x == 0f || scale.y == 0f || scale.z == 0f )\n\t\t{\n\t\t\tFailOn( \"Scale\",\n\t\t\t\t\"Scale cannot be zero on any axis\",\n\t\t\t\t$\"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.\",\n\t\t\t\t\"Set every scale axis to a non-zero value\" );\n\t\t}\n\n\t\t// SCALE FIRST, ABOUT THE PRIMITIVE'S OWN ORIGIN. Applied after the translate it would\n\t\t// multiply the position too, so nudging a scaled box would move it by the scale factor and\n\t\t// the number in the Position field would stop meaning where the box is.\n\t\tif ( scale.x != 1f || scale.y != 1f || scale.z != 1f )\n\t\t\tMeshTransform.Apply( mesh, Xform.Scale( scale ) );\n\n\t\tif ( RotationAngle.Value != 0f && RotationAxis.Value.LengthSquared > 1e-8f )\n\t\t\tMeshTransform.Apply( mesh, Xform.Rotate( RotationAxis.Value.Normal, RotationAngle.Value * MathF.PI / 180f ) );\n\n\t\tif ( Position.Value.LengthSquared > 0f )\n\t\t\tMeshTransform.Apply( mesh, Xform.Translate( Position.Value ) );\n\n\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), Name, mesh ) );\n\t}\n\n\tPolyMesh BuildTube()\n\t{\n\t\t// Caught here rather than left to Primitives, so the message names the parameter the user\n\t\t// can actually see in the dialog.\n\t\tif ( InnerRadius.Clamped >= Radius.Clamped )\n\t\t{\n\t\t\tFailOn( \"Inner radius\",\n\t\t\t\t\"Inner radius must be smaller than radius\",\n\t\t\t\t$\"Inner radius is {InnerRadius.Clamped:0.###} and radius is {Radius.Clamped:0.###}.\",\n\t\t\t\t\"Reduce Inner radius\",\n\t\t\t\t\"Increase Radius\" );\n\t\t}\n\n\t\treturn Primitives.Tube( Radius.Clamped, InnerRadius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped );\n\t}\n}\n\n/// <summary>Move, rotate and scale bodies. Onshape's Transform.</summary>\npublic sealed class TransformFeature : Feature\n{\n\tpublic override string TypeName => \"Transform\";\n\n\tpublic override GeometryKind Accepts => GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \"Bodies\" );\n\tpublic readonly Vec3Param Translate = new( \"Translate\", Vec3.Zero );\n\tpublic readonly Vec3Param RotationAxis = new( \"Rotation axis\", new Vec3( 0, 0, 1 ) );\n\tpublic readonly FloatParam RotationAngle = new( \"Angle\", 0f, unit: \"deg\" );\n\tpublic readonly Vec3Param Scale = new( \"Scale\", Vec3.One );\n\n\tpublic override IReadOnlyList<IParam> Parameters =>\n\t\tnew IParam[] { Bodies, Translate, RotationAxis, RotationAngle, Scale };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar scale = Scale.Value;\n\n\t\tif ( scale.x == 0f || scale.y == 0f || scale.z == 0f )\n\t\t{\n\t\t\tFailOn( \"Scale\",\n\t\t\t\t\"Scale cannot be zero on any axis\",\n\t\t\t\t$\"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.\",\n\t\t\t\t\"Set every scale axis to a non-zero value\" );\n\t\t}\n\n\t\t// Scale, then rotate, then translate \u2014 the order a user expects, and the one that keeps a\n\t\t// rotation about the origin from being skewed by a non-uniform scale applied after it.\n\t\tvar xform =\n\t\t\tXform.Translate( Translate.Value )\n\t\t\t* Xform.Rotate( RotationAxis.Value, RotationAngle.Value * MathF.PI / 180f )\n\t\t\t* Xform.Scale( scale );\n\n\t\tforeach ( var body in RequireBodies( ctx, Bodies ) )\n\t\t\tMeshTransform.Apply( body.Mesh, xform );\n\t}\n}\n\n/// <summary>Copies along a direction. Onshape's Linear pattern.</summary>\npublic sealed class LinearPatternFeature : Feature\n{\n\tpublic override string TypeName => \"Linear pattern\";\n\n\tpublic override GeometryKind Accepts => GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \"Bodies\" );\n\tpublic readonly Vec3Param Direction = new( \"Direction\", new Vec3( 1, 0, 0 ) );\n\tpublic readonly FloatParam Spacing = new( \"Spacing\", 1f, unit: \"u\" );\n\tpublic readonly IntParam Count = new( \"Instances\", 3, 1, 4096 );\n\tpublic readonly BoolParam Merge = new( \"Merge into one body\", false );\n\n\tpublic override IReadOnlyList<IParam> Parameters =>\n\t\tnew IParam[] { Bodies, Direction, Spacing, Count, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Direction.Value.LengthSquared < 1e-12f )\n\t\t{\n\t\t\tFailOn( \"Direction\",\n\t\t\t\t\"Direction cannot be zero\",\n\t\t\t\t\"A linear pattern copies along a direction, and this one has no length, so there is nowhere to put the copies.\",\n\t\t\t\t\"Set Direction to the axis you want the copies to run along\" );\n\t\t}\n\n\t\tvar dir = Direction.Value.Normal;\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\t// Instance 0 is the original, so a count of 3 means the original plus two copies \u2014 which\n\t\t// is what Onshape's instance count means too.\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// Snapshot BEFORE the loop. With Merge on, the loop appends into source.Mesh, so\n\t\t\t// reading source.Mesh each iteration would copy the copies too and the instance count\n\t\t\t// would double rather than increment: 6, 12, 24, 48 faces instead of 6, 12, 18, 24.\n\t\t\tvar original = source.Mesh.Clone();\n\n\t\t\tfor ( var i = 1; i < Count.Clamped; i++ )\n\t\t\t{\n\t\t\t\tvar copy = MeshTransform.Transformed( original, Xform.Translate( dir * (Spacing.Value * i) ) );\n\n\t\t\t\tif ( Merge.Value )\n\t\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\t\telse\n\t\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\"{Name} {i}\", copy ) );\n\t\t\t}\n\t\t}\n\t}\n}\n\n/// <summary>Copies around an axis. Onshape's Circular pattern.</summary>\npublic sealed class CircularPatternFeature : Feature\n{\n\tpublic override string TypeName => \"Circular pattern\";\n\n\tpublic override GeometryKind Accepts => GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \"Bodies\" );\n\tpublic readonly Vec3Param AxisPoint = new( \"Axis through\", Vec3.Zero );\n\tpublic readonly Vec3Param AxisDirection = new( \"Axis\", new Vec3( 0, 0, 1 ) );\n\tpublic readonly IntParam Count = new( \"Instances\", 4, 1, 4096 );\n\tpublic readonly FloatParam TotalAngle = new( \"Angle\", 360f, unit: \"deg\" );\n\tpublic readonly BoolParam Merge = new( \"Merge into one body\", false );\n\n\tpublic override IReadOnlyList<IParam> Parameters =>\n\t\tnew IParam[] { Bodies, AxisPoint, AxisDirection, Count, TotalAngle, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( AxisDirection.Value.LengthSquared < 1e-12f )\n\t\t{\n\t\t\tFailOn( \"Axis\",\n\t\t\t\t\"Axis cannot be zero\",\n\t\t\t\t\"A circular pattern spins around an axis, and this one has no length.\",\n\t\t\t\t\"Set Axis to the direction to spin around\" );\n\t\t}\n\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\tvar count = Count.Clamped;\n\n\t\t// A full turn puts instance N back on instance 0, so the step divides by count. A partial\n\t\t// sweep spreads the instances across the arc inclusive of both ends instead.\n\t\tvar full = MathF.Abs( MathF.Abs( TotalAngle.Value ) - 360f ) < 1e-3f;\n\t\tvar step = count <= 1 ? 0f : TotalAngle.Value / (full ? count : count - 1);\n\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// Snapshot before the loop \u2014 same compounding trap as the linear pattern.\n\t\t\tvar original = source.Mesh.Clone();\n\n\t\t\tfor ( var i = 1; i < count; i++ )\n\t\t\t{\n\t\t\t\tvar xform = Xform.RotateAbout(\n\t\t\t\t\tAxisPoint.Value, AxisDirection.Value, step * i * MathF.PI / 180f );\n\n\t\t\t\tvar copy = MeshTransform.Transformed( original, xform );\n\n\t\t\t\tif ( Merge.Value )\n\t\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\t\telse\n\t\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\"{Name} {i}\", copy ) );\n\t\t\t}\n\t\t}\n\t}\n}\n\n/// <summary>Reflects bodies in a plane. Onshape's Mirror.</summary>\npublic sealed class MirrorFeature : Feature\n{\n\tpublic override string TypeName => \"Mirror\";\n\n\tpublic override GeometryKind Accepts => GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \"Bodies\" );\n\tpublic readonly Vec3Param PlanePoint = new( \"Plane through\", Vec3.Zero );\n\tpublic readonly Vec3Param PlaneNormal = new( \"Plane normal\", new Vec3( 1, 0, 0 ) );\n\tpublic readonly BoolParam KeepOriginal = new( \"Keep original\", true );\n\tpublic readonly BoolParam Merge = new( \"Merge into one body\", false );\n\n\tpublic override IReadOnlyList<IParam> Parameters =>\n\t\tnew IParam[] { Bodies, PlanePoint, PlaneNormal, KeepOriginal, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( PlaneNormal.Value.LengthSquared < 1e-12f )\n\t\t{\n\t\t\tFailOn( \"Plane normal\",\n\t\t\t\t\"Plane normal cannot be zero\",\n\t\t\t\t\"A mirror needs a plane to reflect across, and a zero normal does not define one.\",\n\t\t\t\t\"Set Plane normal to the direction the mirror should face\" );\n\t\t}\n\n\t\tvar xform = Xform.Mirror( PlanePoint.Value, PlaneNormal.Value );\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// MeshTransform.Apply reverses winding for us \u2014 the mirror flips handedness, and\n\t\t\t// without that reversal every mirrored face would point into the solid.\n\t\t\tvar copy = MeshTransform.Transformed( source.Mesh, xform );\n\n\t\t\tif ( Merge.Value )\n\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\telse\n\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\"{Name}\", copy ) );\n\t\t}\n\n\t\tif ( !KeepOriginal.Value )\n\t\t{\n\t\t\tforeach ( var source in sources )\n\t\t\t\tctx.Bodies.Remove( source );\n\t\t}\n\t}\n}\n\n/// <summary>\n/// Catmull-Clark subdivision as a history feature.\n///\n/// Onshape has no equivalent, and this is the deliberate place where the tool stops being CAD.\n/// Putting subdivision IN the tree rather than after it is what keeps the pipeline honest: roll\n/// the bar back above this feature and you are editing the low-poly cage, roll it forward and you\n/// see the dense surface. The cage is what the sculpt eventually bakes down onto, so it has to\n/// stay reachable rather than being consumed by an export step.\n/// </summary>\npublic sealed class SubdivideFeature : Feature\n{\n\tpublic override string TypeName => \"Subdivide\";\n\n\t/// <summary>Faces as well as bodies: an empty Bodies list means the whole document to Execute,\n\t/// so picking is how you say which part - see the refusal in the editor's AddFeature.</summary>\n\tpublic override GeometryKind Accepts => GeometryKind.Face | GeometryKind.Body;\n\n\t/// <summary>\n\t/// Which faces to subdivide. EMPTY MEANS WHOLE BODY OR ALL FACES, depending on <see cref=\"AllFaces\"/>,\n\t/// and the whole-body form is the old behaviour and the honest default \u2014 a subdivision surface\n\t/// is a property of a cage, not of a corner of one.\n\t///\n\t/// Picking faces switches the operation from smooth to linear, and the two really are different\n\t/// operations rather than a flag on one. See CatmullClark.SubdivideFaces: you cannot apply the\n\t/// limit rules to part of a mesh without moving the vertices the rest of it is standing on. So\n\t/// this is density where you need it \u2014 a face about to be sculpted, a panel about to be bent \u2014\n\t/// and the whole-body form remains the one that smooths.\n\t///\n\t/// Held as FaceRefs, like every other face pick, so the choice survives the rebuild that\n\t/// recreates the faces it names.\n\t/// </summary>\n\tpublic List<FaceRef> Faces = new();\n\n\tpublic readonly BodySelectionParam Bodies = new( \"Bodies\" );\n\tpublic readonly IntParam Levels = new( \"Levels\", 1, 0, 6 );\n\n\t/// <summary>\n\t/// Subdivide EVERY face, not the whole body. Only read when <see cref=\"Faces\"/> is empty \u2014 a\n\t/// picked face already names its region, and a pick is more specific than \"everything\".\n\t///\n\t/// The whole-body form (this off, no picks) runs the full Catmull-Clark and SMOOTHS: it moves\n\t/// the original vertices onto the limit surface. This is the density version of the same idea \u2014\n\t/// midpoints and centroids, so the shape stays exactly where it is and every face just gets\n\t/// more polygons. You want it when the whole cage is too coarse but the silhouette is already\n\t/// right, and it is why it is a tick rather than \"pick 40 faces\".\n\t/// </summary>\n\tpublic readonly BoolParam AllFaces = new( \"All faces\", false );\n\n\tpublic override IReadOnlyList<IParam> Parameters => new IParam[] { Bodies, Levels, AllFaces };\n\n\t/// <summary>What this feature will cost at the current settings, for a UI that warns before\n\t/// rather than after. Levels are exponential and the jump from 4 to 6 is 16x.</summary>\n\tpublic (int Vertices, int Faces) PredictCost( IEnumerable<Body> bodies )\n\t{\n\t\tvar v = 0;\n\t\tvar f = 0;\n\t\tvar list = bodies as IList<Body> ?? bodies.ToList();\n\n\t\tif ( Faces.Count > 0 )\n\t\t{\n\t\t\t// Local subdivision only touches the bodies that were picked on, so the bodies it did\n\t\t\t// not touch still cost exactly what they already are.\n\t\t\tvar picked = Resolve( list, out _ );\n\n\t\t\tforeach ( var body in list )\n\t\t\t{\n\t\t\t\tvar (bv, bf) = picked.TryGetValue( body, out var indices )\n\t\t\t\t\t? CatmullClark.PredictLocalCost( body.Mesh, indices, Levels.Clamped )\n\t\t\t\t\t: (body.Mesh.VertexCount, body.Mesh.FaceCount);\n\n\t\t\t\tv += bv;\n\t\t\t\tf += bf;\n\t\t\t}\n\n\t\t\treturn (v, f);\n\t\t}\n\n\t\tforeach ( var body in list.Where( Bodies.Matches ) )\n\t\t{\n\t\t\tvar (bv, bf) = AllFaces.Value\n\t\t\t\t? CatmullClark.PredictLocalCost( body.Mesh, AllIndices( body.Mesh ), Levels.Clamped )\n\t\t\t\t: CatmullClark.PredictCost( body.Mesh, Levels.Clamped );\n\n\t\t\tv += bv;\n\t\t\tf += bf;\n\t\t}\n\n\t\treturn (v, f);\n\t}\n\n\t/// <summary>Picked faces grouped by the body they landed on. <paramref name=\"lost\"/> counts the\n\t/// references that no longer resolve \u2014 geometry upstream changed under them.</summary>\n\tDictionary<Body, List<int>> Resolve( IEnumerable<Body> bodies, out int lost )\n\t{\n\t\tvar byBody = new Dictionary<Body, List<int>>();\n\t\tlost = 0;\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !byBody.TryGetValue( body, out var indices ) )\n\t\t\t\tbyBody[body] = indices = new List<int>();\n\n\t\t\tindices.Add( faceIndex );\n\t\t}\n\n\t\treturn byBody;\n\t}\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Levels.Clamped == 0 )\n\t\t\treturn;\n\n\t\tif ( Faces.Count > 0 )\n\t\t{\n\t\t\t// A picked face already names its body, so the Bodies filter has nothing left to decide and\n\t\t\t// is not consulted here. Two picks on the same body are subdivided in ONE call rather than\n\t\t\t// one call each: the second call would be running against a mesh whose face indices the\n\t\t\t// first has already renumbered.\n\t\t\tvar picked = Resolve( ctx.Bodies, out var lost );\n\n\t\t\tif ( picked.Count == 0 )\n\t\t\t{\n\t\t\t\tFail(\n\t\t\t\t\t\"None of the picked faces are still there\",\n\t\t\t\t\t\"Every face this feature subdivides was removed or replaced by a change further up the tree.\",\n\t\t\t\t\t\"Pick the faces again\",\n\t\t\t\t\t\"Or clear the picks to subdivide the whole body\" );\n\t\t\t}\n\n\t\t\tforeach ( var (body, indices) in picked )\n\t\t\t\tbody.Mesh = CatmullClark.SubdivideFaces( body.Mesh, indices, Levels.Clamped );\n\n\t\t\tif ( lost > 0 )\n\t\t\t{\n\t\t\t\tWarn(\n\t\t\t\t\t$\"{lost} picked {(lost == 1 ? \"face is\" : \"faces are\")} no longer there\",\n\t\t\t\t\t\"A change further up the tree removed or replaced them, so they were skipped.\",\n\t\t\t\t\t\"Pick them again if the extra density is still wanted\" );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// No picks. AllFaces chooses between densifying every face (linear, shape unchanged) and\n\t\t// the whole-body Catmull-Clark that smooths.\n\t\tforeach ( var body in RequireBodies( ctx, Bodies ) )\n\t\t{\n\t\t\tif ( AllFaces.Value )\n\t\t\t\tbody.Mesh = CatmullClark.SubdivideFaces( body.Mesh, AllIndices( body.Mesh ), Levels.Clamped );\n\t\t\telse\n\t\t\t\tbody.Mesh = CatmullClark.Subdivide( body.Mesh, Levels.Clamped );\n\t\t}\n\t}\n\n\t/// <summary>Every face index of a mesh, as a list \u2014 the \"all faces\" version of a pick list.</summary>\n\tstatic int[] AllIndices( PolyMesh mesh )\n\t{\n\t\tvar all = new int[mesh.FaceCount];\n\n\t\tfor ( var i = 0; i < all.Length; i++ )\n\t\t\tall[i] = i;\n\n\t\treturn all;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/Features/Feature.cs",
"FileName": "Feature.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Diagnostics.CodeAnalysis;\nusing System.Linq;\nusing System.Reflection;\n\nnamespace Effigy;\n\n/// <summary>One solid in the studio. Onshape calls these parts; a Part Studio holds several.</summary>\n/// <summary>\n/// A picture stood in the viewport to model against. <see cref=\"View\"/> is which way it faces:\n/// \"front\" stands in the XZ plane facing the Front view, \"side\" in the YZ plane facing Right,\n/// \"top\" lies flat facing Top. <see cref=\"Height\"/> is its size in units (width follows the\n/// picture); <see cref=\"Offset\"/> slides it in its own plane; <see cref=\"Depth\"/> pushes it back\n/// away from the viewer, so it sits behind the model rather than through it.\n/// </summary>\npublic sealed class ReferenceImage\n{\n\tpublic string Path = \"\";\n\tpublic string View = \"front\";\n\tpublic float Height = 72f;\n\tpublic Vec2 Offset;\n\tpublic float Depth = 0f;\n\tpublic float Opacity = 0.5f;\n\tpublic bool Visible = true;\n\n\tpublic ReferenceImage Clone() => new()\n\t{\n\t\tPath = Path, View = View, Height = Height, Offset = Offset, Depth = Depth, Opacity = Opacity, Visible = Visible,\n\t};\n}\n\npublic sealed class Body\n{\n\tpublic string Id;\n\tpublic string Name;\n\tpublic PolyMesh Mesh;\n\n\t/// <summary>Whether this body is drawn. Set from the feature that produced it \u2014 see\n\t/// Feature.Visible for why this is not the same thing as suppression.</summary>\n\tpublic bool Visible = true;\n\n\t/// <summary>Id of the Feature that produced this body, recorded by the rebuild alongside\n\t/// Visible. Bodies are re-made from scratch every rebuild and have no identity a UI can hold\n\t/// onto, so a Parts list needs this to get from a row back to the thing that owns it.</summary>\n\tpublic string FeatureId;\n\n\t/// <summary>Made by a GarmentFeature. Later garments collide with it but are never cut from it,\n\t/// and Fur with nothing picked grows on garments.</summary>\n\tpublic bool IsGarment;\n\n\t/// <summary>Here to be measured against, not to be shipped \u2014 the body a WearerFeature brought\n\t/// in so a garment has something to be cut from and collide with. Everything reads it the way\n\t/// it reads any other body; only export leaves it out. See WearerFeature.</summary>\n\tpublic bool IsReference;\n\n\tpublic Body( string id, string name, PolyMesh mesh )\n\t{\n\t\tId = id;\n\t\tName = name;\n\t\tMesh = mesh;\n\t}\n\n\tpublic Body Clone() => new( Id, Name, Mesh.Clone() )\n\t\t{ Visible = Visible, FeatureId = FeatureId, IsGarment = IsGarment, IsReference = IsReference };\n}\n\n// --- parameters -------------------------------------------------------------------------------\n\n/// <summary>\n/// Feature parameters describe themselves, so one generic panel can render any feature's dialog\n/// rather than each feature needing hand-written UI.\n///\n/// This is copied from Onshape deliberately. Every feature dialog there has the same shape, and\n/// that uniformity is most of why the tool feels coherent with a hundred features in it. Getting\n/// it for free is worth the small indirection here.\n///\n/// The parameter object IS the storage \u2014 the feature reads `_length.Value` \u2014 so there is no\n/// separate copy to keep in sync.\n/// </summary>\npublic interface IParam\n{\n\tstring Label { get; }\n}\n\npublic sealed class FloatParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic float Value;\n\tpublic float Min, Max;\n\tpublic string Unit;\n\n\tpublic FloatParam( string label, float value, float min = float.MinValue, float max = float.MaxValue, string unit = null )\n\t{\n\t\tLabel = label;\n\t\tValue = value;\n\t\tMin = min;\n\t\tMax = max;\n\t\tUnit = unit;\n\t}\n\n\t/// <summary>\n\t/// The expression this parameter was typed as, or null when the number is a literal.\n\t///\n\t/// A LITERAL STAYS A LITERAL. Typing <c>4</c> must not become a formula that re-evaluates;\n\t/// typing <c>#thickness / 2</c> must, or changing the variable does not move the feature.\n\t/// Rebuild evaluates this through the studio's variable table and writes the result into\n\t/// <see cref=\"Value\"/>. An empty or unparsable expression holds the last good value.\n\t/// </summary>\n\tpublic string Expr;\n\n\tpublic float Clamped => Math.Clamp( Value, Min, Max );\n}\n\npublic sealed class IntParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic int Value;\n\tpublic int Min, Max;\n\n\t/// <summary>\n\t/// Whether dragging this number through its range means anything.\n\t///\n\t/// A slider is for a MAGNITUDE \u2014 segment counts, subdivision levels, numbers where the values\n\t/// either side of the one you have are the neighbouring answers. A material slot is an\n\t/// IDENTIFIER that happens to be stored as a number: slot 7 is not \"more\" than slot 6, sweeping\n\t/// through 40 of them on the way says nothing, and the drag bar was the widest control in the\n\t/// Extrude dialog doing the least. Those get a field and no slider.\n\t///\n\t/// The bounds test in the dialog cannot tell the two apart \u2014 0..63 looks exactly as draggable\n\t/// as 0..6 \u2014 so the parameter says which it is.\n\t/// </summary>\n\tpublic bool Slider = true;\n\n\tpublic IntParam( string label, int value, int min = int.MinValue, int max = int.MaxValue )\n\t{\n\t\tLabel = label;\n\t\tValue = value;\n\t\tMin = min;\n\t\tMax = max;\n\t}\n\n\tpublic int Clamped => Math.Clamp( Value, Min, Max );\n}\n\npublic sealed class BoolParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic bool Value;\n\n\tpublic BoolParam( string label, bool value )\n\t{\n\t\tLabel = label;\n\t\tValue = value;\n\t}\n}\n\npublic sealed class Vec3Param : IParam\n{\n\tpublic string Label { get; }\n\tpublic Vec3 Value;\n\n\tpublic Vec3Param( string label, Vec3 value )\n\t{\n\t\tLabel = label;\n\t\tValue = value;\n\t}\n}\n\npublic sealed class ChoiceParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic string[] Options;\n\tpublic int Index;\n\n\tpublic ChoiceParam( string label, string[] options, int index = 0 )\n\t{\n\t\tLabel = label;\n\t\tOptions = options;\n\t\tIndex = index;\n\t}\n\n\tpublic string Value => Options[Math.Clamp( Index, 0, Options.Length - 1 )];\n}\n\n/// <summary>\n/// A free-text parameter. Paths, names, anything a number or a dropdown cannot hold.\n///\n/// Written as the rest of the line, so a path with spaces survives. The dialog renders it as a\n/// field; Import is the feature that asked for it.\n/// </summary>\npublic sealed class StringParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic string Value;\n\n\tpublic StringParam( string label, string value = \"\" )\n\t{\n\t\tLabel = label;\n\t\tValue = value ?? \"\";\n\t}\n}\n\n/// <summary>Which bodies a feature acts on. Empty means every body, which is what Onshape's\n/// \"all\" behaves like and is the sane default for a studio holding one part.</summary>\npublic sealed class BodySelectionParam : IParam\n{\n\tpublic string Label { get; }\n\tpublic List<string> BodyIds = new();\n\n\tpublic BodySelectionParam( string label )\n\t{\n\t\tLabel = label;\n\t}\n\n\tpublic bool Matches( Body b ) => BodyIds.Count == 0 || BodyIds.Contains( b.Id );\n}\n\n/// <summary>\n/// What geometry a tool will take from the selection you already made.\n///\n/// ONE DECLARATION BECAUSE THERE WERE THREE. \"Which tools consume a face\" was written down in three\n/// places that could not see each other - the type switch in <see cref=\"Feature.ApplyGeometrySelection\"/>,\n/// the prose the editor paints under the viewport, and the per-dialog pick-mode flags - so the strip\n/// could be right while the hint was a lie, and a tool could be missing from a list it belonged on\n/// with nothing to catch it. Nothing DECLARED it, so nothing could ASK.\n///\n/// A FLAGS ENUM ON A VIRTUAL PROPERTY, deliberately, and for the same reason EffigyWindow.ToolKind\n/// is an enum rather than a Func: an int survives a hotload, a lambda or a System.Type held in\n/// static editor state comes back pointing into the dead assembly.\n///\n/// This says what a tool will USE, not what it needs to run. Fillet accepts a body because selecting\n/// a part and pressing Fillet fillets that part; it does not follow that a fillet with no selection\n/// is invalid.\n/// </summary>\n[Flags]\npublic enum GeometryKind\n{\n\tNone = 0,\n\tFace = 1,\n\tEdge = 2,\n\tBody = 4,\n\tSketchRegion = 8,\n}\n\n// --- features ---------------------------------------------------------------------------------\n\n/// <summary>The state a feature reads and writes as it runs.</summary>\npublic sealed class FeatureContext\n{\n\tpublic List<Body> Bodies = new();\n\n\t/// <summary>Sketches published by SketchFeature, keyed by that feature's id. Extrude and\n\t/// Revolve look themselves up here rather than holding a reference, so editing the sketch\n\t/// upstream and rebuilding feeds the new geometry through without any wiring to keep in sync.</summary>\n\tpublic Dictionary<string, Sketch> Sketches = new();\n\n\t/// <summary>Splines published by SplineFeature, keyed by that feature's id, and the id of the\n\t/// one published last \u2014 what a curve deform or a sweep means by \"the spline\" when it names\n\t/// none. Same arrangement as Sketches, for the same reason.</summary>\n\tpublic Dictionary<string, Spline3> Splines = new();\n\tpublic string LastSplineId;\n\n\t/// <summary>\n\t/// For a sketch drawn on a face, the id of the body that face belongs to. Keyed by sketch\n\t/// feature id, same as Sketches.\n\t///\n\t/// This is what lets an extrude know it is growing OUT OF something rather than into thin air.\n\t/// A Sketch is pure geometry and has no business knowing about bodies, and the consuming\n\t/// feature never sees the SketchFeature itself \u2014 only what it published here \u2014 so the\n\t/// attachment travels the same way the sketch does.\n\t/// </summary>\n\tpublic Dictionary<string, string> SketchHostBodies = new();\n\n\t/// <summary>\n\t/// Planes published by PlaneFeature, keyed by that feature's id. A sketch \u2014 or another plane \u2014\n\t/// looks itself up here rather than holding the SketchPlane object.\n\t///\n\t/// Beside Sketches and for exactly the same reason: move the plane, rebuild, and everything\n\t/// drawn on it follows, with no second reference anywhere that could be left pointing at the old\n\t/// one. It is also what makes a reference to a plane that is suppressed or below the rollback\n\t/// bar fail honestly rather than quietly using last rebuild's answer \u2014 a feature that has not\n\t/// run is not in here.\n\t/// </summary>\n\tpublic Dictionary<string, SketchPlane> Planes = new();\n\n\t/// <summary>\n\t/// For a plane built from a face, the id of the body that face belongs to \u2014 inherited along a\n\t/// chain of planes. Keyed by plane feature id, same as Planes.\n\t///\n\t/// The plane half of SketchHostBodies above, and it feeds straight into it: a sketch on a plane\n\t/// that came off a part is a sketch on that part as far as Extrude's Auto result is concerned,\n\t/// which is what keeps \"three bosses off one block\" one part rather than four.\n\t/// </summary>\n\tpublic Dictionary<string, string> PlaneHostBodies = new();\n\n\t/// <summary>\n\t/// Resolve a variable name (without the <c>#</c>) during this rebuild. Null when the studio\n\t/// has no table. Set once before the first feature runs, so a feature's value does not depend\n\t/// on when it happened to be asked.\n\t/// </summary>\n\tpublic Func<string, float?> Resolve;\n\n\t/// <summary>The studio's rig, read-only, for features that place things by bone - a garment\n\t/// finds the torso and the arms from it. Null when the studio has none.</summary>\n\tpublic Skeleton Rig;\n\n\t/// <summary>\n\t/// This rebuild is a PREVIEW: somebody is dragging a slider and wants to see the shape move, not\n\t/// to see the finished article.\n\t///\n\t/// A feature that has an expensive final step and a cheap approximation of it should take the\n\t/// cheap one here. A garment's drape is forty simulation steps and its solidify doubles the\n\t/// mesh; neither changes the silhouette you are actually looking at while you drag Length, and\n\t/// together they are the difference between a slider that moves and one that stutters.\n\t///\n\t/// NOT A QUALITY SETTING. Nothing is saved from a preview rebuild and nothing exports from one -\n\t/// the full rebuild always follows, within a fraction of a second of the drag stopping. A\n\t/// feature must therefore never let a preview change anything but geometry: no material slots\n\t/// moving, no bodies appearing or disappearing, or the tree and the parts list would flicker\n\t/// through states that never existed.\n\t/// </summary>\n\tpublic bool Preview;\n\n\tint _nextId = 1;\n\tstring _featureId;\n\tint _featureBodies;\n\n\t/// <summary>\n\t/// A body id belonging to the feature currently running: its feature id, plus a counter of the\n\t/// bodies that feature has made this run.\n\t///\n\t/// IDS USED TO BE A SINGLE RUNNING COUNTER - body1, body2, body3 in creation order across the\n\t/// whole rebuild - and that is the topological naming problem all over again, one level up from\n\t/// the faces FaceRef was written to protect. Add a feature ANYWHERE upstream that produces a\n\t/// body and every id after it shifts by one, so a sketch attached to \"body1\" silently lands on\n\t/// whatever happens to be body1 now. Not an error, not a warning: a boss quietly reattaches\n\t/// itself to an unrelated block.\n\t///\n\t/// Feature ids are assigned once at creation and never reused, so a body named after the\n\t/// feature that made it cannot be renumbered by anything happening elsewhere in the tree.\n\t/// </summary>\n\tpublic string NewBodyId() =>\n\t\t_featureId is null ? $\"body{_nextId++}\" : $\"{_featureId}b{_featureBodies++}\";\n\n\t/// <summary>Called by Feature.Run before Execute, so bodies are named after their maker. The\n\t/// per-feature counter restarts, which is what makes a feature that produces three bodies -\n\t/// a pattern, say - name them the same three ids on every rebuild.</summary>\n\tpublic void BeginFeature( string featureId )\n\t{\n\t\t_featureId = featureId;\n\t\t_featureBodies = 0;\n\t}\n\n\t/// <summary>Vestigial: only the fallback numbering uses it, for a context nothing has called\n\t/// BeginFeature on.</summary>\n\tpublic void SeedIdCounter( int next ) => _nextId = Math.Max( _nextId, next );\n}\n\n/// <summary>\n/// One step in the history. Features run in order, each seeing what the ones before it produced.\n///\n/// THE ORDERED TREE IS THE WHOLE POINT. It is what separates a parametric modeller from a stack of\n/// bakes: roll back to before feature 3, change a number, roll forward and everything downstream\n/// rebuilds against the new value. Every design decision here bends toward keeping that true \u2014\n/// features hold parameters rather than results, and nothing caches geometry inside a feature.\n/// </summary>\npublic abstract class Feature\n{\n\tpublic string Id = Guid.NewGuid().ToString( \"N\" )[..8];\n\tpublic string Name;\n\n\t/// <summary>\n\t/// Suppressed features do not run at all \u2014 their effect is removed from the model.\n\t/// </summary>\n\tpublic bool Suppressed;\n\n\t/// <summary>\n\t/// Whether the geometry this feature produced is DRAWN. Deliberately not the same thing as\n\t/// Suppressed, and conflating the two would be wrong: suppression takes a feature's effect out\n\t/// of the model, hiding only stops you looking at it. A hidden body is still there, still\n\t/// exported, and everything downstream still builds on it.\n\t///\n\t/// Solvespace keeps the same two flags separately on a group for the same reason.\n\t/// </summary>\n\tpublic bool Visible = true;\n\n\t/// <summary>Set by Run when Execute threw. A failed feature does not stop the rebuild \u2014 the\n\t/// tree carries on with the bodies as they were, which is what lets you fix an upstream\n\t/// mistake without every later feature also reporting failure.</summary>\n\tpublic string Error { get; internal set; }\n\n\t/// <summary>\n\t/// Something the feature worked around rather than failed on \u2014 it built, but not from\n\t/// everything it was given.\n\t///\n\t/// Distinct from Error because the two need opposite handling. An error means there is no\n\t/// geometry and the tree below is standing on nothing; a warning means there IS geometry and\n\t/// you should look at it. Collapsing the second into the first is why one stray branch\n\t/// anywhere in a sketch used to fail every extrude that read it.\n\t/// </summary>\n\tpublic string Warning { get; internal set; }\n\n\t/// <summary>\n\t/// Structured form of Error or Warning. Null when the feature ran clean.\n\t///\n\t/// Error and Warning stay as strings because PartStudio, RebuildReport, the feature tree and\n\t/// several tests already read them. They are set alongside this: Error = Diagnostic.Problem\n\t/// on a failure, Warning = Diagnostic.Problem on a warning.\n\t/// </summary>\n\tpublic FeatureDiagnostic Diagnostic { get; internal set; }\n\n\tpublic abstract string TypeName { get; }\n\tpublic abstract IReadOnlyList<IParam> Parameters { get; }\n\n\t/// <summary>\n\t/// The subset of <see cref=\"Parameters\"/> that belongs behind the dialog's Advanced disclosure\n\t/// \u2014 folded away by default, and still every bit as much a parameter as the ones above it.\n\t///\n\t/// It is a SUBSET RATHER THAN A SECOND LIST. Everything generic \u2014 the snapshot Cancel restores\n\t/// from, the document writer, the diagnostics that point at a parameter by label \u2014 walks\n\t/// Parameters, and a parameter that only appeared here would be invisible to all of it. So this\n\t/// says nothing about what a feature HAS; it only says which of them are answered once and then\n\t/// left alone. Extrude is the case that asked for it: taper, a second distance and a material\n\t/// slot are three rows of nothing-to-do-here sitting under the distance that is the whole point\n\t/// of the feature.\n\t///\n\t/// Held by REFERENCE, matched by reference. Naming the parameters by label would tie which rows\n\t/// fold up to the words on screen.\n\t/// </summary>\n\tpublic virtual IReadOnlyList<IParam> AdvancedParameters => Array.Empty<IParam>();\n\n\t/// <summary>\n\t/// Whether this feature's cached result is out of date even though nobody called MarkDirty.\n\t///\n\t/// The convention everywhere else is that whoever edits a feature marks it dirty, and for a\n\t/// dialog full of numbers that is one call in one place. It does not hold for a feature whose\n\t/// state is a live object somebody else is mutating \u2014 SculptFeature's levels are changed by a\n\t/// brush, hundreds of times a stroke, nowhere near the code that owns the studio. Relying on\n\t/// that caller to remember is how \"every parameter edit is a silent no-op\" happened once\n\t/// already, and it looked like three unrelated UI faults for a day.\n\t///\n\t/// So a feature that owns mutable state outside its parameters answers this instead, and the\n\t/// rebuild asks. Must be cheap: it is called for every reusable feature on every rebuild.\n\t/// </summary>\n\tpublic virtual bool IsStale => false;\n\n\t/// <summary>\n\t/// Which kinds of picked geometry this feature will actually use.\n\t///\n\t/// The one place that knows. <see cref=\"ApplyGeometrySelection\"/> is what DOES the consuming, and\n\t/// the two are kept honest against each other by AcceptsTests rather than by hope: a feature that\n\t/// stores FaceRefs and forgets to say Face here fails a test, not a user.\n\t///\n\t/// Must be cheap and must not touch the studio - the editor asks this while painting the toolbar.\n\t/// </summary>\n\tpublic virtual GeometryKind Accepts => GeometryKind.None;\n\n\t/// <summary>\n\t/// Seed this feature from geometry that was already picked, the way Onshape's tools consume\n\t/// the current selection instead of making you pick again after the button.\n\t///\n\t/// Faces go to anything that stores FaceRefs \u2014 a sketch's plane, a datum plane, draft, hole,\n\t/// face material, subdivide, move face, and EXTRUDE, which pulls the face itself. Body ids go onto every\n\t/// BodySelectionParam. A face selection with no explicit\n\t/// body list still names those faces' bodies, so clicking a face and then Fillet fillets that\n\t/// part rather than every part.\n\t///\n\t/// <paramref name=\"bodies\"/> is only needed for Shell, whose openings are still stored as\n\t/// indices rather than FaceRefs and have to be resolved against the live mesh.\n\t/// </summary>\n\tpublic void ApplyGeometrySelection( IReadOnlyList<FaceRef> faces, IReadOnlyList<string> bodyIds,\n\t\tIEnumerable<Body> bodies = null, IReadOnlyList<EdgeRef> edges = null,\n\t\tstring sketchFeatureId = null, IReadOnlyList<Vec2> regionSeeds = null )\n\t{\n\t\tfaces ??= Array.Empty<FaceRef>();\n\t\tbodyIds ??= Array.Empty<string>();\n\t\tedges ??= Array.Empty<EdgeRef>();\n\n\t\tif ( this is SketchFeature sketch && faces.Count > 0 )\n\t\t{\n\t\t\tsketch.Face = faces[0];\n\t\t\tsketch.PlaneFeatureId = \"\";\n\t\t}\n\n\t\t// A datum plane takes a face the same way a sketch does, and the two other things it could\n\t\t// have been built from are cleared: they are three answers to one question, and a stale one\n\t\t// left set would win on the next rebuild (see PlaneFeature.ResolveBasePlane's order).\n\t\tif ( this is PlaneFeature datum && faces.Count > 0 )\n\t\t{\n\t\t\tdatum.Face = faces[0];\n\t\t\tdatum.BasePlaneId = \"\";\n\t\t}\n\n\t\tif ( this is SketchConsumingFeature consumer\n\t\t\t&& !string.IsNullOrEmpty( sketchFeatureId )\n\t\t\t&& sketchFeatureId != SketchConsumingFeature.AwaitingPick )\n\t\t{\n\t\t\tconsumer.SketchFeatureId = sketchFeatureId;\n\t\t\tconsumer.RegionSeeds.Clear();\n\n\t\t\tif ( regionSeeds is { Count: > 0 } )\n\t\t\t\tconsumer.RegionSeeds.AddRange( regionSeeds );\n\t\t}\n\n\t\tvar pickedFaces = this switch\n\t\t{\n\t\t\tFaceMaterialFeature material => material.Faces,\n\t\t\tDraftFeature draft => draft.Faces,\n\t\t\tHoleFeature hole => hole.Faces,\n\t\t\tSubdivideFeature subdivide => subdivide.Faces,\n\t\t\tMoveFaceFeature move => move.Faces,\n\t\t\tExtrudeFeature extrude => extrude.Faces,\n\t\t\tFurFeature fur => fur.Faces,\n\t\t\t_ => null,\n\t\t};\n\n\t\tif ( pickedFaces is not null && faces.Count > 0 )\n\t\t{\n\t\t\tpickedFaces.Clear();\n\t\t\tpickedFaces.AddRange( faces );\n\t\t}\n\n\t\t// A FACE ANSWERS THE QUESTION AwaitingPick WAS ASKING. An Extrude created from the toolbar\n\t\t// starts out refusing to help itself to the nearest sketch (see SketchConsumingFeature\n\t\t// .AwaitingPick), and pointing it at a face is an answer just as much as pointing it at a\n\t\t// sketch is. Leaving the flag set would have the feature build correctly from the face while\n\t\t// every panel that reads it still said \"pick a profile\".\n\t\tif ( this is SketchConsumingFeature awaiting && faces.Count > 0\n\t\t\t&& pickedFaces is not null && awaiting.IsAwaitingPick )\n\t\t\tawaiting.SketchFeatureId = \"\";\n\n\t\tApplyBlendEdges( faces, edges, bodies );\n\n\t\tvar ids = bodyIds.Count > 0\n\t\t\t? bodyIds\n\t\t\t: (IReadOnlyList<string>)faces.Select( f => f.BodyId )\n\t\t\t\t.Concat( edges.Select( e => e.BodyId ) )\n\t\t\t\t.Distinct()\n\t\t\t\t.ToList();\n\n\t\tif ( ids.Count > 0 )\n\t\t{\n\t\t\tforeach ( var param in Parameters )\n\t\t\t{\n\t\t\t\tif ( param is not BodySelectionParam selection )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tselection.BodyIds.Clear();\n\t\t\t\tselection.BodyIds.AddRange( ids );\n\t\t\t}\n\t\t}\n\n\t\tif ( this is not ShellFeature shell || faces.Count == 0 || bodies is null )\n\t\t\treturn;\n\n\t\t// OpenFaces is applied to every selected body, so it only means something when the\n\t\t// openings all live on one part. Two parts and a mixed index list would punch the\n\t\t// wrong faces \u2014 or fail \u2014 on the second body.\n\t\tif ( ids.Count != 1 )\n\t\t\treturn;\n\n\t\tshell.OpenFaces.Clear();\n\n\t\tforeach ( var face in faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( bodies, face, out _, out var index ) )\n\t\t\t\tcontinue;\n\n\t\t\tif ( !shell.OpenFaces.Contains( index ) )\n\t\t\t\tshell.OpenFaces.Add( index );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Fillet and Chamfer store edges, not faces. A picked edge list is copied across; a picked\n\t/// FACE becomes that face's boundary, which is what \"select the top, then Fillet\" means in\n\t/// Onshape.\n\t/// </summary>\n\tvoid ApplyBlendEdges( IReadOnlyList<FaceRef> faces, IReadOnlyList<EdgeRef> edges, IEnumerable<Body> bodies )\n\t{\n\t\tvar dest = this switch\n\t\t{\n\t\t\tFilletFeature fillet => fillet.Edges,\n\t\t\tChamferFeature chamfer => chamfer.Edges,\n\t\t\t_ => null,\n\t\t};\n\n\t\tif ( dest is null )\n\t\t\treturn;\n\n\t\tdest.Clear();\n\n\t\tif ( edges.Count > 0 )\n\t\t{\n\t\t\tdest.AddRange( edges );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( faces.Count == 0 || bodies is null )\n\t\t\treturn;\n\n\t\tvar seen = new HashSet<(string BodyId, EdgeKey Key)>();\n\n\t\tforeach ( var face in faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( bodies, face, out var body, out var index ) )\n\t\t\t\tcontinue;\n\n\t\t\tforeach ( var edge in FacePlane.CaptureBoundary( body, index ) )\n\t\t\t{\n\t\t\t\tif ( !FacePlane.TryResolveEdge( bodies, edge, out _, out var key ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif ( !seen.Add( (body.Id, key) ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tdest.Add( edge );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Re-evaluate every <see cref=\"FloatParam\"/> that still carries its typed expression, so a\n\t/// variable change moves the feature without anyone reopening the dialog.\n\t///\n\t/// ALL PUBLIC FLOAT FIELDS, not only <see cref=\"Parameters\"/>. PrimitiveFeature hides SizeX\n\t/// when the shape is a cylinder, and Execute still reads it; skipping hidden ones would leave\n\t/// a stale number the moment the dropdown flipped back.\n\t/// </summary>\n\tinternal void EvaluateExpressions( Func<string, float?> resolve )\n\t{\n\t\tforeach ( var field in GetType().GetFields() )\n\t\t{\n\t\t\tif ( field.GetValue( this ) is not FloatParam param )\n\t\t\t\tcontinue;\n\n\t\t\tif ( string.IsNullOrWhiteSpace( param.Expr ) )\n\t\t\t\tcontinue;\n\n\t\t\tif ( !Expression.TryEvaluate( param.Expr, param.Unit, resolve, out var value ) )\n\t\t\t\tcontinue;\n\n\t\t\tparam.Value = Math.Clamp( value, param.Min, param.Max );\n\t\t}\n\t}\n\n\tprotected abstract void Execute( FeatureContext ctx );\n\n\tinternal void Run( FeatureContext ctx )\n\t{\n\t\tError = null;\n\t\tWarning = null;\n\t\tDiagnostic = null;\n\n\t\t// Bodies made from here on belong to this feature and are named after it. Set before the\n\t\t// Suppressed check is pointless; set here so every path into Execute is covered.\n\t\tctx.BeginFeature( Id );\n\n\t\tif ( Suppressed )\n\t\t\treturn;\n\n\t\ttry\n\t\t{\n\t\t\tEvaluateExpressions( ctx.Resolve );\n\t\t\tExecute( ctx );\n\t\t}\n\t\tcatch ( FeatureException e )\n\t\t{\n\t\t\tApplyDiagnostic( e.Diagnostic );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Error, e.Message ) );\n\t\t}\n\t}\n\n\t/// <summary>Refuse to proceed. The three arguments are the three things the dialog shows.</summary>\n\t[DoesNotReturn]\n\tprotected static void Fail( string problem, string cause, params string[] remedies ) =>\n\t\tthrow new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, remedies: remedies ) );\n\n\t/// <summary>Refuse, and name the control the dialog should highlight.</summary>\n\t[DoesNotReturn]\n\tprotected static void FailOn( string parameterLabel, string problem, string cause, params string[] remedies ) =>\n\t\tthrow new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, remedies: remedies ) );\n\n\t/// <summary>Refuse, highlight the control, and offer a button that writes <paramref name=\"suggested\"/>\n\t/// into it.</summary>\n\t[DoesNotReturn]\n\tprotected static void FailOn( string parameterLabel, float suggested, string problem, string cause, params string[] remedies ) =>\n\t\tthrow new FeatureException( new FeatureDiagnostic( DiagnosticSeverity.Error, problem, cause, parameterLabel, suggested, remedies ) );\n\n\t/// <summary>The feature built, but not from what was asked. Geometry stays; the dialog goes yellow.</summary>\n\tprotected void Warn( string problem, string cause, params string[] remedies ) =>\n\t\tApplyDiagnostic( new FeatureDiagnostic( DiagnosticSeverity.Warning, problem, cause, remedies: remedies ) );\n\n\t/// <summary>\n\t/// After a cut, put every piece the cut severed into the part list. Returns how many bodies\n\t/// were added, so the caller can say so.\n\t///\n\t/// A boolean is allowed to sever a part and has no obligation to mention it \u2014 \"one mesh\" is a\n\t/// fine answer to \"subtract this\". Whether that mesh is one SOLID is the part list's question,\n\t/// and this is where it gets asked. See MeshSplit for why connectivity is by shared vertex and\n\t/// why the order the pieces come back in is a promise rather than an implementation detail.\n\t///\n\t/// THE ORIGINAL BODY KEEPS ITS ID, and keeps the largest piece. Everything built on this part is\n\t/// holding that id \u2014 a sketch drawn on one of its faces, a later feature's body selection \u2014 and\n\t/// a cut must not invalidate them. The offcuts are new bodies named after the feature that made\n\t/// them, which is the same rule every other body in the studio follows.\n\t/// </summary>\n\tprotected int SeparatePieces( FeatureContext ctx, Body body )\n\t{\n\t\tif ( body?.Mesh is null || MeshSplit.PieceCount( body.Mesh ) < 2 )\n\t\t\treturn 0;\n\n\t\tvar pieces = MeshSplit.ConnectedPieces( body.Mesh );\n\n\t\tbody.Mesh = pieces[0];\n\n\t\t// Straight after the body they came off, so the parts list reads in the order someone would\n\t\t// expect rather than collecting offcuts at the bottom. IndexOf can legitimately miss - a\n\t\t// feature is allowed to hand us a body it has not published - and appending is right then.\n\t\tvar at = ctx.Bodies.IndexOf( body );\n\n\t\tfor ( var i = 1; i < pieces.Count; i++ )\n\t\t{\n\t\t\tvar piece = new Body( ctx.NewBodyId(), $\"{body.Name} ({i + 1})\", pieces[i] )\n\t\t\t{\n\t\t\t\tVisible = body.Visible,\n\t\t\t\tFeatureId = Id,\n\t\t\t};\n\n\t\t\tif ( at < 0 )\n\t\t\t\tctx.Bodies.Add( piece );\n\t\t\telse\n\t\t\t\tctx.Bodies.Insert( at + i, piece );\n\t\t}\n\n\t\treturn pieces.Count - 1;\n\t}\n\n\t/// <summary>\n\t/// The warning a severing cut earns. Never an error: the geometry is right, and the part list\n\t/// having grown is something to look at rather than something to fix.\n\t/// </summary>\n\tprotected void WarnSeparated( int added, string bodyName )\n\t{\n\t\tif ( added <= 0 )\n\t\t\treturn;\n\n\t\tWarn(\n\t\t\tadded == 1\n\t\t\t\t? $\"This cut separated '{bodyName}' into two parts\"\n\t\t\t\t: $\"This cut separated '{bodyName}' into {added + 1} parts\",\n\t\t\t$\"The tool went all the way through, so what was one solid is now {added + 1} that touch nowhere. \"\n\t\t\t\t+ \"The largest keeps the original part's name and id; the rest are new parts below it.\",\n\t\t\t\"Reduce the depth if the cut was meant to be a pocket\",\n\t\t\t\"Nothing to fix if separating the part was the intent\" );\n\t}\n\n\t/// <summary>Names in a sentence: \"'A'\", \"'A' and 'B'\", \"'A', 'B' and 'C'\".</summary>\n\tprotected static string Listed( IReadOnlyList<string> names )\n\t{\n\t\tif ( names is null || names.Count == 0 )\n\t\t\treturn \"nothing\";\n\n\t\tvar quoted = new List<string>( names.Count );\n\n\t\tforeach ( var name in names )\n\t\t\tquoted.Add( $\"'{name}'\" );\n\n\t\tif ( quoted.Count == 1 )\n\t\t\treturn quoted[0];\n\n\t\treturn string.Join( \", \", quoted.GetRange( 0, quoted.Count - 1 ) ) + \" and \" + quoted[^1];\n\t}\n\n\tprotected void ApplyDiagnostic( FeatureDiagnostic diagnostic )\n\t{\n\t\tif ( diagnostic is null )\n\t\t\treturn;\n\n\t\tDiagnostic = diagnostic;\n\n\t\tif ( diagnostic.Severity == DiagnosticSeverity.Error )\n\t\t\tError = diagnostic.Problem;\n\t\telse\n\t\t\tWarning = diagnostic.Problem;\n\t}\n\n\t/// <summary>The bodies this feature acts on, or a refusal if there are none. A feature that\n\t/// did nothing is never a success.</summary>\n\tprotected List<Body> RequireBodies( FeatureContext ctx, BodySelectionParam selection )\n\t{\n\t\tvar bodies = new List<Body>();\n\n\t\tforeach ( var b in ctx.Bodies )\n\t\t{\n\t\t\tif ( selection.Matches( b ) )\n\t\t\t\tbodies.Add( b );\n\t\t}\n\n\t\tif ( bodies.Count > 0 )\n\t\t\treturn bodies;\n\n\t\tif ( ctx.Bodies.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\"This studio has no bodies yet\",\n\t\t\t\t\"There is nothing to act on \u2014 the feature list has not produced a solid.\",\n\t\t\t\t\"Add a Primitive, or extrude a sketch first\" );\n\t\t}\n\n\t\tFail(\n\t\t\t\"No matching body is selected\",\n\t\t\t$\"The studio has {ctx.Bodies.Count} body/bodies but none match this feature's selection.\",\n\t\t\t\"Clear the body selection to act on every body\",\n\t\t\t\"Pick a body that is still in the studio\" );\n\n\t\treturn bodies;\n\t}\n\n\t/// <summary>\n\t/// Run a blend on each selected body, using picked edges when any were given.\n\t///\n\t/// <paramref name=\"edges\"/> empty means the blend's own angle threshold, which is how Fillet\n\t/// and Chamfer behaved before edges could be picked. Non-empty means only those edges, and a\n\t/// body that none of them land on is left alone rather than blended by the threshold \u2014 mixing\n\t/// the two would fillet a part you never pointed at.\n\t/// </summary>\n\tprotected List<(Body Body, BlendReport Report)> BlendBodies( FeatureContext ctx, List<EdgeRef> edges,\n\t\tFunc<PolyMesh, HashSet<EdgeKey>, BlendReport> blend )\n\t{\n\t\tvar reports = new List<(Body Body, BlendReport Report)>();\n\t\tvar picked = edges is { Count: > 0 };\n\n\t\tforeach ( var body in RequireBodies( ctx, BodiesOf() ) )\n\t\t{\n\t\t\tif ( !picked )\n\t\t\t{\n\t\t\t\treports.Add( (body, blend( body.Mesh, null )) );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar keys = FacePlane.ResolveEdges( body, edges );\n\n\t\t\tif ( keys.Count == 0 )\n\t\t\t\tcontinue;\n\n\t\t\treports.Add( (body, blend( body.Mesh, keys )) );\n\t\t}\n\n\t\tif ( picked && reports.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\"None of the picked edges are on the selected bodies\",\n\t\t\t\t\"The edges were stored as geometry so they could survive a rebuild, and none of them could be re-found.\",\n\t\t\t\t\"Pick the edges again\",\n\t\t\t\t\"Clear the edge selection to blend every sharp edge\" );\n\t\t}\n\n\t\treturn reports;\n\t}\n\n\t/// <summary>The BodySelectionParam on this feature, or a fresh empty one (meaning every body)\n\t/// if the feature does not have one. Fillet and Chamfer both do.</summary>\n\tBodySelectionParam BodiesOf()\n\t{\n\t\tforeach ( var param in Parameters )\n\t\t{\n\t\t\tif ( param is BodySelectionParam selection )\n\t\t\t\treturn selection;\n\t\t}\n\n\t\treturn new BodySelectionParam( \"Bodies\" );\n\t}\n\n\t/// <summary>Assign blended meshes, or refuse, only after every body has been tried \u2014 so a\n\t/// failure leaves the studio as it was, which is Feature.Run's contract.</summary>\n\tprotected void CommitBlend( List<(Body Body, BlendReport Report)> reports, string sizeLabel )\n\t{\n\t\tforeach ( var ( _, report ) in reports )\n\t\t{\n\t\t\tif ( report.Failure is null )\n\t\t\t\tcontinue;\n\n\t\t\treport.Failure.ParameterLabel ??= sizeLabel;\n\n\t\t\tif ( report.SuggestedSize > 0f )\n\t\t\t\treport.Failure.SuggestedValue ??= FloorThousandths( report.SuggestedSize );\n\n\t\t\tthrow new FeatureException( report.Failure );\n\t\t}\n\n\t\tforeach ( var ( body, report ) in reports )\n\t\t\tbody.Mesh = report.Mesh;\n\n\t\tvar warnings = new List<FeatureDiagnostic>();\n\n\t\tforeach ( var ( _, report ) in reports )\n\t\t\twarnings.AddRange( report.Warnings );\n\n\t\tif ( warnings.Count == 0 )\n\t\t\treturn;\n\n\t\tif ( warnings.Count == 1 )\n\t\t{\n\t\t\tApplyDiagnostic( warnings[0] );\n\t\t\treturn;\n\t\t}\n\n\t\tWarn(\n\t\t\twarnings[0].Problem,\n\t\t\tstring.Join( \" \", warnings.Select( w => w.Cause ).Where( c => !string.IsNullOrEmpty( c ) ) ),\n\t\t\twarnings.SelectMany( w => w.Remedies ).Distinct().ToArray() );\n\t}\n\n\t/// <summary>A number the user can type that is never larger than the true fit, so suggesting\n\t/// it cannot immediately fail again to rounding.</summary>\n\tprotected static float FloorThousandths( float value ) => MathF.Floor( value * 1000f ) / 1000f;\n\n\tpublic override string ToString() => $\"{TypeName} '{Name ?? Id}'{(Suppressed ? \" (suppressed)\" : \"\")}\";\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/GarmentFit.cs",
"FileName": "GarmentFit.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text;\r\n\r\nnamespace Effigy;\r\n\r\n/// <summary>\r\n/// Which part of a body a face belongs to, read from the rig rather than from the shape.\r\n///\r\n/// A GARMENT IS A RECIPE, NOT A MESH. \"Torso and the top of the upper arms\" means the same thing on\r\n/// the Citizen, on Camhead and on Gearhead, and the rig is the one thing all three have in common.\r\n/// The shape comes from the body itself - see <see cref=\"GarmentFit\"/>.\r\n/// </summary>\r\npublic enum BodyRegion\r\n{\r\n\tNone,\r\n\tHead,\r\n\tNeck,\r\n\tTorso,\r\n\tHips,\r\n\tUpperArm,\r\n\tLowerArm,\r\n\tHand,\r\n\tUpperLeg,\r\n\tLowerLeg,\r\n\tFoot,\r\n}\r\n\r\n/// <summary>A garment's coverage: which regions, and how far along each region's bone.</summary>\r\npublic sealed class GarmentRecipe\r\n{\r\n\tpublic string Name;\r\n\r\n\t/// <summary>\r\n\t/// Region, the span of the bone it covers (0 at the bone's head, 1 at its tail), and which side\r\n\t/// of the body it is on: 0 both, +1 left, -1 right.\r\n\t///\r\n\t/// THE SIDE IS WHAT MAKES REAL CLOTHES POSSIBLE. Everything a span system builds is symmetric by\r\n\t/// construction, and a great deal of clothing is not - one sleeve, a strap over one shoulder, a\r\n\t/// shirt hanging off one side. Tagging the span rather than the recipe means asymmetry composes:\r\n\t/// a garment can be one-sleeved and off-shoulder at once, and each span still means what it\r\n\t/// meant before for anything that does not use it.\r\n\t/// </summary>\r\n\tpublic List<(BodyRegion Region, float From, float To, int Side)> Spans = new();\r\n\r\n\tpublic static readonly string[] Names =\r\n\t{\r\n\t\t\"T-shirt\", \"Long sleeve\", \"Tank top\", \"Trousers\", \"Shorts\", \"Beanie\", \"Gloves\", \"Socks\", \"Bodysuit\",\r\n\t};\r\n\r\n\t/// <summary>Which sleeves or legs a recipe keeps. Sides are the body's own - left is +y, the\r\n\t/// way Source measures it - so \"Left only\" is the wearer's left, not the viewer's.</summary>\r\n\tpublic static readonly string[] SideNames = { \"Both\", \"Left only\", \"Right only\" };\r\n\r\n\t/// <summary>SideNames index to the span tag: both, left (+y), right (-y).</summary>\r\n\tpublic static int SideOf( int index ) => index switch { 1 => 1, 2 => -1, _ => 0 };\r\n\r\n\t/// <summary>\r\n\t/// The recipe with its two lengths applied.\r\n\t///\r\n\t/// <paramref name=\"length\"/> is the hem: how far a top reaches down over the hips, how far a leg\r\n\t/// reaches down the leg, how deep a hat sits. <paramref name=\"sleeve\"/> is how far down the arm a\r\n\t/// sleeve goes, 0 bare to 1 at the wrist. Both are fractions so they mean the same thing on a\r\n\t/// short body and a tall one.\r\n\t/// </summary>\r\n\t/// <param name=\"neckline\">How far down the torso the garment STARTS, 0 at the neck. This is the\r\n\t/// top edge, the counterpart of <paramref name=\"length\"/>: raise it and the collar becomes a\r\n\t/// scoop, then bares the shoulders, then becomes a tube top. The sleeve starts at the same drop,\r\n\t/// because a sleeve still attached to a shoulder the body of the shirt has left behind is a\r\n\t/// floating ring, not a garment.</param>\r\n\t/// <param name=\"sleeveSide\">Which sleeve to keep: 0 both, +1 left, -1 right.</param>\r\n\tpublic static GarmentRecipe Build( int index, float length, float sleeve, float neckline = 0f,\r\n\t\tint sleeveSide = 0 )\r\n\t{\r\n\t\tlength = Math.Clamp( length, 0f, 1f );\r\n\t\tsleeve = Math.Clamp( sleeve, 0f, 1f );\r\n\r\n\t\t// Capped well short of 1: a neckline at the hem is a garment with no garment left, and the\r\n\t\t// fit would refuse with \"covers nothing\" rather than explain itself.\r\n\t\tneckline = Math.Clamp( neckline, 0f, 0.8f );\r\n\r\n\t\tvar r = new GarmentRecipe { Name = Names[Math.Clamp( index, 0, Names.Length - 1 )] };\r\n\r\n\t\tvoid Arms( float s )\r\n\t\t{\r\n\t\t\tif ( s <= 0f )\r\n\t\t\t\treturn;\r\n\r\n\t\t\t// The shoulder drop, shared with the torso. Halved because the upper arm's span runs\r\n\t\t\t// shoulder to elbow and the torso's runs neck to waist - the same fraction would drop\r\n\t\t\t// the sleeve twice as far down the arm as the neckline dropped down the chest.\r\n\t\t\tvar from = Math.Min( 0.6f, neckline * 0.5f );\r\n\r\n\t\t\tr.Spans.Add( (BodyRegion.UpperArm, from, Math.Max( from, Math.Min( 1f, s * 2f ) ), sleeveSide) );\r\n\r\n\t\t\tif ( s > 0.5f )\r\n\t\t\t\tr.Spans.Add( (BodyRegion.LowerArm, 0f, (s - 0.5f) * 2f, sleeveSide) );\r\n\t\t}\r\n\r\n\t\tvoid Legs( float s )\r\n\t\t{\r\n\t\t\tr.Spans.Add( (BodyRegion.UpperLeg, 0f, Math.Min( 1f, Math.Max( 0.05f, s ) * 2f ), 0) );\r\n\r\n\t\t\tif ( s > 0.5f )\r\n\t\t\t\tr.Spans.Add( (BodyRegion.LowerLeg, 0f, (s - 0.5f) * 2f, 0) );\r\n\t\t}\r\n\r\n\t\tswitch ( r.Name )\r\n\t\t{\r\n\t\t\tcase \"T-shirt\":\r\n\t\t\tcase \"Long sleeve\":\r\n\t\t\tcase \"Tank top\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Hips, 0f, length, 0) );\r\n\t\t\t\tArms( r.Name == \"Tank top\" ? 0f : sleeve );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase \"Trousers\":\r\n\t\t\tcase \"Shorts\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );\r\n\t\t\t\tLegs( length );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase \"Beanie\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Head, 1f - Math.Max( 0.1f, length ), 1.01f, 0) );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase \"Gloves\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Hand, 0f, 1.01f, sleeveSide) );\r\n\t\t\t\tr.Spans.Add( (BodyRegion.LowerArm, 1f - length * 0.5f, 1.01f, sleeveSide) );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase \"Socks\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Foot, 0f, 1.01f, 0) );\r\n\t\t\t\tr.Spans.Add( (BodyRegion.LowerLeg, 1f - length * 0.6f, 1.01f, 0) );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase \"Bodysuit\":\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );\r\n\t\t\t\tr.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );\r\n\t\t\t\tArms( sleeve );\r\n\t\t\t\tLegs( length );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\treturn r;\r\n\t}\r\n\r\n\t/// <summary>The defaults a recipe opens with: (length, sleeve).</summary>\r\n\tpublic static (float Length, float Sleeve) Defaults( int index ) => Names[Math.Clamp( index, 0, Names.Length - 1 )] switch\r\n\t{\r\n\t\t\"T-shirt\" => (0.6f, 0.3f),\r\n\t\t\"Long sleeve\" => (0.7f, 1f),\r\n\t\t\"Tank top\" => (0.6f, 0f),\r\n\t\t\"Trousers\" => (1f, 0f),\r\n\t\t\"Shorts\" => (0.4f, 0f),\r\n\t\t\"Beanie\" => (0.55f, 0f),\r\n\t\t\"Gloves\" => (0.3f, 0f),\r\n\t\t\"Socks\" => (0.4f, 0f),\r\n\t\t_ => (1f, 1f),\r\n\t};\r\n\r\n\t/// <param name=\"side\">Which side of the body the face is on: +1 left, -1 right. A span tagged 0\r\n\t/// covers both, which is what every symmetric garment is made of.</param>\r\n\tpublic bool Covers( BodyRegion region, float t, int side = 0 ) =>\r\n\t\tSpans.Any( s => s.Region == region && t >= s.From - 1e-4f && t <= s.To + 1e-4f\r\n\t\t\t&& (s.Side == 0 || s.Side == side) );\r\n}\r\n\r\n/// <summary>\r\n/// Classify a skeleton's bones into regions by name. Covers Citizen's names (<c>arm_upper_R</c>,\r\n/// <c>spine_2</c>), Unreal/Mixamo style (<c>upperarm_r</c>, <c>thigh_l</c>, <c>mixamorig:LeftForeArm</c>)\r\n/// and plain English ones people type into the rig panel.\r\n/// </summary>\r\npublic static class BodyRegions\r\n{\r\n\tpublic static BodyRegion Classify( string boneName )\r\n\t{\r\n\t\tvar n = (boneName ?? \"\").ToLowerInvariant();\r\n\r\n\t\tif ( n.Contains( \"twist\" ) )\r\n\t\t\tn = n.Replace( \"twist\", \"\" );\r\n\r\n\t\tbool Any( params string[] keys ) => keys.Any( n.Contains );\r\n\r\n\t\tif ( Any( \"finger\", \"thumb\", \"index\", \"middle\", \"ring_\", \"pinky\", \"hand\" ) ) return BodyRegion.Hand;\r\n\t\tif ( Any( \"toe\", \"foot\", \"ball_\" ) ) return BodyRegion.Foot;\r\n\t\tif ( Any( \"head\", \"eye\", \"jaw\", \"face\" ) ) return BodyRegion.Head;\r\n\t\tif ( Any( \"neck\" ) ) return BodyRegion.Neck;\r\n\t\tif ( Any( \"arm_lower\", \"forearm\", \"lowerarm\", \"lower_arm\", \"elbow\" ) ) return BodyRegion.LowerArm;\r\n\t\tif ( Any( \"arm_upper\", \"upperarm\", \"upper_arm\" ) ) return BodyRegion.UpperArm;\r\n\t\tif ( Any( \"leg_lower\", \"calf\", \"shin\", \"lowerleg\", \"lower_leg\", \"knee\" ) ) return BodyRegion.LowerLeg;\r\n\t\tif ( Any( \"leg_upper\", \"thigh\", \"upperleg\", \"upper_leg\" ) ) return BodyRegion.UpperLeg;\r\n\t\tif ( Any( \"pelvis\", \"hip\" ) ) return BodyRegion.Hips;\r\n\t\tif ( Any( \"spine\", \"chest\", \"clavicle\", \"torso\", \"abdomen\", \"belly\", \"collar\", \"shoulder\" ) ) return BodyRegion.Torso;\r\n\t\tif ( Any( \"arm\" ) ) return BodyRegion.UpperArm;\r\n\t\tif ( Any( \"leg\" ) ) return BodyRegion.UpperLeg;\r\n\r\n\t\treturn BodyRegion.None;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The region nearest a point, and how far along that region's bone it sits.\r\n\t///\r\n\t/// \"How far along\" is measured on the nearest bone of the region, and a region with a chain of\r\n\t/// bones (a spine, a twist chain) is measured along the whole chain, so 0.5 is halfway down the\r\n\t/// upper arm whether that arm is one bone or three.\r\n\t/// </summary>\r\n\tpublic sealed class Map\r\n\t{\r\n\t\treadonly List<(BodyRegion Region, Vec3 A, Vec3 B)> _segments = new();\r\n\t\treadonly List<int> _segmentBones = new();\r\n\r\n\t\tpublic int Count => _segments.Count;\r\n\r\n\t\t/// <summary>Every region bone as a segment, for fitting capsules to.</summary>\r\n\t\tpublic IReadOnlyList<(BodyRegion Region, Vec3 A, Vec3 B)> Segments => _segments;\r\n\r\n\t\t/// <summary>The skeleton bone each of <see cref=\"Segments\"/> came from.</summary>\r\n\t\tpublic IReadOnlyList<int> SegmentBones => _segmentBones;\r\n\r\n\t\tpublic Map( Skeleton skeleton )\r\n\t\t{\r\n\r\n\t\t\tfor ( var i = 0; i < skeleton.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar region = Classify( skeleton.Bones[i].Name );\r\n\r\n\t\t\t\tif ( region == BodyRegion.None )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar a = skeleton.HeadWorld( i );\r\n\t\t\t\tvar b = skeleton.TailWorld( i );\r\n\r\n\t\t\t\tif ( (b - a).LengthSquared < 1e-8f )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t_segments.Add( (region, a, b) );\r\n\t\t\t\t_segmentBones.Add( i );\r\n\t\t\t}\r\n\r\n\t\t}\r\n\r\n\t\t/// <summary>Nearest region to <paramref name=\"p\"/>, and t along it (0 at the joint nearest\r\n\t\t/// the body's centre, 1 at the far end).</summary>\r\n\t\tpublic (BodyRegion Region, float T) Locate( Vec3 p ) => Locate( p, BodyRegion.None );\r\n\r\n\t\t/// <summary>As <see cref=\"Locate(Vec3)\"/>, but only among <paramref name=\"only\"/>'s bones -\r\n\t\t/// for a surface that already knows which region it belongs to, such as an envelope hull.\r\n\t\t/// None means any region.</summary>\r\n\t\tpublic (BodyRegion Region, float T) Locate( Vec3 p, BodyRegion only )\r\n\t\t{\r\n\t\t\tvar best = float.MaxValue;\r\n\t\t\tvar region = BodyRegion.None;\r\n\t\t\tvar chainT = 0f;\r\n\r\n\t\t\tforeach ( var (r, a, b ) in _segments )\r\n\t\t\t{\r\n\t\t\t\tif ( only != BodyRegion.None && r != only )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar ab = b - a;\r\n\t\t\t\tvar t = Math.Clamp( Vec3.Dot( p - a, ab ) / ab.LengthSquared, 0f, 1f );\r\n\t\t\t\tvar d = (a + ab * t - p).LengthSquared;\r\n\r\n\t\t\t\tif ( d < best )\r\n\t\t\t\t{\r\n\t\t\t\t\tbest = d;\r\n\t\t\t\t\tregion = r;\r\n\t\t\t\t\tchainT = t;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( region == BodyRegion.None )\r\n\t\t\t\treturn (region, 0f);\r\n\r\n\t\t\t// Chains: measure t over the whole region on this side, start to end, so a limb reads\r\n\t\t\t// 0..1 shoulder to wrist whether it is one bone or a bone with twist bones laid over it\r\n\t\t\t// (Citizen's are - summing their lengths would count the arm twice).\r\n\t\t\tif ( Chain( region, Side( p ), out var from, out var to ) )\r\n\t\t\t{\r\n\t\t\t\tvar axis = to - from;\r\n\t\t\t\tchainT = Math.Clamp( Vec3.Dot( p - from, axis ) / axis.LengthSquared, 0f, 1f );\r\n\t\t\t}\r\n\r\n\t\t\t// The head is measured bottom-up (a beanie covers the TOP), the torso and hips top-down\r\n\t\t\t// (a hem reads down from the collar).\r\n\t\t\tif ( region == BodyRegion.Head )\r\n\t\t\t\tchainT = 1f - chainT;\r\n\r\n\t\t\treturn (region, chainT);\r\n\t\t}\r\n\r\n\t\t/// <summary>\r\n\t\t/// The line a region runs along, on one side of the body: from the end nearest the body's\r\n\t\t/// centre (the top, for anything on the spine or a leg) to the far end. This is the axis t\r\n\t\t/// is measured on and the axis a limb's UVs wrap around, so both agree about where a\r\n\t\t/// sleeve starts. False for a region the skeleton does not have.\r\n\t\t/// </summary>\r\n\t\tpublic bool Chain( BodyRegion region, int side, out Vec3 from, out Vec3 to )\r\n\t\t{\r\n\t\t\tfrom = to = Vec3.Zero;\r\n\t\t\tvar ends = new List<Vec3>();\r\n\r\n\t\t\tforeach ( var s in _segments )\r\n\t\t\t{\r\n\t\t\t\tif ( s.Region != region || (!IsCentral( region ) && Side( (s.A + s.B) * 0.5f ) != side) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tends.Add( s.A );\r\n\t\t\t\tends.Add( s.B );\r\n\t\t\t}\r\n\r\n\t\t\tif ( ends.Count < 2 )\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tfrom = IsCentral( region ) || region is BodyRegion.UpperLeg or BodyRegion.LowerLeg or BodyRegion.Foot\r\n\t\t\t\t? ends.OrderByDescending( e => e.z ).First()\r\n\t\t\t\t: ends.OrderBy( e => MathF.Abs( e.y ) ).First();\r\n\r\n\t\t\tvar start = from;\r\n\t\t\tto = ends.OrderByDescending( e => (e - start).LengthSquared ).First();\r\n\r\n\t\t\treturn (to - from).LengthSquared > 1e-8f;\r\n\t\t}\r\n\r\n\t\tpublic static bool IsCentral( BodyRegion r ) => r is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;\r\n\r\n\t\tstatic int Side( Vec3 p ) => p.y > 0 ? 1 : -1;\r\n\r\n\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Make a garment that fits a particular body.\r\n///\r\n/// THE BODY DOES MOST OF THE WORK. The rig only says which faces a recipe covers; the garment is then\r\n/// lifted off the body's own surface, so it starts with that body's proportions and topology - which\r\n/// is also the topology the s&box quality bar asks clothing to follow. After that:\r\n///\r\n/// 1. every vertex is pushed out to the fit offset along its normal,\r\n/// 2. collision: any vertex closer than the clearance to ANY body - the chest pressing into an arm, a\r\n/// collar into a cog - is pushed back out along that surface's normal, and smoothed, a few times,\r\n/// 3. the openings flare out if asked,\r\n/// 4. the sheet is given thickness with <see cref=\"MeshSolidify\"/>.\r\n///\r\n/// It does not drape. Folds come from the flare and from sculpting until editor cloth arrives.\r\n/// </summary>\r\npublic static class GarmentFit\r\n{\r\n\tpublic sealed class Options\r\n\t{\r\n\t\t/// <summary>Round colliders for the drape instead of the bodies' triangles. See Capsule.\r\n\t\t/// Null drapes against the meshes, as before.</summary>\r\n\t\tpublic List<Capsule> Capsules;\r\n\r\n\t\t/// <summary>How far the drape may move the cloth from where the fit put it. 0 takes the\r\n\t\t/// default: a couple of inches, room to settle and no more.</summary>\r\n\t\tpublic float Tether;\r\n\r\n\t\t/// <summary>How far off the body the garment sits, in model units (inches on the Citizen).</summary>\r\n\t\tpublic float Offset = 0.4f;\r\n\r\n\t\t/// <summary>The closest any part of the garment may come to any body, after collision.</summary>\r\n\t\tpublic float Clearance = 0.15f;\r\n\r\n\t\t/// <summary>How far the openings (hems, cuffs) flare out beyond the offset.</summary>\r\n\t\tpublic float Flare;\r\n\r\n\t\t/// <summary>How far up from an opening the flare reaches.</summary>\r\n\t\tpublic float FlareReach = 4f;\r\n\r\n\t\t/// <summary>Cloth thickness; 0 leaves a single-sided sheet.</summary>\r\n\t\tpublic float Thickness = 0.12f;\r\n\r\n\t\t/// <summary>\r\n\t\t/// Inflate the garment along its own normals, in inches. Puffed sleeves, a padded jacket,\r\n\t\t/// a quilted vest.\r\n\t\t///\r\n\t\t/// Marvelous Designer calls this Pressure and Simply Cloth calls it the same; it is on both\r\n\t\t/// tools' front page because it is the cheapest way to turn a fitted shape into a different\r\n\t\t/// GARMENT rather than the same garment at a different size. Looseness moves the whole\r\n\t\t/// surface away from the body uniformly, which reads as a bigger shirt. This pushes along\r\n\t\t/// the local normal, which reads as stuffing.\r\n\t\t/// </summary>\r\n\t\tpublic float Puff;\r\n\r\n\t\t/// <summary>\r\n\t\t/// Pull the openings in towards their own centres, 0 none to 1 closed. A waistband, elastic\r\n\t\t/// cuffs, a gathered ankle on a jogger.\r\n\t\t///\r\n\t\t/// The hems are the only part of a garment that has nothing holding it in, so they are the\r\n\t\t/// part that reads as loose whatever the rest is doing - which is why \"tighten the waistband\"\r\n\t\t/// is a thing people ask for and \"make the shirt smaller\" is not the same request.\r\n\t\t/// </summary>\r\n\t\tpublic float Cinch;\r\n\r\n\t\t/// <summary>How far up from an opening the cinch reaches, in inches. A waistband is an inch;\r\n\t\t/// a gathered ankle is three.</summary>\r\n\t\tpublic float CinchReach = 1.5f;\r\n\r\n\t\t/// <summary>\r\n\t\t/// Creases along the surface, 0 smooth to 1 rumpled.\r\n\t\t///\r\n\t\t/// NOT A SUBSTITUTE FOR THE DRAPE. The solver makes the folds that gravity and collision\r\n\t\t/// actually produce, and those are the ones that sit in the right places. This is the fine\r\n\t\t/// noise on top - the crumple of cloth that has been worn - which a solver would need a much\r\n\t\t/// finer mesh and much longer to find, for something nobody looks at closely enough to tell\r\n\t\t/// apart from noise.\r\n\t\t/// </summary>\r\n\t\tpublic float Wrinkles;\r\n\r\n\t\t/// <summary>Size of a wrinkle, in inches. Small is linen, large is a heavy coat.</summary>\r\n\t\tpublic float WrinkleScale = 2.2f;\r\n\r\n\t\t/// <summary>Collision passes. Each pass pushes out and relaxes once.</summary>\r\n\t\tpublic int Passes = 4;\r\n\r\n\t\tpublic int MaterialSlot;\r\n\r\n\t\t/// <summary>Let the garment settle under gravity against the bodies before it is thickened.</summary>\r\n\t\tpublic bool Drape = true;\r\n\r\n\t\t/// <summary>\r\n\t\t/// What the cloth is made of, as a set of solver settings.\r\n\t\t///\r\n\t\t/// THE FIRST THING EITHER OF THE BIG TOOLS ASKS. Marvelous Designer opens on a fabric\r\n\t\t/// library and Simply Cloth ships presets, because the difference between cotton and leather\r\n\t\t/// is most of the difference between two garments of the same shape - it decides whether the\r\n\t\t/// folds are many and soft or few and standing. Stiffness alone could only say how much a\r\n\t\t/// garment resists being stretched, which is the one property people do not think in.\r\n\t\t///\r\n\t\t/// A STARTING POINT, NOT A MEASUREMENT. These are artistic presets tuned to look right at\r\n\t\t/// this scale, not the physical constants of real textiles; Stiffness still overrides the\r\n\t\t/// stretch on top of whichever is chosen.\r\n\t\t/// </summary>\r\n\t\tpublic Fabric Fabric = Fabric.Cotton;\r\n\r\n\t\t/// <summary>Simulation steps, at 1/60 s each. More settles further and costs more.</summary>\r\n\t\tpublic int DrapeSteps = 40;\r\n\r\n\t\t/// <summary>0 = stretchy jersey, 1 = stiff canvas. How hard the edges hold their fitted length.</summary>\r\n\t\tpublic float Stiffness = 0.8f;\r\n\r\n\t\t/// <summary>Pin the top fraction of the garment (by height) where it is, so trousers keep their\r\n\t\t/// waistband. 0 pins nothing - a top rests on the shoulders by collision alone.</summary>\r\n\t\tpublic float PinTop;\r\n\r\n\t\t/// <summary>\r\n\t\t/// Stop the garment passing through itself where it folds. OFF by default, and deliberately:\r\n\t\t/// it is several times the cost of the whole rest of the drape, and this runs on every\r\n\t\t/// rebuild while a slider moves. The fit pass has already pushed the garment out of the body,\r\n\t\t/// so what this buys is folds resolving against each other - worth asking for on a finished\r\n\t\t/// garment, not worth paying for on every drag.\r\n\t\t/// </summary>\r\n\t\tpublic bool SelfCollision;\r\n\t}\r\n\r\n\tpublic sealed class Result\r\n\t{\r\n\t\tpublic PolyMesh Mesh;\r\n\t\tpublic int SourceFaces;\r\n\t\tpublic int DroppedInside;\r\n\t\tpublic bool Solidified;\r\n\t\tpublic string SolidifyProblem;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build <paramref name=\"recipe\"/> on <paramref name=\"bodies\"/>, located with <paramref name=\"map\"/>.\r\n\t/// Returns null when the recipe covers no face at all.\r\n\t/// </summary>\r\n\tpublic static Result Fit( IReadOnlyList<PolyMesh> wearers, BodyRegions.Map map, GarmentRecipe recipe, Options o,\r\n\t\tIReadOnlyList<PolyMesh> otherGarments = null, IReadOnlyList<BodyRegion> wearerRegions = null )\r\n\t{\r\n\t\t// Everything collides; only the wearers are cut from.\r\n\t\tvar bodies = wearers.Concat( otherGarments ?? Array.Empty<PolyMesh>() ).ToList();\r\n\t\tvar trees = bodies.Select( MeshBVH.Build ).ToList();\r\n\t\tvar closed = bodies.Select( b => MeshValidator.Validate( b ).IsClosed ).ToList();\r\n\t\tvar result = new Result { Mesh = new PolyMesh() };\r\n\t\tvar garment = result.Mesh;\r\n\t\tvar remap = new Dictionary<(int Body, int Vertex), int>();\r\n\t\tvar cutRemap = new Dictionary<(int Body, int A, int B, BodyRegion Region, int End), int>();\r\n\r\n\t\tfor ( var bi = 0; bi < wearers.Count; bi++ )\r\n\t\t{\r\n\t\t\tvar body = bodies[bi];\r\n\r\n\t\t\t// A wearer that already knows its region - an envelope hull - keeps it, and is only\r\n\t\t\t// measured along it. Left to the nearest bone, the front of Camhead's torso hull was\r\n\t\t\t// claimed by the arms hanging beside it and cut out of the shirt.\r\n\t\t\tvar only = wearerRegions is not null && bi < wearerRegions.Count ? wearerRegions[bi] : BodyRegion.None;\r\n\r\n\t\t\tint Vertex( int vi )\r\n\t\t\t{\r\n\t\t\t\tvar key = (bi, vi);\r\n\r\n\t\t\t\tif ( !remap.TryGetValue( key, out var index ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tindex = garment.AddVertex( body.Positions[vi] );\r\n\t\t\t\t\tremap[key] = index;\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn index;\r\n\t\t\t}\r\n\r\n\t\t\tfor ( var fi = 0; fi < body.FaceCount; fi++ )\r\n\t\t\t{\r\n\t\t\t\tvar face = body.Faces[fi];\r\n\r\n\t\t\t\tif ( face.Count < 3 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar centroid = body.FaceCentroid( face );\r\n\t\t\t\tvar (region, t) = map.Locate( centroid, only );\r\n\r\n\t\t\t\t// The side is the face's own position, not the bone's: +y is the body's left, the\r\n\t\t\t\t// way Source measures it. Taken from the face rather than from the bone name so an\r\n\t\t\t\t// imported skeleton with no _L / _R convention still makes a one-sleeved shirt.\r\n\t\t\t\tvar side = centroid.y > 0 ? 1 : -1;\r\n\t\t\t\tvar spans = recipe.Spans.Where( s => s.Region == region && (s.Side == 0 || s.Side == side) ).ToList();\r\n\r\n\t\t\t\tif ( spans.Count == 0 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tstatic bool Within( (BodyRegion Region, float From, float To, int Side) s, float t ) =>\r\n\t\t\t\t\tt >= s.From - 1e-4f && t <= s.To + 1e-4f;\r\n\r\n\t\t\t\t// THE FACE IS CUT AT THE HEM, not kept or dropped whole. Whole faces gave every\r\n\t\t\t\t// opening a sawtooth edge one face deep - the jagged cuffs and collars that said\r\n\t\t\t\t// \"generated\" from across the room - and no amount of relaxing put a straight line\r\n\t\t\t\t// back. The span's two ends are two planes across the bone (t is linear along it),\r\n\t\t\t\t// and the polygon is clipped to each (Sutherland-Hodgman), so the edge is a clean\r\n\t\t\t\t// line round the limb. An end at 0 or 1 is open - a sleeve runs into the shoulder,\r\n\t\t\t\t// a glove past the fingertips - and is not cut. A face is in if any corner of it\r\n\t\t\t\t// is, and it is the span round the centre, or failing that round a corner, that\r\n\t\t\t\t// does the cutting.\r\n\t\t\t\tvar nodes = new List<CutNode>( face.Count + 2 );\r\n\t\t\t\tvar span = spans[0];\r\n\t\t\t\tvar chained = map.Chain( region, side, out var from, out var to );\r\n\r\n\t\t\t\tif ( chained )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar axis = to - from;\r\n\t\t\t\t\tvar lengthSq = axis.LengthSquared;\r\n\r\n\t\t\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar vi = face.Indices[c];\r\n\t\t\t\t\t\tvar p = body.Positions[vi];\r\n\t\t\t\t\t\tnodes.Add( new CutNode( vi, vi, -1, p, Vec3.Dot( p - from, axis ) / lengthSq ) );\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tvar covering = spans.FirstOrDefault( s => Within( s, t ) );\r\n\r\n\t\t\t\t\tif ( covering.Region != region )\r\n\t\t\t\t\t\tcovering = spans.FirstOrDefault( s => nodes.Any( n => Within( s, n.T ) ) );\r\n\r\n\t\t\t\t\tif ( covering.Region != region )\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tspan = covering;\r\n\t\t\t\t}\r\n\t\t\t\telse if ( !spans.Any( s => Within( s, t ) ) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t// A face buried inside another part - an arm cylinder's end inside the torso block,\r\n\t\t\t\t// the way CAD characters are built - would be pushed out onto the surface as a crumpled\r\n\t\t\t\t// flap. It is not skin; skip it.\r\n\t\t\t\tif ( InsideOther( bodies, trees, closed, bi, centroid ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tresult.DroppedInside++;\r\n\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( chained )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( span.From > 0f )\r\n\t\t\t\t\t\tnodes = ClipSpan( nodes, span.From, keepAbove: true, end: 0 );\r\n\r\n\t\t\t\t\tif ( span.To < 1f && nodes.Count >= 3 )\r\n\t\t\t\t\t\tnodes = ClipSpan( nodes, span.To, keepAbove: false, end: 1 );\r\n\r\n\t\t\t\t\tif ( nodes.Count < 3 )\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t\t\t\tnodes.Add( new CutNode( face.Indices[c], face.Indices[c], -1, body.Positions[face.Indices[c]], 0f ) );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar indices = new int[nodes.Count];\r\n\r\n\t\t\t\tfor ( var c = 0; c < nodes.Count; c++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar node = nodes[c];\r\n\r\n\t\t\t\t\t// An original corner; or a cut point on an original edge, which the face on the\r\n\t\t\t\t\t// other side of that edge finds again by the same key, so the cut is watertight.\r\n\t\t\t\t\tif ( node.End < 0 )\r\n\t\t\t\t\t\tindices[c] = Vertex( node.A );\r\n\t\t\t\t\telse if ( node.A >= 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar (lo, hi) = node.A < node.B ? (node.A, node.B) : (node.B, node.A);\r\n\t\t\t\t\t\tvar key = (bi, lo, hi, region, node.End);\r\n\r\n\t\t\t\t\t\tif ( !cutRemap.TryGetValue( key, out var index ) )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tindex = garment.AddVertex( node.P );\r\n\t\t\t\t\t\t\tcutRemap[key] = index;\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\tindices[c] = index;\r\n\t\t\t\t\t}\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tindices[c] = garment.AddVertex( node.P );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tgarment.AddFace( indices, null, o.MaterialSlot );\r\n\t\t\t\tresult.SourceFaces++;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( garment.FaceCount == 0 )\r\n\t\t\treturn null;\r\n\r\n\t\tvar start = garment.Positions.ToArray();\r\n\t\tvar normals = garment.ComputeVertexNormals();\r\n\t\tvar boundary = BoundaryVertices( garment );\r\n\t\tvar flare = FlareWeights( garment, boundary, o.FlareReach );\r\n\r\n\t\t// 1. Out to the fit offset, flaring toward the openings.\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t\tgarment.Positions[i] = start[i] + normals[i] * (o.Offset + o.Flare * flare[i]);\r\n\r\n\t\t// 2. Collision against every body, with a relax between passes so a push does not leave a dent.\r\n\t\tvar neighbours = Neighbours( garment );\r\n\t\tvar reach = o.Offset + o.Flare + o.Clearance + 2f;\r\n\r\n\t\tfor ( var pass = 0; pass < Math.Max( 1, o.Passes ); pass++ )\r\n\t\t{\r\n\t\t\tPushOut( garment, bodies, trees, o.Clearance, reach );\r\n\t\t\tRelax( garment, neighbours, boundary, 0.35f );\r\n\t\t}\r\n\r\n\t\tPushOut( garment, bodies, trees, o.Clearance, reach );\r\n\r\n\t\tif ( o.Drape && o.DrapeSteps > 0 )\r\n\t\t\tDrape( garment, bodies, o );\r\n\r\n\t\t// SHAPING COMES AFTER THE DRAPE AND BEFORE THE THICKNESS, and the order is the whole\r\n\t\t// correctness argument. After the drape, because these are deliberate style decisions and\r\n\t\t// the solver would flatten a puff straight back out as if it were an error. Before the\r\n\t\t// thickness, because Solidify offsets the surface it is given - shaping a solid would move\r\n\t\t// its inner and outer shells apart by different amounts and split the garment open.\r\n\t\t//\r\n\t\t// Then pushed out again: a puff or a wrinkle can drive the cloth back into the body, and a\r\n\t\t// garment that clips is worse than one that is not quite as puffy as asked.\r\n\t\tif ( o.Puff > 0f )\r\n\t\t\tInflate( garment, o.Puff );\r\n\r\n\t\tif ( o.Cinch > 0f )\r\n\t\t\tCinch( garment, o.Cinch, o.CinchReach );\r\n\r\n\t\tif ( o.Wrinkles > 0f )\r\n\t\t\tWrinkle( garment, o.Wrinkles, o.WrinkleScale );\r\n\r\n\t\tif ( o.Puff > 0f || o.Wrinkles > 0f )\r\n\t\t\tPushOut( garment, bodies, trees, o.Clearance, reach );\r\n\r\n\t\tLimbUVs( garment, map );\r\n\r\n\t\tif ( o.Thickness > 0f )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tgarment = MeshSolidify.Solidify( garment, o.Thickness );\r\n\t\t\t\tresult.Solidified = true;\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tresult.SolidifyProblem = e.Message;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tresult.Mesh = garment;\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>A corner of a face being cut: an original vertex (A == B, End -1), or a point on\r\n\t/// the original edge A-B where the span's end (0 the start, 1 the finish) crosses it. A is -1\r\n\t/// for a point on an edge the cut itself made, which no other face shares.</summary>\r\n\treadonly record struct CutNode( int A, int B, int End, Vec3 P, float T );\r\n\r\n\t/// <summary>One Sutherland-Hodgman pass: keep the side of t = limit the span is on.</summary>\r\n\tstatic List<CutNode> ClipSpan( List<CutNode> nodes, float limit, bool keepAbove, int end )\r\n\t{\r\n\t\tvar kept = new List<CutNode>( nodes.Count + 2 );\r\n\r\n\t\tfor ( var i = 0; i < nodes.Count; i++ )\r\n\t\t{\r\n\t\t\tvar cur = nodes[i];\r\n\t\t\tvar nxt = nodes[(i + 1) % nodes.Count];\r\n\t\t\tvar inCur = keepAbove ? cur.T >= limit : cur.T <= limit;\r\n\t\t\tvar inNxt = keepAbove ? nxt.T >= limit : nxt.T <= limit;\r\n\r\n\t\t\tif ( inCur )\r\n\t\t\t\tkept.Add( cur );\r\n\r\n\t\t\tif ( inCur == inNxt )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar s = Math.Clamp( (limit - cur.T) / (nxt.T - cur.T), 0f, 1f );\r\n\t\t\tvar (a, b) = EdgeUnder( cur, nxt );\r\n\t\t\tkept.Add( new CutNode( a, b, end, cur.P + (nxt.P - cur.P) * s, limit ) );\r\n\t\t}\r\n\r\n\t\treturn kept;\r\n\t}\r\n\r\n\t/// <summary>The original edge a segment between two nodes lies on, or (-1, -1) when it is a\r\n\t/// line the first cut drew across the face and nothing else can share.</summary>\r\n\tstatic (int, int) EdgeUnder( CutNode x, CutNode y )\r\n\t{\r\n\t\tstatic bool OnEdge( CutNode cut, int vertex ) => cut.A == vertex || cut.B == vertex;\r\n\r\n\t\tif ( x.End < 0 && y.End < 0 )\r\n\t\t\treturn (x.A, y.A);\r\n\r\n\t\tif ( x.End < 0 && y.A >= 0 && OnEdge( y, x.A ) )\r\n\t\t\treturn (y.A, y.B);\r\n\r\n\t\tif ( y.End < 0 && x.A >= 0 && OnEdge( x, y.A ) )\r\n\t\t\treturn (x.A, x.B);\r\n\r\n\t\tif ( x.End >= 0 && y.End >= 0 && x.A >= 0 && x.A == y.A && x.B == y.B )\r\n\t\t\treturn (x.A, x.B);\r\n\r\n\t\treturn (-1, -1);\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Push every vertex out along its own normal. Pressure, in Marvelous Designer's terms.\r\n\t///\r\n\t/// The normals are computed ONCE, from the shape as it arrives, rather than per vertex as it\r\n\t/// moves. Recomputing as you go makes a bulge feed itself - the more a region inflates the more\r\n\t/// outward its neighbours' normals become - and a padded jacket turns into a balloon.\r\n\t/// </summary>\r\n\tstatic void Inflate( PolyMesh garment, float amount )\r\n\t{\r\n\t\tvar normals = garment.ComputeVertexNormals();\r\n\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t\tgarment.Positions[i] += normals[i] * amount;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Pull the openings in towards their own centres: waistbands, elastic cuffs, gathered ankles.\r\n\t///\r\n\t/// The pull falls off with distance from the edge, so the garment is gathered at the hem and\r\n\t/// untouched an inch above it - which is what elastic in a casing actually does, and what makes\r\n\t/// this read as a waistband rather than as a garment that tapers.\r\n\t/// </summary>\r\n\tstatic void Cinch( PolyMesh garment, float amount, float reach )\r\n\t{\r\n\t\tvar loops = GarmentTrim.OrderedBoundaryLoops( garment );\r\n\r\n\t\tif ( loops.Count == 0 || reach <= 1e-4f )\r\n\t\t\treturn;\r\n\r\n\t\tamount = Math.Clamp( amount, 0f, 1f );\r\n\r\n\t\t// Every vertex moves towards the centre of the NEAREST opening, weighted by how close it is\r\n\t\t// to that opening's edge. Nearest rather than \"the opening it belongs to\": a garment has no\r\n\t\t// notion of which panel a vertex is on, and distance answers the question correctly anyway.\r\n\t\tvar centres = new List<Vec3>();\r\n\t\tvar rings = new List<List<Vec3>>();\r\n\r\n\t\tforeach ( var loop in loops )\r\n\t\t{\r\n\t\t\tvar points = loop.Select( v => garment.Positions[v] ).ToList();\r\n\t\t\tvar centre = Vec3.Zero;\r\n\r\n\t\t\tforeach ( var p in points )\r\n\t\t\t\tcentre += p;\r\n\r\n\t\t\tcentres.Add( centre / points.Count );\r\n\t\t\trings.Add( points );\r\n\t\t}\r\n\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t{\r\n\t\t\tvar p = garment.Positions[i];\r\n\t\t\tvar best = float.MaxValue;\r\n\t\t\tvar which = -1;\r\n\r\n\t\t\tfor ( var li = 0; li < rings.Count; li++ )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var q in rings[li] )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar d = (q - p).LengthSquared;\r\n\r\n\t\t\t\t\tif ( d >= best )\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tbest = d;\r\n\t\t\t\t\twhich = li;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tif ( which < 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar distance = MathF.Sqrt( best );\r\n\r\n\t\t\tif ( distance >= reach )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t// Smoothstep rather than linear, so the top of the band blends into the garment instead\r\n\t\t\t// of leaving a crease exactly `reach` inches up.\r\n\t\t\tvar t = 1f - distance / reach;\r\n\t\t\tvar falloff = t * t * (3f - 2f * t);\r\n\r\n\t\t\tvar centre = centres[which];\r\n\r\n\t\t\t// Along the opening's own plane only: pulling towards the centre POINT would drag the\r\n\t\t\t// hem up the body as well as in, and shorten the garment as a side effect of tightening\r\n\t\t\t// it. Only the part of the offset perpendicular to the ring's axis is wanted.\r\n\t\t\tvar toCentre = centre - p;\r\n\t\t\tvar axis = RingAxis( rings[which], centre );\r\n\r\n\t\t\ttoCentre -= axis * Vec3.Dot( toCentre, axis );\r\n\r\n\t\t\tgarment.Positions[i] = p + toCentre * (amount * falloff);\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The direction an opening faces: the normal of the plane its vertices best lie in.\r\n\t/// Found by summing the fan of cross products around the ring, which is the ring's own area\r\n\t/// vector and needs no fitting.</summary>\r\n\tstatic Vec3 RingAxis( List<Vec3> ring, Vec3 centre )\r\n\t{\r\n\t\tvar area = Vec3.Zero;\r\n\r\n\t\tfor ( var i = 0; i < ring.Count; i++ )\r\n\t\t\tarea += Vec3.Cross( ring[i] - centre, ring[(i + 1) % ring.Count] - centre );\r\n\r\n\t\treturn area.LengthSquared < 1e-8f ? new Vec3( 0, 0, 1 ) : area.Normal;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Fine creases along the surface. See Options.Wrinkles for what this is and is not.\r\n\t///\r\n\t/// Three octaves of a cheap value noise over the vertex's own position, displaced along the\r\n\t/// normal. Position-based rather than UV-based on purpose: UVs are laid out per panel, so a\r\n\t/// UV-space wrinkle would stop dead at every seam, and cloth does not.\r\n\t/// </summary>\r\n\tstatic void Wrinkle( PolyMesh garment, float amount, float scale )\r\n\t{\r\n\t\tif ( scale <= 1e-3f )\r\n\t\t\treturn;\r\n\r\n\t\tvar normals = garment.ComputeVertexNormals();\r\n\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t{\r\n\t\t\tvar p = garment.Positions[i] / scale;\r\n\r\n\t\t\tvar n = Noise( p ) * 0.6f\r\n\t\t\t\t+ Noise( p * 2.3f ) * 0.3f\r\n\t\t\t\t+ Noise( p * 4.7f ) * 0.1f;\r\n\r\n\t\t\tgarment.Positions[i] += normals[i] * (n * amount * scale * 0.09f);\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>A cheap deterministic value noise in -1..1. Deterministic matters more than\r\n\t/// quality: the same garment must rebuild to the same wrinkles, or every rebuild would be a\r\n\t/// different model and undo would be a surprise.</summary>\r\n\tstatic float Noise( Vec3 p )\r\n\t{\r\n\t\tvar x = MathF.Sin( p.x * 12.9898f + p.y * 78.233f + p.z * 37.719f ) * 43758.5453f;\r\n\t\tvar y = MathF.Sin( p.x * 93.9898f + p.y * 67.345f + p.z * 24.123f ) * 28001.8384f;\r\n\r\n\t\treturn (x - MathF.Floor( x )) + (y - MathF.Floor( y )) - 1f;\r\n\t}\r\n\r\n\tstatic bool InsideOther( IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, List<bool> closed, int self, Vec3 p )\r\n\t{\r\n\t\tfor ( var bi = 0; bi < bodies.Count; bi++ )\r\n\t\t{\r\n\t\t\tif ( bi == self || !closed[bi] )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( trees[bi].NearestSurface( bodies[bi], p, 1e6f ) is not { } hit )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( hit.Distance > 1e-4f && Vec3.Dot( p - hit.Point, hit.Normal ) < 0 )\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tinternal static void PushOut( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, float clearance, float reach )\r\n\t{\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t{\r\n\t\t\tvar p = garment.Positions[i];\r\n\r\n\t\t\tfor ( var bi = 0; bi < bodies.Count; bi++ )\r\n\t\t\t{\r\n\t\t\t\tif ( trees[bi].NearestSurface( bodies[bi], p, reach ) is not { } hit )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar along = Vec3.Dot( p - hit.Point, hit.Normal );\r\n\r\n\t\t\t\tif ( along < clearance )\r\n\t\t\t\t\tp += hit.Normal * (clearance - along);\r\n\t\t\t}\r\n\r\n\t\t\tgarment.Positions[i] = p;\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void Relax( PolyMesh mesh, List<int>[] neighbours, bool[] boundary, float amount )\r\n\t{\r\n\t\tvar next = mesh.Positions.ToArray();\r\n\r\n\t\tfor ( var i = 0; i < mesh.VertexCount; i++ )\r\n\t\t{\r\n\t\t\tvar n = neighbours[i];\r\n\r\n\t\t\tif ( n.Count == 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar avg = Vec3.Zero;\r\n\r\n\t\t\tforeach ( var j in n )\r\n\t\t\t\tavg += mesh.Positions[j];\r\n\r\n\t\t\tavg /= n.Count;\r\n\t\t\tnext[i] = Vec3.Lerp( mesh.Positions[i], avg, boundary[i] ? amount * 0.3f : amount );\r\n\t\t}\r\n\r\n\t\tfor ( var i = 0; i < next.Length; i++ )\r\n\t\t\tmesh.Positions[i] = next[i];\r\n\t}\r\n\r\n\tstatic List<int>[] Neighbours( PolyMesh mesh )\r\n\t{\r\n\t\tvar sets = new HashSet<int>[mesh.VertexCount];\r\n\r\n\t\tfor ( var i = 0; i < sets.Length; i++ )\r\n\t\t\tsets[i] = new HashSet<int>();\r\n\r\n\t\tforeach ( var f in mesh.Faces )\r\n\t\t{\r\n\t\t\tfor ( var c = 0; c < f.Count; c++ )\r\n\t\t\t{\r\n\t\t\t\tvar a = f.Indices[c];\r\n\t\t\t\tvar b = f.Indices[(c + 1) % f.Count];\r\n\t\t\t\tsets[a].Add( b );\r\n\t\t\t\tsets[b].Add( a );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn sets.Select( s => s.ToList() ).ToArray();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Let the fitted garment settle under gravity against the bodies, using the kernel's one cloth\r\n\t/// solver. <see cref=\"Options.Stiffness\"/> becomes XPBD stretch compliance \u2014 1 is canvas that\r\n\t/// holds its fitted length, 0 is jersey that gives \u2014 and <see cref=\"Options.PinTop\"/> pins the\r\n\t/// top slice of the mesh so a waistband stays where it was fitted.\r\n\t/// </summary>\r\n\tstatic void Drape( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, Options o )\r\n\t{\r\n\t\t// The fabric first, then the garment's own settings over the top. Order matters: the preset\r\n\t\t// is what the cloth IS and Stiffness is the adjustment somebody made to it, so the\r\n\t\t// adjustment has to win - the other way round, moving the slider would do nothing on any\r\n\t\t// fabric that sets its own stretch.\r\n\t\tvar sim = ClothSim.WithFabric( garment, PinnedTop( garment, o.PinTop ), o.Fabric );\r\n\r\n\t\tsim.Thickness = MathF.Max( o.Clearance, 1e-3f );\r\n\t\tsim.SelfCollision = o.SelfCollision;\r\n\r\n\t\t// Stiffness runs 0..1 and compliance runs rigid..slack over four decades, so the map is\r\n\t\t// exponential: 1 -> 1e-7 (canvas), 0.5 -> 1e-5, 0 -> 1e-3 (jersey).\r\n\t\tvar stretch = MathF.Pow( 10f, -7f + 4f * (1f - Math.Clamp( o.Stiffness, 0f, 1f )) );\r\n\r\n\t\t// Stretch is scaled rather than replaced, so a stretch fabric stays stretchier than denim at\r\n\t\t// the same slider position instead of every fabric collapsing onto one curve.\r\n\t\tsim.StretchCompliance = MathF.Max( sim.StretchCompliance, 1e-7f ) / 1e-5f * stretch;\r\n\t\tsim.BendCompliance = MathF.Max( sim.BendCompliance, sim.StretchCompliance * 10f );\r\n\t\t// The garment was fitted to lie where it should; the drape only settles it. Two inches, or\r\n\t\t// four clearances on a loose fit, is room for a hem to fall and nowhere near room to drop\r\n\t\t// through a body with gaps in it. See ClothSim.Tether.\r\n\t\tsim.Tether = o.Tether > 0f ? o.Tether : MathF.Max( 2f, o.Clearance * 4f );\r\n\r\n\t\tif ( o.Capsules is { Count: > 0 } )\r\n\t\t\tsim.Capsules.AddRange( o.Capsules );\r\n\t\telse\r\n\t\t\tsim.SetColliders( bodies );\r\n\t\tsim.Run( o.DrapeSteps / 60f );\r\n\r\n\t\tfor ( var i = 0; i < garment.VertexCount; i++ )\r\n\t\t\tgarment.Positions[i] = sim.Positions[i];\r\n\t}\r\n\r\n\t/// <summary>Vertices in the top <paramref name=\"fraction\"/> of the mesh's height. Empty when the\r\n\t/// fraction is 0, which pins nothing and lets the garment rest on the body by collision alone.</summary>\r\n\tstatic IEnumerable<int> PinnedTop( PolyMesh mesh, float fraction )\r\n\t{\r\n\t\tfraction = Math.Clamp( fraction, 0f, 1f );\r\n\r\n\t\tif ( fraction <= 0f || mesh.VertexCount == 0 )\r\n\t\t\tyield break;\r\n\r\n\t\tvar top = mesh.Positions.Max( p => p.z );\r\n\t\tvar bottom = mesh.Positions.Min( p => p.z );\r\n\t\tvar cut = top - (top - bottom) * fraction;\r\n\r\n\t\tfor ( var i = 0; i < mesh.VertexCount; i++ )\r\n\t\t\tif ( mesh.Positions[i].z >= cut )\r\n\t\t\t\tyield return i;\r\n\t}\r\n\r\n\tpublic static bool[] BoundaryVertices( PolyMesh mesh )\r\n\t{\r\n\t\tvar boundary = new bool[mesh.VertexCount];\r\n\r\n\t\tforeach ( var (edge, faces) in mesh.BuildEdgeFaces() )\r\n\t\t{\r\n\t\t\tif ( faces.Count == 1 )\r\n\t\t\t{\r\n\t\t\t\tboundary[edge.A] = true;\r\n\t\t\t\tboundary[edge.B] = true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn boundary;\r\n\t}\r\n\r\n\t/// <summary>1 at an opening, falling to 0 at <paramref name=\"reach\"/> away from it.</summary>\r\n\tpublic static float[] FlareWeights( PolyMesh mesh, bool[] boundary, float reach )\r\n\t{\r\n\t\tvar weights = new float[mesh.VertexCount];\r\n\t\tvar edge = new List<Vec3>();\r\n\r\n\t\tfor ( var i = 0; i < boundary.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( boundary[i] )\r\n\t\t\t\tedge.Add( mesh.Positions[i] );\r\n\t\t}\r\n\r\n\t\tif ( edge.Count == 0 || reach <= 0f )\r\n\t\t\treturn weights;\r\n\r\n\t\tfor ( var i = 0; i < mesh.VertexCount; i++ )\r\n\t\t{\r\n\t\t\tvar p = mesh.Positions[i];\r\n\t\t\tvar best = float.MaxValue;\r\n\r\n\t\t\tforeach ( var e in edge )\r\n\t\t\t\tbest = Math.Min( best, (e - p).LengthSquared );\r\n\r\n\t\t\tvar d = MathF.Sqrt( best );\r\n\t\t\tvar w = Math.Clamp( 1f - d / reach, 0f, 1f );\r\n\t\t\tweights[i] = w * w;\r\n\t\t}\r\n\r\n\t\treturn weights;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// UVs wrapped round each limb on its own bone, a foot to a UV unit everywhere.\r\n\t///\r\n\t/// ONE CYLINDER ROUND THE WHOLE BODY WAS WRONG FOR THE SLEEVES. CylinderUVs wraps everything\r\n\t/// about the body's vertical axis, which is right for the torso and stretches a sleeve into a\r\n\t/// smear: the arm is a tube lying across that projection, so its texture ran the wrong way and\r\n\t/// was squeezed to a sliver on the top and bottom of the arm. Trouser legs the same, less so.\r\n\t/// So each face is projected round the bone chain its region runs along - the torso's spine,\r\n\t/// each arm, each leg - with the angle scaled by that limb's own girth, so a check the size of\r\n\t/// a thumbnail on the chest is the size of a thumbnail on the cuff. The seam between a region\r\n\t/// and the next is a UV seam, as it is on every real garment.\r\n\t/// </summary>\r\n\tpublic static void LimbUVs( PolyMesh mesh, BodyRegions.Map map )\r\n\t{\r\n\t\tconst float UnitsPerUv = 12f;\r\n\r\n\t\t// Every face's chain, and every chain's girth from the faces on it.\r\n\t\tvar chains = new Dictionary<(BodyRegion, int), (Vec3 From, Vec3 Axis, Vec3 E1, Vec3 E2, float Radius, int Count)>();\r\n\t\tvar faceChain = new (BodyRegion, int)[mesh.FaceCount];\r\n\r\n\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t{\r\n\t\t\tvar centroid = mesh.FaceCentroid( mesh.Faces[fi] );\r\n\t\t\tvar (region, _) = map.Locate( centroid );\r\n\t\t\tvar side = BodyRegions.Map.IsCentral( region ) ? 0 : centroid.y > 0 ? 1 : -1;\r\n\t\t\tvar key = (region, side);\r\n\t\t\tfaceChain[fi] = key;\r\n\r\n\t\t\tif ( !chains.TryGetValue( key, out var chain ) )\r\n\t\t\t{\r\n\t\t\t\tVec3 from, to;\r\n\r\n\t\t\t\tif ( region == BodyRegion.None || !map.Chain( region, side == 0 ? 1 : side, out from, out to ) )\r\n\t\t\t\t{\r\n\t\t\t\t\t// Nothing to hang it on: the old projection about the vertical.\r\n\t\t\t\t\tfrom = Vec3.Zero;\r\n\t\t\t\t\tto = new Vec3( 0f, 0f, -1f );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar axis = (to - from).Normal;\r\n\t\t\t\tvar reference = MathF.Abs( axis.z ) < 0.9f ? new Vec3( 0f, 0f, 1f ) : new Vec3( 1f, 0f, 0f );\r\n\t\t\t\tvar e1 = Vec3.Cross( reference, axis ).Normal;\r\n\t\t\t\tvar e2 = Vec3.Cross( axis, e1 );\r\n\t\t\t\tchain = (from, axis, e1, e2, 0f, 0);\r\n\t\t\t}\r\n\r\n\t\t\tvar q = centroid - chain.From;\r\n\t\t\tvar radial = q - chain.Axis * Vec3.Dot( q, chain.Axis );\r\n\t\t\tchain.Radius += radial.Length;\r\n\t\t\tchain.Count++;\r\n\t\t\tchains[key] = chain;\r\n\t\t}\r\n\r\n\t\tforeach ( var fi in Enumerable.Range( 0, mesh.FaceCount ) )\r\n\t\t{\r\n\t\t\tvar face = mesh.Faces[fi];\r\n\t\t\tvar chain = chains[faceChain[fi]];\r\n\t\t\tvar radius = MathF.Max( 1f, chain.Radius / Math.Max( 1, chain.Count ) );\r\n\t\t\tvar span = MathF.PI * 2f * radius / UnitsPerUv;\r\n\t\t\tvar uvs = new Vec2[face.Count];\r\n\r\n\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t{\r\n\t\t\t\tvar q = mesh.Positions[face.Indices[c]] - chain.From;\r\n\t\t\t\tvar angle = MathF.Atan2( Vec3.Dot( q, chain.E2 ), Vec3.Dot( q, chain.E1 ) );\r\n\t\t\t\tuvs[c] = new Vec2( angle * radius / UnitsPerUv, Vec3.Dot( q, chain.Axis ) / UnitsPerUv );\r\n\t\t\t}\r\n\r\n\t\t\t// A face straddling the seam at +-180 degrees would stretch across the whole texture.\r\n\t\t\tvar max = uvs.Max( u => u.x );\r\n\r\n\t\t\tfor ( var c = 0; c < uvs.Length; c++ )\r\n\t\t\t\tif ( max - uvs[c].x > span * 0.5f )\r\n\t\t\t\t\tuvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );\r\n\r\n\t\t\tface.UVs = uvs;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A wrap-around projection about the vertical axis, a foot to a UV unit. Fabric tiles and fur\r\n\t/// noise both want something even; the body's own UVs are laid out for the body, and a CAD part\r\n\t/// may have none. LimbUVs is what Fit uses now; this is kept for a mesh with no rig to go by.\r\n\t/// </summary>\r\n\tpublic static void CylinderUVs( PolyMesh mesh )\r\n\t{\r\n\t\tconst float UnitsPerUv = 12f;\r\n\t\tvar centre = Vec3.Zero;\r\n\r\n\t\tforeach ( var p in mesh.Positions )\r\n\t\t\tcentre += p;\r\n\r\n\t\tcentre /= Math.Max( 1, mesh.VertexCount );\r\n\r\n\t\tforeach ( var face in mesh.Faces )\r\n\t\t{\r\n\t\t\tvar uvs = new Vec2[face.Count];\r\n\r\n\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = mesh.Positions[face.Indices[c]];\r\n\t\t\t\tvar angle = MathF.Atan2( p.y - centre.y, p.x - centre.x );\r\n\t\t\t\tuvs[c] = new Vec2( angle / (MathF.PI * 2f) * (MathF.PI * 2f * 8f) / UnitsPerUv, -p.z / UnitsPerUv );\r\n\t\t\t}\r\n\r\n\t\t\t// A face straddling the seam at +-180 degrees would stretch across the whole texture.\r\n\t\t\tvar span = MathF.PI * 2f * 8f / UnitsPerUv;\r\n\t\t\tvar max = uvs.Max( u => u.x );\r\n\r\n\t\t\tfor ( var c = 0; c < uvs.Length; c++ )\r\n\t\t\t{\r\n\t\t\t\tif ( max - uvs[c].x > span * 0.5f )\r\n\t\t\t\t\tuvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );\r\n\t\t\t}\r\n\r\n\t\t\tface.UVs = uvs;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// What is wrong with a garment before you export it, said in the terms of the job.\r\n///\r\n/// WHY THIS IS NOT MeshValidator. That answers \"is this a mesh\" \u2014 corners, indices in range,\r\n/// manifold edges \u2014 and a garment can pass every one of those checks and still be unwearable. The\r\n/// questions that decide whether a piece of clothing is finished are different ones: does it clip\r\n/// through the body when it is worn, can it be textured, will it move with the skeleton, is it\r\n/// affordable. Structure is included because it matters too, but it is one line of five.\r\n///\r\n/// IT REPORTS, IT DOES NOT REFUSE. Every number here has a legitimate reason to be non-zero. A\r\n/// garment SHOULD have openings \u2014 a T-shirt has four \u2014 and calling a neck hole an error would teach\r\n/// people to fear the thing that makes it a shirt. Clipping is the one that is nearly always a\r\n/// fault, and even that is a judgement about how deep and how much rather than a boolean. So this\r\n/// hands back counts and depths for somebody to read, and says which of them usually mean trouble.\r\n/// </summary>\r\npublic sealed class GarmentReport\r\n{\r\n\tpublic string Name = \"\";\r\n\r\n\tpublic int Faces;\r\n\tpublic int Triangles;\r\n\r\n\t/// <summary>Vertices sitting INSIDE a body rather than on or outside it \u2014 the garment clipping\r\n\t/// through whoever is wearing it. The number that most often means real trouble.</summary>\r\n\tpublic int Clipping;\r\n\r\n\t/// <summary>How far the worst of them is in, in inches. A hundredth of an inch is the solver\r\n\t/// resting on the surface; a quarter of an inch is a shoulder through a sleeve.</summary>\r\n\tpublic float DeepestClip;\r\n\r\n\t/// <summary>Where they are, one point per clipping vertex, so a viewport can mark them.</summary>\r\n\tpublic List<Vec3> ClipPoints = new();\r\n\r\n\t/// <summary>How deep each of <see cref=\"ClipPoints\"/> is, in inches, in the same order.</summary>\r\n\tpublic List<float> ClipDepths = new();\r\n\r\n\t/// <summary>Faces with no area \u2014 invisible, and a source of broken normals and bad lighting\r\n\t/// downstream. Always a fault.</summary>\r\n\tpublic int Degenerate;\r\n\r\n\t/// <summary>Boundary loops: the holes a garment is MEANT to have. A T-shirt has four \u2014 neck,\r\n\t/// hem, two cuffs. Reported so the number can be recognised, never as an error.</summary>\r\n\tpublic int Openings;\r\n\r\n\tpublic bool HasUVs;\r\n\tpublic bool HasWeights;\r\n\r\n\t/// <summary>Structural problems, straight from MeshValidator. These are faults.</summary>\r\n\tpublic List<string> Structure = new();\r\n\r\n\t/// <summary>Everything worth saying, in the order somebody reading it would want it \u2014 worst\r\n\t/// first, then the merely notable, then the reassuring.</summary>\r\n\tpublic IEnumerable<string> Lines()\r\n\t{\r\n\t\tif ( Clipping > 0 )\r\n\t\t\tyield return $\"{Clipping} {(Clipping == 1 ? \"vertex clips\" : \"vertices clip\")} through the body, \"\r\n\t\t\t\t+ $\"deepest {DeepestClip:0.##} in. Raise Looseness or Clearance, or shrinkwrap it back out.\";\r\n\r\n\t\tif ( Degenerate > 0 )\r\n\t\t\tyield return $\"{Degenerate} face{(Degenerate == 1 ? \" has\" : \"s have\")} no area. \"\r\n\t\t\t\t+ \"Merge by distance will usually take them out.\";\r\n\r\n\t\tforeach ( var problem in Structure )\r\n\t\t\tyield return problem;\r\n\r\n\t\tif ( !HasWeights )\r\n\t\t\tyield return \"No skin weights - it will not move with the body. Copy weights from the body you cut it from.\";\r\n\r\n\t\tif ( !HasUVs )\r\n\t\t\tyield return \"No UVs - it cannot be textured yet.\";\r\n\r\n\t\tyield return $\"{Triangles} triangles, {Openings} opening{(Openings == 1 ? \"\" : \"s\")}\"\r\n\t\t\t+ $\"{(HasUVs ? \", UVs\" : \"\")}{(HasWeights ? \", weighted\" : \"\")}.\";\r\n\t}\r\n\r\n\t/// <summary>Nothing here is a fault. Openings and triangle count are not faults.</summary>\r\n\tpublic bool Clean => Clipping == 0 && Degenerate == 0 && Structure.Count == 0 && HasUVs && HasWeights;\r\n\r\n\tpublic override string ToString() => string.Join( \" \", Lines() );\r\n}\r\n\r\n/// <summary>Check a garment against the bodies wearing it. See <see cref=\"GarmentReport\"/>.</summary>\r\npublic static class GarmentCheck\r\n{\r\n\t/// <summary>How far inside a surface counts as clipping rather than as resting on it. The\r\n\t/// drape solver settles vertices ON the body by design, and a tolerance smaller than its own\r\n\t/// resting error would report every well-fitted garment as broken.</summary>\r\n\tpublic const float ClipTolerance = 0.01f;\r\n\r\n\tpublic static GarmentReport Run( PolyMesh garment, IReadOnlyList<PolyMesh> bodies )\r\n\t{\r\n\t\tvar report = new GarmentReport();\r\n\r\n\t\tif ( garment is null )\r\n\t\t\treturn report;\r\n\r\n\t\treport.Faces = garment.FaceCount;\r\n\t\treport.HasWeights = garment.IsRigged;\r\n\r\n\t\tforeach ( var face in garment.Faces )\r\n\t\t{\r\n\t\t\treport.Triangles += Math.Max( 0, face.Count - 2 );\r\n\r\n\t\t\tif ( face.UVs is { Length: > 0 } && face.UVs.Any( uv => uv.x != 0f || uv.y != 0f ) )\r\n\t\t\t\treport.HasUVs = true;\r\n\r\n\t\t\tif ( FaceArea( garment, face ) <= 1e-9f )\r\n\t\t\t\treport.Degenerate++;\r\n\t\t}\r\n\r\n\t\tvar validation = MeshValidator.Validate( garment );\r\n\t\treport.Structure.AddRange( validation.Errors );\r\n\r\n\t\treport.Openings = BoundaryLoops( garment );\r\n\r\n\t\t// The same signed-distance test the fitter uses to push a garment out - see\r\n\t\t// GarmentFit.PushOut. Reusing the idiom matters: a check that disagreed with the solver\r\n\t\t// about which side of a surface a point is on would report faults the fitter cannot fix.\r\n\t\tforeach ( var body in bodies ?? Array.Empty<PolyMesh>() )\r\n\t\t{\r\n\t\t\tif ( body is null || body.FaceCount == 0 || ReferenceEquals( body, garment ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar tree = MeshBVH.Build( body );\r\n\r\n\t\t\tforeach ( var p in garment.Positions )\r\n\t\t\t{\r\n\t\t\t\tif ( tree.NearestSurface( body, p, 1e6f ) is not { } hit )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar depth = -Vec3.Dot( p - hit.Point, hit.Normal );\r\n\r\n\t\t\t\tif ( depth <= ClipTolerance )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\treport.Clipping++;\r\n\t\t\t\treport.DeepestClip = Math.Max( report.DeepestClip, depth );\r\n\t\t\t\treport.ClipPoints.Add( p );\r\n\t\t\t\treport.ClipDepths.Add( depth );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn report;\r\n\t}\r\n\r\n\tstatic float FaceArea( PolyMesh mesh, Face face )\r\n\t{\r\n\t\tif ( face.Count < 3 )\r\n\t\t\treturn 0f;\r\n\r\n\t\tvar area = Vec3.Zero;\r\n\t\tvar origin = mesh.Positions[face.Indices[0]];\r\n\r\n\t\tfor ( var i = 1; i + 1 < face.Count; i++ )\r\n\t\t{\r\n\t\t\tvar a = mesh.Positions[face.Indices[i]] - origin;\r\n\t\t\tvar b = mesh.Positions[face.Indices[i + 1]] - origin;\r\n\t\t\tarea += Vec3.Cross( a, b );\r\n\t\t}\r\n\r\n\t\treturn area.Length * 0.5f;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// How many separate holes the mesh has, by walking the boundary edges into loops.\r\n\t///\r\n\t/// LOOPS, NOT EDGES. MeshValidator already counts boundary edges, and that number is useless\r\n\t/// here: a T-shirt's four openings are a few hundred edges, which says nothing a person can\r\n\t/// check. Four is a number you can recognise as right.\r\n\t/// </summary>\r\n\tstatic int BoundaryLoops( PolyMesh mesh )\r\n\t{\r\n\t\tvar next = new Dictionary<int, List<int>>();\r\n\r\n\t\tforeach ( var (key, faces) in mesh.BuildEdgeFaces() )\r\n\t\t{\r\n\t\t\tif ( faces.Count != 1 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( !next.TryGetValue( key.A, out var a ) )\r\n\t\t\t\tnext[key.A] = a = new List<int>();\r\n\r\n\t\t\tif ( !next.TryGetValue( key.B, out var b ) )\r\n\t\t\t\tnext[key.B] = b = new List<int>();\r\n\r\n\t\t\ta.Add( key.B );\r\n\t\t\tb.Add( key.A );\r\n\t\t}\r\n\r\n\t\tvar seen = new HashSet<int>();\r\n\t\tvar loops = 0;\r\n\r\n\t\tforeach ( var start in next.Keys )\r\n\t\t{\r\n\t\t\tif ( !seen.Add( start ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tloops++;\r\n\r\n\t\t\t// Flood the connected run of boundary vertices. A figure-of-eight boundary counts once,\r\n\t\t\t// which is the honest answer - it is one hole pinched, not two.\r\n\t\t\tvar stack = new Stack<int>();\r\n\t\t\tstack.Push( start );\r\n\r\n\t\t\twhile ( stack.Count > 0 )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var n in next[stack.Pop()] )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( seen.Add( n ) )\r\n\t\t\t\t\t\tstack.Push( n );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn loops;\r\n\t}\r\n}\r\n\r\n/// <summary>Where a trim goes, and what it is.</summary>\r\npublic enum TrimStyle\r\n{\r\n\t/// <summary>A gathered strip: longer than the edge it hangs from, so it waves. Frills, ruffles,\r\n\t/// a gathered hem.</summary>\r\n\tFrill,\r\n\r\n\t/// <summary>A flat band following the edge. Ribbon, binding, a cuff band, a waistband.</summary>\r\n\tRibbon,\r\n\r\n\t/// <summary>Folded rather than gathered - the wave is angular instead of round. Pleats, a\r\n\t/// box-pleated hem, an accordion cuff.</summary>\r\n\tPleat,\r\n\r\n\t/// <summary>Cut into separate hanging strips. Fringe, tassels, a shredded hem.</summary>\r\n\tFringe,\r\n}\r\n\r\n/// <summary>\r\n/// Frills, ruffles, ribbons, pleats and fringe: the details that make a garment a piece of FASHION\r\n/// rather than a fitted bag.\r\n///\r\n/// THEY HANG OFF THE OPENINGS, and that is the whole idea. A garment already has exactly the edges\r\n/// trim belongs on - the hem, the collar, the cuffs - because they are its boundary loops, the holes\r\n/// it is made to have. Nothing has to be selected, drawn or marked: the shape of the garment already\r\n/// says where a frill could go, so asking for one is choosing which opening and how much.\r\n///\r\n/// LACE IS NOT HERE, DELIBERATELY. Lace is holes in cloth, and holes in cloth are an alpha-masked\r\n/// material - that is how every shipped game does it, because modelling the mesh of a lace edging\r\n/// costs thousands of triangles to render something a 512px texture renders better. Put lace on the\r\n/// garment as a material with a cut-out; use a Frill for the shape the lace edging follows.\r\n///\r\n/// GATHERED, NOT SCALED. A real frill is a strip of cloth LONGER than the edge it is sewn to, forced\r\n/// to fit by being bunched. That is why it waves, and why the waves get deeper further from the seam\r\n/// while the seam itself stays smooth. So this displaces along the surface normal by an amount that\r\n/// grows with the distance hung, rather than making a wavy ring and pulling it down - which looks\r\n/// like a cake decoration and not like cloth.\r\n/// </summary>\r\npublic static class GarmentTrim\r\n{\r\n\tpublic sealed class Options\r\n\t{\r\n\t\tpublic TrimStyle Style = TrimStyle.Frill;\r\n\r\n\t\t/// <summary>How far the trim hangs from the edge, in inches.</summary>\r\n\t\tpublic float Width = 1.2f;\r\n\r\n\t\t/// <summary>How much longer than its edge the strip is, 0 flat to 1 heavily bunched. What\r\n\t\t/// makes a frill a frill.</summary>\r\n\t\tpublic float Gather = 0.5f;\r\n\r\n\t\t/// <summary>How many waves around the whole opening. More waves, finer gathers.</summary>\r\n\t\tpublic int Waves = 24;\r\n\r\n\t\t/// <summary>Rows of quads from the seam to the free edge. One is enough for a ribbon; a deep\r\n\t\t/// frill wants four or five so the wave has somewhere to develop.</summary>\r\n\t\tpublic int Rows = 3;\r\n\r\n\t\t/// <summary>For Fringe: what fraction of each strip's slot is cut away.</summary>\r\n\t\tpublic float Gap = 0.4f;\r\n\r\n\t\tpublic int MaterialSlot = 0;\r\n\t}\r\n\r\n\tpublic sealed class Result\r\n\t{\r\n\t\tpublic PolyMesh Mesh = new();\r\n\t\tpublic int Openings;\r\n\t\tpublic string Problem;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build trim along some of a mesh's boundary loops.\r\n\t///\r\n\t/// <paramref name=\"which\"/> picks loops by index into the order OrderedBoundaryLoops returns\r\n\t/// them in, which is by height, lowest first - so on a shirt 0 is the hem and the last is the\r\n\t/// collar. Null or empty means every opening.\r\n\t/// </summary>\r\n\tpublic static Result Build( PolyMesh garment, IReadOnlyList<int> which, Options o )\r\n\t{\r\n\t\tvar result = new Result();\r\n\r\n\t\tif ( garment is null || garment.FaceCount == 0 )\r\n\t\t{\r\n\t\t\tresult.Problem = \"there is no garment to trim\";\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tvar loops = OrderedBoundaryLoops( garment );\r\n\r\n\t\tif ( loops.Count == 0 )\r\n\t\t{\r\n\t\t\tresult.Problem = \"this garment has no openings - trim hangs from an edge, and a closed \"\r\n\t\t\t\t+ \"shape has none\";\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tvar normals = garment.ComputeVertexNormals();\r\n\t\tvar rows = Math.Clamp( o.Rows, 1, 12 );\r\n\r\n\t\tfor ( var li = 0; li < loops.Count; li++ )\r\n\t\t{\r\n\t\t\tif ( which is { Count: > 0 } && !which.Contains( li ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( loops[li].Count < 3 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tAddStrip( result.Mesh, garment, loops[li], normals, rows, o );\r\n\t\t\tresult.Openings++;\r\n\t\t}\r\n\r\n\t\tif ( result.Openings == 0 )\r\n\t\t\tresult.Problem = $\"none of this garment's {loops.Count} openings were chosen\";\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\tstatic void AddStrip( PolyMesh into, PolyMesh garment, List<int> ring, Vec3[] normals, int rows,\r\n\t\tOptions o )\r\n\t{\r\n\t\tvar n = ring.Count;\r\n\r\n\t\t// Arc length around the ring, so the wave count means the same thing whether the ring is a\r\n\t\t// cuff or a hem and whether its vertices are evenly spaced or not. Spacing waves by INDEX\r\n\t\t// instead puts every wave of a hem into the densest quarter of it.\r\n\t\tvar arc = new float[n + 1];\r\n\r\n\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t{\r\n\t\t\tvar a = garment.Positions[ring[i]];\r\n\t\t\tvar b = garment.Positions[ring[(i + 1) % n]];\r\n\t\t\tarc[i + 1] = arc[i] + (b - a).Length;\r\n\t\t}\r\n\r\n\t\tvar total = arc[n];\r\n\r\n\t\tif ( total <= 1e-5f )\r\n\t\t\treturn;\r\n\r\n\t\tvar centre = Vec3.Zero;\r\n\r\n\t\tforeach ( var v in ring )\r\n\t\t\tcentre += garment.Positions[v];\r\n\r\n\t\tcentre /= n;\r\n\r\n\t\t// Which way the trim hangs: the direction the cloth would carry on in if the garment did not\r\n\t\t// stop at its own hem. Taken from the edge rather than as \"straight down\", so a cuff's frill\r\n\t\t// runs on down the arm and a collar's stands off the neck.\r\n\t\tvar away = new Vec3[n];\r\n\r\n\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t{\r\n\t\t\tvar p = garment.Positions[ring[i]];\r\n\t\t\tvar prev = garment.Positions[ring[(i - 1 + n) % n]];\r\n\t\t\tvar next = garment.Positions[ring[(i + 1) % n]];\r\n\r\n\t\t\tvar tangent = (next - prev).Normal;\r\n\t\t\tvar outward = Vec3.Cross( tangent, normals[ring[i]] );\r\n\r\n\t\t\tif ( outward.LengthSquared < 1e-8f )\r\n\t\t\t\toutward = p - centre;\r\n\r\n\t\t\toutward = outward.Normal;\r\n\r\n\t\t\t// The cross product's sign flips with the ring's winding, which is not something a\r\n\t\t\t// caller should have to know about. Point it away from the ring's own centre.\r\n\t\t\tif ( Vec3.Dot( outward, p - centre ) < 0f )\r\n\t\t\t\toutward = -outward;\r\n\r\n\t\t\taway[i] = outward;\r\n\t\t}\r\n\r\n\t\tvar waves = Math.Max( 1, o.Waves );\r\n\t\tvar gather = Math.Clamp( o.Gather, 0f, 1f );\r\n\t\tvar width = Math.Max( 0f, o.Width );\r\n\r\n\t\t// Row 0 is the seam: it sits exactly on the garment's own edge, unmoved, so the trim is\r\n\t\t// attached rather than floating near it.\r\n\t\tvar grid = new int[rows + 1][];\r\n\r\n\t\tfor ( var r = 0; r <= rows; r++ )\r\n\t\t{\r\n\t\t\tgrid[r] = new int[n];\r\n\r\n\t\t\tvar t = r / (float)rows;\r\n\r\n\t\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = garment.Positions[ring[i]];\r\n\t\t\t\tvar s = arc[i] / total;\r\n\r\n\t\t\t\tvar swing = o.Style switch\r\n\t\t\t\t{\r\n\t\t\t\t\tTrimStyle.Frill => MathF.Sin( s * MathF.Tau * waves ),\r\n\t\t\t\t\tTrimStyle.Pleat => Triangle( s * waves ),\r\n\t\t\t\t\t_ => 0f,\r\n\t\t\t\t};\r\n\r\n\t\t\t\t// The gather grows with distance from the seam, which is what keeps the seam itself\r\n\t\t\t\t// from rippling. A ribbon has no gather at all and stays a flat band.\r\n\t\t\t\tvar offset = away[i] * (width * t)\r\n\t\t\t\t\t+ normals[ring[i]] * (swing * gather * width * 0.6f * t);\r\n\r\n\t\t\t\tgrid[r][i] = into.Positions.Count;\r\n\t\t\t\tinto.Positions.Add( p + offset );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar fringe = o.Style == TrimStyle.Fringe;\r\n\t\tvar keep = Math.Clamp( 1f - o.Gap, 0.05f, 1f );\r\n\r\n\t\tfor ( var r = 0; r < rows; r++ )\r\n\t\t{\r\n\t\t\tfor ( var i = 0; i < n; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar j = (i + 1) % n;\r\n\r\n\t\t\t\t// Fringe is the same strip with most of it cut away: whole slots are dropped rather\r\n\t\t\t\t// than narrowed, so what is left hangs as separate tassels and not as a comb.\r\n\t\t\t\tif ( fringe && r > 0 )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar phase = arc[i] / total * waves;\r\n\r\n\t\t\t\t\tif ( phase - MathF.Floor( phase ) > keep )\r\n\t\t\t\t\t\tcontinue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tinto.Faces.Add( new Face( new[] { grid[r][i], grid[r][j], grid[r + 1][j], grid[r + 1][i] } )\r\n\t\t\t\t{\r\n\t\t\t\t\tMaterial = o.MaterialSlot,\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>A 0..1 sawtooth as a -1..1 triangle wave: a pleat's fold, angular where a frill's\r\n\t/// is round.</summary>\r\n\tstatic float Triangle( float x )\r\n\t{\r\n\t\tvar f = x - MathF.Floor( x );\r\n\t\treturn f < 0.5f ? f * 4f - 1f : 3f - f * 4f;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Every boundary loop as an ORDERED ring of vertices, lowest first.\r\n\t///\r\n\t/// ORDERED IS THE WHOLE JOB. GarmentCheck only has to COUNT openings, so it floods them; trim\r\n\t/// has to walk one in sequence to lay quads along it. Height ordering is what makes \"opening 0\"\r\n\t/// mean the hem on every garment rather than whichever vertex happened to be found first.\r\n\t/// </summary>\r\n\tpublic static List<List<int>> OrderedBoundaryLoops( PolyMesh mesh )\r\n\t{\r\n\t\tvar neighbours = new Dictionary<int, List<int>>();\r\n\r\n\t\tforeach ( var (key, faces) in mesh.BuildEdgeFaces() )\r\n\t\t{\r\n\t\t\tif ( faces.Count != 1 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( !neighbours.TryGetValue( key.A, out var a ) )\r\n\t\t\t\tneighbours[key.A] = a = new List<int>();\r\n\r\n\t\t\tif ( !neighbours.TryGetValue( key.B, out var b ) )\r\n\t\t\t\tneighbours[key.B] = b = new List<int>();\r\n\r\n\t\t\ta.Add( key.B );\r\n\t\t\tb.Add( key.A );\r\n\t\t}\r\n\r\n\t\tvar loops = new List<List<int>>();\r\n\t\tvar used = new HashSet<int>();\r\n\r\n\t\tforeach ( var start in neighbours.Keys )\r\n\t\t{\r\n\t\t\tif ( used.Contains( start ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar loop = new List<int>();\r\n\t\t\tvar current = start;\r\n\t\t\tvar previous = -1;\r\n\r\n\t\t\t// Walk the ring. A boundary vertex normally has exactly two boundary neighbours; where\r\n\t\t\t// it has more the loop is pinched, and taking the first unused one keeps the walk going\r\n\t\t\t// rather than abandoning the opening.\r\n\t\t\twhile ( current >= 0 && used.Add( current ) )\r\n\t\t\t{\r\n\t\t\t\tloop.Add( current );\r\n\r\n\t\t\t\tvar next = -1;\r\n\r\n\t\t\t\tforeach ( var candidate in neighbours[current] )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( candidate == previous || used.Contains( candidate ) )\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tnext = candidate;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tprevious = current;\r\n\t\t\t\tcurrent = next;\r\n\t\t\t}\r\n\r\n\t\t\tif ( loop.Count >= 3 )\r\n\t\t\t\tloops.Add( loop );\r\n\t\t}\r\n\r\n\t\tloops.Sort( ( a, b ) => Height( mesh, a ).CompareTo( Height( mesh, b ) ) );\r\n\r\n\t\treturn loops;\r\n\t}\r\n\r\n\tstatic float Height( PolyMesh mesh, List<int> loop )\r\n\t{\r\n\t\tvar z = 0f;\r\n\r\n\t\tforeach ( var v in loop )\r\n\t\t\tz += mesh.Positions[v].z;\r\n\r\n\t\treturn z / Math.Max( 1, loop.Count );\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Turn a garment into an s&box `.clothing` definition: the record that makes a mesh into a\r\n/// wearable item rather than a prop.\r\n///\r\n/// THE STEP THIS TOOL STOPPED ONE SHORT OF. Effigy could already compile a garment to a skinned\r\n/// `.vmdl`, and a `.vmdl` is not clothing. s&box dresses a citizen from a Clothing resource,\r\n/// which names the model and then answers three questions the mesh cannot: what KIND of garment it\r\n/// is, which body slots it OCCUPIES so two hats do not both get worn, and which parts of the body it\r\n/// HIDES so the chest does not poke through the shirt. Without it a finished garment cannot be put\r\n/// on a character at all, and the modelling was the easy part of making clothes.\r\n///\r\n/// THE SLOTS COME FROM THE RECIPE, not from the garment's name. The recipe already says exactly\r\n/// which regions of the body it covers - that is what it IS - so a long sleeve claims the wrists and\r\n/// a T-shirt does not, and adjusting Sleeve moves the answer with it. Deriving them from the name\r\n/// would be a second, parallel description of the same garment, and the two would drift apart the\r\n/// first time somebody dragged a slider.\r\n///\r\n/// WRITTEN AS TEXT, IN THE KERNEL. A Clothing resource is JSON, so it needs no engine to produce and\r\n/// can be tested headlessly - which matters more here than usual, because the failure mode of a\r\n/// wrong slot name is an asset that compiles, loads, and silently does not work.\r\n/// </summary>\r\npublic static class ClothingDefinition\r\n{\r\n\t/// <summary>What a garment covers, in s&box's own slot vocabulary. Anything not listed maps\r\n\t/// to nothing, which is the right answer for Neck.</summary>\r\n\tpublic static IEnumerable<string> SlotsFor( GarmentRecipe recipe )\r\n\t{\r\n\t\tvar slots = new List<string>();\r\n\r\n\t\tvoid Add( string slot )\r\n\t\t{\r\n\t\t\tif ( !slots.Contains( slot ) )\r\n\t\t\t\tslots.Add( slot );\r\n\t\t}\r\n\r\n\t\t// A span tagged for one side claims only that side's slots. That is the whole reason the\r\n\t\t// side is on the span: a one-sleeved top that claimed both arms would stop you wearing\r\n\t\t// anything on the bare one.\r\n\t\tvoid Sided( int side, string left, string right )\r\n\t\t{\r\n\t\t\tif ( side >= 0 )\r\n\t\t\t\tAdd( left );\r\n\r\n\t\t\tif ( side <= 0 )\r\n\t\t\t\tAdd( right );\r\n\t\t}\r\n\r\n\t\tforeach ( var (region, from, to, side) in recipe.Spans )\r\n\t\t{\r\n\t\t\tswitch ( region )\r\n\t\t\t{\r\n\t\t\t\tcase BodyRegion.Torso:\r\n\t\t\t\t\tAdd( \"Chest\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Hips:\r\n\t\t\t\t\tAdd( \"Groin\" );\r\n\t\t\t\t\tAdd( \"Waist\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.UpperArm:\r\n\t\t\t\t\tSided( side, \"LeftArm\", \"RightArm\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.LowerArm:\r\n\t\t\t\t\tSided( side, \"LeftArm\", \"RightArm\" );\r\n\r\n\t\t\t\t\t// Only a sleeve that actually reaches the wrist claims it. A three-quarter\r\n\t\t\t\t\t// sleeve stopping at the forearm must leave the wrist free for a watch.\r\n\t\t\t\t\tif ( to > 0.9f )\r\n\t\t\t\t\t\tSided( side, \"LeftWrist\", \"RightWrist\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Hand:\r\n\t\t\t\t\tSided( side, \"LeftHand\", \"RightHand\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.UpperLeg:\r\n\t\t\t\t\tSided( side, \"LeftThigh\", \"RightThigh\" );\r\n\r\n\t\t\t\t\tif ( to > 0.9f )\r\n\t\t\t\t\t\tSided( side, \"LeftKnee\", \"RightKnee\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.LowerLeg:\r\n\t\t\t\t\tSided( side, \"LeftKnee\", \"RightKnee\" );\r\n\t\t\t\t\tSided( side, \"LeftShin\", \"RightShin\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Foot:\r\n\t\t\t\t\tSided( side, \"LeftFoot\", \"RightFoot\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Head:\r\n\t\t\t\t\tAdd( \"HeadTop\" );\r\n\r\n\t\t\t\t\t// A hat that comes down past the ears covers the lower head too - the\r\n\t\t\t\t\t// difference between a cap and a balaclava, and what stops both being wearable\r\n\t\t\t\t\t// at once.\r\n\t\t\t\t\tif ( from < 0.35f )\r\n\t\t\t\t\t\tAdd( \"HeadBottom\" );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn slots;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Which parts of the BODY to stop drawing underneath, in s&box's BodyGroups vocabulary.\r\n\t///\r\n\t/// NOT THE SAME QUESTION AS THE SLOTS, and conflating them is how clothing ends up with skin\r\n\t/// poking through it. Slots are about what else can be worn; this is about the body being drawn\r\n\t/// inside the cloth. It is deliberately conservative - only whole groups the garment substantially\r\n\t/// covers - because hiding a limb a garment only half covers leaves a character with no forearm.\r\n\t/// </summary>\r\n\tpublic static IEnumerable<string> HideBodyFor( GarmentRecipe recipe )\r\n\t{\r\n\t\tvar hide = new List<string>();\r\n\r\n\t\tvoid Add( string group )\r\n\t\t{\r\n\t\t\tif ( !hide.Contains( group ) )\r\n\t\t\t\thide.Add( group );\r\n\t\t}\r\n\r\n\t\tforeach ( var (region, from, to, _) in recipe.Spans )\r\n\t\t{\r\n\t\t\tswitch ( region )\r\n\t\t\t{\r\n\t\t\t\tcase BodyRegion.Torso when to > 0.9f:\r\n\t\t\t\t\tAdd( \"Chest\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\t// Legs come off only for something that reaches the ankle. Shorts hide nothing:\r\n\t\t\t\t// the leg below them is the leg you are meant to see.\r\n\t\t\t\tcase BodyRegion.LowerLeg when to > 0.9f:\r\n\t\t\t\t\tAdd( \"Legs\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Hand:\r\n\t\t\t\t\tAdd( \"Hands\" );\r\n\t\t\t\t\tbreak;\r\n\r\n\t\t\t\tcase BodyRegion.Foot:\r\n\t\t\t\t\tAdd( \"Feet\" );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn hide;\r\n\t}\r\n\r\n\t/// <summary>The s&box ClothingCategory a recipe belongs in. Named from the enum this engine\r\n\t/// actually has \u2014 see the effigy_clothing_probe console command.</summary>\r\n\tpublic static string CategoryFor( GarmentRecipe recipe ) => recipe?.Name switch\r\n\t{\r\n\t\t\"T-shirt\" => \"TShirt\",\r\n\t\t\"Long sleeve\" => \"Shirt\",\r\n\t\t\"Tank top\" => \"Vest\",\r\n\t\t\"Trousers\" => \"Trousers\",\r\n\t\t\"Shorts\" => \"Shorts\",\r\n\t\t\"Beanie\" => \"HatBeanie\",\r\n\t\t\"Gloves\" => \"Gloves\",\r\n\t\t\"Socks\" => \"Socks\",\r\n\t\t\"Bodysuit\" => \"Fullbody\",\r\n\t\t_ => \"Tops\",\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// The `.clothing` file's text.\r\n\t///\r\n\t/// Only the fields that mean something for a garment made here are written. The rest - human\r\n\t/// skin overrides, alternate male and female models, tint gradients, a Steam item id - are\r\n\t/// s&box's own defaults, and writing guesses at them would be claiming a garment supports\r\n\t/// things it has never been tested with.\r\n\t/// </summary>\r\n\t/// <param name=\"modelPath\">The compiled model, as an asset path: models/effigy/shirt.vmdl.</param>\r\n\tpublic static string Write( GarmentRecipe recipe, string modelPath, string title,\r\n\t\tstring subtitle = null )\r\n\t{\r\n\t\tvar slots = string.Join( \", \", SlotsFor( recipe ) );\r\n\t\tvar hide = string.Join( \", \", HideBodyFor( recipe ) );\r\n\r\n\t\tvar sb = new StringBuilder();\r\n\r\n\t\tsb.Append( \"{\\n\" );\r\n\t\tsb.Append( $\" \\\"Title\\\": {Json( title )},\\n\" );\r\n\t\tsb.Append( $\" \\\"Subtitle\\\": {Json( subtitle ?? \"\" )},\\n\" );\r\n\t\tsb.Append( $\" \\\"Category\\\": {Json( CategoryFor( recipe ) )},\\n\" );\r\n\t\tsb.Append( $\" \\\"Model\\\": {Json( modelPath )},\\n\" );\r\n\r\n\t\t// An empty flags enum serialises as 0, not as \"\". s&box writes it that way itself - see any\r\n\t\t// shipped .clothing with no SlotsOver - and a \"\" here is read as an unknown flag name.\r\n\t\tsb.Append( $\" \\\"SlotsUnder\\\": {(slots.Length > 0 ? Json( slots ) : \"0\")},\\n\" );\r\n\t\tsb.Append( \" \\\"SlotsOver\\\": 0,\\n\" );\r\n\t\tsb.Append( $\" \\\"HideBody\\\": {(hide.Length > 0 ? Json( hide ) : \"0\")},\\n\" );\r\n\t\tsb.Append( \" \\\"__references\\\": [],\\n\" );\r\n\t\tsb.Append( \" \\\"__version\\\": 0\\n\" );\r\n\t\tsb.Append( \"}\\n\" );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Several garments as the one recipe their shared model wears.\r\n\t///\r\n\t/// ONE MODEL, ONE DEFINITION. Compile .vmdl bakes every garment in the document into a single\r\n\t/// model, so a shirt and trousers made together are one item and must claim both sets of slots -\r\n\t/// a .clothing that only named the shirt's would let a second pair of trousers go on over the\r\n\t/// first. The category is the first garment's: s&box has exactly one per item, and the first\r\n\t/// thing added is the best guess at what the item is.\r\n\t/// </summary>\r\n\tpublic static GarmentRecipe Combine( IEnumerable<GarmentRecipe> recipes )\r\n\t{\r\n\t\tGarmentRecipe merged = null;\r\n\r\n\t\tforeach ( var recipe in recipes )\r\n\t\t{\r\n\t\t\tif ( recipe is null )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tmerged ??= new GarmentRecipe { Name = recipe.Name };\r\n\t\t\tmerged.Spans.AddRange( recipe.Spans );\r\n\t\t}\r\n\r\n\t\treturn merged;\r\n\t}\r\n\r\n\t/// <summary>A JSON string literal. Small by design - this writes four known fields, and pulling\r\n\t/// in a serialiser for them would put an engine dependency in the kernel.</summary>\r\n\tstatic string Json( string value )\r\n\t{\r\n\t\tif ( value is null )\r\n\t\t\treturn \"null\";\r\n\r\n\t\tvar sb = new StringBuilder( value.Length + 2 );\r\n\t\tsb.Append( '\"' );\r\n\r\n\t\tforeach ( var c in value )\r\n\t\t{\r\n\t\t\tswitch ( c )\r\n\t\t\t{\r\n\t\t\t\tcase '\"': sb.Append( \"\\\\\\\"\" ); break;\r\n\t\t\t\tcase '\\\\': sb.Append( \"\\\\\\\\\" ); break;\r\n\t\t\t\tcase '\\n': sb.Append( \"\\\\n\" ); break;\r\n\t\t\t\tcase '\\r': sb.Append( \"\\\\r\" ); break;\r\n\t\t\t\tcase '\\t': sb.Append( \"\\\\t\" ); break;\r\n\t\t\t\tdefault:\r\n\t\t\t\t\tif ( c < 0x20 )\r\n\t\t\t\t\t\tsb.Append( \"\\\\u\" ).Append( ((int)c).ToString( \"x4\" ) );\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tsb.Append( c );\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tsb.Append( '\"' );\r\n\t\treturn sb.ToString();\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// A smooth stand-in body to cut a garment from, for a wearer that is not a skin.\r\n///\r\n/// WHY A GARMENT NEEDS ONE. GarmentFit lifts the cloth straight off the wearer's own surface,\r\n/// which is exactly right for a skin like Citizen's and exactly wrong for a body built from\r\n/// parts. Camhead is struts, plates and cables with air between them; lifted off that, a\r\n/// T-shirt came out as a tight shell over every bolt, and the drape had nothing to hang across\r\n/// the gaps. A shirt on a robot is a shirt on the robot's SILHOUETTE.\r\n///\r\n/// ONE CONVEX HULL PER REGION AND SIDE - the torso, the hips, each upper arm, each forearm - out\r\n/// of the vertices nearest that region's bones. Convex, so it spans every gap; per region, so\r\n/// the armpit and the crotch stay open instead of the whole body becoming one blob. The hulls\r\n/// overlap at the joints, and Fit already drops faces buried inside another closed body, which\r\n/// is what turns overlapping hulls into one clean outer surface.\r\n///\r\n/// SUBDIVIDED LINEARLY, not smoothed. Catmull-Clark would round the hull inward and let the\r\n/// corners of the real parts poke through the cloth; midpoint splits keep the shape and only add\r\n/// the vertices the drape needs to have something to bend.\r\n/// </summary>\r\npublic static class GarmentEnvelope\r\n{\r\n\t/// <summary>How many separate pieces a mesh is made of. A skin is one or a handful (a head, a\r\n\t/// body, two eyes); a kitbashed robot is hundreds.</summary>\r\n\tpublic static int ShellCount( PolyMesh mesh )\r\n\t{\r\n\t\tvar parent = new int[mesh.VertexCount];\r\n\r\n\t\tfor ( var i = 0; i < parent.Length; i++ )\r\n\t\t\tparent[i] = i;\r\n\r\n\t\tint Find( int x )\r\n\t\t{\r\n\t\t\twhile ( parent[x] != x )\r\n\t\t\t\tx = parent[x] = parent[parent[x]];\r\n\r\n\t\t\treturn x;\r\n\t\t}\r\n\r\n\t\tforeach ( var face in mesh.Faces )\r\n\t\t\tfor ( var c = 1; c < face.Count; c++ )\r\n\t\t\t\tparent[Find( face.Indices[c] )] = Find( face.Indices[0] );\r\n\r\n\t\tvar roots = new HashSet<int>();\r\n\r\n\t\tforeach ( var face in mesh.Faces )\r\n\t\t\tif ( face.Count > 0 )\r\n\t\t\t\troots.Add( Find( face.Indices[0] ) );\r\n\r\n\t\treturn roots.Count;\r\n\t}\r\n\r\n\t/// <summary>Should a garment be cut from an envelope instead of the surface? More than this\r\n\t/// many pieces is a body built from parts. Citizen is under ten.</summary>\r\n\tpublic const int PartsThreshold = 24;\r\n\r\n\tpublic static bool IsKitbashed( IEnumerable<PolyMesh> bodies ) =>\r\n\t\tbodies.Sum( ShellCount ) > PartsThreshold;\r\n\r\n\t/// <summary>The stand-in: one closed, subdivided hull per body region and side.</summary>\r\n\tpublic static List<(BodyRegion Region, PolyMesh Mesh)> Build( IReadOnlyList<PolyMesh> bodies, BodyRegions.Map map, float edge = 1.5f )\r\n\t{\r\n\t\tvar groups = new Dictionary<(BodyRegion, int), List<Vec3>>();\r\n\r\n\t\tforeach ( var body in bodies )\r\n\t\t{\r\n\t\t\tforeach ( var p in body.Positions )\r\n\t\t\t{\r\n\t\t\t\tvar (region, _) = map.Locate( p );\r\n\r\n\t\t\t\tif ( region == BodyRegion.None )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar central = region is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;\r\n\t\t\t\tvar key = (region, central ? 0 : p.y > 0 ? 1 : -1);\r\n\r\n\t\t\t\tif ( !groups.TryGetValue( key, out var list ) )\r\n\t\t\t\t\tgroups[key] = list = new List<Vec3>();\r\n\r\n\t\t\t\tlist.Add( p );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// THE TORSO HANGS. Cloth falls from the widest thing it rests on - shoulders, chest - and\r\n\t\t// does not follow the body back in at the waist, which is the whole difference between a\r\n\t\t// shirt and a wetsuit. So the torso's hull is dropped straight down to the bottom of the\r\n\t\t// hips: a column as wide as the chest, and the drape has something to hang from rather\r\n\t\t// than a waist to crumple into.\r\n\t\tif ( groups.TryGetValue( (BodyRegion.Torso, 0), out var torso ) && groups.TryGetValue( (BodyRegion.Hips, 0), out var hips ) )\r\n\t\t{\r\n\t\t\tvar floor = hips.Min( p => p.z );\r\n\r\n\t\t\t// Only the chest between the arms. Dropped from the shoulder points too, the column is\r\n\t\t\t// as wide as the shoulders all the way down, swallows an A-posed upper arm, and the\r\n\t\t\t// sleeves - the faces of the arm hulls inside it - are cut away with it.\r\n\t\t\tvar inner = groups.Where( g => g.Key.Item1 == BodyRegion.UpperArm )\r\n\t\t\t\t.SelectMany( g => g.Value ).Select( p => MathF.Abs( p.y ) ).DefaultIfEmpty( float.MaxValue ).Min();\r\n\t\t\tvar dropped = torso.Where( p => MathF.Abs( p.y ) < inner )\r\n\t\t\t\t.Select( p => new Vec3( p.x, p.y, floor ) ).ToList();\r\n\r\n\t\t\ttorso.AddRange( dropped );\r\n\t\t}\r\n\r\n\t\tvar hulls = new List<(BodyRegion, PolyMesh)>();\r\n\r\n\t\tforeach ( var ((groupRegion, _), points) in groups )\r\n\t\t{\r\n\t\t\tif ( points.Count < 4 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tPolyMesh hull;\r\n\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\thull = ConvexHull.ToMesh( Extremes( points ), 1e-4f );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception )\r\n\t\t\t{\r\n\t\t\t\t// Flat or degenerate - a single plate on its own. Nothing to wrap.\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( hull is null || hull.FaceCount == 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\thulls.Add( (groupRegion, Refine( hull, edge )) );\r\n\t\t}\r\n\r\n\t\treturn hulls;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The points furthest along a few hundred directions spread evenly over the sphere.\r\n\t///\r\n\t/// ConvexHull is incremental and sized for CAD bodies of a few hundred vertices; a robot's\r\n\t/// torso is thousands, nearly all of them inside, and hulling them all took minutes. The\r\n\t/// extremes are the hull's own corners to within a fraction of an inch, which is far closer\r\n\t/// than cloth sitting a clearance off it can tell apart.\r\n\t/// </summary>\r\n\tstatic List<Vec3> Extremes( List<Vec3> points, int directions = 320 )\r\n\t{\r\n\t\tif ( points.Count <= directions )\r\n\t\t\treturn points;\r\n\r\n\t\tvar keep = new HashSet<int>();\r\n\t\tvar golden = MathF.PI * (3f - MathF.Sqrt( 5f ));\r\n\r\n\t\tfor ( var d = 0; d < directions; d++ )\r\n\t\t{\r\n\t\t\tvar z = 1f - 2f * (d + 0.5f) / directions;\r\n\t\t\tvar r = MathF.Sqrt( MathF.Max( 0f, 1f - z * z ) );\r\n\t\t\tvar dir = new Vec3( r * MathF.Cos( golden * d ), r * MathF.Sin( golden * d ), z );\r\n\r\n\t\t\tvar best = 0;\r\n\t\t\tvar bestDot = float.MinValue;\r\n\r\n\t\t\tfor ( var i = 0; i < points.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar dot = Vec3.Dot( points[i], dir );\r\n\r\n\t\t\t\tif ( dot > bestDot )\r\n\t\t\t\t{\r\n\t\t\t\t\tbestDot = dot;\r\n\t\t\t\t\tbest = i;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tkeep.Add( best );\r\n\t\t}\r\n\r\n\t\treturn keep.Select( i => points[i] ).ToList();\r\n\t}\r\n\r\n\t/// <summary>Triangulate, then split every triangle four ways until the longest edge is under\r\n\t/// <paramref name=\"edge\"/>. Uniform on purpose: every triangle splits every round, so shared\r\n\t/// edges always split together and the hull stays closed - a split that followed each edge's\r\n\t/// own length would leave T-junctions wherever a long face met a short one.</summary>\r\n\tpublic static PolyMesh Refine( PolyMesh mesh, float edge, int maxTriangles = 12000 )\r\n\t{\r\n\t\tvar positions = new List<Vec3>( mesh.Positions );\r\n\t\tvar tris = new List<(int A, int B, int C)>();\r\n\r\n\t\tforeach ( var face in mesh.Faces )\r\n\t\t\tfor ( var c = 1; c + 1 < face.Count; c++ )\r\n\t\t\t\ttris.Add( (face.Indices[0], face.Indices[c], face.Indices[c + 1]) );\r\n\r\n\t\tvar limit = MathF.Max( edge, 0.05f );\r\n\r\n\t\tfloat Longest() => tris.Count == 0 ? 0f : tris.Max( t => MathF.Max( (positions[t.A] - positions[t.B]).Length,\r\n\t\t\tMathF.Max( (positions[t.B] - positions[t.C]).Length, (positions[t.C] - positions[t.A]).Length ) ) );\r\n\r\n\t\twhile ( Longest() > limit && tris.Count * 4 <= maxTriangles )\r\n\t\t{\r\n\t\t\tvar mids = new Dictionary<EdgeKey, int>();\r\n\t\t\tvar next = new List<(int, int, int)>( tris.Count * 4 );\r\n\r\n\t\t\tint Mid( int a, int b )\r\n\t\t\t{\r\n\t\t\t\tvar key = new EdgeKey( a, b );\r\n\r\n\t\t\t\tif ( !mids.TryGetValue( key, out var m ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tm = positions.Count;\r\n\t\t\t\t\tpositions.Add( (positions[a] + positions[b]) * 0.5f );\r\n\t\t\t\t\tmids[key] = m;\r\n\t\t\t\t}\r\n\r\n\t\t\t\treturn m;\r\n\t\t\t}\r\n\r\n\t\t\tforeach ( var (a, b, c) in tris )\r\n\t\t\t{\r\n\t\t\t\tvar ab = Mid( a, b );\r\n\t\t\t\tvar bc = Mid( b, c );\r\n\t\t\t\tvar ca = Mid( c, a );\r\n\r\n\t\t\t\tnext.Add( (a, ab, ca) );\r\n\t\t\t\tnext.Add( (ab, b, bc) );\r\n\t\t\t\tnext.Add( (ca, bc, c) );\r\n\t\t\t\tnext.Add( (ab, bc, ca) );\r\n\t\t\t}\r\n\r\n\t\t\ttris = next;\r\n\t\t}\r\n\r\n\t\tvar result = new PolyMesh();\r\n\r\n\t\tforeach ( var p in positions )\r\n\t\t\tresult.AddVertex( p );\r\n\r\n\t\tforeach ( var (a, b, c) in tris )\r\n\t\t\tresult.AddFace( new[] { a, b, c }, null, 0 );\r\n\r\n\t\treturn result;\r\n\t}\r\n}\r\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/MeshClip.cs",
"FileName": "MeshClip.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// <summary>\n/// Keep the half of a mesh on one side of a plane, clipping faces that straddle it.\n///\n/// THIS IS A VIEW, NOT A FEATURE. Shell builds interiors nobody can look at; a cutaway hides the\n/// near half while looking, without writing anything into the history. Faces that cross the plane\n/// are clipped in place so the cut reads as a hard line rather than as missing polygons. There is\n/// no cap \u2014 an open cut is the point of a section view, and capping would pretend the kernel had\n/// booleaned a solid it has not.\n/// </summary>\npublic static class MeshClip\n{\n\t/// <summary>\n\t/// Everything on the positive side of the plane through <paramref name=\"origin\"/> with\n\t/// <paramref name=\"normal\"/>, including the plane itself. Vertices on the negative side are\n\t/// dropped; edges that cross emit a new vertex at the intersection.\n\t/// </summary>\n\tpublic static PolyMesh Keep( PolyMesh mesh, Vec3 origin, Vec3 normal, float tolerance = 1e-5f )\n\t{\n\t\tif ( mesh is null )\n\t\t\tthrow new ArgumentNullException( nameof( mesh ) );\n\n\t\tvar n = normal.Normal;\n\n\t\tif ( n.LengthSquared < 0.5f )\n\t\t\treturn mesh.Clone();\n\n\t\tvar result = new PolyMesh();\n\n\t\tforeach ( var face in mesh.Faces )\n\t\t{\n\t\t\tif ( face.Count < 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar clipped = ClipFace( mesh, face, origin, n, tolerance );\n\n\t\t\tif ( clipped.Count < 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar indices = new int[clipped.Count];\n\t\t\tvar uvs = new Vec2[clipped.Count];\n\n\t\t\tfor ( var i = 0; i < clipped.Count; i++ )\n\t\t\t{\n\t\t\t\tindices[i] = result.AddVertex( clipped[i].Position );\n\t\t\t\tuvs[i] = clipped[i].Uv;\n\t\t\t}\n\n\t\t\tresult.AddFace( indices, uvs, face.Material );\n\t\t}\n\n\t\treturn result;\n\t}\n\n\treadonly struct ClipVert\n\t{\n\t\tpublic readonly Vec3 Position;\n\t\tpublic readonly Vec2 Uv;\n\n\t\tpublic ClipVert( Vec3 position, Vec2 uv )\n\t\t{\n\t\t\tPosition = position;\n\t\t\tUv = uv;\n\t\t}\n\t}\n\n\tstatic List<ClipVert> ClipFace( PolyMesh mesh, Face face, Vec3 origin, Vec3 normal, float tolerance )\n\t{\n\t\tvar polygon = new List<ClipVert>( face.Count );\n\n\t\tfor ( var i = 0; i < face.Count; i++ )\n\t\t{\n\t\t\tvar uv = face.UVs is not null && i < face.UVs.Length ? face.UVs[i] : default;\n\t\t\tpolygon.Add( new ClipVert( mesh.Positions[face.Indices[i]], uv ) );\n\t\t}\n\n\t\t// Sutherland\u2013Hodgman against one plane: walk each edge, emit the intersection when the\n\t\t// edge crosses, and keep the vertex when it sits on the kept side.\n\t\tvar output = new List<ClipVert>( polygon.Count + 2 );\n\n\t\tfor ( var i = 0; i < polygon.Count; i++ )\n\t\t{\n\t\t\tvar current = polygon[i];\n\t\t\tvar previous = polygon[( i + polygon.Count - 1 ) % polygon.Count];\n\t\t\tvar currentDist = Vec3.Dot( current.Position - origin, normal );\n\t\t\tvar previousDist = Vec3.Dot( previous.Position - origin, normal );\n\t\t\tvar currentIn = currentDist >= -tolerance;\n\t\t\tvar previousIn = previousDist >= -tolerance;\n\n\t\t\tif ( currentIn )\n\t\t\t{\n\t\t\t\tif ( !previousIn )\n\t\t\t\t\toutput.Add( Intersect( previous, current, previousDist, currentDist ) );\n\n\t\t\t\toutput.Add( current );\n\t\t\t}\n\t\t\telse if ( previousIn )\n\t\t\t{\n\t\t\t\toutput.Add( Intersect( previous, current, previousDist, currentDist ) );\n\t\t\t}\n\t\t}\n\n\t\treturn output;\n\t}\n\n\tstatic ClipVert Intersect( ClipVert a, ClipVert b, float da, float db )\n\t{\n\t\tvar denom = da - db;\n\n\t\t// Both on the plane, or an edge so close the numbers vanish: pick B rather than divide.\n\t\tvar t = MathF.Abs( denom ) < 1e-12f ? 1f : da / denom;\n\t\tt = Math.Clamp( t, 0f, 1f );\n\n\t\treturn new ClipVert( a.Position + ( b.Position - a.Position ) * t, a.Uv + ( b.Uv - a.Uv ) * t );\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/MeshSection.cs",
"FileName": "MeshSection.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// <summary>\n/// Where a mesh meets a plane, as line segments.\n///\n/// This exists for one job: showing the FOOTPRINT another body leaves on a face. Effigy does not\n/// union bodies, so an extrude standing on a slab is two separate solids that happen to overlap,\n/// and the slab's top face is one uninterrupted rectangle with no idea anything is sitting on it.\n/// Outlining only that rectangle makes a face with a block on it look completely clear.\n///\n/// Two cases, and both turn up constantly because they are the two ways bodies get stacked:\n/// a solid that PASSES THROUGH the plane, whose faces cross it and produce a chord each; and one\n/// that SITS ON it, whose bottom face lies in the plane and produces its own outline. Handling\n/// only the first would miss every part built by sketching on a face, which is most of them.\n/// </summary>\npublic static class MeshSection\n{\n\t/// <summary>\n\t/// Every segment where <paramref name=\"mesh\"/> meets the plane through\n\t/// <paramref name=\"planeOrigin\"/> with <paramref name=\"planeNormal\"/>.\n\t/// </summary>\n\tpublic static List<(Vec3 A, Vec3 B)> CrossSection( PolyMesh mesh, Vec3 planeOrigin, Vec3 planeNormal,\n\t\tfloat tolerance = 1e-4f )\n\t{\n\t\tvar segments = new List<(Vec3, Vec3)>();\n\n\t\tif ( mesh is null || mesh.Faces.Count == 0 )\n\t\t\treturn segments;\n\n\t\tvar normal = planeNormal.Normal;\n\n\t\tforeach ( var face in mesh.Faces )\n\t\t{\n\t\t\tif ( face.Count < 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar distances = new float[face.Count];\n\t\t\tvar above = false;\n\t\t\tvar below = false;\n\n\t\t\tfor ( var i = 0; i < face.Count; i++ )\n\t\t\t{\n\t\t\t\tdistances[i] = Vec3.Dot( mesh.Positions[face.Indices[i]] - planeOrigin, normal );\n\n\t\t\t\tif ( distances[i] > tolerance )\n\t\t\t\t\tabove = true;\n\t\t\t\telse if ( distances[i] < -tolerance )\n\t\t\t\t\tbelow = true;\n\t\t\t}\n\n\t\t\tvar coplanar = !above && !below;\n\n\t\t\t// Sitting flat ON the plane: the face's own edges ARE the footprint.\n\t\t\tif ( coplanar )\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i < face.Count; i++ )\n\t\t\t\t{\n\t\t\t\t\tsegments.Add( (mesh.Positions[face.Indices[i]],\n\t\t\t\t\t\tmesh.Positions[face.Indices[(i + 1) % face.Count]]) );\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// STRICTLY BOTH SIDES, or this face does not cross the plane - it only touches it.\n\t\t\t// A block standing on a slab touches with all four of its side walls, each along its\n\t\t\t// bottom edge, and every one of those edges is already an edge of the coplanar bottom\n\t\t\t// face. Without this the whole footprint gets drawn twice.\n\t\t\tif ( !above || !below )\n\t\t\t\tcontinue;\n\n\t\t\t// Passing through it: collect where the face's edges cross, which for a convex face is\n\t\t\t// exactly two points and therefore one chord.\n\t\t\tvar crossings = new List<Vec3>( 2 );\n\n\t\t\tfor ( var i = 0; i < face.Count; i++ )\n\t\t\t{\n\t\t\t\tvar a = mesh.Positions[face.Indices[i]];\n\t\t\t\tvar b = mesh.Positions[face.Indices[(i + 1) % face.Count]];\n\t\t\t\tvar da = distances[i];\n\t\t\t\tvar db = distances[(i + 1) % face.Count];\n\n\t\t\t\t// A corner sitting on the plane is reported once, by the edge that arrives at it -\n\t\t\t\t// counting it twice would leave a zero-length segment behind.\n\t\t\t\tif ( MathF.Abs( da ) <= tolerance )\n\t\t\t\t{\n\t\t\t\t\tcrossings.Add( a );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif ( MathF.Abs( db ) <= tolerance || da * db > 0f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tcrossings.Add( a + (b - a) * (da / (da - db)) );\n\t\t\t}\n\n\t\t\tfor ( var i = 0; i + 1 < crossings.Count; i += 2 )\n\t\t\t{\n\t\t\t\tif ( (crossings[i + 1] - crossings[i]).LengthSquared > tolerance * tolerance )\n\t\t\t\t\tsegments.Add( (crossings[i], crossings[i + 1]) );\n\t\t\t}\n\t\t}\n\n\t\treturn segments;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/ObjWriter.cs",
"FileName": "ObjWriter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Text;\n\nnamespace Effigy;\n\n/// <summary>\n/// Wavefront OBJ export \u2014 the DEBUG and interchange format, not the export path.\n///\n/// THIS IS NO LONGER HOW MODELS REACH s&box. OBJ cannot carry bones or vertex weights, so it stops\n/// being viable the moment a model is rigged, and rigging is in scope. SmdWriter is the export\n/// path for both static and skinned models; see the note at the top of it for why one format\n/// covers both.\n///\n/// What OBJ is still the best tool for, and why it stays:\n///\n/// It is the only format here that PRESERVES QUADS. SMD triangulates on the way out, so an OBJ is\n/// the only way to look at the cage as the kernel actually holds it \u2014 which is exactly what you\n/// need when checking whether a subdivision result is right. Open it in Blender and the topology\n/// is there to read.\n///\n/// The test suite writes one per primitive for that reason, and round-trips them to prove the\n/// writer emits something parseable with its topology intact.\n///\n/// Godot needs neither path \u2014 there the kernel hands vertices to ArrayMesh directly.\n/// </summary>\npublic static class ObjWriter\n{\n\t/// <summary>Kept as an alias so existing callers do not have to know where this moved to.</summary>\n\tpublic const float DefaultSmoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees;\n\n\t/// <summary>What an unnamed slot is called. Shared with SmdWriter and DmxWriter so a model\n\t/// exported three ways names its materials the same three times.</summary>\n\tpublic static string DefaultMaterialName( int slot ) => $\"material_{slot}\";\n\n\tpublic static void WriteFile( PolyMesh mesh, string path, string objectName = \"model\",\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )\n\t{\n\t\tFile.WriteAllText( path, Write( mesh, objectName, smoothingAngleDegrees, materialName ) );\n\t}\n\n\t/// <param name=\"materialName\">What to call each material slot. Defaults to material_0, material_1\n\t/// and so on \u2014 a name a person chose is the difference between binding by meaning and binding by\n\t/// number in whatever the model lands in.</param>\n\tpublic static string Write( PolyMesh mesh, string objectName = \"model\",\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )\n\t{\n\t\tvar sb = new StringBuilder();\n\n\t\tsb.Append( \"# generated by Effigy\\n\" );\n\t\tsb.Append( $\"# {mesh.VertexCount} vertices, {mesh.FaceCount} faces\\n\" );\n\n\t\tvar offsets = (Vertices: 0, UVs: 0, Normals: 0);\n\t\tAppendPiece( sb, mesh, objectName, smoothingAngleDegrees, materialName, ref offsets );\n\n\t\treturn sb.ToString();\n\t}\n\n\t/// <summary>\n\t/// Several named pieces in one file, each under its own <c>o</c> marker \u2014 which is what makes\n\t/// it come back as several parts rather than one welded lump.\n\t///\n\t/// THE MERGE IS THE LOSSY STEP, not the format. A part-segmentation export exists precisely so\n\t/// that a hand is a hand and a torso is a torso, and flattening fourteen of those into one mesh\n\t/// on the way out throws away the only thing that made the file worth segmenting: everything\n\t/// downstream that works per part \u2014 rigging a bone to a body, hiding one while you sculpt\n\t/// another, a Remesh with a budget of its own \u2014 has nothing left to work per part ON.\n\t///\n\t/// OBJ's indices are file-global rather than per-object, which is the entire reason this cannot\n\t/// be done by concatenating what <see cref=\"Write(PolyMesh, string, float, Func{int, string})\"/>\n\t/// returns: every piece after the first would reference the first one's vertices.\n\t/// </summary>\n\tpublic static void WriteFile( IReadOnlyList<ObjReader.ObjPiece> pieces, string path,\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )\n\t{\n\t\tFile.WriteAllText( path, Write( pieces, smoothingAngleDegrees, materialName ) );\n\t}\n\n\t/// <inheritdoc cref=\"WriteFile(IReadOnlyList{ObjReader.ObjPiece}, string, float, Func{int, string})\"/>\n\tpublic static string Write( IReadOnlyList<ObjReader.ObjPiece> pieces,\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )\n\t{\n\t\tvar sb = new StringBuilder();\n\t\tvar offsets = (Vertices: 0, UVs: 0, Normals: 0);\n\n\t\tvar vertices = 0;\n\t\tvar faces = 0;\n\n\t\tforeach ( var piece in pieces )\n\t\t{\n\t\t\tvertices += piece.Mesh?.VertexCount ?? 0;\n\t\t\tfaces += piece.Mesh?.FaceCount ?? 0;\n\t\t}\n\n\t\tsb.Append( \"# generated by Effigy\\n\" );\n\t\tsb.Append( $\"# {vertices} vertices, {faces} faces, {pieces.Count} objects\\n\" );\n\n\t\tfor ( var i = 0; i < pieces.Count; i++ )\n\t\t{\n\t\t\tvar piece = pieces[i];\n\n\t\t\tif ( piece.Mesh is null || piece.Mesh.FaceCount == 0 )\n\t\t\t\tcontinue;\n\n\t\t\t// An unnamed piece still needs a marker, or it merges into the one before it on the way\n\t\t\t// back in. ReadPieces hands back \"\" for faces that appeared before any o/g line.\n\t\t\tvar name = string.IsNullOrWhiteSpace( piece.Name ) ? $\"part_{i}\" : piece.Name;\n\n\t\t\tAppendPiece( sb, piece.Mesh, name, smoothingAngleDegrees, materialName, ref offsets );\n\t\t}\n\n\t\treturn sb.ToString();\n\t}\n\n\t/// <summary>One object's worth of the file. The offsets go in as the counts written so far and\n\t/// come back raised by this piece's own, because OBJ numbers v/vt/vn across the whole file.\n\t/// </summary>\n\tprivate static void AppendPiece( StringBuilder sb, PolyMesh mesh, string objectName,\n\t\tfloat smoothingAngleDegrees, Func<int, string> materialName,\n\t\tref (int Vertices, int UVs, int Normals) offsets )\n\t{\n\t\tvar c = CultureInfo.InvariantCulture;\n\n\t\tsb.Append( $\"o {objectName}\\n\" );\n\n\t\tfor ( var i = 0; i < mesh.Positions.Count; i++ )\n\t\t{\n\t\t\tvar p = mesh.Positions[i];\n\n\t\t\tsb.Append( string.Format( c, \"v {0:0.######} {1:0.######} {2:0.######}\", p.x, p.y, p.z ) );\n\n\t\t\t// Vertex colour, when there is one. OBJ vertex colour is RGB, and \"no paint\" is WHITE \u2014\n\t\t\t// the consumer multiplies the material by the colour, so white means \"unchanged\" and\n\t\t\t// coverage fades the vertex from white toward the paint colour.\n\t\t\tif ( mesh.VertexColors is not null && i < mesh.VertexColors.Length )\n\t\t\t{\n\t\t\t\tvar tint = mesh.VertexColors[i].Tint();\n\n\t\t\t\tsb.Append( string.Format( c, \" {0:0.######} {1:0.######} {2:0.######}\",\n\t\t\t\t\ttint.x, tint.y, tint.z ) );\n\t\t\t}\n\n\t\t\tsb.Append( '\\n' );\n\t\t}\n\n\t\t// UVs are per corner, so the same value recurs constantly. Deduping keeps the file to a\n\t\t// sane size without changing what it means.\n\t\tvar uvIndex = new Dictionary<(long, long), int>();\n\t\tvar faceUVRefs = new int[mesh.FaceCount][];\n\n\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\n\t\t{\n\t\t\tvar f = mesh.Faces[fi];\n\t\t\tfaceUVRefs[fi] = new int[f.Count];\n\n\t\t\tfor ( var i = 0; i < f.Count; i++ )\n\t\t\t{\n\t\t\t\tvar uv = f.UVs[i];\n\n\t\t\t\t// OBJ puts the UV origin at the bottom-left where Effigy's is top-left, so V is\n\t\t\t\t// flipped on the way out \u2014 the same flip FbxWriter makes for the same reason, and the\n\t\t\t\t// one DmxWriter avoids by leaving flipVCoordinates off and writing V as-is. Without it\n\t\t\t\t// a compiled static model samples its texture upside down relative to the viewport.\n\t\t\t\tvar flippedV = 1f - uv.y;\n\t\t\t\tvar key = ((long)MathF.Round( uv.x * 1e5f ), (long)MathF.Round( flippedV * 1e5f ));\n\n\t\t\t\tif ( !uvIndex.TryGetValue( key, out var idx ) )\n\t\t\t\t{\n\t\t\t\t\tidx = uvIndex.Count;\n\t\t\t\t\tuvIndex[key] = idx;\n\t\t\t\t\tsb.Append( string.Format( c, \"vt {0:0.######} {1:0.######}\\n\", uv.x, flippedV ) );\n\t\t\t\t}\n\n\t\t\t\tfaceUVRefs[fi][i] = idx;\n\t\t\t}\n\t\t}\n\n\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\n\n\t\tforeach ( var n in normals )\n\t\t\tsb.Append( string.Format( c, \"vn {0:0.######} {1:0.######} {2:0.######}\\n\", n.x, n.y, n.z ) );\n\n\t\tvar currentMaterial = int.MinValue;\n\n\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\n\t\t{\n\t\t\tvar f = mesh.Faces[fi];\n\n\t\t\tif ( f.Material != currentMaterial )\n\t\t\t{\n\t\t\t\tcurrentMaterial = f.Material;\n\t\t\t\tsb.Append( $\"usemtl {(materialName ?? DefaultMaterialName)( currentMaterial )}\\n\" );\n\t\t\t}\n\n\t\t\tsb.Append( 'f' );\n\n\t\t\tfor ( var i = 0; i < f.Count; i++ )\n\t\t\t{\n\t\t\t\t// OBJ indices are 1-based, and counted from the top of the FILE rather than from\n\t\t\t\t// the top of this object \u2014 hence the offsets, which are zero for a one-piece write.\n\t\t\t\tsb.Append( $\" {f.Indices[i] + 1 + offsets.Vertices}\"\n\t\t\t\t\t+ $\"/{faceUVRefs[fi][i] + 1 + offsets.UVs}\"\n\t\t\t\t\t+ $\"/{cornerNormals[fi][i] + 1 + offsets.Normals}\" );\n\t\t\t}\n\n\t\t\tsb.Append( '\\n' );\n\t\t}\n\n\t\toffsets = (offsets.Vertices + mesh.VertexCount, offsets.UVs + uvIndex.Count,\n\t\t\toffsets.Normals + normals.Count);\n\t}\n\n}\n\n/// <summary>\n/// Wavefront OBJ reader \u2014 the import path, and the round-trip for tests.\n///\n/// Understands what <see cref=\"ObjWriter\"/> emits, plus the subset a Meshy or Blender export\n/// actually uses: positions, optional vertex colour, UVs, faces (with or without vt/vn), and\n/// <c>usemtl material_N</c>. Everything else on a line is ignored rather than refused, because\n/// a real OBJ is full of <c>o</c> / <c>g</c> / <c>s</c> / <c>vn</c> / <c>mtllib</c> that do not\n/// change the solid.\n///\n/// V IS FLIPPED, matching the writer. OBJ's UV origin is bottom-left; Effigy's is top-left.\n/// Reading without the flip would import every textured mesh upside down, and a writer\u2192reader\n/// round trip would invert V twice and look correct by accident.\n/// </summary>\npublic static class ObjReader\n{\n\tpublic static PolyMesh ReadFile( string path ) => Read( File.ReadAllText( path ) );\n\n\t/// <summary>One named lump of an OBJ \u2014 an <c>o</c> object or a <c>g</c> group, with the faces\n\t/// that followed it. See <see cref=\"ReadPieces\"/>.</summary>\n\tpublic readonly struct ObjPiece\n\t{\n\t\tpublic readonly string Name;\n\t\tpublic readonly PolyMesh Mesh;\n\n\t\tpublic ObjPiece( string name, PolyMesh mesh )\n\t\t{\n\t\t\tName = name;\n\t\t\tMesh = mesh;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// The whole file as one mesh, objects and groups fused. What every caller wanted before\n\t/// imports could be split, and what a writer's round-trip still compares against.\n\t/// </summary>\n\tpublic static PolyMesh Read( string text )\n\t{\n\t\tvar parsed = Parse( text );\n\t\tvar mesh = new PolyMesh { Positions = parsed.Positions };\n\n\t\tforeach ( var f in parsed.Faces )\n\t\t\tmesh.AddFace( f.Indices, f.UVs, f.Material );\n\n\t\tif ( parsed.SawColor )\n\t\t\tmesh.VertexColors = parsed.Colors.ToArray();\n\n\t\treturn mesh;\n\t}\n\n\t/// <summary>\n\t/// The file split at its <c>o</c> and <c>g</c> lines, one mesh per lump.\n\t///\n\t/// WHY THIS EXISTS: an OBJ's vertex indices are global to the FILE, not to the object, so an\n\t/// exporter that carefully kept a character's eyelids, brows and hair as separate objects hands\n\t/// us one flat pile of triangles with the boundaries recorded only in these marker lines.\n\t/// Reading it with <see cref=\"Read\"/> throws that structure away and produces one welded body \u2014\n\t/// which is exactly the body you cannot build a tidy feature tree out of, and cannot hide,\n\t/// re-material or weight a piece of on its own.\n\t///\n\t/// Each piece gets its own vertex list, renumbered, holding only the vertices its faces\n\t/// actually use. Faces before the first marker land in a piece with an empty name; the caller\n\t/// decides what to call that, since only it knows what the file was called.\n\t///\n\t/// A file with no markers at all comes back as one unnamed piece, so a caller can always take\n\t/// this path rather than branching on whether the exporter bothered.\n\t/// </summary>\n\tpublic static List<ObjPiece> ReadPieces( string text )\n\t{\n\t\tvar parsed = Parse( text );\n\t\tvar pieces = new List<ObjPiece>();\n\n\t\t// Grouped by index rather than by name so two objects sharing a name stay two pieces \u2014\n\t\t// a duplicated name is an exporter's business, not a reason to weld geometry together.\n\t\tvar byGroup = new Dictionary<int, List<ObjFace>>();\n\t\tvar order = new List<int>();\n\n\t\tforeach ( var f in parsed.Faces )\n\t\t{\n\t\t\tif ( !byGroup.TryGetValue( f.Group, out var list ) )\n\t\t\t{\n\t\t\t\tbyGroup[f.Group] = list = new List<ObjFace>();\n\t\t\t\torder.Add( f.Group );\n\t\t\t}\n\n\t\t\tlist.Add( f );\n\t\t}\n\n\t\tforeach ( var group in order )\n\t\t{\n\t\t\tvar faces = byGroup[group];\n\t\t\tvar mesh = new PolyMesh();\n\t\t\tvar remap = new Dictionary<int, int>();\n\t\t\tvar colors = parsed.SawColor ? new List<Vec4>() : null;\n\n\t\t\tforeach ( var f in faces )\n\t\t\t{\n\t\t\t\tvar indices = new int[f.Indices.Length];\n\n\t\t\t\tfor ( var i = 0; i < f.Indices.Length; i++ )\n\t\t\t\t{\n\t\t\t\t\tvar source = f.Indices[i];\n\n\t\t\t\t\tif ( !remap.TryGetValue( source, out var local ) )\n\t\t\t\t\t{\n\t\t\t\t\t\tlocal = mesh.AddVertex( parsed.Positions[source] );\n\t\t\t\t\t\tremap[source] = local;\n\t\t\t\t\t\tcolors?.Add( parsed.Colors[source] );\n\t\t\t\t\t}\n\n\t\t\t\t\tindices[i] = local;\n\t\t\t\t}\n\n\t\t\t\tmesh.AddFace( indices, f.UVs, f.Material );\n\t\t\t}\n\n\t\t\tif ( colors is not null )\n\t\t\t\tmesh.VertexColors = colors.ToArray();\n\n\t\t\tpieces.Add( new ObjPiece( group >= 0 ? parsed.GroupNames[group] : \"\", mesh ) );\n\t\t}\n\n\t\treturn pieces;\n\t}\n\n\treadonly struct ObjFace\n\t{\n\t\tpublic readonly int[] Indices;\n\t\tpublic readonly Vec2[] UVs;\n\t\tpublic readonly int Material;\n\t\tpublic readonly int Group;\n\n\t\tpublic ObjFace( int[] indices, Vec2[] uvs, int material, int group )\n\t\t{\n\t\t\tIndices = indices;\n\t\t\tUVs = uvs;\n\t\t\tMaterial = material;\n\t\t\tGroup = group;\n\t\t}\n\t}\n\n\tsealed class ObjData\n\t{\n\t\tpublic List<Vec3> Positions = new();\n\t\tpublic List<Vec4> Colors = new();\n\t\tpublic List<Vec2> UVs = new();\n\t\tpublic List<ObjFace> Faces = new();\n\t\tpublic List<string> GroupNames = new();\n\t\tpublic bool SawColor;\n\t}\n\n\t/// <summary>\n\t/// One pass over the text. Both readers above run on this, so a fused read and a split read\n\t/// cannot disagree about what the file says \u2014 which is the whole reason it is factored out\n\t/// rather than written twice.\n\t/// </summary>\n\tstatic ObjData Parse( string text )\n\t{\n\t\tvar data = new ObjData();\n\t\tvar material = 0;\n\t\tvar group = -1;\n\n\t\t// One pass over the string as spans. The string.Split / Trim version this replaced did\n\t\t// three allocations a line on a dense file \u2014 the line, the token array, and every token\n\t\t// itself \u2014 which on a million-face OBJ is tens of millions of objects for the GC to chew.\n\t\t// Spans keep the whole parse allocation-free except for the data it is actually building.\n\t\tvar remaining = text.AsSpan();\n\n\t\twhile ( remaining.Length > 0 )\n\t\t{\n\t\t\tvar newline = remaining.IndexOf( '\\n' );\n\t\t\tvar line = newline < 0 ? remaining : remaining.Slice( 0, newline );\n\t\t\tremaining = newline < 0 ? default : remaining.Slice( newline + 1 );\n\n\t\t\tline = line.Trim();\n\n\t\t\tif ( line.Length == 0 || line[0] == '#' )\n\t\t\t\tcontinue;\n\n\t\t\tvar space = line.IndexOf( ' ' );\n\t\t\tvar head = space < 0 ? line : line.Slice( 0, space );\n\t\t\tvar rest = space < 0 ? default : line.Slice( space + 1 );\n\n\t\t\tswitch ( head[0] )\n\t\t\t{\n\t\t\t\tcase 'v':\n\t\t\t\t{\n\t\t\t\t\tif ( head.Length == 2 )\n\t\t\t\t\t{\n\t\t\t\t\t\t// vt: writer flips V on the way out; this un-flips it. External OBJs\n\t\t\t\t\t\t// (bottom-left origin) land in Effigy space the same way. vn (normals) is\n\t\t\t\t\t\t// skipped, as it always was.\n\t\t\t\t\t\tif ( head[1] == 't' )\n\t\t\t\t\t\t\tdata.UVs.Add( new Vec2( NextFloat( ref rest ), 1f - NextFloat( ref rest ) ) );\n\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tdata.Positions.Add( new Vec3(\n\t\t\t\t\t\tNextFloat( ref rest ),\n\t\t\t\t\t\tNextFloat( ref rest ),\n\t\t\t\t\t\tNextFloat( ref rest ) ) );\n\n\t\t\t\t\tif ( CountTokens( rest ) >= 3 )\n\t\t\t\t\t{\n\t\t\t\t\t\tdata.SawColor = true;\n\t\t\t\t\t\tdata.Colors.Add( new Vec4(\n\t\t\t\t\t\t\tNextFloat( ref rest ),\n\t\t\t\t\t\t\tNextFloat( ref rest ),\n\t\t\t\t\t\t\tNextFloat( ref rest ),\n\t\t\t\t\t\t\t1f ) );\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tdata.Colors.Add( new Vec4( 1f, 1f, 1f, 1f ) );\n\t\t\t\t\t}\n\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tcase 'f':\n\t\t\t\t{\n\t\t\t\t\tvar n = CountTokens( rest );\n\n\t\t\t\t\tif ( n < 3 )\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\tvar indices = new int[n];\n\t\t\t\t\tvar faceUVs = new Vec2[n];\n\n\t\t\t\t\tfor ( var i = 0; i < n; i++ )\n\t\t\t\t\t{\n\t\t\t\t\t\tNextToken( ref rest, out var corner );\n\n\t\t\t\t\t\tvar slash = corner.IndexOf( '/' );\n\t\t\t\t\t\tindices[i] = ObjIndex( slash < 0 ? corner : corner.Slice( 0, slash ), data.Positions.Count );\n\n\t\t\t\t\t\tif ( slash >= 0 )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar after = corner.Slice( slash + 1 );\n\t\t\t\t\t\t\tvar second = after.IndexOf( '/' );\n\t\t\t\t\t\t\tvar vt = second < 0 ? after : after.Slice( 0, second );\n\n\t\t\t\t\t\t\tif ( vt.Length > 0 && data.UVs.Count > 0 )\n\t\t\t\t\t\t\t\tfaceUVs[i] = data.UVs[ObjIndex( vt, data.UVs.Count )];\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tdata.Faces.Add( new ObjFace( indices, faceUVs, material, group ) );\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\t// BOTH MARKERS START A PIECE. Exporters disagree about which one means \"object\":\n\t\t\t\t// Blender writes `o` per object and `g` for its vertex groups, other tools only\n\t\t\t\t// ever write `g`. Treating either as a boundary is what makes the split work on\n\t\t\t\t// files this tool did not write, which is the only kind it gets.\n\t\t\t\tcase 'o':\n\t\t\t\tcase 'g':\n\t\t\t\t\tdata.GroupNames.Add( rest.Length > 0 ? JoinTokens( rest ) : \"\" );\n\t\t\t\t\tgroup = data.GroupNames.Count - 1;\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase 'u':\n\t\t\t\t\t// usemtl \u2014 the only u-keyword the reader acts on.\n\t\t\t\t\tif ( head.SequenceEqual( \"usemtl\".AsSpan() ) )\n\t\t\t\t\t\tmaterial = SlotFromMaterialName( NextToken( ref rest, out var name ) ? name.ToString() : \"\", material );\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\treturn data;\n\t}\n\n\t/// <summary>The next whitespace-delimited token, advancing <paramref name=\"s\"/> past it.</summary>\n\tstatic bool NextToken( ref ReadOnlySpan<char> s, out ReadOnlySpan<char> token )\n\t{\n\t\twhile ( s.Length > 0 && (s[0] == ' ' || s[0] == '\\t') )\n\t\t\ts = s.Slice( 1 );\n\n\t\tif ( s.Length == 0 )\n\t\t{\n\t\t\ttoken = default;\n\t\t\treturn false;\n\t\t}\n\n\t\tvar end = 0;\n\n\t\twhile ( end < s.Length && s[end] != ' ' && s[end] != '\\t' )\n\t\t\tend++;\n\n\t\ttoken = s.Slice( 0, end );\n\t\ts = s.Slice( end );\n\t\treturn true;\n\t}\n\n\t/// <summary>The next token as a float.</summary>\n\tstatic float NextFloat( ref ReadOnlySpan<char> s )\n\t{\n\t\tNextToken( ref s, out var token );\n\t\treturn ParseFloat( token );\n\t}\n\n\t/// <summary>\n\t/// A decimal float with no culture and no allocation.\n\t///\n\t/// The reader used <c>float.Parse</c>, which on a dense OBJ is the whole of the load time \u2014 a\n\t/// million vertices is a few million culture-aware, correctly-rounding parses into a general\n\t/// number machinery. OBJ numbers are plain decimals, so the digits are accumulated into an\n\t/// exact integer and scaled by a power of ten, which matches <c>float.Parse</c> on every value a\n\t/// Wavefront file actually contains and runs many times faster.\n\t/// </summary>\n\tstatic float ParseFloat( ReadOnlySpan<char> s )\n\t{\n\t\tvar i = 0;\n\t\tvar len = s.Length;\n\n\t\tvar negative = false;\n\n\t\tif ( i < len && (s[i] == '-' || s[i] == '+') )\n\t\t{\n\t\t\tnegative = s[i] == '-';\n\t\t\ti++;\n\t\t}\n\n\t\t// Integer digits, then fraction digits, accumulated into one integer. Stopped at 15 digits:\n\t\t// below 2^53 the double conversion is exact, and 15 significant figures is already more than\n\t\t// a float can carry, so nothing is lost. Digits past that only move the decimal point.\n\t\tconst long Cap = 1_000_000_000_000_000L;\n\t\tlong mantissa = 0;\n\t\tvar digits = 0;\n\t\tvar intDigits = 0;\n\n\t\twhile ( i < len && s[i] >= '0' && s[i] <= '9' )\n\t\t{\n\t\t\tif ( mantissa < Cap )\n\t\t\t{\n\t\t\t\tmantissa = mantissa * 10 + (s[i] - '0');\n\t\t\t\tdigits++;\n\t\t\t}\n\n\t\t\tintDigits++;\n\t\t\ti++;\n\t\t}\n\n\t\tif ( i < len && s[i] == '.' )\n\t\t{\n\t\t\ti++;\n\n\t\t\twhile ( i < len && s[i] >= '0' && s[i] <= '9' )\n\t\t\t{\n\t\t\t\tif ( mantissa < Cap )\n\t\t\t\t{\n\t\t\t\t\tmantissa = mantissa * 10 + (s[i] - '0');\n\t\t\t\t\tdigits++;\n\t\t\t\t}\n\n\t\t\t\ti++;\n\t\t\t}\n\t\t}\n\n\t\t// Where the decimal point sits relative to the accumulated digits.\n\t\tvar exponent = intDigits - digits;\n\n\t\tif ( i < len && (s[i] == 'e' || s[i] == 'E') )\n\t\t{\n\t\t\ti++;\n\n\t\t\tvar expNegative = false;\n\n\t\t\tif ( i < len && (s[i] == '-' || s[i] == '+') )\n\t\t\t{\n\t\t\t\texpNegative = s[i] == '-';\n\t\t\t\ti++;\n\t\t\t}\n\n\t\t\tvar e = 0;\n\n\t\t\twhile ( i < len && s[i] >= '0' && s[i] <= '9' )\n\t\t\t{\n\t\t\t\te = e * 10 + (s[i] - '0');\n\t\t\t\ti++;\n\t\t\t}\n\n\t\t\texponent += expNegative ? -e : e;\n\t\t}\n\n\t\tvar value = (double)mantissa * Pow10( exponent );\n\n\t\treturn (float)(negative ? -value : value);\n\t}\n\n\tstatic readonly double[] Pow10Positive = BuildPow10();\n\n\tstatic double[] BuildPow10()\n\t{\n\t\tvar table = new double[39];\n\t\ttable[0] = 1.0;\n\n\t\tfor ( var i = 1; i < table.Length; i++ )\n\t\t\ttable[i] = table[i - 1] * 10.0;\n\n\t\treturn table;\n\t}\n\n\t/// <summary>10 to the power of <paramref name=\"e\"/>, from a table for the exponents a mesh\n\t/// coordinate uses and <see cref=\"Math.Pow\"/> for the rest.</summary>\n\tstatic double Pow10( int e )\n\t{\n\t\tif ( e >= 0 && e < Pow10Positive.Length )\n\t\t\treturn Pow10Positive[e];\n\n\t\tif ( e < 0 && -e < Pow10Positive.Length )\n\t\t\treturn 1.0 / Pow10Positive[-e];\n\n\t\treturn Math.Pow( 10.0, e );\n\t}\n\n\t/// <summary>How many whitespace-delimited tokens remain.</summary>\n\tstatic int CountTokens( ReadOnlySpan<char> s )\n\t{\n\t\tvar count = 0;\n\n\t\twhile ( NextToken( ref s, out _ ) )\n\t\t\tcount++;\n\n\t\treturn count;\n\t}\n\n\t/// <summary>The remaining tokens rejoined with single spaces \u2014 what string.Join over the split\n\t/// produced before.</summary>\n\tstatic string JoinTokens( ReadOnlySpan<char> s )\n\t{\n\t\tvar builder = new StringBuilder();\n\n\t\twhile ( NextToken( ref s, out var token ) )\n\t\t{\n\t\t\tif ( builder.Length > 0 )\n\t\t\t\tbuilder.Append( ' ' );\n\n\t\t\tbuilder.Append( token );\n\t\t}\n\n\t\treturn builder.ToString();\n\t}\n\n\t/// <summary>Wavefront indices are 1-based, and negative counts back from the last element.</summary>\n\tstatic int ObjIndex( ReadOnlySpan<char> token, int count )\n\t{\n\t\tvar n = ParseInt( token );\n\t\treturn n > 0 ? n - 1 : count + n;\n\t}\n\n\t/// <summary>A plain base-10 integer, no culture, no allocation. Face indices only, so small.</summary>\n\tstatic int ParseInt( ReadOnlySpan<char> s )\n\t{\n\t\tvar i = 0;\n\t\tvar negative = false;\n\n\t\tif ( i < s.Length && (s[i] == '-' || s[i] == '+') )\n\t\t{\n\t\t\tnegative = s[i] == '-';\n\t\t\ti++;\n\t\t}\n\n\t\tvar value = 0;\n\n\t\twhile ( i < s.Length )\n\t\t{\n\t\t\tvalue = value * 10 + (s[i] - '0');\n\t\t\ti++;\n\t\t}\n\n\t\treturn negative ? -value : value;\n\t}\n\n\t/// <summary><see cref=\"ObjWriter.DefaultMaterialName\"/> is <c>material_N</c>; anything else\n\t/// leaves the current slot alone rather than inventing one.</summary>\n\tstatic int SlotFromMaterialName( string name, int current )\n\t{\n\t\tconst string prefix = \"material_\";\n\n\t\tif ( name.StartsWith( prefix, StringComparison.OrdinalIgnoreCase )\n\t\t\t&& int.TryParse( name.AsSpan( prefix.Length ), NumberStyles.Integer, CultureInfo.InvariantCulture, out var slot )\n\t\t\t&& slot >= 0 )\n\t\t\treturn slot;\n\n\t\treturn current;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/PaintStroke.cs",
"FileName": "PaintStroke.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\n\nnamespace Effigy;\n\n/// <summary>\n/// One point on a paint stroke's path: where the brush touched, and which way the surface faced\n/// there.\n///\n/// WHY THE NORMAL IS STORED BESIDE THE POSITION. A paint dab is a sphere in object space, and a\n/// sphere at a point on a thin wall reaches both sides of the wall \u2014 paint it from one side and the\n/// far face gets painted too, which reads as paint on the wrong surface rather than as thin\n/// geometry. Recording the normal lets the replay reject faces that point away from the brush, the\n/// same guard NormalBake gives the bake with its MaxDistance, and the same failure when it is\n/// skipped.\n/// </summary>\npublic readonly struct PaintStrokePoint\n{\n\tpublic readonly Vec3 Position;\n\tpublic readonly Vec3 Normal;\n\n\tpublic PaintStrokePoint( Vec3 position, Vec3 normal )\n\t{\n\t\tPosition = position;\n\t\tNormal = normal;\n\t}\n}\n\n/// <summary>\n/// One stroke of paint, stored as a PATH rather than as a list of dabs.\n///\n/// WHY A PATH. A dab list at ten floats a dab grows without bound and bloats the hand-written text\n/// format; a stroke stores its settings once and a handful of path points, then regenerates its dabs\n/// deterministically along the path on replay, by the same spacing rule SculptSession already uses\n/// to fill in a fast drag. That smallness is what lets paint live in the .effigy text beside the\n/// feature tree instead of in a binary side-car \u2014 and what makes it diff, which for somebody's model\n/// matters more than it sounds.\n/// </summary>\npublic sealed class PaintStroke\n{\n\t/// <summary>Brush colour, straight RGBA in 0..1.</summary>\n\tpublic float R = 1f;\n\tpublic float G = 1f;\n\tpublic float B = 1f;\n\tpublic float A = 1f;\n\n\t/// <summary>Brush radius, in world units.</summary>\n\tpublic float Radius = 0.1f;\n\n\t/// <summary>How hard the stroke presses, 0..1.</summary>\n\tpublic float Strength = 1f;\n\n\t/// <summary>Reuses <see cref=\"BrushFalloff\"/> \u2014 Smooth / Linear / Sharp / Constant. Deliberately\n\t/// not a second enum: the replay weights dabs through <see cref=\"Brush.Falloff\"/> and two copies\n\t/// of the same list is the thing that drifts.</summary>\n\tpublic BrushFalloff Falloff = BrushFalloff.Smooth;\n\n\t/// <summary>Distance between dabs along the path, as a fraction of <see cref=\"Radius\"/>.</summary>\n\tpublic float Spacing = 0.5f;\n\n\t/// <summary>\n\t/// Whether this stroke TAKES paint away rather than laying it down.\n\t///\n\t/// AN ERASE IS A STROKE, NOT AN EDIT TO THE CANVAS. The canvas is a derived artifact replayed\n\t/// from this list on every rebuild, so an erase that scrubbed texels directly would be undone by\n\t/// the next rebuild \u2014 the paint would come back on its own, which is worse than having no eraser\n\t/// at all. Recorded here it sits in the log in the order the hand made it, and colour blending\n\t/// does not commute, so that order is the whole point.\n\t///\n\t/// THE COLOUR FIELDS ARE STILL CARRIED AND STILL MEAN NOTHING HERE. An erase subtracts its\n\t/// coverage from the canvas's alpha and never reads R/G/B. They stay rather than moving to a\n\t/// second type so that one writer and one reader serve both kinds \u2014 see StudioDocument, where an\n\t/// erase is a different LINE KIND rather than a ninth header field, because appending a field\n\t/// would shift the path every older document parses from.\n\t/// </summary>\n\tpublic bool Erase;\n\n\t/// <summary>The path, in the order it was painted, each point carrying a position and the\n\t/// surface normal there. Order is the whole point: strokes are a log and colour blending does\n\t/// not commute.</summary>\n\tpublic readonly List<PaintStrokePoint> Path = new();\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/Rig/SkeletonRetarget.cs",
"FileName": "SkeletonRetarget.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// <summary>\n/// Move a skinned mesh from one skeleton onto another.\n///\n/// WHAT THIS IS FOR. A playermodel is a mesh bound to CITIZEN'S skeleton, in citizen's bind pose,\n/// because that is what citizen's animations were authored against and they bind by bone name. A\n/// model rigged in Effigy - or imported from anywhere else - has its own bones with its own names\n/// in its own pose. Renaming the bones is the obvious move and it is wrong: the animation supplies\n/// a rotation relative to the bind pose it was authored against, so feeding it a different bind\n/// pose bends every joint by the difference. Citizen stands in an A-pose with its arms ~40 degrees\n/// below horizontal; hand a T-posed rig citizen's walk cycle and the arms go through the floor.\n///\n/// TWO WAYS TO MAKE THE TWO AGREE, and they are not interchangeable. <see cref=\"To\"/> moves the\n/// MESH onto the target's skeleton, so the model takes on the target's proportions.\n/// <see cref=\"Fit\"/> moves the target's SKELETON into the mesh, so the model keeps its own. Which\n/// one a model wants depends on whether it is meant to look like itself.\n///\n/// For <see cref=\"To\"/>: for each source bone this builds the transform that takes its bind pose\n/// to the target bone's bind pose, and applies that to the vertices it influences, blended by the\n/// same weights that will skin it afterwards. The result is the same mesh standing in the target's\n/// pose, bound to the target's bones - which is exactly the situation the target's animations\n/// expect. It is the operation a DCC tool calls \"bind to a new skeleton\", written out.\n///\n/// LINEAR BLEND, AND ITS ONE KNOWN FAILURE. A vertex influenced by several bones is transformed by\n/// each and averaged. That is the same maths skinning itself uses, so the result is consistent\n/// with how the model will actually deform - and it inherits the same candy-wrapper pinching where\n/// two bones differ by a large twist. The joints that move most here are shoulders and elbows,\n/// which is where it will show first if it shows at all.\n///\n/// TWO KINDS OF CORRESPONDENCE, AND CONFLATING THEM TEARS THE MESH. Mapping `upperarm_L` onto\n/// `arm_upper_L` means \"these are the same bone\": the source bone's bind position SNAPS onto the\n/// target's, which is how the model takes on the target's proportions, and is the whole point.\n/// Mapping a brow bone onto `head` means something else entirely - citizen has no brow, and the\n/// geometry should simply ride along with the head where it already is. Snapping it would drag the\n/// brow into the middle of the skull. Same for a fifth finger onto a four-fingered hand.\n///\n/// So a source bone can be named as RIDE-ALONG: its weight is rebound to the target bone, but its\n/// geometry moves by the nearest true correspondence above it instead of by its own. The cost of\n/// getting this wrong is not subtle - it was long spikes radiating out of the Gearhead's face and\n/// fingers stretched onto the wrong knuckles.\n///\n/// THE MAP IS THE CALLER'S PROBLEM, deliberately. Guessing that `upperarm_L` means `arm_upper_L`\n/// is a heuristic that will be wrong on somebody's rig, silently, in a way that looks like bad\n/// weights rather than a bad guess. So the correspondence is supplied and this reports exactly\n/// what it could not place.\n/// </summary>\npublic static class SkeletonRetarget\n{\n\t/// <summary>What a retarget produced, and everything that did not survive it cleanly.</summary>\n\tpublic sealed class Result\n\t{\n\t\t/// <summary>The mesh, skinned to the target's bones - moved into the target's bind pose by\n\t\t/// <see cref=\"To\"/>, left exactly where it was by <see cref=\"Fit\"/>.</summary>\n\t\tpublic PolyMesh Mesh;\n\n\t\t/// <summary>The skeleton <see cref=\"Mesh\"/> is bound to: the target itself after\n\t\t/// <see cref=\"To\"/>, the target fitted into the mesh after <see cref=\"Fit\"/>. Export this,\n\t\t/// not the target you passed in.</summary>\n\t\tpublic Skeleton Skeleton;\n\n\t\t/// <summary>Source bone names with no entry in the map, or an entry naming a bone the\n\t\t/// target does not have. Their weight had nowhere to go.</summary>\n\t\tpublic List<string> Unmapped = new();\n\n\t\t/// <summary>Vertices that lost some weight because of <see cref=\"Unmapped\"/>, and were\n\t\t/// renormalised over whatever was left.</summary>\n\t\tpublic int VerticesRenormalised;\n\n\t\t/// <summary>Vertices with no mapped influence at all. These could not be moved and were\n\t\t/// left where they were - the one failure that shows up as geometry in the wrong\n\t\t/// place.</summary>\n\t\tpublic int VerticesStranded;\n\n\t\t/// <summary>Target bones nothing was weighted onto. Expected rather than alarming: citizen\n\t\t/// carries twists, helpers and IK targets that the anim graph drives and no mesh is bound\n\t\t/// to.</summary>\n\t\tpublic int TargetBonesUnused;\n\t}\n\n\t/// <summary>\n\t/// Retarget <paramref name=\"mesh\"/> from <paramref name=\"from\"/> onto <paramref name=\"to\"/>.\n\t///\n\t/// <paramref name=\"map\"/> is keyed by SOURCE bone name and names a target bone. Several source\n\t/// bones may name the same target - a five-fingered hand onto citizen's four is exactly that -\n\t/// and their weights are summed on the way through.\n\t///\n\t/// <paramref name=\"rideAlong\"/> names source bones that have no true counterpart and should\n\t/// keep their place rather than snapping onto the bone they are bound to - see the note above.\n\t///\n\t/// <paramref name=\"adoptRoll\"/> takes the target bone's frame whole instead of keeping the\n\t/// source's roll - see <see cref=\"Land\"/> for why that is not the default. Kept as a switch\n\t/// because the two only differ once something animates, and the only way to know which a given\n\t/// pair of skeletons wants is to bake both and look.\n\t///\n\t/// The mesh is not modified; the result carries a clone.\n\t/// </summary>\n\tpublic static Result To( PolyMesh mesh, Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map,\n\t\tIReadOnlyCollection<string> rideAlong = null, bool adoptRoll = false )\n\t{\n\t\tArgumentNullException.ThrowIfNull( mesh );\n\t\tArgumentNullException.ThrowIfNull( from );\n\t\tArgumentNullException.ThrowIfNull( to );\n\t\tArgumentNullException.ThrowIfNull( map );\n\n\t\tif ( !mesh.IsRigged )\n\t\t\tthrow new ArgumentException( \"The mesh has no skin weights - there is nothing to retarget.\", nameof( mesh ) );\n\n\t\tvar result = new Result { Mesh = mesh.Clone(), Skeleton = to };\n\n\t\t// Per source bone: where it maps to, and the transform from its bind pose to that one.\n\t\tvar target = Resolve( from, to, map, result );\n\t\tvar delta = new Xform[from.Count];\n\n\t\tfor ( var b = 0; b < from.Count; b++ )\n\t\t{\n\t\t\tvar t = target[b];\n\n\t\t\tif ( t < 0 )\n\t\t\t\tcontinue;\n\n\t\t\t// Source bind pose to target bind pose, both in model space. Undo where the source\n\t\t\t// bone stands, then stand where the target one does - but with the source's own roll\n\t\t\t// carried across rather than the target's. See Land.\n\t\t\tdelta[b] = (adoptRoll ? to.WorldBind( t )\n\t\t\t\t: Land( from.WorldBind( b ), from.BoneDirection( b ), to.WorldBind( t ), to.BoneDirection( t ) ))\n\t\t\t\t* from.WorldBind( b ).Inverse;\n\t\t}\n\n\t\t// Ride-along bones keep the weight they were given but borrow their MOVEMENT from the\n\t\t// nearest ancestor that is a real correspondence, so their geometry stays where it sits\n\t\t// relative to that bone instead of collapsing onto the one it is now bound to. Walked\n\t\t// child-last so a chain of them (a whole pinky) all reach the same real ancestor.\n\t\tif ( rideAlong is { Count: > 0 } )\n\t\t{\n\t\t\t// Its own set, case-insensitively, because the map is matched that way and a caller\n\t\t\t// handing this in as IReadOnlyCollection would otherwise get LINQ's ordinal Contains\n\t\t\t// no matter what comparer their set was built with - a silent miss that looks exactly\n\t\t\t// like a missing entry.\n\t\t\tvar passengers = new HashSet<string>( rideAlong, StringComparer.OrdinalIgnoreCase );\n\t\t\tvar passenger = new bool[from.Count];\n\n\t\t\tfor ( var b = 0; b < from.Count; b++ )\n\t\t\t\tpassenger[b] = target[b] >= 0 && passengers.Contains( from.Bones[b].Name );\n\n\t\t\tfor ( var b = 0; b < from.Count; b++ )\n\t\t\t{\n\t\t\t\tif ( !passenger[b] )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar anchor = from.Bones[b].Parent;\n\n\t\t\t\twhile ( anchor >= 0 && (passenger[anchor] || target[anchor] < 0) )\n\t\t\t\t\tanchor = from.Bones[anchor].Parent;\n\n\t\t\t\tif ( anchor >= 0 )\n\t\t\t\t\tdelta[b] = delta[anchor];\n\t\t\t\telse\n\t\t\t\t\tresult.Unmapped.Add( $\"{from.Bones[b].Name} rides along but has no anchored ancestor\" );\n\t\t\t}\n\t\t}\n\n\t\tRebind( result, from.Count, to.Count, target, delta );\n\t\treturn result;\n\t}\n\n\t/// <summary>\n\t/// The other way round from <see cref=\"To\"/>: move the TARGET skeleton into the mesh and leave\n\t/// the mesh exactly where it is.\n\t///\n\t/// WHY THIS EXISTS. <see cref=\"To\"/> snaps every joint of the mesh onto the target's joints, so\n\t/// the model takes on the target's proportions. On a body that is a different shape that is a\n\t/// mangle: each bone's geometry moves rigidly to where the target's bone starts - citizen's\n\t/// upper arm is 10in against the Gearhead's 7.7 - and the skin bridges every gap by stretching.\n\t/// The Gearhead came out long-limbed and pinched at the joints with nothing wrong in its\n\t/// weights.\n\t///\n\t/// So here the bones move instead. Every target bone with a source counterpart stands on that\n\t/// bone's head; the ones with none - twists, helpers, IK - ride their parent, turned and\n\t/// stretched with it. Each bone keeps the TARGET's frame convention, swung by the shortest arc\n\t/// onto the direction the source limb actually runs: citizen's +X still runs down the bone and\n\t/// its roll is still citizen's. That is what lets citizen's clips drive it. A clip is local\n\t/// rotations in citizen's convention, so a bone whose axes mean what citizen's mean ends up\n\t/// pointing where citizen's would, and the mesh hanging off it follows. A T-posed arm on an\n\t/// A-posed skeleton is just a larger swing at the shoulder.\n\t///\n\t/// WHICH WAY A BONE RUNS is named by <paramref name=\"aims\"/> - target bone name to the target\n\t/// bones it points at, averaged - not guessed. \"Furthest child\" is wrong on citizen twice over:\n\t/// spine_2's children include both clavicles, and the hand's furthest child is an IK rule bone\n\t/// forty inches away. A bone with no aim, or none that landed anywhere, keeps its parent's\n\t/// swing, which is right for a head, a fingertip, or anything rigid on its parent.\n\t///\n\t/// THE CLIPS STILL CARRY CITIZEN'S LENGTHS. Local translation is part of every clip, and a\n\t/// fitted bone played at citizen's translations is pulled straight back to citizen's\n\t/// proportions. The model that wears this must mark those bones ignore_Translation - see\n\t/// <see cref=\"CitizenBoneMap.KeepsOwnLength\"/>.\n\t///\n\t/// Weights are rebound by the same map as <see cref=\"To\"/>. Ride-along bones place a target\n\t/// bone only where no true correspondence does - the Gearhead's lids hinge where its own eyelid\n\t/// bones are because nothing else claims citizen's.\n\t/// </summary>\n\tpublic static Result Fit( PolyMesh mesh, Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map,\n\t\tIReadOnlyCollection<string> rideAlong, IReadOnlyDictionary<string, string[]> aims )\n\t{\n\t\tArgumentNullException.ThrowIfNull( mesh );\n\t\tArgumentNullException.ThrowIfNull( from );\n\t\tArgumentNullException.ThrowIfNull( to );\n\t\tArgumentNullException.ThrowIfNull( map );\n\t\tArgumentNullException.ThrowIfNull( aims );\n\n\t\tif ( !mesh.IsRigged )\n\t\t\tthrow new ArgumentException( \"The mesh has no skin weights - there is nothing to retarget.\", nameof( mesh ) );\n\n\t\tvar result = new Result { Mesh = mesh.Clone() };\n\t\tvar target = Resolve( from, to, map, result );\n\t\tvar passengers = new HashSet<string>( rideAlong ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase );\n\n\t\t// Which source bone's head each target bone stands on. True correspondences first, then\n\t\t// passengers for whatever is still free; within each, the bone nearest the root wins, so\n\t\t// `chest` and `spine_03` both naming spine_2 cannot fight over it.\n\t\tvar placedBy = new int[to.Count];\n\t\tArray.Fill( placedBy, -1 );\n\n\t\tforeach ( var passes in new[] { false, true } )\n\t\t{\n\t\t\tfor ( var b = 0; b < from.Count; b++ )\n\t\t\t{\n\t\t\t\tvar t = target[b];\n\n\t\t\t\tif ( t >= 0 && placedBy[t] < 0 && passengers.Contains( from.Bones[b].Name ) == passes )\n\t\t\t\t\tplacedBy[t] = b;\n\t\t\t}\n\t\t}\n\n\t\tvar bind = new Xform[to.Count];\n\t\tvar at = new Vec3[to.Count];\n\n\t\tfor ( var t = 0; t < to.Count; t++ )\n\t\t{\n\t\t\tbind[t] = to.WorldBind( t );\n\n\t\t\tif ( placedBy[t] >= 0 )\n\t\t\t\tat[t] = from.WorldBind( placedBy[t] ).Origin;\n\t\t}\n\n\t\t// Parents before children, which the skeleton guarantees: an unplaced bone needs its\n\t\t// parent's final swing and stretch, and an aim only ever reads bones placed up front.\n\t\tvar swing = new Xform[to.Count];\n\t\tvar stretch = new float[to.Count];\n\t\tvar fitted = new Xform[to.Count];\n\n\t\tfor ( var t = 0; t < to.Count; t++ )\n\t\t{\n\t\t\tvar parent = to.Bones[t].Parent;\n\n\t\t\tswing[t] = parent < 0 ? Xform.Identity : swing[parent];\n\t\t\tstretch[t] = parent < 0 ? 1f : stretch[parent];\n\n\t\t\t// Nothing stands here: carry the target's own offset from the parent, turned and\n\t\t\t// scaled the way the parent was, so a twist halfway down a limb stays halfway down it.\n\t\t\tif ( placedBy[t] < 0 )\n\t\t\t{\n\t\t\t\tat[t] = parent < 0 ? bind[t].Origin\n\t\t\t\t\t: at[parent] + swing[parent].TransformDirection( bind[t].Origin - bind[parent].Origin ) * stretch[parent];\n\t\t\t}\n\n\t\t\tif ( aims.TryGetValue( to.Bones[t].Name, out var aimNames ) )\n\t\t\t{\n\t\t\t\tvar landed = aimNames.Select( to.IndexOf ).Where( a => a >= 0 && placedBy[a] >= 0 ).ToList();\n\n\t\t\t\tif ( landed.Count > 0 )\n\t\t\t\t{\n\t\t\t\t\tvar was = Centroid( landed.Select( a => bind[a].Origin ) ) - bind[t].Origin;\n\t\t\t\t\tvar now = Centroid( landed.Select( a => at[a] ) ) - at[t];\n\n\t\t\t\t\tif ( was.Length > 1e-4f && now.Length > 1e-4f )\n\t\t\t\t\t{\n\t\t\t\t\t\tswing[t] = Arc( was.Normal, now.Normal );\n\t\t\t\t\t\tstretch[t] = now.Length / was.Length;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar turned = swing[t] * new Xform( bind[t].X, bind[t].Y, bind[t].Z, Vec3.Zero );\n\t\t\tfitted[t] = new Xform( turned.X, turned.Y, turned.Z, at[t] );\n\t\t}\n\n\t\tvar skeleton = to.Clone();\n\n\t\tfor ( var t = 0; t < to.Count; t++ )\n\t\t{\n\t\t\tvar parent = to.Bones[t].Parent;\n\t\t\tskeleton.Bones[t].Local = parent < 0 ? fitted[t] : fitted[parent].Inverse * fitted[t];\n\t\t}\n\n\t\tresult.Skeleton = skeleton;\n\n\t\tRebind( result, from.Count, to.Count, target, delta: null );\n\t\treturn result;\n\t}\n\n\t/// <summary>Which target bone each source bone's weight goes to, or -1, with every miss\n\t/// recorded on <paramref name=\"result\"/>.</summary>\n\tstatic int[] Resolve( Skeleton from, Skeleton to, IReadOnlyDictionary<string, string> map, Result result )\n\t{\n\t\tvar target = new int[from.Count];\n\n\t\tfor ( var b = 0; b < from.Count; b++ )\n\t\t{\n\t\t\tvar name = from.Bones[b].Name;\n\n\t\t\tif ( !map.TryGetValue( name, out var wanted ) || string.IsNullOrEmpty( wanted ) )\n\t\t\t{\n\t\t\t\ttarget[b] = -1;\n\t\t\t\tresult.Unmapped.Add( name );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar t = to.IndexOf( wanted );\n\n\t\t\tif ( t < 0 )\n\t\t\t{\n\t\t\t\ttarget[b] = -1;\n\t\t\t\tresult.Unmapped.Add( $\"{name} -> {wanted} (the target has no such bone)\" );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\ttarget[b] = t;\n\t\t}\n\n\t\treturn target;\n\t}\n\n\t/// <summary>\n\t/// Put every vertex's weight onto the target bones, and move it by <paramref name=\"delta\"/>\n\t/// if there is one - <see cref=\"To\"/> moves the mesh, <see cref=\"Fit\"/> passes null and only\n\t/// renames what each vertex hangs off.\n\t/// </summary>\n\tstatic void Rebind( Result result, int fromCount, int toCount, int[] target, Xform[] delta )\n\t{\n\t\tvar used = new bool[toCount];\n\t\tvar skin = result.Mesh.Skin;\n\n\t\tfor ( var v = 0; v < result.Mesh.VertexCount; v++ )\n\t\t{\n\t\t\tvar weights = skin[v];\n\t\t\tvar kept = 0f;\n\n\t\t\tforeach ( var w in weights )\n\t\t\t{\n\t\t\t\tif ( w.Bone >= 0 && w.Bone < fromCount && target[w.Bone] >= 0 )\n\t\t\t\t\tkept += w.Weight;\n\t\t\t}\n\n\t\t\tif ( kept <= 1e-6f )\n\t\t\t{\n\t\t\t\t// Nothing this vertex is attached to went anywhere. Moving it by an arbitrary bone\n\t\t\t\t// would be a guess; leaving it is at least honest, and the count says how bad it is.\n\t\t\t\tresult.VerticesStranded++;\n\t\t\t\tskin[v] = Array.Empty<BoneWeight>();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( kept < 0.999f )\n\t\t\t\tresult.VerticesRenormalised++;\n\n\t\t\t// The vertex goes where its bones take it, averaged - the same blend that will skin it.\n\t\t\tvar p = result.Mesh.Positions[v];\n\t\t\tvar moved = Vec3.Zero;\n\t\t\tvar merged = new Dictionary<int, float>();\n\n\t\t\tforeach ( var w in weights )\n\t\t\t{\n\t\t\t\tif ( w.Bone < 0 || w.Bone >= fromCount || target[w.Bone] < 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar share = w.Weight / kept;\n\n\t\t\t\tif ( delta is not null )\n\t\t\t\t\tmoved += delta[w.Bone].TransformPoint( p ) * share;\n\n\t\t\t\tvar t = target[w.Bone];\n\t\t\t\tmerged[t] = merged.TryGetValue( t, out var had ) ? had + share : share;\n\t\t\t\tused[t] = true;\n\t\t\t}\n\n\t\t\tif ( delta is not null )\n\t\t\t\tresult.Mesh.Positions[v] = moved;\n\n\t\t\tskin[v] = merged.Select( kv => new BoneWeight( kv.Key, kv.Value ) )\n\t\t\t\t.OrderByDescending( w => w.Weight )\n\t\t\t\t.ToArray();\n\t\t}\n\n\t\tresult.TargetBonesUnused = used.Count( u => !u );\n\t\tresult.Unmapped = result.Unmapped.Distinct().ToList();\n\t}\n\n\tstatic Vec3 Centroid( IEnumerable<Vec3> points )\n\t{\n\t\tvar sum = Vec3.Zero;\n\t\tvar n = 0;\n\n\t\tforeach ( var p in points )\n\t\t{\n\t\t\tsum += p;\n\t\t\tn++;\n\t\t}\n\n\t\treturn n == 0 ? sum : sum / n;\n\t}\n\n\t/// <summary>The shortest rotation taking unit <paramref name=\"from\"/> onto unit\n\t/// <paramref name=\"to\"/>. Antiparallel has no shortest one, so it turns about any fixed\n\t/// perpendicular - stable, and no worse than every other choice.</summary>\n\tstatic Xform Arc( Vec3 from, Vec3 to )\n\t{\n\t\tvar axis = Vec3.Cross( from, to );\n\t\tvar sin = axis.Length;\n\t\tvar cos = Vec3.Dot( from, to );\n\n\t\tif ( sin >= 1e-6f )\n\t\t\treturn Xform.Rotate( axis, MathF.Atan2( sin, cos ) );\n\n\t\tif ( cos > 0f )\n\t\t\treturn Xform.Identity;\n\n\t\tvar seed = MathF.Abs( from.x ) < 0.9f ? new Vec3( 1, 0, 0 ) : new Vec3( 0, 1, 0 );\n\t\treturn Xform.Rotate( Vec3.Cross( from, seed ), MathF.PI );\n\t}\n\n\t/// <summary>\n\t/// Where a source bone should stand on the target skeleton: the target bone's position and\n\t/// direction, and the SOURCE bone's roll about that direction.\n\t///\n\t/// WHY NOT JUST TAKE THE TARGET'S FRAME. Aiming a bone head to tail pins two of its axes and\n\t/// leaves it free to spin about its own length, so every rig picks that roll by a convention\n\t/// of its own - citizen's came out of its fbx, a bone built by <see cref=\"Skeleton.AddBone\"/>\n\t/// from head and tail points gets whatever <c>PerpendicularAxes</c> seeded. Two skeletons\n\t/// agreeing perfectly on where every joint is can still disagree about roll on every single\n\t/// bone, by any angle up to 180 degrees. Adopting the target's roll then spins each limb's\n\t/// geometry about its own length by a different arbitrary amount, which does not look like a\n\t/// convention mismatch - it looks like the mesh has been through a shredder.\n\t///\n\t/// So the twist is dropped and only the swing is kept: the shortest rotation that takes the\n\t/// source bone's direction onto the target's, applied to the source's own cross-axes. Nothing\n\t/// is lost by it. The skin binds against the target's real bind pose either way, and at rest\n\t/// pose equals bind, so the vertex stays exactly where this puts it - the delta is ours to\n\t/// choose, and the choice that moves geometry least is the right one.\n\t///\n\t/// DIRECTION COMES FROM GEOMETRY, NOT THE FRAME. <paramref name=\"sourceDir\"/> and\n\t/// <paramref name=\"targetDir\"/> are the bones' head-to-child directions, not their +Y axes -\n\t/// a frame's +Y is only the bone direction for an Effigy-built skeleton, and citizen is\n\t/// +X-down-the-bone. Swinging .Y onto .Y across that convention boundary spins the mesh a\n\t/// quarter turn about every limb.\n\t/// </summary>\n\tstatic Xform Land( Xform source, Vec3 sourceDir, Xform target, Vec3 targetDir )\n\t{\n\t\tvar from = sourceDir.Normal;\n\t\tvar to = targetDir.Normal;\n\n\t\tvar axis = Vec3.Cross( from, to );\n\t\tvar sin = axis.Length;\n\t\tvar cos = Vec3.Dot( from, to );\n\n\t\t// Antiparallel has no shortest rotation - every half turn about a perpendicular axis is\n\t\t// equally short - so take the one about the source's own X, which is perpendicular by\n\t\t// construction and keeps the choice stable.\n\t\tvar swing = sin < 1e-6f\n\t\t\t? (cos > 0f ? Xform.Identity : Xform.Rotate( source.X, MathF.PI ))\n\t\t\t: Xform.Rotate( axis, MathF.Atan2( sin, cos ) );\n\n\t\t// Swing the WHOLE source frame, not just its cross-axes with the target direction bolted\n\t\t// into the +Y slot. Slotting the direction into +Y assumed the target's bone direction IS\n\t\t// its +Y, which is true of an Effigy skeleton and false of citizen's +X-down-the-bone - on\n\t\t// the identity retarget that single-slot form stopped being the source frame and moved the\n\t\t// mesh. Applying the swing to every axis keeps the source's roll and collapses to the\n\t\t// source frame (and so an identity delta) exactly when the two directions already agree.\n\t\treturn new Xform(\n\t\t\tswing.TransformDirection( source.X.Normal ),\n\t\t\tswing.TransformDirection( source.Y.Normal ),\n\t\t\tswing.TransformDirection( source.Z.Normal ),\n\t\t\ttarget.Origin );\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/Script/BuildScript.cs",
"FileName": "BuildScript.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Reflection;\nusing System.Text;\nusing Effigy.Render;\n\nnamespace Effigy;\n\n/// <summary>\n/// A build script: what to add to the studio, in order, in one text file that runs headlessly\n/// and in the editor alike.\n///\n/// # a comment\n/// add Primitive name=Torso shape=Box sizex=20 sizey=12 sizez=30 position=@ground+0,0,15\n/// add Part name=Buckle part=Buckle size=6 length=4 [email protected]+1,0,-6\n/// add Spline name=Cable [email protected];-10,4,20;-14,0,8 radius=0.8\n/// add CurveDeform bodies=Tail\n/// set Torso sizez=32\n/// fix Torso all\n/// render out/torso.png view=sheet\n/// describe\n/// match front reference.png out/match.png\n///\n/// `add TYPE` takes a feature type (Primitive, Spline, Part, Profile, Transform, Mirror,\n/// Subdivide\u2026) and `key=value` pairs naming its parameters by label or field. Anywhere a point\n/// goes, `@landmark` or `@landmark+dx,dy,dz` names a place on the model \u2014 a body's side, a\n/// bone, the ground \u2014 resolved by <see cref=\"ModelMeasure.TryLandmark\"/> against the model AS\n/// BUILT SO FAR, so a part can sit on the body the line before it made.\n///\n/// Every command runs a rebuild when it changes the model, and a feature that fails stops the\n/// script with the feature's own diagnostic \u2014 its cause and its remedies \u2014 so what comes back\n/// is what to change, not a stack trace.\n/// </summary>\npublic static class BuildScript\n{\n\tpublic sealed class Result\n\t{\n\t\tpublic bool Ok = true;\n\t\tpublic List<string> Log = new();\n\t\tpublic List<string> Written = new();\n\t\tpublic string Error;\n\t\tpublic int ErrorLine;\n\n\t\t/// <summary>Where each body turns or stands, by body name, set with `pivot`. Export moves the\n\t\t/// body so this point is its model origin, and the layout says where that point was.</summary>\n\t\tpublic Dictionary<string, Vec3> Pivots = new( StringComparer.OrdinalIgnoreCase );\n\n\t\tpublic override string ToString()\n\t\t{\n\t\t\tvar sb = new StringBuilder();\n\t\t\tforeach ( var line in Log ) sb.AppendLine( line );\n\t\t\tif ( Error is not null ) sb.AppendLine( $\"ERROR line {ErrorLine}: {Error}\" );\n\t\t\treturn sb.ToString().TrimEnd();\n\t\t}\n\t}\n\n\t/// <summary>Run <paramref name=\"text\"/> against <paramref name=\"studio\"/>. Files a `render`,\n\t/// `match` or `save` writes go under <paramref name=\"baseDir\"/> when their path is relative.</summary>\n\tpublic static Result Run( PartStudio studio, string text, string baseDir = null )\n\t{\n\t\tvar result = new Result();\n\t\tbaseDir ??= Directory.GetCurrentDirectory();\n\n\t\tif ( studio is null )\n\t\t{\n\t\t\tresult.Ok = false;\n\t\t\tresult.Error = \"No studio.\";\n\t\t\treturn result;\n\t\t}\n\n\t\tvar lines = (text ?? \"\").Replace( \"\\r\\n\", \"\\n\" ).Split( '\\n' );\n\n\t\tfor ( var i = 0; i < lines.Length; i++ )\n\t\t{\n\t\t\tvar line = lines[i].Trim();\n\n\t\t\tif ( line.Length == 0 || line.StartsWith( '#' ) )\n\t\t\t\tcontinue;\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( !Command( studio, Tokenise( line ), baseDir, result ) )\n\t\t\t\t{\n\t\t\t\t\tresult.Ok = false;\n\t\t\t\t\tresult.ErrorLine = i + 1;\n\t\t\t\t\treturn result;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\tresult.Ok = false;\n\t\t\t\tresult.ErrorLine = i + 1;\n\t\t\t\tresult.Error = e.Message;\n\t\t\t\treturn result;\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tstatic bool Command( PartStudio studio, List<string> tokens, string baseDir, Result r )\n\t{\n\t\tif ( tokens.Count == 0 )\n\t\t\treturn true;\n\n\t\tvar verb = tokens[0].ToLowerInvariant();\n\t\tvar args = tokens.Skip( 1 ).ToList();\n\n\t\tswitch ( verb )\n\t\t{\n\t\t\tcase \"add\":\n\t\t\t{\n\t\t\t\tif ( args.Count == 0 )\n\t\t\t\t\treturn Fail( r, \"add needs a feature type: add Primitive shape=Box \u2026\" );\n\n\t\t\t\tvar feature = Create( args[0] );\n\n\t\t\t\tif ( feature is null )\n\t\t\t\t\treturn Fail( r, $\"No feature type '{args[0]}'. Types: {string.Join( \", \", FeatureTypeNames() )}.\" );\n\n\t\t\t\tvar pairs = Pairs( args.Skip( 1 ) );\n\n\t\t\t\tif ( !Assign( studio, feature, pairs, r ) )\n\t\t\t\t\treturn false;\n\n\t\t\t\tstudio.Add( feature );\n\t\t\t\treturn RebuildAndCheck( studio, feature, r, $\"added {feature.TypeName} '{feature.Name ?? feature.Id}'\" );\n\t\t\t}\n\n\t\t\tcase \"set\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 2 )\n\t\t\t\t\treturn Fail( r, \"set needs a feature name and key=value pairs.\" );\n\n\t\t\t\tvar feature = FindFeature( studio, args[0] );\n\n\t\t\t\tif ( feature is null )\n\t\t\t\t\treturn Fail( r, $\"No feature called '{args[0]}'. Features: {string.Join( \", \", studio.Features.Select( f => f.Name ?? f.TypeName ) )}.\" );\n\n\t\t\t\tif ( !Assign( studio, feature, Pairs( args.Skip( 1 ) ), r ) )\n\t\t\t\t\treturn false;\n\n\t\t\t\tstudio.MarkDirty( feature );\n\t\t\t\treturn RebuildAndCheck( studio, feature, r, $\"set {feature.Name ?? feature.TypeName}\" );\n\t\t\t}\n\n\t\t\tcase \"remove\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 1 )\n\t\t\t\t\treturn Fail( r, \"remove needs a feature name.\" );\n\n\t\t\t\tvar feature = FindFeature( studio, args[0] );\n\n\t\t\t\tif ( feature is null )\n\t\t\t\t\treturn Fail( r, $\"No feature called '{args[0]}'.\" );\n\n\t\t\t\tstudio.Remove( feature );\n\t\t\t\tstudio.Rebuild();\n\t\t\t\tr.Log.Add( $\"removed {feature.Name ?? feature.TypeName}\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"rebuild\":\n\t\t\t\tstudio.Rebuild();\n\t\t\t\tr.Log.Add( \"rebuilt\" );\n\t\t\t\treturn true;\n\n\t\t\tcase \"fix\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 2 )\n\t\t\t\t\treturn Fail( r, \"fix needs a body (or all) and the fixes: fix Torso loose holes normals\" );\n\n\t\t\t\tvar targets = args[0].Equals( \"all\", StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t? studio.Bodies.ToList()\n\t\t\t\t\t: new List<Body> { ModelMeasure.FindBody( studio, args[0] ) };\n\n\t\t\t\tif ( targets.Count == 0 || targets[0] is null )\n\t\t\t\t\treturn Fail( r, $\"No body called '{args[0]}'. Bodies: {string.Join( \", \", studio.Bodies.Select( b => b.Name ?? b.Id ) )}.\" );\n\n\t\t\t\tforeach ( var body in targets )\n\t\t\t\t\tforeach ( var line in MeshFix.Apply( body.Mesh, args.Skip( 1 ) ) )\n\t\t\t\t\t\tr.Log.Add( $\"{body.Name ?? body.Id} {line}\" );\n\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"render\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 1 )\n\t\t\t\t\treturn Fail( r, \"render needs an output path: render out/model.png view=sheet\" );\n\n\t\t\t\tvar path = Resolve( baseDir, args[0] );\n\t\t\t\tvar opts = Pairs( args.Skip( 1 ) );\n\t\t\t\tvar viewName = opts.GetValueOrDefault( \"view\", \"sheet\" );\n\t\t\t\tvar size = (int)Number( opts.GetValueOrDefault( \"size\", \"512\" ) );\n\t\t\t\tvar options = new RenderOptions\n\t\t\t\t{\n\t\t\t\t\tWireframe = Truthy( opts.GetValueOrDefault( \"wire\", \"1\" ) ),\n\t\t\t\t\tBones = Truthy( opts.GetValueOrDefault( \"bones\", \"0\" ) ),\n\t\t\t\t\tLabels = Truthy( opts.GetValueOrDefault( \"labels\", \"1\" ) ),\n\t\t\t\t\tGrid = Truthy( opts.GetValueOrDefault( \"grid\", \"1\" ) ),\n\t\t\t\t\tFocusBodyId = opts.TryGetValue( \"focus\", out var focus ) ? ModelMeasure.FindBody( studio, focus )?.Id : null,\n\t\t\t\t};\n\n\t\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? \".\" );\n\n\t\t\t\tModelRender.Named( studio.Bodies, viewName, size, options, studio.Rig ).SavePng( path );\n\n\t\t\t\tr.Written.Add( path );\n\t\t\t\tr.Log.Add( $\"rendered {viewName} to {path}\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"describe\":\n\t\t\t\tr.Log.Add( ModelMeasure.Describe( studio ) );\n\t\t\t\treturn true;\n\n\t\t\tcase \"measure\":\n\t\t\tcase \"at\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 1 )\n\t\t\t\t\treturn Fail( r, \"measure needs a landmark or a point: measure Torso.top\" );\n\n\t\t\t\tif ( !Point( studio, args[0], out var p, out var why ) )\n\t\t\t\t\treturn Fail( r, why );\n\n\t\t\t\tr.Log.Add( ModelMeasure.At( studio, p ) );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"distance\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 2 )\n\t\t\t\t\treturn Fail( r, \"distance needs two landmarks: distance Hand_L Hand_R\" );\n\n\t\t\t\tif ( !Point( studio, args[0], out var a, out var whyA ) ) return Fail( r, whyA );\n\t\t\t\tif ( !Point( studio, args[1], out var b, out var whyB ) ) return Fail( r, whyB );\n\n\t\t\t\tvar d = b - a;\n\t\t\t\tr.Log.Add( $\"{args[0]} to {args[1]}: {d.Length:0.###} (dx {d.x:0.###}, dy {d.y:0.###}, dz {d.z:0.###})\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"symmetry\":\n\t\t\t{\n\t\t\t\tforeach ( var body in args.Count > 0 ? new[] { ModelMeasure.FindBody( studio, args[0] ) } : studio.Bodies.ToArray() )\n\t\t\t\t{\n\t\t\t\t\tif ( body?.Mesh is null ) continue;\n\t\t\t\t\tr.Log.Add( $\"{body.Name ?? body.Id}: left-right symmetry error {ModelMeasure.SymmetryError( body.Mesh ) * 100f:0.#}% of width\" );\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"match\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 2 )\n\t\t\t\t\treturn Fail( r, \"match needs a view and a reference PNG: match front ref.png [out.png]\" );\n\n\t\t\t\tvar view = RenderView.Named( args[0] );\n\n\t\t\t\tif ( view is null )\n\t\t\t\t\treturn Fail( r, $\"No view called '{args[0]}'.\" );\n\n\t\t\t\tvar refPath = Resolve( baseDir, args[1] );\n\n\t\t\t\tif ( !File.Exists( refPath ) )\n\t\t\t\t\treturn Fail( r, $\"No reference image at {refPath}.\" );\n\n\t\t\t\tconst int size = 256;\n\t\t\t\tvar (w, h, rgb, alpha) = Raster.LoadPng( refPath );\n\t\t\t\tvar reference = ReferenceMatch.ReferenceSilhouette( w, h, rgb, alpha, size, out var aspect );\n\t\t\t\tvar match = ReferenceMatch.Compare( studio.Bodies, view, reference, aspect, size );\n\n\t\t\t\tr.Log.Add( match.Report );\n\n\t\t\t\tif ( args.Count > 2 )\n\t\t\t\t{\n\t\t\t\t\tvar outPath = Resolve( baseDir, args[2] );\n\t\t\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( outPath ) ) ?? \".\" );\n\t\t\t\t\tmatch.Diff.SavePng( outPath );\n\t\t\t\t\tr.Written.Add( outPath );\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"save\":\n\t\t\t{\n\t\t\t\tif ( args.Count < 1 )\n\t\t\t\t\treturn Fail( r, \"save needs a path.\" );\n\n\t\t\t\tvar path = Resolve( baseDir, args[0] );\n\t\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( Path.GetFullPath( path ) ) ?? \".\" );\n\t\t\t\tStudioDocument.WriteFile( studio, path );\n\t\t\t\tr.Written.Add( path );\n\t\t\t\tr.Log.Add( $\"saved {path}\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"echo\":\n\t\t\t\tr.Log.Add( string.Join( \" \", args ) );\n\t\t\t\treturn true;\n\n\t\t\tcase \"material\":\n\t\t\t{\n\t\t\t\t// material 1 materials/alley/brass.vmat \u2014 what slot 1 is exported as.\n\t\t\t\tif ( args.Count < 2 || !int.TryParse( args[0], out var slot ) )\n\t\t\t\t\treturn Fail( r, \"material needs a slot number and a .vmat path: material 1 materials/alley/brass.vmat\" );\n\n\t\t\t\tstudio.MaterialNames[slot] = args[1];\n\t\t\t\tstudio.MarkDirty( 0 );\n\t\t\t\tr.Log.Add( $\"slot {slot} is {args[1]}\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"pivot\":\n\t\t\t{\n\t\t\t\t// pivot Bob3 @Beam.bottom \u2014 the point this part turns about, which export makes its origin.\n\t\t\t\tif ( args.Count < 2 )\n\t\t\t\t\treturn Fail( r, \"pivot needs a body and a point: pivot Arm @Hub.centre\" );\n\n\t\t\t\tif ( ModelMeasure.FindBody( studio, args[0] ) is null )\n\t\t\t\t\treturn Fail( r, $\"No body called '{args[0]}'.\" );\n\n\t\t\t\tif ( !Point( studio, args[1], out var p, out var why ) )\n\t\t\t\t\treturn Fail( r, why );\n\n\t\t\t\tr.Pivots[args[0]] = p;\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase \"export\":\n\t\t\t{\n\t\t\t\t// export models/civic_art/orrery split=1 assets=C:/proj/Assets name=orrery\n\t\t\t\tif ( args.Count < 1 )\n\t\t\t\t\treturn Fail( r, \"export needs a folder under the assets root: export models/civic_art/orrery [split=1] [assets=root] [name=x]\" );\n\n\t\t\t\tvar folder = args[0].Replace( '\\\\', '/' ).Trim( '/' );\n\t\t\t\tvar opts = Pairs( args.Skip( 1 ) );\n\t\t\t\tvar root = opts.TryGetValue( \"assets\", out var assets ) ? assets : baseDir;\n\t\t\t\tvar split = Truthy( opts.GetValueOrDefault( \"split\", \"0\" ) );\n\t\t\t\tvar name = opts.GetValueOrDefault( \"name\", folder.Contains( '/' ) ? folder[(folder.LastIndexOf( '/' ) + 1)..] : folder );\n\t\t\t\tvar dir = Path.Combine( root, folder );\n\t\t\t\tDirectory.CreateDirectory( dir );\n\n\t\t\t\tvar layout = new StringBuilder();\n\t\t\t\tlayout.AppendLine( \"# part\\tmodel\\tpivot x\\ty\\tz\\tsize x\\ty\\tz\" );\n\n\t\t\t\tvar bodies = studio.Bodies.Where( b => b?.Mesh is not null && b.Mesh.Faces.Count > 0 && b.Visible ).ToList();\n\n\t\t\t\tif ( bodies.Count == 0 )\n\t\t\t\t\treturn Fail( r, \"Nothing to export: no visible bodies.\" );\n\n\t\t\t\tif ( split )\n\t\t\t\t{\n\t\t\t\t\tforeach ( var body in bodies )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar partName = Safe( body.Name ?? body.Id );\n\t\t\t\t\t\tvar mesh = body.Mesh.Clone();\n\t\t\t\t\t\tvar (min, max) = ModelMeasure.Bounds( mesh );\n\t\t\t\t\t\tvar pivot = r.Pivots.TryGetValue( body.Name ?? \"\", out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z );\n\n\t\t\t\t\t\tMeshTransform.Apply( mesh, Xform.Translate( -pivot ) );\n\t\t\t\t\t\tWriteModel( studio, mesh, dir, folder, partName );\n\t\t\t\t\t\tlayout.AppendLine( $\"{body.Name ?? body.Id}\\t{folder}/{partName}.vmdl\\t{N( pivot.x )}\\t{N( pivot.y )}\\t{N( pivot.z )}\\t{N( max.x - min.x )}\\t{N( max.y - min.y )}\\t{N( max.z - min.z )}\" );\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tvar mesh = new PolyMesh();\n\t\t\t\t\tforeach ( var body in bodies )\n\t\t\t\t\t\tMeshTransform.Append( mesh, body.Mesh );\n\n\t\t\t\t\tvar (min, max) = ModelMeasure.Bounds( mesh );\n\t\t\t\t\tvar pivot = r.Pivots.TryGetValue( name, out var p ) ? p : new Vec3( (min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f, min.z );\n\t\t\t\t\tMeshTransform.Apply( mesh, Xform.Translate( -pivot ) );\n\t\t\t\t\tWriteModel( studio, mesh, dir, folder, Safe( name ) );\n\t\t\t\t\tlayout.AppendLine( $\"{name}\\t{folder}/{Safe( name )}.vmdl\\t{N( pivot.x )}\\t{N( pivot.y )}\\t{N( pivot.z )}\\t{N( max.x - min.x )}\\t{N( max.y - min.y )}\\t{N( max.z - min.z )}\" );\n\t\t\t\t}\n\n\t\t\t\tvar layoutPath = Path.Combine( dir, \"layout.txt\" );\n\t\t\t\tFile.WriteAllText( layoutPath, layout.ToString() );\n\t\t\t\tr.Written.Add( layoutPath );\n\t\t\t\tr.Log.Add( $\"exported {(split ? bodies.Count : 1)} model(s) to {dir}; layout in layout.txt\" );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tdefault:\n\t\t\t\treturn Fail( r, $\"Unknown command '{verb}'. Commands: add, set, remove, rebuild, fix, render, describe, measure, distance, symmetry, match, material, pivot, export, save, echo.\" );\n\t\t}\n\t}\n\n\t/// <summary>One .obj and the .vmdl that loads it, materials bound from the studio's slots.</summary>\n\tstatic void WriteModel( PartStudio studio, PolyMesh mesh, string dir, string folder, string name )\n\t{\n\t\tvar objPath = Path.Combine( dir, name + \".obj\" );\n\t\tObjWriter.WriteFile( mesh, objPath, name, materialName: studio.NameForSlot );\n\n\t\tvar materials = VmdlMaterials.GroupList( studio, mesh );\n\t\tFile.WriteAllText( Path.Combine( dir, name + \".vmdl\" ), VmdlDocument.Static( $\"{folder}/{name}.obj\", \"\", materials ) );\n\t}\n\n\tstatic string Safe( string name )\n\t{\n\t\tvar sb = new StringBuilder();\n\t\tforeach ( var c in (name ?? \"part\").Trim().ToLowerInvariant() )\n\t\t\tsb.Append( char.IsLetterOrDigit( c ) ? c : '_' );\n\t\treturn sb.Length == 0 ? \"part\" : sb.ToString();\n\t}\n\n\tstatic string N( float v ) => v.ToString( \"0.###\", CultureInfo.InvariantCulture );\n\n\tstatic bool RebuildAndCheck( PartStudio studio, Feature feature, Result r, string did )\n\t{\n\t\tstudio.Rebuild();\n\n\t\tif ( feature.Error is not null )\n\t\t{\n\t\t\tvar d = feature.Diagnostic;\n\t\t\tvar sb = new StringBuilder();\n\t\t\tsb.Append( $\"{feature.TypeName} '{feature.Name ?? feature.Id}' failed: {feature.Error}\" );\n\n\t\t\tif ( d is not null )\n\t\t\t{\n\t\t\t\tif ( !string.IsNullOrEmpty( d.Cause ) ) sb.Append( $\" Because: {d.Cause}\" );\n\t\t\t\tforeach ( var remedy in d.Remedies ) sb.Append( $\" Try: {remedy}.\" );\n\t\t\t}\n\n\t\t\treturn Fail( r, sb.ToString() );\n\t\t}\n\n\t\tr.Log.Add( did + (feature.Warning is not null ? $\" (note: {feature.Warning})\" : \"\" ) );\n\t\treturn true;\n\t}\n\n\tstatic bool Fail( Result r, string message )\n\t{\n\t\tr.Error = message;\n\t\treturn false;\n\t}\n\n\t// --- assigning parameters ---------------------------------------------------------------------\n\n\tstatic bool Assign( PartStudio studio, Feature feature, Dictionary<string, string> pairs, Result r )\n\t{\n\t\tforeach ( var (key, value) in pairs )\n\t\t{\n\t\t\tif ( key == \"name\" )\n\t\t\t{\n\t\t\t\tfeature.Name = value;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar target = FindParam( feature, key, out var field );\n\n\t\t\tif ( target is null && field is null )\n\t\t\t\treturn Fail( r, $\"{feature.TypeName} has no parameter '{key}'. It has: {string.Join( \", \", ParamNames( feature ) )}.\" );\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( target is not null && !AssignParam( studio, target, value, out var why ) )\n\t\t\t\t\treturn Fail( r, $\"{key}: {why}\" );\n\n\t\t\t\tif ( target is null && !AssignField( studio, feature, field, value, out var whyField ) )\n\t\t\t\t\treturn Fail( r, $\"{key}: {whyField}\" );\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\treturn Fail( r, $\"{key}: {e.Message}\" );\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\tstatic bool AssignParam( PartStudio studio, IParam param, string value, out string why )\n\t{\n\t\twhy = null;\n\n\t\tswitch ( param )\n\t\t{\n\t\t\tcase FloatParam f:\n\t\t\t\tif ( !TryNumber( value, out var fv ) ) { why = $\"'{value}' is not a number.\"; return false; }\n\t\t\t\tf.Value = fv;\n\t\t\t\tf.Expr = null;\n\t\t\t\treturn true;\n\n\t\t\tcase IntParam i:\n\t\t\t\tif ( !TryNumber( value, out var iv ) ) { why = $\"'{value}' is not a number.\"; return false; }\n\t\t\t\ti.Value = (int)MathF.Round( iv );\n\t\t\t\treturn true;\n\n\t\t\tcase BoolParam b:\n\t\t\t\tb.Value = Truthy( value );\n\t\t\t\treturn true;\n\n\t\t\tcase ChoiceParam c:\n\t\t\t{\n\t\t\t\tvar index = Array.FindIndex( c.Options, o => string.Equals( o, value, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t|| string.Equals( o.Replace( ' ', '_' ), value, StringComparison.OrdinalIgnoreCase ) );\n\n\t\t\t\t// \"Left\" for \"Left (y)\", \"box\" for \"Box\": the start of an option, then anywhere in it.\n\t\t\t\tif ( index < 0 )\n\t\t\t\t\tindex = Array.FindIndex( c.Options, o => o.StartsWith( value, StringComparison.OrdinalIgnoreCase ) );\n\n\t\t\t\tif ( index < 0 )\n\t\t\t\t\tindex = Array.FindIndex( c.Options, o => o.Contains( value, StringComparison.OrdinalIgnoreCase ) );\n\n\t\t\t\tif ( index < 0 && int.TryParse( value, out var n ) && n >= 0 && n < c.Options.Length )\n\t\t\t\t\tindex = n;\n\n\t\t\t\tif ( index < 0 ) { why = $\"'{value}' is not one of {string.Join( \", \", c.Options )}.\"; return false; }\n\t\t\t\tc.Index = index;\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase Vec3Param v:\n\t\t\t\tif ( !Point( studio, value, out var p, out why ) ) return false;\n\t\t\t\tv.Value = p;\n\t\t\t\treturn true;\n\n\t\t\tcase StringParam s:\n\t\t\t\ts.Value = value;\n\t\t\t\treturn true;\n\n\t\t\tcase BodySelectionParam bodies:\n\t\t\t{\n\t\t\t\tbodies.BodyIds.Clear();\n\n\t\t\t\tforeach ( var name in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) )\n\t\t\t\t{\n\t\t\t\t\tvar body = ModelMeasure.FindBody( studio, name.Trim() );\n\t\t\t\t\tif ( body is null ) { why = $\"No body called '{name.Trim()}'. Bodies: {string.Join( \", \", studio.Bodies.Select( b => b.Name ?? b.Id ) )}.\"; return false; }\n\t\t\t\t\tbodies.BodyIds.Add( body.Id );\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\twhy = $\"a {param.GetType().Name} cannot be set from a script.\";\n\t\treturn false;\n\t}\n\n\tstatic bool AssignField( PartStudio studio, Feature feature, FieldInfo field, string value, out string why )\n\t{\n\t\twhy = null;\n\t\tvar current = field.GetValue( feature );\n\n\t\tswitch ( current )\n\t\t{\n\t\t\tcase List<Vec3> points:\n\t\t\t{\n\t\t\t\tpoints.Clear();\n\n\t\t\t\tforeach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )\n\t\t\t\t{\n\t\t\t\t\tif ( !Point( studio, part.Trim(), out var p, out why ) ) return false;\n\t\t\t\t\tpoints.Add( p );\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase List<Vec2> pairs:\n\t\t\t{\n\t\t\t\tpairs.Clear();\n\n\t\t\t\tforeach ( var part in value.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )\n\t\t\t\t{\n\t\t\t\t\tvar xy = part.Split( ',' );\n\t\t\t\t\tif ( xy.Length != 2 || !TryNumber( xy[0], out var x ) || !TryNumber( xy[1], out var y ) ) { why = $\"'{part}' is not a pair a,b.\"; return false; }\n\t\t\t\t\tpairs.Add( new Vec2( x, y ) );\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase string:\n\t\t\t{\n\t\t\t\t// A field holding another feature's id \u2014 a path sketch, a spline \u2014 takes its name.\n\t\t\t\tvar other = FindFeature( studio, value );\n\t\t\t\tfield.SetValue( feature, other?.Id ?? value );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tcase List<string> texts:\n\t\t\t{\n\t\t\t\ttexts.Clear();\n\t\t\t\tforeach ( var part in value.Split( new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries ) )\n\t\t\t\t\ttexts.Add( FindFeature( studio, part.Trim() )?.Id ?? part.Trim() );\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\twhy = $\"a {field.FieldType.Name} cannot be set from a script.\";\n\t\treturn false;\n\t}\n\n\tstatic IParam FindParam( Feature feature, string key, out FieldInfo field )\n\t{\n\t\tfield = null;\n\t\tvar wanted = Normalise( key );\n\n\t\tforeach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ) )\n\t\t\tif ( Normalise( param.Label ) == wanted )\n\t\t\t\treturn param;\n\n\t\tforeach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) )\n\t\t{\n\t\t\tif ( Normalise( f.Name ) != wanted )\n\t\t\t\tcontinue;\n\n\t\t\tif ( f.GetValue( feature ) is IParam p )\n\t\t\t\treturn p;\n\n\t\t\tfield = f;\n\t\t\treturn null;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tstatic IEnumerable<string> ParamNames( Feature feature )\n\t{\n\t\tforeach ( var p in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() )\n\t\t\tyield return p.Label;\n\n\t\tforeach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) )\n\t\t\tif ( f.GetValue( feature ) is not IParam && f.Name is not (\"Id\" or \"Name\" or \"Suppressed\" or \"Visible\") )\n\t\t\t\tyield return f.Name;\n\t}\n\n\tstatic string Normalise( string s ) => (s ?? \"\").ToLowerInvariant().Replace( \" \", \"\" ).Replace( \"_\", \"\" ).Replace( \"'\", \"\" );\n\n\t// --- points and landmarks ---------------------------------------------------------------------\n\n\t/// <summary>`x,y,z`, `@landmark`, or `@landmark+dx,dy,dz` / `@landmark-dx,dy,dz`.</summary>\n\tpublic static bool Point( PartStudio studio, string text, out Vec3 point, out string why )\n\t{\n\t\twhy = null;\n\t\tpoint = Vec3.Zero;\n\t\ttext = (text ?? \"\").Trim();\n\n\t\tif ( !text.StartsWith( '@' ) )\n\t\t{\n\t\t\tif ( ModelMeasure.TryParseVec3( text, out point ) )\n\t\t\t\treturn true;\n\n\t\t\t// A bare name is a landmark too.\n\t\t\treturn ModelMeasure.TryLandmark( studio, text, out point, out why );\n\t\t}\n\n\t\tvar body = text[1..];\n\t\tvar offset = Vec3.Zero;\n\t\tvar plus = body.IndexOfAny( new[] { '+', '-' }, 1 );\n\n\t\tif ( plus > 0 )\n\t\t{\n\t\t\tvar sign = body[plus] == '-' ? -1f : 1f;\n\t\t\tvar offsetText = body[(plus + 1)..];\n\t\t\tbody = body[..plus];\n\n\t\t\tif ( !ModelMeasure.TryParseVec3( offsetText, out offset ) )\n\t\t\t{\n\t\t\t\twhy = $\"'{offsetText}' after the landmark is not an offset dx,dy,dz.\";\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\toffset *= sign;\n\t\t}\n\n\t\tif ( !ModelMeasure.TryLandmark( studio, body, out point, out why ) )\n\t\t\treturn false;\n\n\t\tpoint += offset;\n\t\treturn true;\n\t}\n\n\t// --- features by type and name ----------------------------------------------------------------\n\n\tstatic Feature Create( string typeName )\n\t{\n\t\tvar wanted = Normalise( typeName );\n\n\t\tforeach ( var type in FeatureTypes() )\n\t\t{\n\t\t\tvar name = Normalise( type.Name );\n\t\t\tvar bare = name.EndsWith( \"feature\" ) ? name[..^7] : name;\n\n\t\t\tif ( name == wanted || bare == wanted )\n\t\t\t\treturn (Feature)Activator.CreateInstance( type );\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tstatic IEnumerable<Type> FeatureTypes() => typeof( Feature ).Assembly.GetTypes()\n\t\t.Where( t => t.IsSubclassOf( typeof( Feature ) ) && !t.IsAbstract )\n\t\t.OrderBy( t => t.Name, StringComparer.Ordinal );\n\n\t/// <summary>A feature type's parameters, one per line, with what each takes \u2014 the help a script\n\t/// author reads before writing `add`.</summary>\n\tpublic static string Describe( string typeName )\n\t{\n\t\tvar feature = Create( typeName );\n\n\t\tif ( feature is null )\n\t\t\treturn $\"No feature type '{typeName}'. Types: {string.Join( \", \", FeatureTypeNames() )}.\";\n\n\t\tvar sb = new StringBuilder();\n\t\tsb.AppendLine( $\"{feature.TypeName} (add {typeName})\" );\n\n\t\tforeach ( var param in feature.Parameters.Concat( feature.AdvancedParameters ).Distinct() )\n\t\t{\n\t\t\tvar key = Normalise( param.Label );\n\t\t\tvar kind = param switch\n\t\t\t{\n\t\t\t\tFloatParam f => $\"number, default {f.Value}\" + (f.Min > float.MinValue ? $\", min {f.Min}\" : \"\") + (f.Max < float.MaxValue ? $\", max {f.Max}\" : \"\"),\n\t\t\t\tIntParam i => $\"whole number, default {i.Value}, {i.Min}..{i.Max}\",\n\t\t\t\tBoolParam b => $\"on/off, default {(b.Value ? \"on\" : \"off\")}\",\n\t\t\t\tChoiceParam c => $\"one of {string.Join( \"|\", c.Options )}, default {c.Value}\",\n\t\t\t\tVec3Param v => $\"point x,y,z or @landmark, default {v.Value.x},{v.Value.y},{v.Value.z}\",\n\t\t\t\tStringParam => \"text\",\n\t\t\t\tBodySelectionParam => \"body names, comma-separated (empty = every body)\",\n\t\t\t\t_ => param.GetType().Name,\n\t\t\t};\n\t\t\tsb.AppendLine( $\" {key} = {kind}\" );\n\t\t}\n\n\t\tforeach ( var f in feature.GetType().GetFields( BindingFlags.Public | BindingFlags.Instance ) )\n\t\t{\n\t\t\tif ( f.GetValue( feature ) is IParam || f.Name is \"Id\" or \"Name\" or \"Suppressed\" or \"Visible\" )\n\t\t\t\tcontinue;\n\n\t\t\tvar kind = f.FieldType == typeof( List<Vec3> ) ? \"points x,y,z;x,y,z;\u2026 (landmarks allowed)\"\n\t\t\t\t: f.FieldType == typeof( List<Vec2> ) ? \"pairs a,b;a,b;\u2026\"\n\t\t\t\t: f.FieldType == typeof( string ) ? \"a feature name\"\n\t\t\t\t: f.FieldType == typeof( List<string> ) ? \"feature names, comma-separated\"\n\t\t\t\t: null;\n\n\t\t\tif ( kind is not null )\n\t\t\t\tsb.AppendLine( $\" {Normalise( f.Name )} = {kind}\" );\n\t\t}\n\n\t\tsb.AppendLine( \" name = what to call it\" );\n\t\treturn sb.ToString().TrimEnd();\n\t}\n\n\tpublic static IEnumerable<string> FeatureTypeNames() =>\n\t\tFeatureTypes().Select( t => t.Name.EndsWith( \"Feature\" ) ? t.Name[..^7] : t.Name );\n\n\tstatic Feature FindFeature( PartStudio studio, string name )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( name ) )\n\t\t\treturn null;\n\n\t\tif ( name.StartsWith( '#' ) && int.TryParse( name[1..], out var index ) && index >= 1 && index <= studio.Features.Count )\n\t\t\treturn studio.Features[index - 1];\n\n\t\treturn studio.Features.FirstOrDefault( f => f.Id == name )\n\t\t\t?? studio.Features.LastOrDefault( f => string.Equals( f.Name, name, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t?? studio.Features.LastOrDefault( f => string.Equals( f.Name?.Replace( ' ', '_' ), name, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t?? studio.Features.LastOrDefault( f => string.Equals( f.TypeName, name, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t// --- lexing -------------------------------------------------------------------------------------\n\n\tstatic List<string> Tokenise( string line )\n\t{\n\t\tvar tokens = new List<string>();\n\t\tvar sb = new StringBuilder();\n\t\tvar quoted = false;\n\n\t\tforeach ( var c in line )\n\t\t{\n\t\t\tif ( c == '\"' ) { quoted = !quoted; continue; }\n\n\t\t\tif ( char.IsWhiteSpace( c ) && !quoted )\n\t\t\t{\n\t\t\t\tif ( sb.Length > 0 ) { tokens.Add( sb.ToString() ); sb.Clear(); }\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tsb.Append( c );\n\t\t}\n\n\t\tif ( sb.Length > 0 )\n\t\t\ttokens.Add( sb.ToString() );\n\n\t\treturn tokens;\n\t}\n\n\tstatic Dictionary<string, string> Pairs( IEnumerable<string> tokens )\n\t{\n\t\tvar pairs = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );\n\n\t\tforeach ( var token in tokens )\n\t\t{\n\t\t\tvar eq = token.IndexOf( '=' );\n\n\t\t\tif ( eq <= 0 )\n\t\t\t\tthrow new ArgumentException( $\"'{token}' is not key=value.\" );\n\n\t\t\tpairs[token[..eq].Trim().ToLowerInvariant()] = token[(eq + 1)..].Trim();\n\t\t}\n\n\t\treturn pairs;\n\t}\n\n\tstatic bool TryNumber( string text, out float value ) =>\n\t\tfloat.TryParse( (text ?? \"\").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value );\n\n\tstatic float Number( string text ) => TryNumber( text, out var v ) ? v : throw new ArgumentException( $\"'{text}' is not a number.\" );\n\n\tstatic bool Truthy( string text ) => (text ?? \"\").Trim().ToLowerInvariant() is \"1\" or \"true\" or \"on\" or \"yes\";\n\n\tstatic string Resolve( string baseDir, string path ) => Path.IsPathRooted( path ) ? path : Path.Combine( baseDir, path );\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/Sketch/SketchReference.cs",
"FileName": "SketchReference.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// <summary>\n/// Geometry the sketch does not own, projected into its plane so it can be seen and snapped to.\n///\n/// WHAT THIS IS FOR. A sketch on the face of an existing part is nearly always ABOUT that face \u2014\n/// a boss centred on it, a pocket set in from one of its corners, a rib running along one of its\n/// edges. Until this existed the face went blank the moment the sketcher opened: the plane was\n/// derived from it and then nothing about it was drawn or snappable, so lining a new rectangle up\n/// with the edge directly underneath it was done by eye against the shaded solid. That is exactly\n/// the kind of \"close enough\" that turns into a 0.03-unit sliver after an extrude.\n///\n/// EVERY OTHER CAD PACKAGE CALLS THIS PROJECTED REFERENCE GEOMETRY and makes you ask for it a\n/// curve at a time \u2014 Onshape's Use tool, SolidWorks' Convert Entities. Effigy shows the whole\n/// face's boundary automatically instead, because the face was CHOSEN as the plane a moment ago,\n/// which is a much stronger statement of intent than clicking one edge of it.\n///\n/// IT IS NOT PART OF THE SKETCH. Nothing here is in Sketch.Points or Sketch.Curves, so it never\n/// reaches ProfileFinder, never extrudes, and is never saved. It is rebuilt from the model every\n/// time the sketch is opened, which is what keeps it honest when the face underneath changes \u2014\n/// the same reason FaceRef stores geometry rather than \"Face6\".\n/// </summary>\npublic sealed class SketchReference\n{\n\t/// <summary>Corners of the referenced geometry, in sketch-plane coordinates.</summary>\n\tpublic readonly List<Vec2> Points = new();\n\n\t/// <summary>Edges, as index pairs into <see cref=\"Points\"/>.</summary>\n\tpublic readonly List<(int A, int B)> Edges = new();\n\n\tpublic bool IsEmpty => Points.Count == 0;\n\n\t/// <summary>An edge as the two positions it runs between. Bounds-checked to a zero-length\n\t/// segment rather than throwing: this is drawn and hit-tested every frame from an index that a\n\t/// rebuild underneath could have invalidated, and a viewport that throws once per frame is\n\t/// worse than one that briefly draws nothing.</summary>\n\tpublic (Vec2 A, Vec2 B) Segment( int index )\n\t{\n\t\tif ( index < 0 || index >= Edges.Count )\n\t\t\treturn (Vec2.Zero, Vec2.Zero);\n\n\t\tvar (a, b) = Edges[index];\n\n\t\tif ( a < 0 || a >= Points.Count || b < 0 || b >= Points.Count )\n\t\t\treturn (Vec2.Zero, Vec2.Zero);\n\n\t\treturn (Points[a], Points[b]);\n\t}\n\n\t/// <summary>\n\t/// Copy one reference edge into the sketch as a real line \u2014 Onshape's Use, one edge at a time.\n\t///\n\t/// WHY A COPY AND NOT A LIVE LINK. Onshape's projected curves stay attached to what they were\n\t/// taken from and move when it moves. That is the better behaviour and it is also a whole\n\t/// feature: the sketch would need a second class of curve that the user cannot drag or delete,\n\t/// that ProfileFinder unions in, and that is rebuilt rather than saved. A copy is what the tool\n\t/// does here, and it is honest about it \u2014 the line becomes ordinary sketch geometry, yours to\n\t/// trim and drag, and it does NOT follow the face afterwards.\n\t///\n\t/// Points are reused through SketchSnapper.PointIndex, so an edge copied in welds onto whatever\n\t/// is already at its ends rather than laying a second point on top of the first. That is what\n\t/// makes \"use all four edges, then draw a line across\" close into two regions instead of into\n\t/// nothing at all.\n\t/// </summary>\n\t/// <returns>The line added, or null when the edge is degenerate or the sketch already has it.</returns>\n\tpublic SketchLine UseEdge( Sketch sketch, int edgeIndex )\n\t{\n\t\tif ( sketch is null || edgeIndex < 0 || edgeIndex >= Edges.Count )\n\t\t\treturn null;\n\n\t\tvar (a, b) = Segment( edgeIndex );\n\n\t\tvar start = SketchSnapper.PointIndex( sketch, a );\n\t\tvar end = SketchSnapper.PointIndex( sketch, b );\n\n\t\t// A zero-length line is not geometry - ProfileFinder links it into the adjacency map twice\n\t\t// at one point and calls the sketch branching. The line tool has the same guard.\n\t\tif ( start == end )\n\t\t\treturn null;\n\n\t\t// USING THE SAME EDGE TWICE MUST NOT LAY A SECOND LINE ON THE FIRST. Two curves between the\n\t\t// same pair of points is exactly the branching case ProfileFinder refuses, so clicking an\n\t\t// edge you already used would quietly destroy the profile you were building.\n\t\tforeach ( var curve in sketch.Curves )\n\t\t{\n\t\t\tvar (from, to) = curve.Endpoints;\n\n\t\t\tif ( (from == start && to == end) || (from == end && to == start) )\n\t\t\t\treturn null;\n\t\t}\n\n\t\treturn sketch.Add( new SketchLine( start, end ) );\n\t}\n\n\t/// <summary>Copy every reference edge in, which is the common case: the whole face outline, so a\n\t/// single line drawn across it closes two regions. Returns how many were added \u2014 edges already\n\t/// in the sketch are skipped, so running it twice is harmless.</summary>\n\tpublic int UseAll( Sketch sketch )\n\t{\n\t\tvar added = 0;\n\n\t\tfor ( var i = 0; i < Edges.Count; i++ )\n\t\t{\n\t\t\tif ( UseEdge( sketch, i ) is not null )\n\t\t\t\tadded++;\n\t\t}\n\n\t\treturn added;\n\t}\n\n\t/// <summary>\n\t/// The boundary of the face a sketch is attached to, in that sketch's plane.\n\t///\n\t/// THE BOUNDARY OF THE SURFACE, NOT OF ONE n-GON. A face that has been through a boolean is\n\t/// usually several faces sharing a plane, and outlining each of them separately draws the seams\n\t/// where they were split \u2014 lines that are not edges of anything, sitting in the middle of what\n\t/// looks like one flat surface, and snapping to them is snapping to an artefact of how the mesh\n\t/// happens to be cut up. FaceSurface is what decides where the surface stops, and it is the\n\t/// same answer the viewport highlights and the edge picker offers: three things that used to\n\t/// each work it out for themselves and disagree on screen at the same time.\n\t///\n\t/// PROJECTED, NOT INTERSECTED, so a sketch with an offset still gets the face's outline \u2014\n\t/// directly below where it will be drawn, which is what makes the offset useful for a boss\n\t/// standing clear of the surface it grows from.\n\t/// </summary>\n\tpublic static SketchReference FromFace( IEnumerable<Body> bodies, FaceRef reference, SketchPlane plane )\n\t{\n\t\tvar result = new SketchReference();\n\n\t\tif ( plane is null || !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )\n\t\t\treturn result;\n\n\t\tvar mesh = body.Mesh;\n\n\t\t// Scaled to the part, for the same reason every other tolerance in the sketcher is: a\n\t\t// constant that is generous on a 100-unit block silently merges every vertex of a 0.1-unit\n\t\t// one. See SketchSnapper's header for what fixed tolerances did to this sketcher.\n\t\tvar tolerance = MathF.Max( mesh.BoundsDiagonal * 1e-4f, 1e-5f );\n\n\t\tvar surface = FaceSurface.FromFace( mesh, faceIndex );\n\t\tvar mapped = new Dictionary<int, int>();\n\n\t\tforeach ( var (from, to) in surface.Boundary )\n\t\t{\n\t\t\tvar a = Map( result, mapped, mesh, plane, tolerance, from );\n\t\t\tvar b = Map( result, mapped, mesh, plane, tolerance, to );\n\n\t\t\tif ( a != b )\n\t\t\t\tresult.Edges.Add( (a, b) );\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// <summary>Mesh vertex to reference point, projected and de-duplicated. Two mesh vertices at\n\t/// the same position \u2014 which a boolean leaves behind routinely \u2014 must become ONE snap target,\n\t/// or the corner of the face has two dots on it and the cursor picks between them at\n\t/// random.</summary>\n\tstatic int Map( SketchReference result, Dictionary<int, int> mapped, PolyMesh mesh,\n\t\tSketchPlane plane, float tolerance, int vertex )\n\t{\n\t\tif ( mapped.TryGetValue( vertex, out var existing ) )\n\t\t\treturn existing;\n\n\t\tvar p = plane.ToPlane( mesh.Positions[vertex] );\n\n\t\tfor ( var i = 0; i < result.Points.Count; i++ )\n\t\t{\n\t\t\tif ( (result.Points[i] - p).LengthSquared > tolerance * tolerance )\n\t\t\t\tcontinue;\n\n\t\t\tmapped[vertex] = i;\n\t\t\treturn i;\n\t\t}\n\n\t\tresult.Points.Add( p );\n\t\tmapped[vertex] = result.Points.Count - 1;\n\n\t\treturn result.Points.Count - 1;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/Effigy/SkinBlockout.cs",
"FileName": "SkinBlockout.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// <summary>\n/// A body from a skeleton \u2014 Blender's Skin modifier, aimed at a rig instead of a wire. Every\n/// joint becomes a cube the size of its radius, every bone a tube between the two cubes it joins,\n/// and a couple of levels of Catmull-Clark turn the result into a smooth, all-quad blob that is\n/// already a closed solid with a clean flow along each limb. The block-out a character starts\n/// from, in one click, on the bones it will be rigged to.\n///\n/// SIX BONES PER JOINT AT MOST: each takes one face of the joint's cube. A joint with more is\n/// how a real skeleton never looks, so the seventh bone is refused rather than fudged.\n/// </summary>\npublic static class SkinBlockout\n{\n\t/// <summary>\n\t/// Build the body. <paramref name=\"radiusOf\"/> gives each bone's thickness, or null for a\n\t/// fifth of its length; <paramref name=\"smoothLevels\"/> is how many times to subdivide the\n\t/// blocky skin (0 keeps the cubes and tubes, which is what to edit by hand).\n\t/// </summary>\n\tpublic static PolyMesh Build( Skeleton skeleton, Func<int, float> radiusOf = null, int smoothLevels = 2 )\n\t{\n\t\tif ( skeleton is null || skeleton.Count == 0 )\n\t\t\tthrow new InvalidOperationException( \"There are no bones to build a body on. Draw a rig first.\" );\n\n\t\tradiusOf ??= i => MathF.Max( skeleton.Bones[i].Length * 0.2f, 0.01f );\n\n\t\t// Joints: heads and tails, merged where a child's head sits on its parent's tail.\n\t\tvar nodes = new List<Vec3>();\n\t\tvar nodeRadius = new List<float>();\n\t\tint NodeAt( Vec3 p, float r )\n\t\t{\n\t\t\tfor ( var i = 0; i < nodes.Count; i++ )\n\t\t\t{\n\t\t\t\tif ( (nodes[i] - p).LengthSquared < 1e-4f )\n\t\t\t\t{\n\t\t\t\t\tnodeRadius[i] = MathF.Max( nodeRadius[i], r );\n\t\t\t\t\treturn i;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tnodes.Add( p );\n\t\t\tnodeRadius.Add( r );\n\t\t\treturn nodes.Count - 1;\n\t\t}\n\n\t\tvar bones = new List<(int head, int tail)>();\n\t\tfor ( var b = 0; b < skeleton.Count; b++ )\n\t\t{\n\t\t\tvar head = skeleton.HeadWorld( b );\n\t\t\tvar tail = skeleton.TailWorld( b );\n\t\t\tif ( (tail - head).LengthSquared < 1e-6f )\n\t\t\t\tcontinue;\n\n\t\t\tvar r = MathF.Max( radiusOf( b ), 0.01f );\n\t\t\tbones.Add( (NodeAt( head, r ), NodeAt( tail, r )) );\n\t\t}\n\n\t\tif ( bones.Count == 0 )\n\t\t\tthrow new InvalidOperationException( \"Every bone has no length, so there is nothing to build along.\" );\n\n\t\t// A cube per joint: eight corners, six faces, each face free until a bone claims it.\n\t\tvar mesh = new PolyMesh();\n\t\tvar cubeCorner = new int[nodes.Count][];\n\t\tvar faceTaken = new bool[nodes.Count][];\n\t\tfor ( var n = 0; n < nodes.Count; n++ )\n\t\t{\n\t\t\tvar c = nodes[n];\n\t\t\tvar r = nodeRadius[n];\n\t\t\tcubeCorner[n] = new int[8];\n\t\t\tfor ( var k = 0; k < 8; k++ )\n\t\t\t{\n\t\t\t\tcubeCorner[n][k] = mesh.Positions.Count;\n\t\t\t\tmesh.Positions.Add( c + new Vec3( (k & 1) == 0 ? -r : r, (k & 2) == 0 ? -r : r, (k & 4) == 0 ? -r : r ) );\n\t\t\t}\n\n\t\t\tfaceTaken[n] = new bool[6];\n\t\t}\n\n\t\t// The six faces of a cube, wound outward, and the way each looks.\n\t\tvar faceNormals = new[] { new Vec3( 1, 0, 0 ), new Vec3( -1, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 0, -1, 0 ), new Vec3( 0, 0, 1 ), new Vec3( 0, 0, -1 ) };\n\t\tvar faceCorners = new[]\n\t\t{\n\t\t\tnew[] { 1, 3, 7, 5 }, // +x\n\t\t\tnew[] { 0, 4, 6, 2 }, // -x\n\t\t\tnew[] { 2, 6, 7, 3 }, // +y\n\t\t\tnew[] { 0, 1, 5, 4 }, // -y\n\t\t\tnew[] { 4, 5, 7, 6 }, // +z\n\t\t\tnew[] { 0, 2, 3, 1 }, // -z\n\t\t};\n\n\t\tint Claim( int node, Vec3 direction )\n\t\t{\n\t\t\tvar best = -1;\n\t\t\tvar bestDot = float.MinValue;\n\t\t\tfor ( var f = 0; f < 6; f++ )\n\t\t\t{\n\t\t\t\tif ( faceTaken[node][f] )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar d = Vec3.Dot( faceNormals[f], direction );\n\t\t\t\tif ( d > bestDot )\n\t\t\t\t{\n\t\t\t\t\tbestDot = d;\n\t\t\t\t\tbest = f;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( best < 0 )\n\t\t\t\tthrow new InvalidOperationException( \"A joint has more than six bones meeting at it, which is more than a body can be built round.\" );\n\n\t\t\tfaceTaken[node][best] = true;\n\t\t\treturn best;\n\t\t}\n\n\t\t// Tubes: each bone claims a face at each end and bridges them.\n\t\tforeach ( var (head, tail) in bones )\n\t\t{\n\t\t\tvar axis = (nodes[tail] - nodes[head]).Normal;\n\t\t\tvar fa = Claim( head, axis );\n\t\t\tvar fb = Claim( tail, axis * -1f );\n\n\t\t\tvar a = new int[4];\n\t\t\tvar b = new int[4];\n\t\t\tfor ( var i = 0; i < 4; i++ )\n\t\t\t{\n\t\t\t\ta[i] = cubeCorner[head][faceCorners[fa][i]];\n\t\t\t\tb[i] = cubeCorner[tail][faceCorners[fb][3 - i]];\n\t\t\t}\n\n\t\t\t// Turn the far ring so its first corner is the one nearest the near ring's first,\n\t\t\t// measured across the axis, so the tube does not twist.\n\t\t\tvar start = 0;\n\t\t\tvar bestD = float.MaxValue;\n\t\t\tvar pa = mesh.Positions[a[0]] - axis * Vec3.Dot( mesh.Positions[a[0]] - nodes[head], axis );\n\t\t\tfor ( var j = 0; j < 4; j++ )\n\t\t\t{\n\t\t\t\tvar pb = mesh.Positions[b[j]] - axis * Vec3.Dot( mesh.Positions[b[j]] - nodes[head], axis );\n\t\t\t\tvar d = (pb - pa).LengthSquared;\n\t\t\t\tif ( d < bestD )\n\t\t\t\t{\n\t\t\t\t\tbestD = d;\n\t\t\t\t\tstart = j;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor ( var i = 0; i < 4; i++ )\n\t\t\t{\n\t\t\t\tvar i2 = (i + 1) % 4;\n\t\t\t\tvar j = (start + i) % 4;\n\t\t\t\tvar j2 = (start + i + 1) % 4;\n\t\t\t\tmesh.AddFace( new[] { a[i], a[i2], b[j2], b[j] } );\n\t\t\t}\n\t\t}\n\n\t\t// Whatever faces no bone claimed stay: they cap the ends of limbs and close the joints.\n\t\tfor ( var n = 0; n < nodes.Count; n++ )\n\t\t{\n\t\t\tfor ( var f = 0; f < 6; f++ )\n\t\t\t{\n\t\t\t\tif ( faceTaken[n][f] )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar idx = new int[4];\n\t\t\t\tfor ( var i = 0; i < 4; i++ )\n\t\t\t\t\tidx[i] = cubeCorner[n][faceCorners[f][i]];\n\t\t\t\tmesh.AddFace( idx );\n\t\t\t}\n\t\t}\n\n\t\treturn smoothLevels > 0 ? CatmullClark.Subdivide( mesh, smoothLevels ) : mesh;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigyIcons.cs",
"FileName": "EffigyIcons.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing System.Collections.Generic;\nusing Sandbox;\nusing System;\n\nnamespace Marionette.EditorTools;\n\n/// <summary>Which drawing a feature-tool button paints. One per creation tool.</summary>\ninternal enum EffigyIcon\n{\n\tSketch,\n\tPlane,\n\tPrimitive,\n\tExtrude,\n\tRevolve,\n\tSweep,\n\tLoft,\n\tChamfer,\n\tFillet,\n\tShell,\n\tSubdivide,\n\n\t// Subdivide's opposite number, and drawn as it: the same square, with the fine grid it is\n\t// losing shown faint underneath the coarse one it keeps.\n\tRemesh,\n\n\tMirror,\n\tLinearPattern,\n\tCircularPattern,\n\tTransform,\n\tUVProject,\n\tFaceMaterial,\n\tBoolean,\n\n\t// --- sketch tools -------------------------------------------------------------------------\n\t// These were Material Icon NAMES until now, and generic ones: show_chart (a zigzag line chart)\n\t// for Line, cached (two refresh arrows) for Arc, crop_square for Rectangle. They said nothing\n\t// about the operation, and half of them said something actively misleading.\n\tSelectTool,\n\tLineTool,\n\tLineMidpointTool,\n\tRectangleTool,\n\tRectangleCentreTool,\n\tCircleTool,\n\tCircleThreePointTool,\n\tArcTool,\n\tArcThreePointTool,\n\tPolygonTool,\n\tPolygonCircumscribedTool,\n\tSlotTool,\n\tPointTool,\n\tConstructionTool,\n\tProfileInspectorTool,\n\tFinishSketchTool,\n\n\t// --- sculpt tools -------------------------------------------------------------------------\n\t// The brushes are drawn as what they DO to a surface rather than as tool shapes: a row of six\n\t// identical brush heads distinguished by a tiny badge is six ways to pick the wrong one. Every\n\t// glyph here is a surface line and what the brush does to it.\n\tSculpt,\n\tSculptDraw,\n\tSculptSmooth,\n\tSculptInflate,\n\tSculptGrab,\n\tSculptFlatten,\n\tSculptPinch,\n\tSculptMask,\n\tSculptLevelDown,\n\tSculptLevelUp,\n\tSculptBake,\n\n\t// --- paint tools ---------------------------------------------------------------------------\n\tPaint,\n\tPaintBrush,\n\tPaintEraser,\n\n\t// --- solid tools that act on picked faces ---------------------------------------------------\n\tDraft,\n\tMoveFace,\n\tHole,\n\n\t// --- the six sketch tools whose kernel half was finished first ------------------------------\n\tEllipseTool,\n\tSplineTool,\n\tTrimTool,\n\tExtendTool,\n\tSketchFilletTool,\n\tOffsetTool,\n\tMirrorTool,\n\n\t// --- taking the face's own outline into the sketch -------------------------------------------\n\tUseTool,\n\tUseAllTool,\n\n\t// --- the one sketch tool driven by a drag ----------------------------------------------------\n\tCutTool,\n\n\t// --- grease pencil: annotation, not geometry -------------------------------------------------\n\t// Both are drawn as the real-world objects rather than as marks, because that is the one thing\n\t// that says \"this is not a modelling operation\" before the tooltip gets a chance to. Every other\n\t// glyph in the bar is a shape being changed; these are stationery.\n\tNoteTool,\n\tNoteEraseTool,\n\n\t// --- lighting: viewport scenery, not geometry ------------------------------------------------\n\t// Drawn as lamps and as what a lamp DOES to a shape, for the same reason the notes above are\n\t// drawn as stationery: nothing on this stage changes the model, and the glyphs should say so\n\t// before the tooltip gets a chance to. The three rigs are the same sphere lit three ways, which\n\t// is the only honest way to draw the difference between them.\n\tLightFullBright,\n\tLightPoint,\n\tLightSpot,\n\tLightSun,\n\tLightRigThreePoint,\n\tLightRigRim,\n\tLightRigTop,\n\tLightRigKey,\n\tLightClear,\n\n\t// --- rig ---\n\tBone,\n\tBoneBind,\n\tBoneSoft,\n\tSoftPreview,\n\tSoftRest,\n\n\t// --- Model > Edit: direct polygon editing ------------------------------------------------------\n\t// Drawn as a small quad cage with the thing being picked or changed lit, so the three pick\n\t// modes read as \"which part of the same cage\" and every operation reads as what it does to it.\n\tMeshEdit,\n\tSelectVertex,\n\tSelectEdge,\n\tSelectFace,\n\tInset,\n\tLoopCut,\n\tMerge,\n\tDissolve,\n\tDeleteGeometry,\n\tShrinkwrap,\n\tSolidify,\n\tDrape,\n\tFabric,\n\n\t// --- UV: where the texture is cut and how it is laid out ------------------------------------\n\tSeam,\n\tUnwrap,\n\n\t// --- Clothing: trim hung off a garment's edge ------------------------------------------------\n\t// A scalloped edge: the wave a gathered strip takes. Drawn as the EDGE rather than as a frill in\n\t// the round, because what the tool does is decorate a boundary, and the boundary is the part a\n\t// person is choosing.\n\tFrill,\n\n\t// --- Clothing: the body being dressed -------------------------------------------------------\n\t// A dressmaker's dummy, not a person and not a skeleton. The two things it has to say at a\n\t// glance are \"this is a whole body\" and \"the body is not the thing you are making\" - a stand\n\t// says both, which neither a figure nor a bone does.\n\tWearer,\n}\n\n/// <summary>\n/// Painted icons for the feature-creation strip, drawn rather than looked up in a font.\n///\n/// Same reasoning as RigIconButton (see Editor/RigControlEditor): s&box ships CLASSIC Material\n/// Icons, not the newer Material Symbols, so a name from the Symbols set silently renders as\n/// nothing \u2014 and the strip was leaning on generic names like \"square\", \"flip\" and \"call_made\"\n/// that, where they resolved at all, said nothing about the CAD operation behind them. A drawn\n/// glyph can show the actual operation: Chamfer cuts a corner off a square, Shell puts a wall\n/// inside one, Mirror reflects a solid shape into an outlined one.\n///\n/// Every icon is drawn around <c>center</c> inside a nominal 18x18 box, so they all read at the\n/// same weight, then scaled up as one by the <c>scale</c> argument to fit the button drawing it.\n/// </summary>\ninternal static class EffigyIcons\n{\n\t/// <summary>Stroke width every outline uses, so no icon looks heavier than its neighbours.</summary>\n\tprivate const float Stroke = 1.6f;\n\n\t// --- the pencil's own colours -------------------------------------------------------------\n\t//\n\t// The ONLY icon that does not draw entirely in the colour it is handed. Sketch is the tool\n\t// every part starts with and the only button in the strip carrying a text label, so it is the\n\t// one worth making findable at a glance rather than another grey glyph in a row of grey\n\t// glyphs. A yellow #2 is about as legible as a small object gets.\n\t//\n\t// Chosen against a dark viewport: the graphite is a mid grey rather than near-black, because a\n\t// true graphite point disappears into the background exactly where the icon needs to read.\n\n\tprivate static readonly Color PencilBody = new( 0.96f, 0.76f, 0.15f );\n\tprivate static readonly Color PencilFerrule = new( 0.74f, 0.77f, 0.80f );\n\tprivate static readonly Color PencilEraser = new( 0.91f, 0.56f, 0.58f );\n\tprivate static readonly Color PencilWood = new( 0.87f, 0.68f, 0.44f );\n\tprivate static readonly Color PencilGraphite = new( 0.45f, 0.47f, 0.50f );\n\n\t/// <summary>Multiplier applied to every coordinate, radius and pen width for the icon being\n\t/// drawn right now. Every glyph is authored against the nominal 18x18 box, so one factor set\n\t/// here at the top of Draw is enough to resize all of them together \u2014 the strip's buttons grew\n\t/// past the size the glyphs were drawn for and a fixed-size glyph in a big button reads as a\n\t/// mistake. Painting only ever happens on the editor UI thread, so a plain static is safe.</summary>\n\tprivate static float _scale = 1f;\n\n\tpublic static void Draw( EffigyIcon icon, Vector2 center, Color color, float scale = 1f )\n\t{\n\t\tEditor.Paint.Antialiasing = true;\n\t\t_scale = scale;\n\n\t\tswitch ( icon )\n\t\t{\n\t\t\tcase EffigyIcon.Sketch: PaintSketch( center, color ); return;\n\t\t\tcase EffigyIcon.Primitive: PaintPrimitive( center, color ); return;\n\t\t\tcase EffigyIcon.Extrude: PaintExtrude( center, color ); return;\n\t\t\tcase EffigyIcon.Revolve: PaintRevolve( center, color ); return;\n\t\t\tcase EffigyIcon.Sweep: PaintSweep( center, color ); return;\n\t\t\tcase EffigyIcon.Loft: PaintLoft( center, color ); return;\n\t\t\tcase EffigyIcon.Chamfer: PaintChamfer( center, color ); return;\n\t\t\tcase EffigyIcon.Fillet: PaintFillet( center, color ); return;\n\t\t\tcase EffigyIcon.Shell: PaintShell( center, color ); return;\n\t\t\tcase EffigyIcon.Subdivide: PaintSubdivide( center, color ); return;\n\t\t\tcase EffigyIcon.Remesh: PaintRemesh( center, color ); return;\n\t\t\tcase EffigyIcon.Mirror: PaintMirror( center, color ); return;\n\t\t\tcase EffigyIcon.LinearPattern: PaintLinearPattern( center, color ); return;\n\t\t\tcase EffigyIcon.CircularPattern: PaintCircularPattern( center, color ); return;\n\t\t\tcase EffigyIcon.Transform: PaintTransform( center, color ); return;\n\t\t\tcase EffigyIcon.UVProject: PaintUVProject( center, color ); return;\n\t\t\tcase EffigyIcon.FaceMaterial: PaintFaceMaterial( center, color ); return;\n\t\t\tcase EffigyIcon.Boolean: PaintBoolean( center, color ); return;\n\t\t\tcase EffigyIcon.Plane: PaintPlane( center, color ); return;\n\n\t\t\tcase EffigyIcon.SelectTool: PaintSelectTool( center, color ); return;\n\t\t\tcase EffigyIcon.LineTool: PaintLineTool( center, color ); return;\n\t\t\tcase EffigyIcon.LineMidpointTool: PaintLineMidpointTool( center, color ); return;\n\t\t\tcase EffigyIcon.RectangleTool: PaintRectangleTool( center, color ); return;\n\t\t\tcase EffigyIcon.RectangleCentreTool: PaintRectangleCentreTool( center, color ); return;\n\t\t\tcase EffigyIcon.CircleTool: PaintCircleTool( center, color ); return;\n\t\t\tcase EffigyIcon.CircleThreePointTool: PaintCircleThreePointTool( center, color ); return;\n\t\t\tcase EffigyIcon.ArcTool: PaintArcTool( center, color ); return;\n\t\t\tcase EffigyIcon.ArcThreePointTool: PaintArcThreePointTool( center, color ); return;\n\t\t\tcase EffigyIcon.PolygonTool: PaintPolygonTool( center, color ); return;\n\t\t\tcase EffigyIcon.PolygonCircumscribedTool: PaintPolygonCircumscribedTool( center, color ); return;\n\t\t\tcase EffigyIcon.SlotTool: PaintSlotTool( center, color ); return;\n\t\t\tcase EffigyIcon.PointTool: PaintPointTool( center, color ); return;\n\t\t\tcase EffigyIcon.ConstructionTool: PaintConstructionTool( center, color ); return;\n\t\t\tcase EffigyIcon.ProfileInspectorTool: PaintProfileInspectorTool( center, color ); return;\n\t\t\tcase EffigyIcon.FinishSketchTool: PaintFinishSketchTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.Sculpt: PaintSculpt( center, color ); return;\n\t\t\tcase EffigyIcon.SculptDraw: PaintSculptDraw( center, color ); return;\n\t\t\tcase EffigyIcon.SculptSmooth: PaintSculptSmooth( center, color ); return;\n\t\t\tcase EffigyIcon.SculptInflate: PaintSculptInflate( center, color ); return;\n\t\t\tcase EffigyIcon.SculptGrab: PaintSculptGrab( center, color ); return;\n\t\t\tcase EffigyIcon.SculptFlatten: PaintSculptFlatten( center, color ); return;\n\t\t\tcase EffigyIcon.SculptPinch: PaintSculptPinch( center, color ); return;\n\t\t\tcase EffigyIcon.SculptMask: PaintSculptMask( center, color ); return;\n\t\t\tcase EffigyIcon.SculptLevelDown: PaintSculptLevelDown( center, color ); return;\n\t\t\tcase EffigyIcon.SculptLevelUp: PaintSculptLevelUp( center, color ); return;\n\t\t\tcase EffigyIcon.SculptBake: PaintSculptBake( center, color ); return;\n\n\t\t\tcase EffigyIcon.Paint: PaintPaint( center, color ); return;\n\t\t\tcase EffigyIcon.PaintBrush: PaintPaintBrush( center, color ); return;\n\t\t\tcase EffigyIcon.PaintEraser: PaintPaintEraser( center, color ); return;\n\n\t\t\tcase EffigyIcon.Draft: PaintDraft( center, color ); return;\n\t\t\tcase EffigyIcon.MoveFace: PaintMoveFace( center, color ); return;\n\t\t\tcase EffigyIcon.Hole: PaintHole( center, color ); return;\n\n\t\t\tcase EffigyIcon.NoteTool: PaintNoteTool( center, color ); return;\n\t\t\tcase EffigyIcon.NoteEraseTool: PaintNoteEraseTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.LightFullBright: PaintLightFullBright( center, color ); return;\n\t\t\tcase EffigyIcon.LightPoint: PaintLightPoint( center, color ); return;\n\t\t\tcase EffigyIcon.LightSpot: PaintLightSpot( center, color ); return;\n\t\t\tcase EffigyIcon.LightSun: PaintLightSun( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigThreePoint: PaintLightRigThreePoint( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigRim: PaintLightRigRim( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigTop: PaintLightRigTop( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigKey: PaintLightRigKey( center, color ); return;\n\t\t\tcase EffigyIcon.LightClear: PaintLightClear( center, color ); return;\n\n\t\t\tcase EffigyIcon.EllipseTool: PaintEllipseTool( center, color ); return;\n\t\t\tcase EffigyIcon.SplineTool: PaintSplineTool( center, color ); return;\n\t\t\tcase EffigyIcon.TrimTool: PaintTrimTool( center, color ); return;\n\t\t\tcase EffigyIcon.ExtendTool: PaintExtendTool( center, color ); return;\n\t\t\tcase EffigyIcon.SketchFilletTool: PaintSketchFilletTool( center, color ); return;\n\t\t\tcase EffigyIcon.OffsetTool: PaintOffsetTool( center, color ); return;\n\t\t\tcase EffigyIcon.MirrorTool: PaintMirrorTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.UseTool: PaintUseTool( center, color ); return;\n\t\t\tcase EffigyIcon.UseAllTool: PaintUseAllTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.MeshEdit: PaintMeshEdit( center, color ); return;\n\t\t\tcase EffigyIcon.SelectVertex: PaintCage( center, color, lit: 1 ); return;\n\t\t\tcase EffigyIcon.SelectEdge: PaintCage( center, color, lit: 2 ); return;\n\t\t\tcase EffigyIcon.SelectFace: PaintCage( center, color, lit: 3 ); return;\n\t\t\tcase EffigyIcon.Inset: PaintInset( center, color ); return;\n\t\t\tcase EffigyIcon.LoopCut: PaintLoopCut( center, color ); return;\n\t\t\tcase EffigyIcon.Merge: PaintMerge( center, color ); return;\n\t\t\tcase EffigyIcon.Dissolve: PaintDissolve( center, color ); return;\n\t\t\tcase EffigyIcon.DeleteGeometry: PaintDeleteGeometry( center, color ); return;\n\t\t\tcase EffigyIcon.Shrinkwrap: PaintShrinkwrap( center, color ); return;\n\t\t\tcase EffigyIcon.Solidify: PaintSolidify( center, color ); return;\n\t\t\tcase EffigyIcon.Drape: PaintDrape( center, color ); return;\n\t\t\tcase EffigyIcon.Fabric: PaintFabric( center, color ); return;\n\t\t\tcase EffigyIcon.Seam: PaintSeam( center, color ); return;\n\t\t\tcase EffigyIcon.Unwrap: PaintUnwrap( center, color ); return;\n\t\t\tcase EffigyIcon.Wearer: PaintWearer( center, color ); return;\n\t\t\tcase EffigyIcon.Frill: PaintFrill( center, color ); return;\n\n\t\t\tcase EffigyIcon.CutTool: PaintCutTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.Bone: PaintBone( center, color ); return;\n\t\t\tcase EffigyIcon.BoneBind: PaintBoneBind( center, color ); return;\n\t\t\tcase EffigyIcon.BoneSoft: PaintBoneSoft( center, color ); return;\n\t\t\tcase EffigyIcon.SoftPreview: PaintSoftPreview( center, color ); return;\n\t\t\tcase EffigyIcon.SoftRest: PaintSoftRest( center, color ); return;\n\t\t}\n\t}\n\n\t// --- drawing helpers --------------------------------------------------------------------\n\n\tprivate static void Stroked( Color color, float width = Stroke )\n\t{\n\t\tEditor.Paint.ClearBrush();\n\t\tEditor.Paint.SetPen( color, width * _scale );\n\t}\n\n\tprivate static void Filled( Color color )\n\t{\n\t\tEditor.Paint.ClearPen();\n\t\tEditor.Paint.SetBrush( color );\n\t}\n\n\t/// <summary>Closed outline through the given points \u2014 DrawPolygon fills, so an outlined shape\n\t/// has to be walked as lines.</summary>\n\tprivate static void Outline( params Vector2[] points )\n\t{\n\t\tfor ( var i = 0; i < points.Length; i++ )\n\t\t\tEditor.Paint.DrawLine( points[i], points[(i + 1) % points.Length] );\n\t}\n\n\t/// <summary>An arc as a polyline. There is no arc primitive in Paint, and approximating with\n\t/// segments is exact enough at icon size.</summary>\n\tprivate static void Arc( Vector2 center, float radius, float fromDegrees, float toDegrees, int segments = 14 )\n\t{\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i <= segments; i++ )\n\t\t{\n\t\t\tvar t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tvar point = center + new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale;\n\n\t\t\tif ( i > 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// <summary>An elliptical arc as a polyline. Arc() draws a circle and cannot say \"this circle\n\t/// is lying flat\"; that foreshortening is the entire difference between a glyph that reads as\n\t/// a rotation about an axis and one that reads as a spiral.</summary>\n\tprivate static void EllipseArc( Vector2 center, float radiusX, float radiusY,\n\t\tfloat fromDegrees, float toDegrees, int segments = 24 )\n\t{\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i <= segments; i++ )\n\t\t{\n\t\t\tvar t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tvar point = center + new Vector2( MathF.Cos( radians ) * radiusX, MathF.Sin( radians ) * radiusY ) * _scale;\n\n\t\t\tif ( i > 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// <summary>A solid triangular arrow head, pointing along <paramref name=\"direction\"/>.</summary>\n\tprivate static void ArrowHead( Vector2 tip, Vector2 direction, Color color, float size = 3.4f )\n\t{\n\t\tvar d = direction.Normal;\n\t\tvar side = new Vector2( -d.y, d.x );\n\n\t\tsize *= _scale;\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\ttip,\n\t\t\ttip - d * size + side * size * 0.62f,\n\t\t\ttip - d * size - side * size * 0.62f );\n\t}\n\n\tprivate static Vector2 At( Vector2 center, float x, float y ) => center + new Vector2( x, y ) * _scale;\n\n\t/// <summary>A rect in the same nominal icon space At() uses, for the glyphs that need\n\t/// DrawRect/DrawCircle rather than a walked outline.</summary>\n\tprivate static Rect Box( Vector2 center, float x, float y, float width, float height )\n\t\t=> new Rect( center.x + x * _scale, center.y + y * _scale, width * _scale, height * _scale );\n\n\t// --- the icons --------------------------------------------------------------------------\n\n\t/// <summary>\n\t/// A pencil drawing on a sheet, its point resting ON the paper's top edge.\n\t///\n\t/// The pencil used to be a plain parallelogram - blunt at both ends, with one of its corners\n\t/// landing on the paper line. A pencil reads as a pencil because of the cone at the end, and\n\t/// the mark reads as DRAWING because that cone touches the paper rather than hovering above\n\t/// it or crossing through it. So: a solid tapered point that ends exactly on the line, an\n\t/// outlined barrel behind it, and a band where the ferrule would be.\n\t///\n\t/// Every coordinate is derived from the tip and the pencil's axis, laid out along a 45 degree\n\t/// diagonal, so the point cannot drift off the paper if the proportions are adjusted.\n\t///\n\t/// The paper keeps the colour it is handed; the pencil does not (see PencilBody and friends).\n\t/// </summary>\n\tprivate static void PaintSketch( Vector2 c, Color color )\n\t{\n\t\t// Paper: a single flat horizontal line, matching the compact reference glyph. Everything\n\t\t// else is placed against PaperY.\n\t\tconst float PaperY = 4.8f;\n\n\t\t// How thick the pencil is, across the barrel.\n\t\tconst float BarrelWidth = 1.7f;\n\n\t\tStroked( color, Stroke );\n\t\tEditor.Paint.DrawLine( At( c, -6.6f, PaperY ), At( c, 6.4f, PaperY ) );\n\n\t\t// The sharpened cone, sitting on the paper. Solid, because at 27px a hollow cone is a\n\t\t// smudge - the filled wedge is what makes it read as sharpened. Its base is exactly as\n\t\t// wide as the barrel's stroke, so the two meet without a step.\n\t\tFilled( PencilWood );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4.4f, PaperY ),\n\t\t\tAt( c, -1.678f, 3.28f ),\n\t\t\tAt( c, -2.88f, 2.078f ) );\n\n\t\t// The exposed lead, the outer 40% of that cone. Drawn over the wood rather than beside it,\n\t\t// so the two always agree about where the point is.\n\t\tFilled( PencilGraphite );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4.4f, PaperY ),\n\t\t\tAt( c, -3.257f, 4.161f ),\n\t\t\tAt( c, -3.761f, 3.657f ) );\n\n\t\t// Barrel: ONE STROKED LINE, not an outlined shape. A pencil this slim has a body 1.7 units\n\t\t// across, and two outline strokes inside that merge into a blob - the line IS the barrel,\n\t\t// and it is the only way to get a thin pencil that still reads at icon size. The ferrule\n\t\t// and eraser are further stretches of the same line, which is also why they cannot drift\n\t\t// out of alignment with it.\n\t\tStroked( PencilBody, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, -2.279f, 2.679f ), At( c, 4.156f, -3.756f ) );\n\n\t\tStroked( PencilFerrule, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, 4.156f, -3.756f ), At( c, 4.948f, -4.548f ) );\n\n\t\tStroked( PencilEraser, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, 4.948f, -4.548f ), At( c, 5.853f, -5.453f ) );\n\t}\n\n\t/// <summary>An isometric cube \u2014 the generic \"a solid body\" mark.</summary>\n\tprivate static void PaintPrimitive( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline(\n\t\t\tAt( c, 0, -7.5f ), At( c, 7, -3.6f ), At( c, 7, 3.6f ),\n\t\t\tAt( c, 0, 7.5f ), At( c, -7, 3.6f ), At( c, -7, -3.6f ) );\n\n\t\t// The three edges meeting at the near corner are what make it read as a cube rather than\n\t\t// a hexagon.\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, 0, 7.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, 7, -3.6f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, -7, -3.6f ) );\n\t}\n\n\t/// <summary>\n\t/// A profile lying flat, and the solid pulled UP off it.\n\t///\n\t/// The old glyph had the profile on top with the arrow pointing down, which reads as something\n\t/// falling rather than as something being drawn out \u2014 and at toolbar size the whole thing came\n\t/// out looking like a plumb bob. Arrow and profile now agree about which way an extrude goes.\n\t/// </summary>\n\tprivate static void PaintExtrude( Vector2 c, Color color )\n\t{\n\t\t// The sketch, in plan, dimmed: it is what the operation starts FROM, not what it makes.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.5f );\n\t\tOutline( At( c, -7.5f, 6.5f ), At( c, 0, 9.4f ), At( c, 7.5f, 6.5f ), At( c, 0, 3.6f ) );\n\n\t\tStroked( color, 2.6f );\n\t\tEditor.Paint.DrawLine( At( c, 0, 6.5f ), At( c, 0, -4.2f ) );\n\n\t\tArrowHead( At( c, 0, -8.6f ), new Vector2( 0, -1 ), color, 4.2f );\n\t}\n\n\t/// <summary>\n\t/// A sketch sitting on an axis, and the spin that turns it into a solid.\n\t///\n\t/// Extrude is a straight arrow off a profile. This is the same grammar bent into a C \u2014 axis,\n\t/// profile, curved arrow \u2014 which is what every CAD tool draws for Revolve and what the last\n\t/// version threw out. That version drew a vase in section and hoped the silhouette would\n\t/// carry it; at toolbar size it was a lumpy outline with a dashed line through its face.\n\t/// Fill the profile (same weight as Chamfer and Shell) and let the arrow be the operation.\n\t/// </summary>\n\tprivate static void PaintRevolve( Vector2 c, Color color )\n\t{\n\t\t// ONSHAPE'S OWN REVOLVE ICON: a disc with a wedge taken out of it. po pointed at it, and it\n\t\t// is better than either drawing that came before.\n\t\t//\n\t\t// The first attempt stacked a dashed axis, a filled profile rectangle and a circular arrow\n\t\t// on top of each other; the arc swept straight through the rectangle it was meant to be\n\t\t// spinning and the three merged into a tall dark blob that read as the letter D. The\n\t\t// second replaced it with a lathe - profile beside an axis, ellipse sweeping round it -\n\t\t// which was legible but was three small shapes doing the work of one, and small shapes are\n\t\t// what stop reading first when the strip is the only chrome on a 3D viewport.\n\t\t//\n\t\t// A disc with a mouth is ONE shape. It shows the result rather than the mechanism, which\n\t\t// is the idiom the rest of this strip already uses - Chamfer cuts a corner off a square,\n\t\t// Shell puts a wall inside one. The mouth is what makes it a revolve rather than a circle:\n\t\t// the two cut faces meeting at the centre are the start and end of the sweep, and they say\n\t\t// \"this was swept through an angle\" without needing an arrow to explain it. It is also\n\t\t// square and solid, so it holds its weight next to Extrude and Loft instead of being the\n\t\t// one thin glyph on the row.\n\n\t\tconst float Radius = 8.6f;\n\n\t\t// How much of the disc is missing. Wide enough to read as a deliberate mouth at 32px\n\t\t// rather than as a nick in the outline, narrow enough that the shape is still a disc.\n\t\tconst float MouthHalfAngle = 38f;\n\n\t\t// The rim, from one cut face round to the other, then in to the centre. Closing the loop\n\t\t// draws the second cut face, so the whole silhouette is one walk.\n\t\tvar rim = ArcPoints( c, Radius, MouthHalfAngle, 360f - MouthHalfAngle, 30 );\n\t\trim.Add( c );\n\n\t\tvar silhouette = rim.ToArray();\n\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon( silhouette );\n\n\t\tStroked( color, 1.6f );\n\t\tOutline( silhouette );\n\t}\n\n\t/// <summary>\n\t/// A profile carried along a path, with the path drawn as the thing that shapes it.\n\t///\n\t/// Sweep and Extrude are the same sentence with a different verb \u2014 a profile, and where it goes\n\t/// \u2014 so they are drawn with the same grammar: the starting profile dim because it is what the\n\t/// operation begins FROM rather than what it makes, and an arrow for the operation itself. The\n\t/// difference between them is the whole point, so here the path is a curve and the solid\n\t/// follows it instead of standing straight up.\n\t/// </summary>\n\tprivate static void PaintSweep( Vector2 c, Color color )\n\t{\n\t\t// Hub, radius and extent of the path. Everything else is derived from these, so the glyph\n\t\t// stays consistent with itself if the arc is retuned.\n\t\tconst float HubX = -7f;\n\t\tconst float HubY = -7.5f;\n\t\tconst float Radius = 12f;\n\n\t\t// Half the profile's width, so the band either side of the path IS the solid.\n\t\tconst float Half = 2.6f;\n\n\t\tconst float From = 0f;\n\t\tconst float To = 90f;\n\t\tconst float ArrowAt = To + 10f;\n\n\t\tvar hub = At( c, HubX, HubY );\n\n\t\t// The swept solid: the path offset either side of itself. Faint rather than outlined at\n\t\t// full weight, so the path stays the strongest line in the glyph.\n\t\tStroked( color.WithAlpha( 0.32f ), 2f );\n\t\tArc( hub, Radius - Half, From, To, 18 );\n\t\tArc( hub, Radius + Half, From, To, 18 );\n\n\t\tStroked( color, 1.7f );\n\t\tArc( hub, Radius, From, ArrowAt, 20 );\n\n\t\t// Where it starts, and where it arrives.\n\t\tSweepStation( hub, Radius, Half, From, color.WithAlpha( 0.55f ), 1.4f );\n\t\tSweepStation( hub, Radius, Half, To, color, 1.7f );\n\n\t\tvar end = ArrowAt * MathF.PI / 180f;\n\t\tvar tip = hub + new Vector2( MathF.Cos( end ), MathF.Sin( end ) ) * Radius * _scale;\n\n\t\tArrowHead( tip, new Vector2( -MathF.Sin( end ), MathF.Cos( end ) ), color, 3.6f );\n\t}\n\n\t/// <summary>The profile at one station of a sweep: a diamond spanning the swept band, drawn\n\t/// ACROSS the path rather than lying flat, because a sweep takes its profile perpendicular to\n\t/// where it is going \u2014 see SweepFeature.</summary>\n\tprivate static void SweepStation( Vector2 hub, float radius, float half, float degrees, Color color, float width )\n\t{\n\t\tvar angle = degrees * MathF.PI / 180f;\n\t\tvar radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\n\t\tvar tangent = new Vector2( -radial.y, radial.x );\n\t\tvar centre = hub + radial * radius * _scale;\n\n\t\tStroked( color, width );\n\t\tOutline(\n\t\t\tcentre + radial * half * _scale,\n\t\t\tcentre + tangent * half * 0.62f * _scale,\n\t\t\tcentre - radial * half * _scale,\n\t\t\tcentre - tangent * half * 0.62f * _scale );\n\t}\n\n\t/// <summary>\n\t/// Two sections, and the skin ruled between them.\n\t///\n\t/// The sections are drawn as flat diamonds \u2014 the same plan-view profile Extrude and Sweep use,\n\t/// so a closed sketch reads the same way everywhere on this strip \u2014 one small and one large, so\n\t/// what lies between them has to be a loft rather than an extrusion. The sides are STRAIGHT,\n\t/// which is what the kernel actually does: neighbouring sections joined by a ruled surface, not\n\t/// a spline smoothly through them.\n\t/// </summary>\n\tprivate static void PaintLoft( Vector2 c, Color color )\n\t{\n\t\tconst float TopY = -7f;\n\t\tconst float TopHalf = 3.4f;\n\t\tconst float BottomY = 6.6f;\n\t\tconst float BottomHalf = 7.6f;\n\n\t\t// The skin, as a tint between the two sections \u2014 same weight as Chamfer and Shell, so it\n\t\t// reads as material rather than as two more lines.\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -TopHalf, TopY ), At( c, TopHalf, TopY ),\n\t\t\tAt( c, BottomHalf, BottomY ), At( c, -BottomHalf, BottomY ) );\n\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -TopHalf, TopY ), At( c, -BottomHalf, BottomY ) );\n\t\tEditor.Paint.DrawLine( At( c, TopHalf, TopY ), At( c, BottomHalf, BottomY ) );\n\n\t\tLoftSection( c, TopY, TopHalf, TopHalf * 0.46f, color );\n\t\tLoftSection( c, BottomY, BottomHalf, BottomHalf * 0.32f, color );\n\t}\n\n\t/// <summary>One loft section, in plan: a diamond as wide as the section and shallow enough to\n\t/// read as lying flat, rather than as the top and bottom edges of a trapezium.</summary>\n\tprivate static void LoftSection( Vector2 c, float y, float half, float depth, Color color )\n\t{\n\t\tStroked( color, 1.5f );\n\t\tOutline( At( c, -half, y ), At( c, 0, y - depth ), At( c, half, y ), At( c, 0, y + depth ) );\n\t}\n\n\t/// <summary>\n\t/// A solid block with its corner cut away, the cut face called out.\n\t///\n\t/// The old glyph was an outlined square with a small nick in one corner, which at toolbar size\n\t/// is a page icon and nothing else. Two changes fix it: fill the body, so it reads as a solid\n\t/// rather than as a sheet, and cut deep enough that the chamfer is a face rather than a nick.\n\t/// The faint lines show the corner that was removed.\n\t/// </summary>\n\tprivate static void PaintChamfer( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\tStroked( color, 1.5f );\n\t\tOutline( At( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\t// The cut face, in the same amber the sketch pencil draws with \u2014 the accent on this strip\n\t\t// means \"the thing this operation did\".\n\t\tStroked( ClickColor, 2.8f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -1.5f, -7 ) );\n\n\t\tStroked( color.WithAlpha( 0.3f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );\n\t}\n\n\t/// <summary>\n\t/// The chamfer's twin, and deliberately so: the same solid, the same corner gone, the same\n\t/// ghost of the corner that was removed \u2014 the ONLY difference is that the accent is an arc\n\t/// instead of a straight line.\n\t///\n\t/// That is the whole point. These two sit next to each other on the strip and the thing a\n\t/// person needs to tell apart at 40px is round versus flat, which a shared body makes obvious\n\t/// and two unrelated drawings would bury.\n\t/// </summary>\n\tprivate static void PaintFillet( Vector2 c, Color color )\n\t{\n\t\t// The arc's centre is the inner corner of the cut, so it runs from (-7,-1.5) to (-1.5,-7)\n\t\t// exactly where the chamfer's straight cut does.\n\t\tvar arc = ArcPoints( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );\n\n\t\tvar body = new List<Vector2>( arc );\n\t\tbody.Add( At( c, 7, -7 ) );\n\t\tbody.Add( At( c, 7, 7 ) );\n\t\tbody.Add( At( c, -7, 7 ) );\n\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon( body.ToArray() );\n\n\t\tStroked( color, 1.5f );\n\t\tOutline( body.ToArray() );\n\n\t\tStroked( ClickColor, 2.8f );\n\t\tArc( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );\n\n\t\tStroked( color.WithAlpha( 0.3f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );\n\t}\n\n\t/// <summary>The points Arc walks, for a glyph that needs the arc as part of a filled outline\n\t/// rather than as a stroke. Same maths, so the fill and the stroke cannot drift apart.</summary>\n\tprivate static List<Vector2> ArcPoints( Vector2 center, float radius,\n\t\tfloat fromDegrees, float toDegrees, int segments )\n\t{\n\t\tvar points = new List<Vector2>( segments + 1 );\n\n\t\tfor ( var i = 0; i <= segments; i++ )\n\t\t{\n\t\t\tvar t = fromDegrees + (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\n\t\t\tpoints.Add( center + new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale );\n\t\t}\n\n\t\treturn points;\n\t}\n\n\t/// <summary>\n\t/// A hollowed solid in section: material on three sides, opening at the top.\n\t///\n\t/// A square inside a square is a frame, a border, a picture \u2014 it was never going to say\n\t/// \"hollowed to a wall thickness\". THE WALL IS THE OBJECT, so the wall is what gets filled and\n\t/// the void is what gets left out, which is how a section drawing says it.\n\t/// </summary>\n\t/// <summary>\n\t/// Two overlapping squares with the shared region solid \u2014 a boolean, drawn as what it operates\n\t/// on rather than as which of the three it is.\n\t///\n\t/// ONE GLYPH FOR ALL THREE OPERATIONS, because the button carries a dropdown and the variant\n\t/// chosen is named on it. A Venn lens is the universal mark for this and reads at twenty-four\n\t/// pixels; three near-identical lenses differing only in which part is filled do not, and the\n\t/// one thing worse than an icon you have to think about is three you have to tell apart.\n\t///\n\t/// SQUARES RATHER THAN CIRCLES so the overlap is exact: the intersection of two axis-aligned\n\t/// rectangles is a rectangle, which DrawRect can fill honestly. Two circles would need the lens\n\t/// approximated by a polygon, and at this size the approximation is what you would see.\n\t/// </summary>\n\tprivate static void PaintBoolean( Vector2 c, Color color )\n\t{\n\t\t// The shared volume first, so the outlines drawn over it keep their edges crisp.\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -2f, -3f, 4f, 6f ), 0.8f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.85f ), 1.2f );\n\t\tEditor.Paint.DrawRect( Box( c, -7f, -6f, 9f, 9f ), 1.4f * _scale );\n\t\tEditor.Paint.DrawRect( Box( c, -2f, -3f, 9f, 9f ), 1.4f * _scale );\n\t}\n\n\tprivate static void PaintShell( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -7.8f, -7f ), At( c, -3.6f, -7f ), At( c, -3.6f, 3.2f ),\n\t\t\tAt( c, 3.6f, 3.2f ), At( c, 3.6f, -7f ), At( c, 7.8f, -7f ),\n\t\t\tAt( c, 7.8f, 7.4f ), At( c, -7.8f, 7.4f ) );\n\n\t\t// The opening, as a faint lid line, so the U reads as a container rather than as a letter.\n\t\tStroked( color.WithAlpha( 0.45f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -3.6f, -7f ), At( c, 3.6f, -7f ) );\n\t}\n\n\t/// <summary>\n\t/// A quad split into four, with one of those four split again \u2014 subdivision, drawn literally.\n\t///\n\t/// The old glyph was a rounded square with a cross and a dot in the middle, which is the\n\t/// universal \"add\" icon and was read as one. Showing one quadrant DENSER than its neighbours is\n\t/// what the operation actually does, and the density is carried by a tint as well as by lines so\n\t/// it survives being small \u2014 at twenty-four pixels a 4x4 of hairlines is a grey smear.\n\t/// </summary>\n\tprivate static void PaintSubdivide( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.6f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\n\t\tFilled( color.WithAlpha( 0.3f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -8 ), At( c, 0, -8 ), At( c, 0, 0 ), At( c, -8, 0 ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, 0 ), At( c, 8, 0 ) );\n\n\t\tStroked( color.WithAlpha( 0.85f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -4, -8 ), At( c, -4, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, -4 ), At( c, 0, -4 ) );\n\t}\n\n\t/// <summary>\n\t/// The Subdivide glyph read backwards: a faint fine grid, and a bold coarse one over it.\n\t///\n\t/// DELIBERATELY THE SAME SQUARE. The two operations are the same axis in opposite directions,\n\t/// and a button that looked unrelated to Subdivide would hide that \u2014 they sit next to each other\n\t/// on two bars. What tells them apart is which grid is emphasised: Subdivide draws the fine\n\t/// lines solid because those are the ones it is adding, and this draws them faint because those\n\t/// are the ones going away.\n\t/// </summary>\n\tprivate static void PaintRemesh( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.6f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\n\t\t// What is being lost.\n\t\tStroked( color.WithAlpha( 0.28f ), 1f );\n\t\tfor ( var i = -4f; i < 8f; i += 4f )\n\t\t{\n\t\t\tEditor.Paint.DrawLine( At( c, i, -8 ), At( c, i, 8 ) );\n\t\t\tEditor.Paint.DrawLine( At( c, -8, i ), At( c, 8, i ) );\n\t\t}\n\n\t\t// What is left: one diagonal across the whole square, so it reads as two big triangles\n\t\t// rather than as a coarser grid of the same thing.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -8, 8 ), At( c, 8, -8 ) );\n\t}\n\n\t/// <summary>A solid shape and its reflection across a dashed mirror line.</summary>\n\tprivate static void PaintMirror( Vector2 c, Color color )\n\t{\n\t\t// Mirror plane, dashed.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.2f );\n\t\tfor ( var y = -8f; y < 8f; y += 3.6f )\n\t\t\tEditor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 2.1f ) );\n\n\t\t// Source: solid.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon( At( c, -2.4f, -5.6f ), At( c, -8, 0 ), At( c, -2.4f, 5.6f ) );\n\n\t\t// Reflection: outlined, so the two are not mistaken for a pattern.\n\t\tStroked( color );\n\t\tOutline( At( c, 2.4f, -5.6f ), At( c, 8, 0 ), At( c, 2.4f, 5.6f ) );\n\t}\n\n\t/// <summary>\n\t/// A plane placed clear of the one it came from: a faint parallelogram below, a solid one above,\n\t/// and the gap between them called out.\n\t///\n\t/// TWO OF THEM, NOT ONE. A single parallelogram is what \"face\" or \"surface\" looks like in every\n\t/// icon set there is, and this tool is not about a plane \u2014 it is about a plane's RELATIONSHIP to\n\t/// something else. The pair says offset without a caption, and the arrow between them says which\n\t/// of the two is the new one.\n\t/// </summary>\n\tprivate static void PaintPlane( Vector2 c, Color color )\n\t{\n\t\t// The reference: faint, because it is what you already had.\n\t\tStroked( color.WithAlpha( 0.4f ) );\n\t\tOutline( Parallelogram( c, 3.9f ) );\n\n\t\t// The new plane, filled just enough to read as a surface rather than as a wire loop.\n\t\tFilled( color.WithAlpha( 0.18f ) );\n\t\tEditor.Paint.DrawPolygon( Parallelogram( c, -3.9f ) );\n\n\t\tStroked( color );\n\t\tOutline( Parallelogram( c, -3.9f ) );\n\n\t\t// The offset itself, pointing at the plane that was made rather than the one it came from.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, 0, 3.2f ), At( c, 0, -2.2f ) );\n\t\tArrowHead( At( c, 0, -3.2f ), new Vector2( 0, -1 ), color.WithAlpha( 0.75f ), 2.6f );\n\t}\n\n\t/// <summary>A plane in three-quarter view, centred on <paramref name=\"y\"/>. Shared by the two\n\t/// halves of the Plane glyph so they cannot drift into different shapes.</summary>\n\tprivate static Vector2[] Parallelogram( Vector2 c, float y )\n\t{\n\t\tconst float Half = 5.4f;\n\t\tconst float SkewX = 2.5f;\n\t\tconst float SkewY = 2.2f;\n\n\t\treturn new[]\n\t\t{\n\t\t\tAt( c, -Half + SkewX, y - SkewY ),\n\t\t\tAt( c, Half + SkewX, y - SkewY ),\n\t\t\tAt( c, Half - SkewX, y + SkewY ),\n\t\t\tAt( c, -Half - SkewX, y + SkewY ),\n\t\t};\n\t}\n\n\t/// <summary>One body copied along a direction \u2014 first solid, copies outlined and fading.</summary>\n\tprivate static void PaintLinearPattern( Vector2 c, Color color )\n\t{\n\t\t// TWO FAULTS, AND THE SECOND IS THE ONE THAT MATTERED. Three 6-unit squares starting at\n\t\t// -8.4 ran to +12, so the glyph was 20.4 units wide in a box of 18 - and, worse, its centre\n\t\t// of mass sat 1.8 units RIGHT of the button's centre, because the run was never balanced\n\t\t// about it. An off-centre glyph in a row of centred ones is visible long before an\n\t\t// oversized one is, and neither is visible while looking at this icon on its own.\n\t\t//\n\t\t// Three 5.2 squares with 1.2 between them is 18 exactly, laid out symmetrically about c.\n\t\tconst float Square = 5.2f;\n\t\tconst float Gap = 1.2f;\n\t\tconst float First = -9f;\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawRect( Box( c, First, -Square / 2f, Square, Square ), 1.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, First + Square + Gap, -Square / 2f, Square, Square ), 1.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.45f ) );\n\t\tEditor.Paint.DrawRect( Box( c, First + 2f * (Square + Gap), -Square / 2f, Square, Square ), 1.2f * _scale );\n\t}\n\n\t/// <summary>\n\t/// Copies stepped around an axis, on a ring that can actually be seen.\n\t///\n\t/// The old glyph drew the ring as twelve dashes, and at toolbar size twelve dashes are a faint\n\t/// smudge \u2014 which left three small squares floating with nothing to explain them. A solid thin\n\t/// ring and a dot at the centre cost less ink and say more, and the copies are smaller than they\n\t/// were so they sit ON the ring instead of swallowing it.\n\t/// </summary>\n\tprivate static void PaintCircularPattern( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.7f ), 1.4f );\n\t\tArc( c, 6.6f, 0f, 360f, 40 );\n\n\t\tFilled( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -1.3f, -1.3f, 2.6f, 2.6f ), 1.3f * _scale );\n\n\t\tvar angles = new[] { -90f, 30f, 150f };\n\n\t\tfor ( var i = 0; i < angles.Length; i++ )\n\t\t{\n\t\t\tvar radians = angles[i] * MathF.PI / 180f;\n\n\t\t\tvar box = Box( c,\n\t\t\t\tMathF.Cos( radians ) * 6.6f - 2.5f,\n\t\t\t\tMathF.Sin( radians ) * 6.6f - 2.5f, 5f, 5f );\n\n\t\t\t// One filled, the rest outlined \u2014 the same \"this is the original, these are the copies\"\n\t\t\t// grammar the linear pattern uses, so the pair read as a family.\n\t\t\tif ( i == 0 )\n\t\t\t{\n\t\t\t\tFilled( color );\n\t\t\t\tEditor.Paint.DrawRect( box, 1f * _scale );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tStroked( color.WithAlpha( 0.9f ), 1.5f );\n\t\t\t\tEditor.Paint.DrawRect( box, 1f * _scale );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>Move/rotate/scale \u2014 a body with four-way translation arrows through it.</summary>\n\tprivate static void PaintTransform( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ) );\n\t\tOutline( At( c, -4, -4 ), At( c, 4, -4 ), At( c, 4, 4 ), At( c, -4, 4 ) );\n\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -5.4f ), At( c, 0, 5.4f ) );\n\t\tEditor.Paint.DrawLine( At( c, -5.4f, 0 ), At( c, 5.4f, 0 ) );\n\n\t\tArrowHead( At( c, 0, -8.2f ), new Vector2( 0, -1 ), color, 3f );\n\t\tArrowHead( At( c, 0, 8.2f ), new Vector2( 0, 1 ), color, 3f );\n\t\tArrowHead( At( c, -8.2f, 0 ), new Vector2( -1, 0 ), color, 3f );\n\t\tArrowHead( At( c, 8.2f, 0 ), new Vector2( 1, 0 ), color, 3f );\n\t}\n\n\t/// <summary>A UV grid with one texel lit, and the projection arriving from off-surface.</summary>\n\tprivate static void PaintUVProject( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -7, -2 ), At( c, 7, -2 ), At( c, 7, 8 ), At( c, -7, 8 ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -2.4f, -2 ), At( c, -2.4f, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.4f, -2 ), At( c, 2.4f, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, 3 ), At( c, 7, 3 ) );\n\n\t\t// One lit texel, so the grid reads as a texture rather than a wireframe.\n\t\tFilled( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -6.3f, -1.3f, 3.8f, 3.6f ), 0.6f * _scale );\n\n\t\t// The projection coming down onto it.\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8.4f ), At( c, 0, -4.4f ) );\n\t\tArrowHead( At( c, 0, -2.6f ), new Vector2( 0, 1 ), color, 3f );\n\t}\n\n\t/// <summary>A cube with ONE of its three visible faces filled \u2014 the operation is \"this face,\n\t/// not that one\", so what the glyph has to show is faces being told apart. A paint pot or a\n\t/// swatch would say \"material\" without saying \"per face\", which is the whole distinction.</summary>\n\tprivate static void PaintFaceMaterial( Vector2 c, Color color )\n\t{\n\t\t// Isometric cube: top rhombus, then the two visible side quads.\n\t\tvar top = At( c, 0, -8 );\n\t\tvar right = At( c, 8, -3.5f );\n\t\tvar bottom = At( c, 0, 1 );\n\t\tvar left = At( c, -8, -3.5f );\n\n\t\t// The lit face, filled. DrawPolygon fills, which is exactly what is wanted here and is why\n\t\t// the other faces are walked as lines instead.\n\t\tFilled( color.WithAlpha( 0.85f ) );\n\t\tEditor.Paint.DrawPolygon( top, right, bottom, left );\n\n\t\tStroked( color );\n\t\tOutline( top, right, bottom, left );\n\n\t\t// The two side faces, left plain so the filled top reads as the odd one out.\n\t\tvar lowLeft = At( c, -8, 5.5f );\n\t\tvar lowMid = At( c, 0, 10 );\n\t\tvar lowRight = At( c, 8, 5.5f );\n\n\t\tOutline( left, bottom, lowMid, lowLeft );\n\t\tOutline( bottom, right, lowRight, lowMid );\n\t}\n\n\t// --- sketch tools ---------------------------------------------------------------------------\n\t//\n\t// One rule for the whole row: SHOW THE SHAPE THE TOOL MAKES, AND SHOW HOW IT IS PLACED.\n\t//\n\t// The second half is what earns its keep. Every family behind a chevron draws the identical\n\t// shape and differs only in which points you click \u2014 a corner rectangle and a centre rectangle\n\t// are the same rectangle \u2014 so the shape alone cannot tell them apart. The shape is the body of\n\t// the glyph and the click points are accent dots on it, which makes the pair legible side by\n\t// side without either needing a label.\n\t//\n\t// The dots are annotation and must never outweigh the shape. They were half again this size to\n\t// begin with, which looked right on a large preview and swallowed the geometry at the size these\n\t// are actually seen at.\n\n\t/// <summary>The colour of a click point. Deliberately the one warm accent in a monochrome row,\n\t/// so \"this is where you press\" reads before anything else does.</summary>\n\tprivate static readonly Color ClickColor = new( 1f, 0.77f, 0.24f, 1f );\n\n\t/// <summary>An end that does not join up. Warm rather than red \u2014 this is information, not an\n\t/// error, and a sketch mid-draw is full of them.</summary>\n\tprivate static readonly Color LooseEndColor = new( 1f, 0.48f, 0.36f, 1f );\n\n\t/// <summary>A guide line \u2014 a radius, a diagonal, a centre line. Something the tool uses to place\n\t/// the shape rather than part of the shape itself.</summary>\n\tprivate static Color GuideColor( Color color ) => color.WithAlpha( 0.35f );\n\n\t/// <summary>A filled dot in the nominal icon space. DrawRect with a corner radius of half its\n\t/// own size, since Paint has no circle of its own and this is exact.</summary>\n\tprivate static void Dot( Vector2 center, float radius, Color color )\n\t{\n\t\tFilled( color );\n\t\tEditor.Paint.DrawRect( Box( center, -radius, -radius, radius * 2f, radius * 2f ), radius * _scale );\n\t}\n\n\tprivate static void ClickDot( Vector2 p, float radius = 1.8f ) => Dot( p, radius, ClickColor );\n\n\t/// <summary>The corners of a regular hexagon, for the two polygon tools.</summary>\n\tprivate static Vector2[] Hexagon( Vector2 c, float radius, float rotationDegrees )\n\t{\n\t\tvar points = new Vector2[6];\n\n\t\tfor ( var i = 0; i < 6; i++ )\n\t\t{\n\t\t\tvar a = (rotationDegrees + i * 60f) * MathF.PI / 180f;\n\t\t\tpoints[i] = At( c, MathF.Cos( a ) * radius, MathF.Sin( a ) * radius );\n\t\t}\n\n\t\treturn points;\n\t}\n\n\t/// <summary>A cursor with a point caught under it. Select drags sketch POINTS, which is what the\n\t/// dot says and a bare arrow would not.</summary>\n\tprivate static void PaintSelectTool( Vector2 c, Color color )\n\t{\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4, -8 ), At( c, -4, 4 ), At( c, -1, 1 ), At( c, 1.5f, 6 ),\n\t\t\tAt( c, 4, 5 ), At( c, 1.5f, 0.2f ), At( c, 5, -0.5f ) );\n\n\t\tClickDot( At( c, 5, 5 ), 2.2f );\n\t}\n\n\tprivate static void PaintLineTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );\n\n\t\tClickDot( At( c, -6.5f, 6 ) );\n\t\tClickDot( At( c, 6.5f, -6 ) );\n\t}\n\n\t/// <summary>The same line, marked at its MIDDLE - with the tick mark that means midpoint in every\n\t/// CAD package there is. One click dot, because the second click is an end and the far one comes\n\t/// for free.</summary>\n\tprivate static void PaintLineMidpointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );\n\n\t\t// Across the line rather than along it, so it reads as a mark ON the line instead of a second\n\t\t// shorter line beside it.\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -2.1f, -2.3f ), At( c, 2.1f, 2.3f ) );\n\n\t\tClickDot( c, 1.9f );\n\t\tClickDot( At( c, 6.5f, -6 ) );\n\t}\n\n\t/// <summary>Two opposite corners marked: click one, then the other.</summary>\n\tprivate static void PaintRectangleTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\tClickDot( At( c, -6.5f, -5 ) );\n\t\tClickDot( At( c, 6.5f, 5 ) );\n\t}\n\n\t/// <summary>The same rectangle, marked at its CENTRE instead \u2014 with the half-diagonal it is\n\t/// dragged out along.</summary>\n\tprivate static void PaintRectangleCentreTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( c, At( c, 6.5f, 5 ) );\n\n\t\tClickDot( c, 1.9f );\n\t}\n\n\tprivate static void PaintCircleTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tArc( c, 6.2f, 0, 360, 28 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( c, At( c, 6.2f, 0 ) );\n\n\t\tClickDot( c, 1.9f );\n\t}\n\n\t/// <summary>The same circle with three points ON the rim and no centre \u2014 which is precisely the\n\t/// difference between the two ways of placing it.</summary>\n\tprivate static void PaintCircleThreePointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tArc( c, 6.2f, 0, 360, 28 );\n\n\t\tforeach ( var degrees in new[] { -90f, 30f, 150f } )\n\t\t{\n\t\t\tvar a = degrees * MathF.PI / 180f;\n\t\t\tClickDot( At( c, MathF.Cos( a ) * 6.2f, MathF.Sin( a ) * 6.2f ) );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// An arc standing on its centre, with both radii drawn.\n\t///\n\t/// It was drawn small and off to one side first, and at the size these are actually used it read\n\t/// as a tick mark rather than a curve. An arc has to span the box to look like an arc.\n\t/// </summary>\n\tprivate static void PaintArcTool( Vector2 c, Color color )\n\t{\n\t\t// RADIUS 9, NOT 10.5, AND THE HUB SITS AT 4.2 RATHER THAN 5.5. A radius of 10.5 put the\n\t\t// guide rails 21 units apart inside a box this file says is 18, which made this the second\n\t\t// widest glyph on the strip - and nothing about one icon in isolation shows that. Nine\n\t\t// fills the width exactly, and dropping the hub centres the drawing's own height in the\n\t\t// button instead of hanging it below the middle.\n\t\tvar hub = At( c, 0, 4.2f );\n\n\t\tStroked( color, 1.9f );\n\t\tArc( hub, 9f, 180, 360, 20 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( hub, At( c, -9f, 4.2f ) );\n\t\tEditor.Paint.DrawLine( hub, At( c, 9f, 4.2f ) );\n\n\t\tClickDot( hub, 1.9f );\n\t}\n\n\t/// <summary>The same arc with no centre at all, marked instead at both ends and the point it\n\t/// passes through.</summary>\n\tprivate static void PaintArcThreePointTool( Vector2 c, Color color )\n\t{\n\t\t// A SMALLER ARC THAN PaintArcTool's, ON PURPOSE. This was the widest glyph on the strip by\n\t\t// a distance - 24.6 units against a nominal 18 - and the arc was not what made it so: the\n\t\t// three dots are the whole point of the tool, they sit ON the arc's ends, and a 1.8 dot at\n\t\t// x = 10.5 reaches 12.3. The dots are part of the drawing, so the arc gives up the room for\n\t\t// them rather than the pair of icons disagreeing about how wide an arc glyph is: both now\n\t\t// fill exactly 18 units, which is the measurement the eye actually compares.\n\t\tvar hub = At( c, 0, 4.2f );\n\n\t\tStroked( color, 1.9f );\n\t\tArc( hub, 7.2f, 180, 360, 20 );\n\n\t\tClickDot( At( c, -7.2f, 4.2f ) );\n\t\tClickDot( At( c, 0, -3f ) );\n\t\tClickDot( At( c, 7.2f, 4.2f ) );\n\t}\n\n\t/// <summary>\n\t/// A polygon with its corners ON the circle.\n\t///\n\t/// The circle is drawn brighter than a guide normally would be, because where it sits relative\n\t/// to the polygon IS the whole difference between this and the circumscribed version. Draw it at\n\t/// guide strength and the two glyphs become the same hexagon.\n\t/// </summary>\n\tprivate static void PaintPolygonTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tArc( c, 7f, 0, 360, 28 );\n\n\t\tStroked( color, 1.7f );\n\t\tOutline( Hexagon( c, 7f, -90f ) );\n\n\t\tClickDot( c, 1.7f );\n\t}\n\n\t/// <summary>Edges on the circle instead, so it sits visibly inside the polygon \u2014 the apothem is\n\t/// 0.866 of the radius, a gap wide enough to read small.</summary>\n\tprivate static void PaintPolygonCircumscribedTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tOutline( Hexagon( c, 7.6f, -90f ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tArc( c, 6.6f, 0, 360, 28 );\n\n\t\tClickDot( c, 1.7f );\n\t}\n\n\t/// <summary>A slot, with the centre line you actually click marked at both ends.</summary>\n\tprivate static void PaintSlotTool( Vector2 c, Color color )\n\t{\n\t\tconst float r = 4.6f;\n\n\t\tStroked( color );\n\t\tEditor.Paint.DrawLine( At( c, -3, -r ), At( c, 3, -r ) );\n\t\tEditor.Paint.DrawLine( At( c, -3, r ), At( c, 3, r ) );\n\t\tArc( At( c, 3, 0 ), r, -90, 90, 12 );\n\t\tArc( At( c, -3, 0 ), r, 90, 270, 12 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -3, 0 ), At( c, 3, 0 ) );\n\n\t\tClickDot( At( c, -3, 0 ) );\n\t\tClickDot( At( c, 3, 0 ) );\n\t}\n\n\t/// <summary>Crosshairs around a point. The gap at the centre is what stops it reading as a plus\n\t/// sign.</summary>\n\tprivate static void PaintPointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -7, 0 ), At( c, -2.5f, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, 0 ), At( c, 7, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, -7 ), At( c, 0, -2.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 2.5f ), At( c, 0, 7 ) );\n\n\t\tClickDot( c, 2.2f );\n\t}\n\n\t/// <summary>A dashed line: geometry that guides and never becomes part of a profile. Dashed\n\t/// because that is how construction geometry is drawn in the viewport, so the button and the\n\t/// thing it makes look like each other.</summary>\n\tprivate static void PaintConstructionTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tforeach ( var (from, to) in new[] { (0f, 0.22f), (0.39f, 0.61f), (0.78f, 1f) } )\n\t\t{\n\t\t\tEditor.Paint.DrawLine(\n\t\t\t\tAt( c, -7 + 14 * from, 6 - 12 * from ),\n\t\t\t\tAt( c, -7 + 14 * to, 6 - 12 * to ) );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// A shaded region with a gap in its outline, and the two loose ends called out.\n\t///\n\t/// This is exactly what the inspector shows: which regions closed, and where a chain did not.\n\t/// Drawn first as a stub with a dot on it, which at the size these are seen reads as a box with\n\t/// a speck in the corner and says nothing. The fill has to be solid enough to read as shading and\n\t/// the gap has to be a real hole in the outline.\n\t/// </summary>\n\tprivate static void PaintProfileInspectorTool( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.41f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\t// Walked as an open polyline rather than an outline, because the gap is the point.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -1.6f ), At( c, 6.5f, -5 ) );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -5 ), At( c, -6.5f, -5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, -5 ), At( c, -6.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 5 ), At( c, 6.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, 5 ), At( c, 6.5f, 1.6f ) );\n\n\t\tDot( At( c, 6.5f, -1.6f ), 1.9f, LooseEndColor );\n\t\tDot( At( c, 6.5f, 1.6f ), 1.9f, LooseEndColor );\n\t}\n\n\t/// <summary>A plain tick. The one glyph in the row that must not be clever: it ends the mode,\n\t/// and the confirm colour it is painted in already carries the meaning.</summary>\n\tprivate static void PaintFinishSketchTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 2.2f );\n\t\tEditor.Paint.DrawLine( At( c, -6, 0.5f ), At( c, -1.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, 5 ), At( c, 6.5f, -5 ) );\n\t}\n\n\t// --- sculpt tools ---------------------------------------------------------------------------\n\t//\n\t// EVERY ONE OF THESE IS A SURFACE AND WHAT HAPPENS TO IT. The obvious way to draw six brushes is\n\t// six brush heads with a small badge each, which at 27px is six identical blobs. Drawing the\n\t// EFFECT instead means the row can be read at a glance without learning it: a bump rising, a\n\t// ripple flattening, a peak dragged sideways.\n\t//\n\t// The surface runs across the lower half so every glyph shares a baseline and the row reads as\n\t// one family.\n\n\t/// <summary>A surface with a bump pushed up out of it, and the brush's ring resting on the\n\t/// bump. The feature-strip glyph, so it says \"sculpting\" rather than any one brush.</summary>\n\tprivate static void PaintSculpt( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tSurfaceWithBump( c, 4.5f );\n\n\t\t// The brush ring, seen at a slight angle so it reads as sitting ON the surface.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.3f );\n\t\tArc( At( c, 0, -3.4f ), 5.6f, 0f, 360f, 20 );\n\t}\n\n\t/// <summary>A bump, and an arrow pushing outward from it: draw adds material along the normal.</summary>\n\tprivate static void PaintSculptDraw( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tSurfaceWithBump( c, 4f );\n\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -1.5f ), At( c, 0, -7f ) );\n\t\tArrowHead( At( c, 0, -8f ), new Vector2( 0, -1 ), color );\n\t}\n\n\t/// <summary>A ripple above, the same surface calmed below. Smooth is the one brush whose whole\n\t/// meaning is the difference between two lines.</summary>\n\tprivate static void PaintSculptSmooth( Vector2 c, Color color )\n\t{\n\t\t// Rippled.\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tvar previous = At( c, -8.5f, -4f );\n\n\t\tfor ( var i = 1; i <= 24; i++ )\n\t\t{\n\t\t\tvar t = i / 24f;\n\t\t\tvar x = -8.5f + 17f * t;\n\t\t\tvar y = -4f + MathF.Sin( t * MathF.PI * 3f ) * 2.6f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// Calmed.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, 8.5f, 5f ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -0.5f ), At( c, 0, 2.2f ) );\n\t\tArrowHead( At( c, 0, 3.4f ), new Vector2( 0, 1 ), color.WithAlpha( 0.6f ), 2.8f );\n\t}\n\n\t/// <summary>A closed shape with arrows pushing out all round it \u2014 inflate acts everywhere at\n\t/// once, which is what tells it apart from draw.</summary>\n\tprivate static void PaintSculptInflate( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tArc( c, 4.6f, 0f, 360f, 20 );\n\n\t\tfor ( var i = 0; i < 4; i++ )\n\t\t{\n\t\t\tvar radians = (45f + i * 90f) * MathF.PI / 180f;\n\t\t\tvar dir = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );\n\n\t\t\tStroked( color.WithAlpha( 0.9f ), 1.3f );\n\t\t\tEditor.Paint.DrawLine( c + dir * 5.8f * _scale, c + dir * 8f * _scale );\n\t\t\tArrowHead( c + dir * 9.2f * _scale, dir, color, 2.8f );\n\t\t}\n\t}\n\n\t/// <summary>A surface dragged sideways into a lean, with the pull shown as an arrow. Grab moves\n\t/// what it holds rather than adding to it, so nothing here points along the normal.</summary>\n\tprivate static void PaintSculptGrab( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\t// A peak that leans right, rather than a symmetric bump.\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, -2.5f, 5f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.5f, 5f ), At( c, 2.5f, -3f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, -3f ), At( c, 5.5f, 5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 5.5f, 5f ), At( c, 8.5f, 5f ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -2f, -6f ), At( c, 3.5f, -6f ) );\n\t\tArrowHead( At( c, 5f, -6f ), new Vector2( 1, 0 ), color );\n\t}\n\n\t/// <summary>A bump with a straight edge laid across it \u2014 flatten is a plane meeting a surface.\n\t/// </summary>\n\tprivate static void PaintSculptFlatten( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.65f ), 1.5f );\n\t\tSurfaceWithBump( c, 5.5f );\n\n\t\t// The plane it is being cut back to.\n\t\tStroked( color, 2f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, -2.5f ), At( c, 8.5f, -2.5f ) );\n\t}\n\n\t/// <summary>Two arrows squeezing towards one ridge. Pinch gathers a surface rather than moving\n\t/// it, so both arrows point inward at the same line.</summary>\n\tprivate static void PaintSculptPinch( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -7.5f ), At( c, 0, 7.5f ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -8f, 0 ), At( c, -3.5f, 0 ) );\n\t\tArrowHead( At( c, -2.2f, 0 ), new Vector2( 1, 0 ), color );\n\n\t\tEditor.Paint.DrawLine( At( c, 8f, 0 ), At( c, 3.5f, 0 ) );\n\t\tArrowHead( At( c, 2.2f, 0 ), new Vector2( -1, 0 ), color );\n\t}\n\n\t/// <summary>A patch of the surface hatched off. Masking protects rather than shapes, so this is\n\t/// the one sculpt glyph that is not a deformation.</summary>\n\tprivate static void PaintSculptMask( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tOutline( At( c, -8, -6.5f ), At( c, 8, -6.5f ), At( c, 8, 6.5f ), At( c, -8, 6.5f ) );\n\n\t\t// Hatching, the universal \"held back\" texture.\n\t\tStroked( color.WithAlpha( 0.7f ), 1.2f );\n\n\t\tfor ( var x = -6f; x <= 8f; x += 3.4f )\n\t\t{\n\t\t\tvar top = MathF.Max( x - 13f, -8f );\n\t\t\tvar bottom = MathF.Min( x, 8f );\n\n\t\t\tEditor.Paint.DrawLine( At( c, bottom, -6.5f ), At( c, top, 6.5f ) );\n\t\t}\n\t}\n\n\t/// <summary>A coarse grid with a chevron down: fewer, bigger faces.</summary>\n\tprivate static void PaintSculptLevelDown( Vector2 c, Color color ) => PaintSculptLevel( c, color, 2, down: true );\n\n\t/// <summary>A fine grid with a chevron up: four times the faces, which is the whole cost.\n\t/// </summary>\n\tprivate static void PaintSculptLevelUp( Vector2 c, Color color ) => PaintSculptLevel( c, color, 4, down: false );\n\n\tprivate static void PaintSculptLevel( Vector2 c, Color color, int divisions, bool down )\n\t{\n\t\tconst float Half = 6.5f;\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tOutline( At( c, -Half, -Half - 1.5f ), At( c, Half, -Half - 1.5f ),\n\t\t\tAt( c, Half, Half - 1.5f ), At( c, -Half, Half - 1.5f ) );\n\n\t\tStroked( color.WithAlpha( 0.65f ), 1f );\n\n\t\tfor ( var i = 1; i < divisions; i++ )\n\t\t{\n\t\t\tvar t = -Half + i * (Half * 2f / divisions);\n\n\t\t\tEditor.Paint.DrawLine( At( c, t, -Half - 1.5f ), At( c, t, Half - 1.5f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, -Half, t - 1.5f ), At( c, Half, t - 1.5f ) );\n\t\t}\n\n\t\t// The chevron, below the grid so the two never overlap at strip size.\n\t\tStroked( color, 1.8f );\n\n\t\tif ( down )\n\t\t{\n\t\t\tEditor.Paint.DrawLine( At( c, -3.5f, 6f ), At( c, 0, 9f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, 0, 9f ), At( c, 3.5f, 6f ) );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tEditor.Paint.DrawLine( At( c, -3.5f, 9f ), At( c, 0, 6f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, 0, 6f ), At( c, 3.5f, 9f ) );\n\t\t}\n\t}\n\n\t/// <summary>A dense surface collapsing into a flat square: the sculpt becoming a texture, which\n\t/// is the whole point of the pipeline and the one operation here that produces a file.</summary>\n\t/// <summary>\n\t/// A grease pencil laid over a wavy scribble.\n\t///\n\t/// NOT THE SKETCH PENCIL, which this sits two buttons away from and must not be mistaken for.\n\t/// That one is a sharp #2 drawing a straight line and it makes geometry; this is a fat blunt\n\t/// marker over a loose squiggle, and the squiggle is doing the work \u2014 a scribble is what\n\t/// handwriting looks like at 18px, and nothing that produces a solid in this bar is drawn\n\t/// scribbly.\n\t/// </summary>\n\tprivate static void PaintNoteTool( Vector2 c, Color color )\n\t{\n\t\t// The scribble first, so the marker sits on top of it the way a pen sits on its own line.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.5f );\n\n\t\tvar previous = At( c, -8f, 5.5f );\n\n\t\tfor ( var i = 1; i <= 20; i++ )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar point = At( c, -8f + 13f * t, 5.5f + MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// The barrel, drawn as a slab on the diagonal rather than the sketch pencil's thin shaft.\n\t\tvar tip = At( c, -5.5f, 1.5f );\n\t\tvar along = (At( c, 7f, -7.5f ) - tip).Normal;\n\t\tvar across = new Vector2( -along.y, along.x ) * (2.6f * _scale);\n\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\ttip + across * 0.35f,\n\t\t\tAt( c, 7f, -7.5f ) + across,\n\t\t\tAt( c, 8.5f, -8.5f ) + across,\n\t\t\tAt( c, 8.5f, -8.5f ) - across,\n\t\t\tAt( c, 7f, -7.5f ) - across,\n\t\t\ttip - across * 0.35f );\n\n\t\t// The nib, filled: the one part of a marker that is a different colour from the barrel, and\n\t\t// what makes the shape read as pointing at the scribble rather than away from it.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon( tip, At( c, -3.2f, -0.4f ) + across * 0.75f, At( c, -3.2f, -0.4f ) - across * 0.75f );\n\t}\n\n\t/// <summary>An eraser on the same scribble, taking a bite out of it. The gap in the line is the\n\t/// whole glyph \u2014 an eraser drawn hovering over an intact scribble is just a second block\n\t/// shape.</summary>\n\tprivate static void PaintNoteEraseTool( Vector2 c, Color color )\n\t{\n\t\t// Left half of the scribble survives; the right half is where the eraser has been.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.5f );\n\n\t\tvar previous = At( c, -8.5f, 5.5f );\n\n\t\tfor ( var i = 1; i <= 10; i++ )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar point = At( c, -8.5f + 13f * t, 5.5f + MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// The block, tilted so it reads as being pushed along the line rather than parked on it.\n\t\tvar down = new Vector2( 0.34f, 0.94f );\n\t\tvar side = new Vector2( -down.y, down.x );\n\t\tvar centre = At( c, 3f, -1f );\n\t\tvar half = 4.6f * _scale;\n\t\tvar length = 5.6f * _scale;\n\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\tcentre - down * length + side * half,\n\t\t\tcentre + down * length + side * half,\n\t\t\tcentre + down * length - side * half,\n\t\t\tcentre - down * length - side * half );\n\n\t\t// The ferrule line across it, which is what separates an eraser from a plain rectangle.\n\t\tEditor.Paint.DrawLine( centre + side * half, centre - side * half );\n\t}\n\n\tprivate static void PaintSculptBake( Vector2 c, Color color )\n\t{\n\t\t// The sculpted surface, up top.\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tvar previous = At( c, -8.5f, -5f );\n\n\t\tfor ( var i = 1; i <= 20; i++ )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar x = -8.5f + 17f * t;\n\t\t\tvar y = -5f + MathF.Sin( t * MathF.PI * 2f ) * 2.2f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// Into the map.\n\t\tStroked( color.WithAlpha( 0.7f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -1f ), At( c, 0, 1.6f ) );\n\t\tArrowHead( At( c, 0, 2.8f ), new Vector2( 0, 1 ), color.WithAlpha( 0.7f ), 2.8f );\n\n\t\tStroked( color, 1.6f );\n\t\tOutline( At( c, -7, 4f ), At( c, 7, 4f ), At( c, 7, 9f ), At( c, -7, 9f ) );\n\n\t\tFilled( color.WithAlpha( 0.3f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -7, 4f, 14f, 5f ) );\n\t}\n\n\t/// <summary>A paint brush: a handle, a ferrule, and a bristle tip dragging a stroke of colour\n\t/// behind it. The stroke is the point \u2014 this is a tool that leaves marks, not one that changes\n\t/// the surface's shape.</summary>\n\tprivate static void PaintPaint( Vector2 c, Color color )\n\t{\n\t\t// The stroke it leaves, drawn first so it sits behind the brush.\n\t\tStroked( color.WithAlpha( 0.7f ), 2.4f );\n\t\tEditor.Paint.DrawLine( At( c, 4.5f, 5f ), At( c, -1f, -6f ) );\n\n\t\t// The handle, tilted.\n\t\tStroked( color, 2f );\n\t\tEditor.Paint.DrawLine( At( c, -3f, 8f ), At( c, -1.5f, 3.5f ) );\n\n\t\t// The ferrule.\n\t\tEditor.Paint.DrawLine( At( c, -2f, 3.5f ), At( c, 2.5f, 1.2f ) );\n\n\t\t// The bristle tip.\n\t\tFilled( color );\n\t\tOutline( At( c, 1.6f, 1f ), At( c, 3.4f, -2f ), At( c, 4.5f, -3.6f ), At( c, 6.4f, -1.8f ) );\n\t}\n\n\t/// <summary>The same brush as PaintPaint, with the ferrule separated by a hair so the stroke\n\t/// reads as a wide band rather than a line.</summary>\n\tprivate static void PaintPaintBrush( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.7f ), 3.2f );\n\t\tEditor.Paint.DrawLine( At( c, 5f, 4f ), At( c, -1f, -7f ) );\n\n\t\tStroked( color, 2f );\n\t\tEditor.Paint.DrawLine( At( c, -3f, 8f ), At( c, -1.5f, 3.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2f, 3.5f ), At( c, 2.5f, 1.2f ) );\n\n\t\tFilled( color );\n\t\tOutline( At( c, 1.6f, 1f ), At( c, 3.4f, -2f ), At( c, 4.5f, -3.6f ), At( c, 6.4f, -1.8f ) );\n\t}\n\n\t/// <summary>An eraser block, worn at one corner, with its band cut away. Drawn as stationery\n\t/// rather than as a mark, for the same reason the notes are: it removes rather than adds.</summary>\n\tprivate static void PaintPaintEraser( Vector2 c, Color color )\n\t{\n\t\tvar half = 4.5f;\n\t\tvar length = 7f;\n\n\t\tStroked( color.WithAlpha( 0.7f ), 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6f, 3.5f ), At( c, 3f, -6f ) );\n\n\t\tStroked( color, 1.8f );\n\t\tvar topLeft = At( c, -half, -length );\n\t\tvar topRight = At( c, half, -length );\n\t\tvar bottomLeft = At( c, -half, length );\n\n\t\t// Worn corner: the bottom-right corner is cut off.\n\t\tOutline( topLeft, topRight, At( c, half, 2f ), At( c, 2f, length ), bottomLeft );\n\n\t\t// The band.\n\t\tEditor.Paint.DrawLine( At( c, -half, -3f ), At( c, half, -3f ) );\n\t}\n\n\n\t/// <summary>The shared baseline: a flat surface with one smooth bump in the middle of it. Every\n\t/// brush glyph starts from this so the row reads as one family acting on one thing.</summary>\n\tprivate static void SurfaceWithBump( Vector2 c, float height )\n\t{\n\t\tvar previous = At( c, -8.5f, 5f );\n\n\t\tfor ( var i = 1; i <= 24; i++ )\n\t\t{\n\t\t\tvar t = i / 24f;\n\t\t\tvar x = -8.5f + 17f * t;\n\n\t\t\t// A raised cosine, flat at both ends so it meets the surface without a corner.\n\t\t\tvar bump = 0.5f * (1f + MathF.Cos( MathF.Max( MathF.Min( x / 5.5f, 1f ), -1f ) * MathF.PI ));\n\t\t\tvar point = At( c, x, 5f - bump * height );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// A wall leaning off vertical, with the vertical it leans from left dashed beside it.\n\t///\n\t/// The angle IS the operation, so the glyph is the angle. Drawing a moulded part instead would\n\t/// say \"moulding\" and leave you guessing which of the six tools on the strip does the leaning.\n\t/// </summary>\n\t/// <summary>\n\t/// A face lifting off the solid it belongs to: the body drawn faintly where it was, the face\n\t/// itself solid at its new height, and an arrow between them.\n\t///\n\t/// THE GHOST IS THE WHOLE GLYPH. Without it this is an arrow over a rectangle, which is what\n\t/// Transform looks like; with it, the picture is of one face of a part having moved and the rest\n\t/// having stayed \u2014 which is exactly what the tool does and what tells it apart from Draft\n\t/// standing next to it.\n\t/// </summary>\n\tprivate static void PaintMoveFace( Vector2 c, Color color )\n\t{\n\t\t// Where the face was, and the walls that stretched to follow it.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.1f );\n\t\tOutline( At( c, -7f, 1f ), At( c, 7f, 1f ), At( c, 7f, 8f ), At( c, -7f, 8f ) );\n\n\t\t// The face, at its new height.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, -5f ), At( c, 7f, -5f ) );\n\n\t\t// The sides it dragged up with it.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, -5f ), At( c, -7f, 1f ) );\n\t\tEditor.Paint.DrawLine( At( c, 7f, -5f ), At( c, 7f, 1f ) );\n\n\t\t// Which way it went.\n\t\tStroked( color, 1.6f );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -1f ), At( c, 0f, -8f ) );\n\t\tEditor.Paint.DrawLine( At( c, -3f, -5f ), At( c, 0f, -8f ) );\n\t\tEditor.Paint.DrawLine( At( c, 3f, -5f ), At( c, 0f, -8f ) );\n\t}\n\n\tprivate static void PaintDraft( Vector2 c, Color color )\n\t{\n\t\t// The parting line the taper is measured from.\n\t\tStroked( color.WithAlpha( 0.45f ), 1.1f );\n\t\tfor ( var x = -9f; x < 9f; x += 3.4f )\n\t\t\tEditor.Paint.DrawLine( At( c, x, 0 ), At( c, x + 2f, 0 ) );\n\n\t\t// The tapered wall: narrow at the top, wide at the bottom, closed as an outline.\n\t\tStroked( color, 1.7f );\n\t\tOutline( At( c, -3.4f, -8 ), At( c, 3.4f, -8 ), At( c, 6.6f, 8 ), At( c, -6.6f, 8 ) );\n\n\t\t// The vertical it is leaning away from, so the lean reads as deliberate rather than as a\n\t\t// wonky rectangle.\n\t\tStroked( color.WithAlpha( 0.5f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, 3.4f, -8 ), At( c, 3.4f, 8 ) );\n\t}\n\n\t/// <summary>\n\t/// A counterbore in section: a wide mouth stepping down to a narrow shaft, through a plate.\n\t///\n\t/// Drawn as a SECTION rather than as a circle on a surface, because a circle is what every other\n\t/// round thing on this strip already looks like from above - and the step is the whole reason\n\t/// this is a feature rather than a sketched circle.\n\t/// </summary>\n\tprivate static void PaintHole( Vector2 c, Color color )\n\t{\n\t\t// The plate, in section.\n\t\tStroked( color.WithAlpha( 0.8f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, -9, -6 ), At( c, -3.2f, -6 ) );\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 9, -6 ) );\n\t\tEditor.Paint.DrawLine( At( c, -9, 7 ), At( c, 9, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -9, -6 ), At( c, -9, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 9, -6 ), At( c, 9, 7 ) );\n\n\t\t// The bore: wide at the mouth, stepping in to the shaft.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -3.2f, -6 ), At( c, -3.2f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, -3.2f, -1 ), At( c, -1.4f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.4f, -1 ), At( c, -1.4f, 7 ) );\n\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 3.2f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -1 ), At( c, 1.4f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 1.4f, -1 ), At( c, 1.4f, 7 ) );\n\n\t\t// The void itself, so the shape reads as absence rather than as a post.\n\t\tFilled( color.WithAlpha( 0.18f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -3.2f, -6 ), At( c, 3.2f, -6 ), At( c, 3.2f, -1 ), At( c, 1.4f, -1 ),\n\t\t\tAt( c, 1.4f, 7 ), At( c, -1.4f, 7 ), At( c, -1.4f, -1 ), At( c, -3.2f, -1 ) );\n\t}\n\t// --- the six sketch tools -------------------------------------------------------------------\n\t//\n\t// The four EDIT tools all show the same thing: a curve, and what the tool does to it, with the\n\t// part being removed or added drawn faintly. A trim that showed only the result would be\n\t// indistinguishable from a plain line at 27 pixels.\n\n\t/// <summary>An ellipse, with its long axis marked so it is not mistaken for a circle.</summary>\n\tprivate static void PaintEllipseTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i <= 40; i++ )\n\t\t{\n\t\t\tvar a = i / 40f * MathF.PI * 2f;\n\t\t\tvar point = At( c, MathF.Cos( a ) * 8.6f, MathF.Sin( a ) * 5f );\n\n\t\t\tif ( i > 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\n\t\tStroked( color.WithAlpha( 0.5f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -8.6f, 0 ), At( c, 8.6f, 0 ) );\n\t}\n\n\t/// <summary>A curve through its control points, which is what a spline IS - the points are the\n\t/// thing you place and the curve is what follows.</summary>\n\tprivate static void PaintSplineTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tvar previous = Vector2.Zero;\n\n\t\t// A cubic-ish wiggle through the three dots below.\n\t\tfor ( var i = 0; i <= 32; i++ )\n\t\t{\n\t\t\tvar t = i / 32f;\n\t\t\tvar x = -7.5f + 15f * t;\n\t\t\tvar y = MathF.Sin( t * MathF.PI * 1.6f + 0.4f ) * 5.2f - 1f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tif ( i > 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\n\t\tFilled( color );\n\n\t\t// 15 UNITS OF CURVE, NOT 17. The end dots are centred on the curve's own ends and are 3\n\t\t// units across, so a curve spanning 17 drew a glyph spanning 20 in a box of 18. Fifteen\n\t\t// plus the two half-dots is exactly 18. The dot positions are recomputed from the same\n\t\t// expression as the curve rather than being written out, so the two cannot drift apart.\n\t\tforeach ( var t in new[] { 0f, 0.5f, 1f } )\n\t\t{\n\t\t\tvar x = -7.5f + 15f * t;\n\t\t\tvar y = MathF.Sin( t * MathF.PI * 1.6f + 0.4f ) * 5.2f - 1f;\n\n\t\t\tEditor.Paint.DrawRect( Box( c, x - 1.5f, y - 1.5f, 3f, 3f ), 1.5f * _scale );\n\t\t}\n\t}\n\n\t/// <summary>Two crossing lines with the stub past the crossing drawn faintly - the piece that\n\t/// goes. Trim is defined by what it removes, so that is what the glyph shows.</summary>\n\tprivate static void PaintTrimTool( Vector2 c, Color color )\n\t{\n\t\t// The cutting line.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, -8.5f ), At( c, 2.5f, 8.5f ) );\n\n\t\t// The part that stays.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, 2.5f, 0f ) );\n\n\t\t// The part that goes, dashed.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.5f );\n\t\tfor ( var t = 0f; t < 1f; t += 0.28f )\n\t\t{\n\t\t\tvar a = At( c, 2.5f + 6f * t, -1.6f * t );\n\t\t\tvar b = At( c, 2.5f + 6f * (t + 0.16f), -1.6f * (t + 0.16f) );\n\n\t\t\tEditor.Paint.DrawLine( a, b );\n\t\t}\n\t}\n\n\t/// <summary>A line reaching a boundary, with the new length drawn faintly and an arrow head -\n\t/// the mirror of Trim, and drawn as its mirror so the pair reads as a pair.</summary>\n\tprivate static void PaintExtendTool( Vector2 c, Color color )\n\t{\n\t\t// The boundary it reaches to.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -8.5f ), At( c, 6.5f, 8.5f ) );\n\n\t\t// What is there now.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, -1f, 1f ) );\n\n\t\t// Where it is going.\n\t\tStroked( color.WithAlpha( 0.4f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -1f, 1f ), At( c, 5f, -0.6f ) );\n\t\tArrowHead( At( c, 6.4f, -1f ), new Vector2( 1f, -0.26f ), color.WithAlpha( 0.75f ), 3f );\n\t}\n\n\t/// <summary>A rounded corner with the square one it replaces dashed behind it.</summary>\n\tprivate static void PaintSketchFilletTool( Vector2 c, Color color )\n\t{\n\t\t// The corner that was.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -7 ), At( c, 7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -7 ), At( c, 7, 8 ) );\n\n\t\t// The corner that is.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -7 ), At( c, 0f, -7 ) );\n\t\tArc( At( c, 0f, 0f ), 7f, -90f, 0f, 12 );\n\t\tEditor.Paint.DrawLine( At( c, 7, 0f ), At( c, 7, 8 ) );\n\t}\n\n\t/// <summary>A shape and a second one running parallel outside it, which is the whole of what an\n\t/// offset is - the same curve, held away at a distance.</summary>\n\tprivate static void PaintOffsetTool( Vector2 c, Color color )\n\t{\n\t\t// The original.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6, 6 ), At( c, -6, -2 ) );\n\t\tArc( At( c, -1.5f, -2f ), 4.5f, 180f, 270f, 10 );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, -6.5f ), At( c, 5, -6.5f ) );\n\n\t\t// Its offset, outside and parallel.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -9.5f, 6 ), At( c, -9.5f, -2 ) );\n\t\tArc( At( c, -1.5f, -2f ), 8f, 180f, 270f, 12 );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, -10f ), At( c, 5, -10f ) );\n\t}\n\n\t/// <summary>An open L and its reflection across a dashed line you click. Distinct from the\n\t/// solid-feature Mirror glyph, which is a filled triangle: this one is a sketch polyline, and\n\t/// the dot on the axis is what says the line is the thing you pick.</summary>\n\tprivate static void PaintMirrorTool( Vector2 c, Color color )\n\t{\n\t\t// The axis, dashed, copied from PaintMirror so the two glyphs share a language.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.2f );\n\t\tfor ( var y = -8f; y < 8f; y += 3.6f )\n\t\t\tEditor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 2.1f ) );\n\n\t\t// Source: an open L, solid.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 6.5f ), At( c, -6.5f, -6.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, -6.5f ), At( c, -1.4f, -6.5f ) );\n\n\t\t// Reflection, fainter, so it reads as a copy rather than as a second original.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.6f );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, 6.5f ), At( c, 6.5f, -6.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -6.5f ), At( c, 1.4f, -6.5f ) );\n\n\t\tClickDot( At( c, 0, 0 ) );\n\t}\n\n\t/// <summary>The face's outline drawn faint, with ONE of its edges taken - solid, and in the\n\t/// green the viewport paints reference geometry, so the button and the thing it acts on are\n\t/// obviously the same thing.</summary>\n\tprivate static void PaintUseTool( Vector2 c, Color color )\n\t{\n\t\tFaint( color );\n\t\tOutline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\t// The one edge that has been taken.\n\t\tStroked( ReferenceColor, 2.6f );\n\t\tEditor.Paint.DrawLine( At( c, -7, 7 ), At( c, 7, 7 ) );\n\n\t\tClickDot( At( c, 0, 7 ) );\n\t}\n\n\t/// <summary>The same square with every edge taken, which is what the button does in one press.\n\t/// </summary>\n\tprivate static void PaintUseAllTool( Vector2 c, Color color )\n\t{\n\t\tFaint( color );\n\t\tEditor.Paint.DrawLine( At( c, -7, 0 ), At( c, 7, 0 ) );\n\n\t\tStroked( ReferenceColor, 2.6f );\n\t\tOutline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\t}\n\n\t/// <summary>\n\t/// A freehand stroke swept across three lines, with the pieces it went through dashed away.\n\t///\n\t/// The STROKE is the subject and is drawn in the red the viewport draws it in, because that is\n\t/// the thing the button is offering to let you do. Trim's glyph shows one clean crossing; this\n\t/// one shows a wobble through several, which is the difference between the two tools.\n\t/// </summary>\n\tprivate static void PaintCutTool( Vector2 c, Color color )\n\t{\n\t\t// Three uprights, each with the piece the stroke went through faded out. The gap is what the\n\t\t// tool does; the two solid ends are what it leaves.\n\t\tfor ( var i = 0; i < 3; i++ )\n\t\t{\n\t\t\tvar x = -6f + i * 6f;\n\n\t\t\tStroked( color, 1.8f );\n\t\t\tEditor.Paint.DrawLine( At( c, x, -8.5f ), At( c, x, -2.5f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, x, 3.5f ), At( c, x, 8.5f ) );\n\n\t\t\tStroked( color.WithAlpha( 0.28f ), 1.4f );\n\t\t\tEditor.Paint.DrawLine( At( c, x, -2.5f ), At( c, x, 3.5f ) );\n\t\t}\n\n\t\t// The stroke itself, sagging so that it passes through all three gaps rather than running\n\t\t// straight - straight would be Trim's glyph with two more lines in it.\n\t\tStroked( CutColor, 2.1f );\n\n\t\tvar previous = At( c, -9f, -2f );\n\n\t\tfor ( var i = 1; i <= 12; i++ )\n\t\t{\n\t\t\tvar t = i / 12f;\n\t\t\tvar point = At( c, -9f + 18f * t, -2f + MathF.Sin( t * MathF.PI ) * 3.5f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// <summary>The part of a Use glyph that is still only scenery.</summary>\n\tprivate static void Faint( Color color ) => Stroked( color.WithAlpha( 0.4f ), 1.3f );\n\n\t/// <summary>The green the sketcher paints a face's outline in - see SketchReferenceColor in\n\t/// EffigyViewport.Sketching.cs. Kept in step by eye rather than shared, because that one carries\n\t/// an alpha for drawing in the world and this one has to read on a small dark button.</summary>\n\tprivate static readonly Color ReferenceColor = new( 0.45f, 1f, 0.6f, 1f );\n\n\t/// <summary>The red a cut stroke is drawn in - see SketchCutColor in\n\t/// EffigyViewport.SketchTools.cs. Kept in step by eye, the same as ReferenceColor above: that\n\t/// one carries an alpha for drawing in the world and this one has to read on a small dark\n\t/// button.</summary>\n\tprivate static readonly Color CutColor = new( 1f, 0.45f, 0.35f, 1f );\n\n\t// --- lighting ------------------------------------------------------------------------------\n\t//\n\t// THE THREE RIGS ARE THE SAME SPHERE, LIT THREE WAYS. Three-point, rim and top-down differ only\n\t// in where the light comes from, so drawing them as three different objects would invent a\n\t// distinction that is not there. Each is a circle with the lit part filled and the rest left as\n\t// outline, which is the difference itself rather than a symbol standing in for it.\n\n\t/// <summary>The lit sphere the rig glyphs share: an outlined circle with a filled crescent on\n\t/// whichever side the light is on. <paramref name=\"from\"/> is the direction the light arrives\n\t/// from, in icon space.</summary>\n\tprivate static void LitSphere( Vector2 c, Color color, Vector2 from, float radius = 5.2f )\n\t{\n\t\tvar d = from.Normal;\n\n\t\t// The terminator is perpendicular to the light, so the lit cap runs 180 degrees centred on\n\t\t// the light's own bearing. Drawn as a filled polygon fan rather than an arc, because an\n\t\t// outline would read as a second circle rather than as brightness.\n\t\tvar bearing = MathF.Atan2( d.y, d.x ) * 180f / MathF.PI;\n\t\tvar points = new List<Vector2>();\n\n\t\tfor ( var i = 0; i <= 18; i++ )\n\t\t{\n\t\t\tvar t = (bearing - 90f) + 180f * (i / 18f);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tpoints.Add( At( c, MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) );\n\t\t}\n\n\t\tFilled( color.WithAlpha( 0.85f ) );\n\t\tEditor.Paint.DrawPolygon( points.ToArray() );\n\n\t\tStroked( color, 1.4f );\n\t\tArc( c, radius, 0f, 360f, 28 );\n\t}\n\n\t/// <summary>A short ray coming in toward the sphere, so the glyph says which side the lamp is\n\t/// on as well as which side is bright.</summary>\n\tprivate static void LightRay( Vector2 c, Color color, Vector2 from, float outer = 8.6f, float inner = 6.4f )\n\t{\n\t\tvar d = from.Normal;\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, d.x * outer, d.y * outer ), At( c, d.x * inner, d.y * inner ) );\n\t}\n\n\t/// <summary>Even light from every side: a circle with rays all the way round, none of them\n\t/// longer than the others. Full bright has no key direction and the glyph must not imply\n\t/// one \u2014 that is the entire difference between this and the sun below.</summary>\n\tprivate static void PaintLightFullBright( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawCircle( c, 4.1f * _scale );\n\n\t\tStroked( color, 1.3f );\n\n\t\tfor ( var i = 0; i < 8; i++ )\n\t\t{\n\t\t\tvar radians = i * 45f * MathF.PI / 180f;\n\t\t\tvar d = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );\n\n\t\t\tEditor.Paint.DrawLine( At( c, d.x * 6f, d.y * 6f ), At( c, d.x * 8.4f, d.y * 8.4f ) );\n\t\t}\n\t}\n\n\t/// <summary>A bulb: glass, a screw base, and three rays. The one lamp with no direction, drawn\n\t/// as the object rather than as its effect.</summary>\n\tprivate static void PaintLightPoint( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.5f );\n\t\tArc( c, 4.2f, 0f, 360f, 24 );\n\n\t\t// The base, under the glass.\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, 2.2f, 5.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.6f, 7f ), At( c, 1.6f, 7f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, -1.6f, 7f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, 5.2f ), At( c, 1.6f, 7f ) );\n\n\t\tStroked( color.WithAlpha( 0.8f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -7.4f, -4.6f ), At( c, -5.6f, -3.4f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -8.2f ), At( c, 0f, -6.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, 7.4f, -4.6f ), At( c, 5.6f, -3.4f ) );\n\t}\n\n\t/// <summary>A cone of light widening downward, with the pool it lands in. Aimed, which is the\n\t/// property that separates it from the bulb.</summary>\n\tprivate static void PaintLightSpot( Vector2 c, Color color )\n\t{\n\t\t// The housing.\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\tAt( c, -3f, -7.6f ),\n\t\t\tAt( c, 3f, -7.6f ),\n\t\t\tAt( c, 2.2f, -4.4f ),\n\t\t\tAt( c, -2.2f, -4.4f ) );\n\n\t\t// The cone, dimmer than the lamp that casts it.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, -4.4f ), At( c, -6.4f, 5.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, -4.4f ), At( c, 6.4f, 5.2f ) );\n\n\t\t// The pool on the floor, flattened so it reads as ground rather than as a ball.\n\t\tEllipseArc( At( c, 0f, 5.2f ), 6.4f, 1.9f, 0f, 360f, 26 );\n\t}\n\n\t/// <summary>Parallel rays at an angle: a directional light has no position, only a bearing, and\n\t/// parallel is the one thing that says \"these rays never converge\".</summary>\n\tprivate static void PaintLightSun( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawCircle( At( c, -3.4f, -3.4f ), 3.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.85f ), 1.3f );\n\n\t\t// Three rays on the same bearing, spaced across the diagonal. Arrow heads because a sun is\n\t\t// the only lamp here whose glyph would otherwise be three plain lines.\n\t\tfor ( var i = -1; i <= 1; i++ )\n\t\t{\n\t\t\tvar offset = new Vector2( i * 4.6f, -i * 4.6f );\n\t\t\tvar tail = At( c, 1.4f + offset.x, 1.4f + offset.y );\n\t\t\tvar tip = At( c, 6.6f + offset.x, 6.6f + offset.y );\n\n\t\t\tEditor.Paint.DrawLine( tail, tip );\n\t\t\tArrowHead( tip, new Vector2( 1f, 1f ), color.WithAlpha( 0.85f ), 2.8f );\n\t\t\tStroked( color.WithAlpha( 0.85f ), 1.3f );\n\t\t}\n\t}\n\n\t/// <summary>Key, fill and rim: the sphere lit from the upper right, with the two weaker lamps\n\t/// marked as rays on the sides they come from.</summary>\n\tprivate static void PaintLightRigThreePoint( Vector2 c, Color color )\n\t{\n\t\tLitSphere( c, color, new Vector2( 1f, -1f ) );\n\n\t\tLightRay( c, color, new Vector2( 1f, -1f ) );\n\t\tLightRay( c, color.WithAlpha( 0.5f ), new Vector2( -1f, -0.2f ) );\n\t\tLightRay( c, color.WithAlpha( 0.65f ), new Vector2( -0.3f, 1f ) );\n\t}\n\n\t/// <summary>Lit from behind: the far edge glows and the face is dark. The one rig whose whole\n\t/// point is the outline rather than the surface.</summary>\n\tprivate static void PaintLightRigRim( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.55f ), 1.4f );\n\t\tArc( c, 5.2f, 0f, 360f, 28 );\n\n\t\t// Only the far crescent, drawn heavy. No fill at all, because a rim light leaves the body\n\t\t// of the shape unlit and filling it would be the opposite of what this means.\n\t\tStroked( color, 2.4f );\n\t\tArc( c, 5.2f, -155f, -25f, 20 );\n\n\t\tLightRay( c, color, new Vector2( 0.2f, -1f ) );\n\t}\n\n\t/// <summary>Lit from straight above: the lamp is drawn, and the top of the sphere is bright.</summary>\n\tprivate static void PaintLightRigTop( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -4.2f, -8f ), At( c, 4.2f, -8f ) );\n\n\t\tStroked( color.WithAlpha( 0.8f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -2.6f, -7.2f ), At( c, -3.6f, -5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -7.2f ), At( c, 0f, -5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.6f, -7.2f ), At( c, 3.6f, -5f ) );\n\n\t\tLitSphere( At( c, 0f, 1.6f ), color, new Vector2( 0f, -1f ), 4.6f );\n\t}\n\n\t/// <summary>One lamp and nothing else: the sphere lit hard from one side, the other side left\n\t/// empty. No fill ray anywhere, which is the difference from three-point.</summary>\n\tprivate static void PaintLightRigKey( Vector2 c, Color color )\n\t{\n\t\tLitSphere( c, color, new Vector2( 1f, -1f ) );\n\t\tLightRay( c, color, new Vector2( 1f, -1f ) );\n\t}\n\n\t/// <summary>A bulb with a stroke through it. Clear is the only button on the stage that takes\n\t/// light away, and a struck-through glyph is the one shape that reads as removal without\n\t/// needing a colour to say so.</summary>\n\tprivate static void PaintLightClear( Vector2 c, Color color )\n\t{\n\t\tvar dim = color.WithAlpha( 0.55f );\n\n\t\tStroked( dim, 1.5f );\n\t\tArc( At( c, 0f, -1f ), 4.2f, 0f, 360f, 24 );\n\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, 2.2f, 4.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.6f, 6f ), At( c, 1.6f, 6f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, -1.6f, 6f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, 4.2f ), At( c, 1.6f, 6f ) );\n\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, 7f ), At( c, 7f, -7f ) );\n\t}\n\n\t// --- rig ----------------------------------------------------------------------------------\n\n\t/// <summary>\n\t/// The dog bone the viewport already draws, flattened to two dimensions.\n\t///\n\t/// SAME SHAPE THE TOOL COMMITS, for the same reason EffigyViewport.Rig.cs previews with the\n\t/// real DrawDogBone rather than a placeholder dot: the button and the thing it makes should be\n\t/// recognisably one object. A knob at each end and a tapered shaft between them is the whole\n\t/// silhouette - it is Blender's octahedral bone seen side-on, which is the form anyone arriving\n\t/// from a rigging tool already reads as \"bone\" without being told.\n\t/// </summary>\n\tprivate static void PaintBone( Vector2 c, Color color )\n\t{\n\t\t// Head at the top, tail at the bottom - bones hang downward in every tree view this\n\t\t// editor draws, so a vertical glyph matches the direction the rig panel lists them in.\n\t\tvar head = At( c, 0f, -7f );\n\t\tvar tail = At( c, 0f, 7f );\n\n\t\t// The shaft, widest a third of the way down from the head. That is where DogBone puts its\n\t\t// cross-section, and a diamond with its waist at the midpoint reads as a kite instead.\n\t\tFilled( color.WithAlpha( 0.28f ) );\n\t\tEditor.Paint.DrawPolygon( head, At( c, -3.4f, -2.4f ), tail, At( c, 3.4f, -2.4f ) );\n\n\t\tStroked( color );\n\t\tOutline( head, At( c, -3.4f, -2.4f ), tail, At( c, 3.4f, -2.4f ) );\n\n\t\t// The joints. Solid, because they are the part you click and drag.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawCircle( head, 2.6f * _scale );\n\t\tEditor.Paint.DrawCircle( tail, 2f * _scale );\n\t}\n\n\t/// <summary>\n\t/// A bone with a body pinned to it - the bone at the left, a solid at the right, a tie between.\n\t///\n\t/// The bone half is deliberately the SAME silhouette as PaintBone rather than a fresh drawing,\n\t/// only smaller and turned: Add Bone and Assign Body sit on adjacent stages of the rig bar, and\n\t/// two unrelated glyphs would hide that they are about the same object.\n\t/// </summary>\n\tprivate static void PaintBoneBind( Vector2 c, Color color )\n\t{\n\t\tvar head = At( c, -8f, 3f );\n\t\tvar tail = At( c, -1.5f, -3f );\n\n\t\tStroked( color );\n\t\tOutline( head, At( c, -6.6f, -1.6f ), tail, At( c, -2.9f, 1.6f ) );\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawCircle( head, 2f * _scale );\n\t\tEditor.Paint.DrawCircle( tail, 1.5f * _scale );\n\n\t\t// The body it is being pinned to: outlined, the same way PaintMirror outlines the copy, so\n\t\t// the bone stays the solid thing and the body stays the thing being attached to it.\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawRect( Box( c, 2f, -1f, 7f, 7f ), 1f );\n\n\t\t// The tie. Dashed, because the assignment is a named link rather than geometry.\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tfor ( var x = -1.2f; x < 2f; x += 1.6f )\n\t\t\tEditor.Paint.DrawLine( At( c, x, -1.6f ), At( c, x + 0.9f, -1.6f ) );\n\t}\n\n\t/// <summary>\n\t/// A bone that has swung off its pose, with the pose it left behind still showing.\n\t///\n\t/// THE GHOST IS THE WHOLE ICON. A bone drawn bent means nothing on its own - bones are bent.\n\t/// What says \"soft\" is the pair: where the animation put it, dashed, and where the spring\n\t/// actually took it, solid, with the gap between them being exactly the lag the four numbers in\n\t/// the inspector control. Same trick PaintMirror uses, where the outlined copy is only\n\t/// meaningful next to the solid original.\n\t/// </summary>\n\tprivate static void PaintBoneSoft( Vector2 c, Color color )\n\t{\n\t\tvar head = At( c, -2f, -7f );\n\n\t\t// The pose: where the bone would be if it were rigid. Dashed and faint, because it is the\n\t\t// thing that is NOT happening.\n\t\tStroked( color.WithAlpha( 0.4f ), 1.2f );\n\t\tfor ( var t = 0f; t < 1f; t += 0.28f )\n\t\t{\n\t\t\tvar a = At( c, -2f, -7f + 14f * t );\n\t\t\tvar b = At( c, -2f, -7f + 14f * (t + 0.16f) );\n\t\t\tEditor.Paint.DrawLine( a, b );\n\t\t}\n\n\t\t// The bone as the spring left it: swung out and trailing behind. Same knob-shaft-knob\n\t\t// silhouette as PaintBone so the two read as the same object.\n\t\tvar tail = At( c, 6f, 5.5f );\n\n\t\tStroked( color );\n\t\tOutline( head, At( c, -1f, -1f ), tail, At( c, 3.6f, -2.6f ) );\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawCircle( head, 2.4f * _scale );\n\t\tEditor.Paint.DrawCircle( tail, 1.9f * _scale );\n\t}\n\n\t/// <summary>A play triangle with the swing drawn behind it \u2014 the ordinary \"run this\" shape,\n\t/// made specific to what it runs so it is not mistaken for a generic play button on a bar that\n\t/// has no other one.</summary>\n\tprivate static void PaintSoftPreview( Vector2 c, Color color )\n\t{\n\t\t// Motion trails, fading backwards, the way the bone actually arrives at where it is going.\n\t\tStroked( color.WithAlpha( 0.30f ), 1.3f );\n\t\tArc( At( c, -9f, 0f ), 7.5f, -52f, 52f, 10 );\n\n\t\tStroked( color.WithAlpha( 0.55f ), 1.3f );\n\t\tArc( At( c, -9f, 0f ), 11f, -40f, 40f, 10 );\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon( At( c, 1.5f, -6.5f ), At( c, 8.5f, 0f ), At( c, 1.5f, 6.5f ) );\n\t}\n\n\t/// <summary>\n\t/// A bone dropped back onto its line, with the swing it gave up drawn as a fading arc.\n\t///\n\t/// NOT A REFRESH ARROW, which is the obvious choice and the wrong one: a circular arrow means\n\t/// \"do it again\" everywhere else in an editor, and this does the opposite - it stops the thing\n\t/// that is happening and puts it back where it started.\n\t/// </summary>\n\tprivate static void PaintSoftRest( Vector2 c, Color color )\n\t{\n\t\t// What is being given up: the arc the tail was swinging through, fading out as it goes.\n\t\tStroked( color.WithAlpha( 0.28f ), 1.2f );\n\t\tArc( At( c, 0f, -7f ), 13f, 62f, 90f, 8 );\n\n\t\t// The bone, back on its pose - straight down, the direction PaintBone draws it.\n\t\tvar head = At( c, 0f, -7f );\n\t\tvar tail = At( c, 0f, 6f );\n\n\t\tStroked( color );\n\t\tOutline( head, At( c, -3f, -2.2f ), tail, At( c, 3f, -2.2f ) );\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawCircle( head, 2.4f * _scale );\n\t\tEditor.Paint.DrawCircle( tail, 1.8f * _scale );\n\n\t\t// The line it came to rest on, so \"rest\" is a place rather than only a state.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -6f, 8.6f ), At( c, 6f, 8.6f ) );\n\t}\n\n\t// --- Model > Edit ---------------------------------------------------------------------------\n\n\t/// <summary>A cube's cage with one corner grabbed and pulled \u2014 editing the mesh itself.</summary>\n\tprivate static void PaintMeshEdit( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.55f ), 1.2f );\n\t\tOutline( At( c, -7, -3 ), At( c, 3, -3 ), At( c, 3, 7 ), At( c, -7, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -3 ), At( c, -3, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -3, -7 ), At( c, 7, -8 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -8 ), At( c, 3, -3 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -8 ), At( c, 7, 3 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, 3 ), At( c, 3, 7 ) );\n\t\tDot( At( c, 7, -8 ), 2.4f, color );\n\t}\n\n\t/// <summary>A 2x2 quad cage. lit: 1 a vertex, 2 an edge, 3 a face \u2014 the pick mode, shown as the\n\t/// part of the same cage it picks.</summary>\n\tprivate static void PaintCage( Vector2 c, Color color, int lit )\n\t{\n\t\tif ( lit == 3 )\n\t\t{\n\t\t\tFilled( color.WithAlpha( 0.55f ) );\n\t\t\tEditor.Paint.DrawPolygon( At( c, 0, -8 ), At( c, 8, -8 ), At( c, 8, 0 ), At( c, 0, 0 ) );\n\t\t}\n\n\t\tStroked( color.WithAlpha( 0.45f ), 1.1f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, 0 ), At( c, 8, 0 ) );\n\n\t\tif ( lit == 2 )\n\t\t{\n\t\t\tStroked( color, 2.6f );\n\t\t\tEditor.Paint.DrawLine( At( c, -8, 0 ), At( c, 0, 0 ) );\n\t\t}\n\n\t\tif ( lit == 1 )\n\t\t\tDot( At( c, 0, 0 ), 3f, color );\n\t}\n\n\t/// <summary>A face with a smaller face inside it and the ring between them.</summary>\n\tprivate static void PaintInset( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ), 1.3f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, -8 ), At( c, -4, -4 ) );\n\t\tEditor.Paint.DrawLine( At( c, 8, -8 ), At( c, 4, -4 ) );\n\t\tEditor.Paint.DrawLine( At( c, 8, 8 ), At( c, 4, 4 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, 8 ), At( c, -4, 4 ) );\n\t\tFilled( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -4, -4 ), At( c, 4, -4 ), At( c, 4, 4 ), At( c, -4, 4 ) );\n\t}\n\n\t/// <summary>A strip of quads with a new loop running across it, dashed where it is being added.</summary>\n\tprivate static void PaintLoopCut( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ), 1.3f );\n\t\tOutline( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );\n\t\tStroked( color, 2.2f );\n\t\tfor ( var y = -6f; y < 6f; y += 3f )\n\t\t\tEditor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 1.8f ) );\n\t}\n\n\t/// <summary>Three vertices pulled together into one.</summary>\n\tprivate static void PaintMerge( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -6 ), At( c, 0, 3 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -6 ), At( c, 0, 3 ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 3 ) );\n\t\tDot( At( c, -7, -6 ), 1.6f, color.WithAlpha( 0.6f ) );\n\t\tDot( At( c, 7, -6 ), 1.6f, color.WithAlpha( 0.6f ) );\n\t\tDot( At( c, 0, -8 ), 1.6f, color.WithAlpha( 0.6f ) );\n\t\tDot( At( c, 0, 4 ), 3f, color );\n\t}\n\n\t/// <summary>Two faces whose shared edge is fading out \u2014 they become one.</summary>\n\tprivate static void PaintDissolve( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.3f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );\n\t\tStroked( color, 1.5f );\n\t\tOutline( At( c, -8, -6 ), At( c, 8, -6 ), At( c, 8, 6 ), At( c, -8, 6 ) );\n\t\tStroked( color.WithAlpha( 0.35f ), 1.2f );\n\t\tfor ( var y = -6f; y < 6f; y += 3f )\n\t\t\tEditor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y + 1f ) );\n\t}\n\n\t/// <summary>A cage with one face knocked out, leaving a hole.</summary>\n\tprivate static void PaintDeleteGeometry( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.45f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -8 ), At( c, 0, -8 ), At( c, 0, 8 ), At( c, -8, 8 ) );\n\t\tEditor.Paint.DrawPolygon( At( c, 0, 0 ), At( c, 8, 0 ), At( c, 8, 8 ), At( c, 0, 8 ) );\n\t\tStroked( color, 1.4f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\t\tStroked( new Color( 1f, 0.45f, 0.4f ), 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, 2, -6 ), At( c, 6, -2 ) );\n\t\tEditor.Paint.DrawLine( At( c, 6, -6 ), At( c, 2, -2 ) );\n\t}\n\n\t/// <summary>A body with a dashed sheet pulled tight around it.</summary>\n\tprivate static void PaintShrinkwrap( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.35f ) );\n\t\tEditor.Paint.DrawCircle( At( c, 0, 1 ), 5.5f * _scale );\n\t\tStroked( color, 1.6f );\n\t\tvar prev = At( c, -8, 1 );\n\t\tfor ( var i = 1; i <= 12; i++ )\n\t\t{\n\t\t\tvar a = MathF.PI + i / 12f * MathF.PI;\n\t\t\tvar p = At( c, MathF.Cos( a ) * 8f, 1f + MathF.Sin( a ) * 8f );\n\t\t\tif ( i % 2 == 1 )\n\t\t\t\tEditor.Paint.DrawLine( prev, p );\n\t\t\tprev = p;\n\t\t}\n\t\tEditor.Paint.DrawLine( At( c, -8, 1 ), At( c, -5.5f, 1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 8, 1 ), At( c, 5.5f, 1 ) );\n\t}\n\n\t/// <summary>A thin sheet and the same sheet given a thickness.</summary>\n\tprivate static void PaintSolidify( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.5f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );\n\t\tFilled( color.WithAlpha( 0.85f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, 1 ), At( c, 8, 1 ), At( c, 8, 7 ), At( c, -8, 7 ) );\n\t}\n\n\t/// <summary>Cloth pinned along the top and sagging under its own weight: two pins, a rail, and\n\t/// the catenary the fabric hangs in.</summary>\n\t/// <summary>\n\t/// A dressmaker's dummy: torso on a stand.\n\t///\n\t/// DELIBERATELY NOT A PERSON AND NOT A BONE. The button loads somebody else's finished model,\n\t/// and the two things the glyph has to carry are that it is a whole body and that the body is\n\t/// not what you are making. A figure says the first and denies the second; the rig glyphs next\n\t/// door say neither. A stand says both, and it is what the thing is actually for.\n\t/// </summary>\n\t/// <summary>A scalloped edge - the hem the tool makes, seen side on.</summary>\n\tprivate static void PaintFrill( Vector2 c, Color color )\n\t{\n\t\t// The cloth the trim is sewn to: a plain band, so the scallop below reads as an addition to\n\t\t// an edge rather than as a shape in its own right.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, -6 ), At( c, -8, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 8, -6 ), At( c, 8, -1 ) );\n\n\t\t// The seam it hangs from.\n\t\tStroked( color, 1.6f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -1 ), At( c, 8, -1 ) );\n\n\t\t// Four scallops. Cosine off the seam, deepening as it falls - the same shape the generator\n\t\t// makes, for the same reason: the gather grows with the distance from the seam.\n\t\tStroked( color, 1.7f );\n\n\t\tvar prev = At( c, -8, -1 );\n\n\t\tfor ( var i = 1; i <= 32; i++ )\n\t\t{\n\t\t\tvar t = i / 32f;\n\t\t\tvar x = -8f + t * 16f;\n\t\t\tvar drop = 4f + 2.6f * MathF.Cos( t * MathF.PI * 8f );\n\n\t\t\tvar p = At( c, x, -1f + drop );\n\t\t\tEditor.Paint.DrawLine( prev, p );\n\t\t\tprev = p;\n\t\t}\n\t}\n\n\tprivate static void PaintWearer( Vector2 c, Color color )\n\t{\n\t\t// The torso: shoulders wide, waist in, hips out again. Drawn as a filled silhouette because\n\t\t// it is a solid object, unlike the wireframe operations either side of it on the bar.\n\t\tFilled( color );\n\n\t\tvar torso = new List<Vector2>();\n\n\t\tfor ( var i = 0; i <= 16; i++ )\n\t\t{\n\t\t\tvar t = i / 16f;\n\t\t\tvar y = -8f + t * 11f;\n\n\t\t\t// Shoulder 5.2 -> waist 3.0 -> hip 4.4, as one smooth sweep rather than three segments.\n\t\t\tvar w = 5.2f - 2.2f * MathF.Sin( t * MathF.PI * 0.72f ) + 2.4f * t * t;\n\n\t\t\ttorso.Add( At( c, w, y ) );\n\t\t}\n\n\t\tfor ( var i = 16; i >= 0; i-- )\n\t\t{\n\t\t\tvar t = i / 16f;\n\t\t\tvar y = -8f + t * 11f;\n\t\t\tvar w = 5.2f - 2.2f * MathF.Sin( t * MathF.PI * 0.72f ) + 2.4f * t * t;\n\n\t\t\ttorso.Add( At( c, -w, y ) );\n\t\t}\n\n\t\tEditor.Paint.DrawPolygon( torso );\n\n\t\t// The neck stub, so the shoulders read as shoulders and not as the top of a vase.\n\t\tEditor.Paint.DrawRect( new Rect( At( c, -1.4f, -9.6f ), new Vector2( 2.8f * _scale, 2f * _scale ) ) );\n\n\t\t// The stand. Stroked rather than filled - it is furniture holding the subject up, and\n\t\t// drawing it as lightly as the pins in PaintDrape keeps the body the thing you see first.\n\t\tStroked( color, 1.6f );\n\t\tEditor.Paint.DrawLine( At( c, 0, 3 ), At( c, 0, 7.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, -4, 8 ), At( c, 4, 8 ) );\n\t}\n\n\tprivate static void PaintDrape( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.5f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -6 ), At( c, 8, -6 ) );\n\n\t\t// The pins that hold it up - the selection, in the tool's own terms.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawCircle( At( c, -6, -6 ), 1.8f * _scale );\n\t\tEditor.Paint.DrawCircle( At( c, 6, -6 ), 1.8f * _scale );\n\n\t\t// cosh, sampled: the shape a hanging sheet actually takes.\n\t\tStroked( color, 1.7f );\n\t\tvar prev = At( c, -6, -6 );\n\t\tfor ( var i = 1; i <= 14; i++ )\n\t\t{\n\t\t\tvar t = -1f + i / 7f;\n\t\t\tvar p = At( c, t * 6f, -6f + (MathF.Cosh( 1.6f ) - MathF.Cosh( t * 1.6f )) * 4.6f );\n\t\t\tEditor.Paint.DrawLine( prev, p );\n\t\t\tprev = p;\n\t\t}\n\t}\n\n\t/// <summary>A swatch of woven cloth: the weave is what the fabric setting picks.</summary>\n\tprivate static void PaintFabric( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.8f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, 7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, 7 ), At( c, 7, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, -7, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -7 ), At( c, 7, 7 ) );\n\n\t\tStroked( color.WithAlpha( 0.55f ), 1.2f );\n\t\tfor ( var i = 1; i <= 2; i++ )\n\t\t{\n\t\t\tvar o = -7f + i * 14f / 3f;\n\t\t\tEditor.Paint.DrawLine( At( c, o, -7 ), At( c, o, 7 ) );\n\t\t\tEditor.Paint.DrawLine( At( c, -7, o ), At( c, 7, o ) );\n\t\t}\n\t}\n\n\t/// <summary>A surface cut open along a line: the two halves parting, and the cut itself in the\n\t/// red every tool uses for a seam.</summary>\n\tprivate static void PaintSeam( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.35f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -7 ), At( c, -2, -7 ), At( c, -2, 7 ), At( c, -8, 7 ) );\n\t\tEditor.Paint.DrawPolygon( At( c, 2, -7 ), At( c, 8, -7 ), At( c, 8, 7 ), At( c, 2, 7 ) );\n\n\t\t// The cut. Red, because that is what a marked seam is drawn as in the viewport.\n\t\tStroked( new Color( 0.93f, 0.26f, 0.21f ), 2f );\n\t\tEditor.Paint.DrawLine( At( c, -2, -7 ), At( c, -2, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 2, -7 ), At( c, 2, 7 ) );\n\t}\n\n\t/// <summary>A box opened out into its net - the thing unwrapping does, in one picture.</summary>\n\tprivate static void PaintUnwrap( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.75f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -2.5f, -2.5f ), At( c, 2.5f, -2.5f ), At( c, 2.5f, 2.5f ), At( c, -2.5f, 2.5f ) );\n\n\t\t// The four flaps folded out around it.\n\t\tFilled( color.WithAlpha( 0.35f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -2.5f, -8 ), At( c, 2.5f, -8 ), At( c, 2.5f, -3.5f ), At( c, -2.5f, -3.5f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -2.5f, 3.5f ), At( c, 2.5f, 3.5f ), At( c, 2.5f, 8 ), At( c, -2.5f, 8 ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -2.5f ), At( c, -3.5f, -2.5f ), At( c, -3.5f, 2.5f ), At( c, -8, 2.5f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, 3.5f, -2.5f ), At( c, 8, -2.5f ), At( c, 8, 2.5f ), At( c, 3.5f, 2.5f ) );\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigyPreview.cs",
"FileName": "EffigyPreview.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Effigy;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace Marionette.EditorTools;\r\n\r\n/// <summary>\r\n/// Turns the Part Studio's in-memory PolyMesh straight into a runtime Model, so the viewport can\r\n/// show the result of a feature the moment it is added.\r\n///\r\n/// The alternative \u2014 the path Export/Compile still take \u2014 is OBJ on disk, a generated .vmdl, and\r\n/// a call into the asset compiler. That takes hundreds of milliseconds and writes files, which is\r\n/// fine for producing a placeable prop and hopeless as the response to dragging a slider. This\r\n/// path never touches the disk.\r\n///\r\n/// It is deliberately NOT a second geometry pipeline: it shares MeshNormals with ObjWriter, so\r\n/// what you see here and what the compiler bakes are smoothed by the same rule.\r\n///\r\n/// MATERIALS RENDER HERE NOW. A face carries a slot number and PartStudio.MaterialNames binds the\r\n/// slot to a vmat, so the preview groups faces by the material they resolve to and builds one\r\n/// submesh per material \u2014 the same grouping the exporters express as usemtl runs. Faces on slot 0,\r\n/// on a slot nothing is bound to, or on a slot whose vmat will not load fall back to the flat\r\n/// placeholder rather than rendering as nothing. Pass no resolver \u2014 the sculpt preview does \u2014 and\r\n/// the whole model is the placeholder, which is the old behaviour.\r\n/// </summary>\r\ninternal static class EffigyPreview\r\n{\r\n\t/// <summary>\r\n\t/// FLAT grey, with no pattern on it at all. The fallback for a face whose slot names no\r\n\t/// material, or names one that will not load.\r\n\t///\r\n\t/// It used to be dev/reflectivity_30, and that material actively lies about scale: its texture\r\n\t/// is a grid with the number \"30\" printed in every tile - the material's reflectivity, nothing\r\n\t/// to do with size - and caps take plane coordinates straight through as UVs, so it tiles once\r\n\t/// per sketch unit. A 30x30 face therefore came out covered in thirty-odd squares each labelled\r\n\t/// \"30\", which reads as a part 900 units across. gray_50's texture is a single flat colour.\r\n\t/// </summary>\r\n\tprivate const string PreviewMaterial = \"materials/dev/gray_50.vmat\";\r\n\r\n\t/// <summary>\r\n\t/// Build a Model from the mesh, optionally resolving each face's material slot to a real vmat.\r\n\t/// </summary>\r\n\t/// <param name=\"materialForSlot\">Slot number to bound material path, or null / empty for an\r\n\t/// unbound slot. Pass null to render everything on the placeholder.</param>\r\n\tpublic static Model Build( PolyMesh mesh, Func<int, string> materialForSlot = null,\r\n\t\tfloat smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )\r\n\t{\r\n\t\tif ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 )\r\n\t\t\treturn null;\r\n\r\n\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\r\n\r\n\t\tvar placeholder = Material.Load( PreviewMaterial );\r\n\t\tvar bounds = BoundsOf( mesh );\r\n\r\n\t\t// Faces bucketed by the material they render with. One drop of brushed steel onto three\r\n\t\t// faces is one bucket and one submesh; a part nobody assigned is one bucket on the\r\n\t\t// placeholder. The slot is resolved once and cached, not once per face.\r\n\t\tvar buckets = new Dictionary<Material, List<int>>();\r\n\t\tvar slotCache = new Dictionary<int, Material>();\r\n\r\n\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t{\r\n\t\t\tif ( mesh.Faces[fi].Count < 3 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );\r\n\r\n\t\t\tif ( !buckets.TryGetValue( material, out var faces ) )\r\n\t\t\t\tbuckets[material] = faces = new List<int>();\r\n\r\n\t\t\tfaces.Add( fi );\r\n\t\t}\r\n\r\n\t\tvar builder = Model.Builder;\r\n\t\tvar any = false;\r\n\r\n\t\tforeach ( var (material, faces) in buckets )\r\n\t\t{\r\n\t\t\tif ( BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds ) is { } sub )\r\n\t\t\t{\r\n\t\t\t\tbuilder.AddMesh( sub );\r\n\t\t\t\tany = true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn any ? builder.Create() : null;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build a Model from the mesh wearing ONE material on every face, slot resolution ignored.\r\n\t///\r\n\t/// THIS IS THE PAINT-PREVIEW PATH. While painting, the whole part wears the painted atlas \u2014 a\r\n\t/// <see cref=\"Material.CreateCopy\"/> with the live canvas texture bound \u2014 so the slot bucketing\r\n\t/// above is the wrong answer there. One material, one submesh, the same smoothing as the ordinary\r\n\t/// preview, and the caller owns the material's lifetime across rebuilds.\r\n\t/// </summary>\r\n\tpublic static Model Build( PolyMesh mesh, Material material,\r\n\t\tfloat smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )\r\n\t{\r\n\t\tif ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 || material is null )\r\n\t\t\treturn null;\r\n\r\n\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\r\n\t\tvar bounds = BoundsOf( mesh );\r\n\r\n\t\tvar faces = new List<int>( mesh.FaceCount );\r\n\r\n\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t{\r\n\t\t\tif ( mesh.Faces[fi].Count >= 3 )\r\n\t\t\t\tfaces.Add( fi );\r\n\t\t}\r\n\r\n\t\tvar sub = BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds );\r\n\r\n\t\tif ( sub is null )\r\n\t\t\treturn null;\r\n\r\n\t\tvar builder = Model.Builder;\r\n\t\tbuilder.AddMesh( sub );\r\n\r\n\t\treturn builder.Create();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A preview Model that can be rewritten in place when only vertex positions change \u2014 a body\r\n\t/// being dragged by the Transform handle. Rebuilding the Model, its buffers and its GameObject\r\n\t/// every frame is what made dragging a dense import sluggish, so this retains the meshes and\r\n\t/// overwrites their vertex buffers instead. Buckets, triangulation and buffer allocation are\r\n\t/// done once; a drag only recomputes normals and re-uploads the vertex data.\r\n\t/// </summary>\r\n\tinternal sealed class LivePreview\r\n\t{\r\n\t\tpublic Model Model { get; private set; }\r\n\r\n\t\tprivate readonly List<Mesh> _meshes = new();\r\n\t\tprivate readonly List<List<SimpleVertex>> _vertices = new();\r\n\t\tprivate readonly Dictionary<Material, int> _bucketIndex = new();\r\n\t\tprivate int[] _faceMesh;\r\n\t\tprivate int[] _faceCorners;\r\n\t\tprivate int _faceCount;\r\n\r\n\t\t// The corner normals the vertex buffers were last built with. Kept so a position-only update\r\n\t\t// (a sculpt stroke, which moves vertices without changing topology) can reuse them instead of\r\n\t\t// re-running the O(vertices) fan pass every dab. The normals snap correct on the next full\r\n\t\t// update.\r\n\t\tprivate int[][] _cornerNormals;\r\n\t\tprivate List<Vec3> _normals;\r\n\r\n\t\tprivate LivePreview() { }\r\n\r\n\t\tpublic static LivePreview Build( PolyMesh mesh, Func<int, string> materialForSlot,\r\n\t\t\tfloat smoothingAngleDegrees )\r\n\t\t{\r\n\t\t\tvar live = new LivePreview { _faceCount = mesh.FaceCount };\r\n\t\t\tlive.Rebuild( mesh, materialForSlot, smoothingAngleDegrees );\r\n\t\t\treturn live;\r\n\t\t}\r\n\r\n\t\t/// <summary>\r\n\t\t/// Rewrite the vertex buffers from a mesh whose topology matches the one this was built\r\n\t\t/// from. Returns false \u2014 the caller must rebuild \u2014 when the face count, material layout or\r\n\t\t/// corner count changed, because any of those moves the bucketing and the index buffers.\r\n\t\t/// </summary>\r\n\t\tpublic bool TryUpdate( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )\r\n\t\t{\r\n\t\t\tif ( Model is null || mesh.FaceCount != _faceCount || mesh.HasVertexColors )\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tvar placeholder = Material.Load( PreviewMaterial );\r\n\t\t\tvar slotCache = new Dictionary<int, Material>();\r\n\t\t\tvar faceLists = new List<List<int>>( _meshes.Count );\r\n\r\n\t\t\tfor ( var i = 0; i < _meshes.Count; i++ )\r\n\t\t\t\tfaceLists.Add( new List<int>() );\r\n\r\n\t\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t\t{\r\n\t\t\t\t// The index buffers are laid out corner by corner, so two faces swapping corner\r\n\t\t\t\t// counts keeps the per-bucket vertex total the same while scrambling the triangles.\r\n\t\t\t\tif ( mesh.Faces[fi].Count != _faceCorners[fi] )\r\n\t\t\t\t\treturn false;\r\n\r\n\t\t\t\tif ( mesh.Faces[fi].Count < 3 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );\r\n\r\n\t\t\t\t// A face on a bucket that was not there at build time means the material layout\r\n\t\t\t\t// changed, which an in-place update cannot express.\r\n\t\t\t\tif ( !_bucketIndex.TryGetValue( material, out var bi ) || bi != _faceMesh[fi] )\r\n\t\t\t\t\treturn false;\r\n\r\n\t\t\t\tfaceLists[bi].Add( fi );\r\n\t\t\t}\r\n\r\n\t\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\r\n\t\t\t_cornerNormals = cornerNormals;\r\n\t\t\t_normals = normals;\r\n\t\t\tvar bounds = BoundsOf( mesh );\r\n\r\n\t\t\tfor ( var i = 0; i < _meshes.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar vertices = BuildVertices( mesh, faceLists[i], cornerNormals, normals );\r\n\r\n\t\t\t\tif ( vertices.Count != _vertices[i].Count )\r\n\t\t\t\t\treturn false;\r\n\r\n\t\t\t\t_meshes[i].SetVertexBufferData( vertices, 0 );\r\n\t\t\t\t_meshes[i].Bounds = bounds;\r\n\t\t\t}\r\n\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\t/// <summary>\r\n\t\t/// Rewrite positions only, reusing the normals and UVs the buffers were last built with. A\r\n\t\t/// sculpt stroke moves vertices without changing topology, and this skips the O(vertices)\r\n\t\t/// corner-normal fan pass that a full update re-runs every dab \u2014 the normals snap correct on\r\n\t\t/// the next full update (stroke end). Returns false when the face layout has changed, which\r\n\t\t/// means a full rebuild instead.\r\n\t\t/// </summary>\r\n\t\tpublic bool TryUpdatePositions( PolyMesh mesh )\r\n\t\t{\r\n\t\t\tif ( Model is null || mesh.FaceCount != _faceCount || _cornerNormals is null || mesh.HasVertexColors )\r\n\t\t\t\treturn false;\r\n\r\n\t\t\tvar faceLists = new List<List<int>>( _meshes.Count );\r\n\r\n\t\t\tfor ( var i = 0; i < _meshes.Count; i++ )\r\n\t\t\t\tfaceLists.Add( new List<int>() );\r\n\r\n\t\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t\t{\r\n\t\t\t\tif ( mesh.Faces[fi].Count != _faceCorners[fi] )\r\n\t\t\t\t\treturn false;\r\n\r\n\t\t\t\tif ( mesh.Faces[fi].Count < 3 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tfaceLists[_faceMesh[fi]].Add( fi );\r\n\t\t\t}\r\n\r\n\t\t\tfor ( var i = 0; i < _meshes.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar vertices = BuildVertices( mesh, faceLists[i], _cornerNormals, _normals );\r\n\r\n\t\t\t\tif ( vertices.Count != _vertices[i].Count )\r\n\t\t\t\t\treturn false;\r\n\r\n\t\t\t\t_meshes[i].SetVertexBufferData( vertices, 0 );\r\n\t\t\t\t_vertices[i] = vertices;\r\n\t\t\t}\r\n\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tvoid Rebuild( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )\r\n\t\t{\r\n\t\t\t_meshes.Clear();\r\n\t\t\t_vertices.Clear();\r\n\t\t\t_bucketIndex.Clear();\r\n\r\n\t\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\r\n\t\t\t_cornerNormals = cornerNormals;\r\n\t\t\t_normals = normals;\r\n\t\t\tvar bounds = BoundsOf( mesh );\r\n\t\t\tvar placeholder = Material.Load( PreviewMaterial );\r\n\t\t\tvar slotCache = new Dictionary<int, Material>();\r\n\r\n\t\t\tvar buckets = new List<(Material Material, List<int> Faces)>();\r\n\t\t\t_faceMesh = new int[mesh.FaceCount];\r\n\t\t\t_faceCorners = new int[mesh.FaceCount];\r\n\r\n\t\t\tfor ( var fi = 0; fi < mesh.FaceCount; fi++ )\r\n\t\t\t{\r\n\t\t\t\t_faceCorners[fi] = mesh.Faces[fi].Count;\r\n\r\n\t\t\t\tif ( mesh.Faces[fi].Count < 3 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );\r\n\r\n\t\t\t\tif ( !_bucketIndex.TryGetValue( material, out var bi ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tbi = buckets.Count;\r\n\t\t\t\t\t_bucketIndex[material] = bi;\r\n\t\t\t\t\tbuckets.Add( (material, new List<int>()) );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tbuckets[bi].Faces.Add( fi );\r\n\t\t\t\t_faceMesh[fi] = bi;\r\n\t\t\t}\r\n\r\n\t\t\tvar builder = Model.Builder;\r\n\r\n\t\t\tforeach ( var (material, faces) in buckets )\r\n\t\t\t{\r\n\t\t\t\tvar vertices = BuildVertices( mesh, faces, cornerNormals, normals );\r\n\r\n\t\t\t\tif ( vertices.Count == 0 )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar indices = TriangulateIndices( mesh, faces );\r\n\t\t\t\tvar sbMesh = new Mesh( material );\r\n\r\n\t\t\t\tif ( mesh.HasVertexColors )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar coloured = BuildColouredVertices( mesh, faces, cornerNormals, normals );\r\n\t\t\t\t\tsbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t\tsbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );\r\n\t\t\t\tsbMesh.CreateIndexBuffer( indices.Count, indices );\r\n\t\t\t\tsbMesh.Bounds = bounds;\r\n\r\n\t\t\t\tbuilder.AddMesh( sbMesh );\r\n\t\t\t\t_meshes.Add( sbMesh );\r\n\t\t\t\t_vertices.Add( vertices );\r\n\t\t\t}\r\n\r\n\t\t\tModel = _meshes.Count > 0 ? builder.Create() : null;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The material a slot renders with: the vmat bound to it, or the flat placeholder.\r\n\t///\r\n\t/// Slot 0 is the slot every face starts on and never carries a material, so it is the\r\n\t/// placeholder without a lookup. A named slot whose vmat will not load \u2014 a path into a package\r\n\t/// that is not installed, a material deleted since it was assigned \u2014 also falls back rather\r\n\t/// than rendering the part as nothing, which is the failure the single-material preview used\r\n\t/// to avoid wholesale.\r\n\t/// </summary>\r\n\tprivate static Material ResolveMaterial( int slot, Func<int, string> materialForSlot,\r\n\t\tMaterial placeholder, Dictionary<int, Material> cache )\r\n\t{\r\n\t\tif ( slot <= 0 || materialForSlot is null )\r\n\t\t\treturn placeholder;\r\n\r\n\t\tif ( cache.TryGetValue( slot, out var cached ) )\r\n\t\t\treturn cached;\r\n\r\n\t\tvar name = materialForSlot( slot );\r\n\t\tvar material = string.IsNullOrWhiteSpace( name ) ? null : Material.Load( name );\r\n\r\n\t\t// A named slot whose vmat would not load is the one case the fallback hides \u2014 the face\r\n\t\t// renders as placeholder grey and nothing says why. Say why, once per slot per build.\r\n\t\tif ( material is null && !string.IsNullOrWhiteSpace( name ) )\r\n\t\t\tLog.Warning( $\"[effigy-preview] slot {slot}: material '{name}' failed to load \u2014 using placeholder\" );\r\n\r\n\t\treturn cache[slot] = material ?? placeholder;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One Mesh over a subset of the faces, all sharing one material.\r\n\t///\r\n\t/// ONE VERTEX FORMAT, since paint stopped being vertex colour. There was a second path here that\r\n\t/// built the richer <c>Vertex</c> so the engine could multiply a material by a per-vertex colour;\r\n\t/// it was chosen by <c>PolyMesh.HasVertexColors</c>, which nothing sets any more, so it rendered\r\n\t/// nothing that this path does not. Paint reaches the viewport as the painted body's material\r\n\t/// now, the same way a dropped material does.\r\n\t/// </summary>\r\n\tprivate static Mesh BuildSubmesh( PolyMesh mesh, List<int> faceIndices, int[][] cornerNormals,\r\n\t\tList<Vec3> normals, Material material, BBox bounds )\r\n\t{\r\n\t\tvar vertices = BuildVertices( mesh, faceIndices, cornerNormals, normals );\r\n\t\tvar indices = TriangulateIndices( mesh, faceIndices );\r\n\r\n\t\tif ( indices.Count == 0 )\r\n\t\t\treturn null;\r\n\r\n\t\tvar sbMesh = new Mesh( material );\r\n\r\n\t\tif ( mesh.HasVertexColors )\r\n\t\t{\r\n\t\t\tvar coloured = BuildColouredVertices( mesh, faceIndices, cornerNormals, normals );\r\n\t\t\tsbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );\r\n\t\t}\r\n\t\telse\r\n\t\t\tsbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );\r\n\r\n\t\tsbMesh.CreateIndexBuffer( indices.Count, indices );\r\n\t\tsbMesh.Bounds = bounds;\r\n\r\n\t\treturn sbMesh;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The same corners as <see cref=\"BuildVertices\"/>, carrying the mesh's vertex colour.\r\n\t///\r\n\t/// BACK FOR FUR. The vertex-colour path was retired when paint became a texture; it returns\r\n\t/// because s&box's fur.shader reads the mesh COLOR stream - red is each shell's height - and\r\n\t/// without it every shell reads as the root layer and the fur renders as a solid lump. Every other\r\n\t/// shader ignores the stream, so the rest of a furred part looks exactly as it did.\r\n\t/// </summary>\r\n\tprivate static List<Vertex> BuildColouredVertices( PolyMesh mesh, List<int> faceIndices,\r\n\t\tint[][] cornerNormals, List<Vec3> normals )\r\n\t{\r\n\t\tvar colours = mesh.VertexColors;\r\n\t\tvar vertices = new List<Vertex>( faceIndices.Count * 4 );\r\n\r\n\t\tforeach ( var fi in faceIndices )\r\n\t\t{\r\n\t\t\tvar face = mesh.Faces[fi];\r\n\t\t\tvar corners = cornerNormals[fi];\r\n\r\n\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = mesh.Positions[face.Indices[c]];\r\n\t\t\t\tvar n = normals[corners[c]];\r\n\t\t\t\tvar uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;\r\n\t\t\t\tvar tint = colours[face.Indices[c]].Tint();\r\n\t\t\t\tvar normal = new Vector3( n.x, n.y, n.z );\r\n\t\t\t\tvar tangent = MathF.Abs( normal.z ) < 0.9f\r\n\t\t\t\t\t? Vector3.Cross( Vector3.Up, normal ).Normal\r\n\t\t\t\t\t: Vector3.Cross( Vector3.Forward, normal ).Normal;\r\n\r\n\t\t\t\tvertices.Add( new Vertex\r\n\t\t\t\t{\r\n\t\t\t\t\tPosition = new Vector3( p.x, p.y, p.z ),\r\n\t\t\t\t\tNormal = normal,\r\n\t\t\t\t\tTangent = new Vector4( tangent, 1f ),\r\n\t\t\t\t\tTexCoord0 = new Vector2( uv.x, uv.y ),\r\n\t\t\t\t\tColor = new Color32(\r\n\t\t\t\t\t\t(byte)MathF.Round( Math.Clamp( tint.x, 0f, 1f ) * 255f ),\r\n\t\t\t\t\t\t(byte)MathF.Round( Math.Clamp( tint.y, 0f, 1f ) * 255f ),\r\n\t\t\t\t\t\t(byte)MathF.Round( Math.Clamp( tint.z, 0f, 1f ) * 255f ),\r\n\t\t\t\t\t\t255 ),\r\n\t\t\t\t} );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn vertices;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One vertex per face corner rather than per position, in the same order\r\n\t/// <see cref=\"TriangulateIndices\"/> indexes them. Corner normals are the whole point of\r\n\t/// MeshNormals - sharing a vertex between two faces that disagree about the normal is exactly\r\n\t/// what rounds off a box's edges.\r\n\t/// </summary>\r\n\tprivate static List<SimpleVertex> BuildVertices( PolyMesh mesh, List<int> faceIndices,\r\n\t\tint[][] cornerNormals, List<Vec3> normals )\r\n\t{\r\n\t\tvar vertices = new List<SimpleVertex>( faceIndices.Count * 4 );\r\n\r\n\t\tforeach ( var fi in faceIndices )\r\n\t\t{\r\n\t\t\tvar face = mesh.Faces[fi];\r\n\t\t\tvar corners = cornerNormals[fi];\r\n\r\n\t\t\tfor ( var c = 0; c < face.Count; c++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = mesh.Positions[face.Indices[c]];\r\n\t\t\t\tvar n = normals[corners[c]];\r\n\t\t\t\tvar uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;\r\n\r\n\t\t\t\tvar position = new Vector3( p.x, p.y, p.z );\r\n\t\t\t\tvar normal = new Vector3( n.x, n.y, n.z );\r\n\r\n\t\t\t\tvertices.Add( new SimpleVertex( position, normal, Vector3.Forward, new Vector2( uv.x, uv.y ) ) );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn vertices;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Ear-clipping, not a fan. This used to fan from corner 0 on the grounds that every face the\r\n\t/// kernel produces is convex. Extrude caps are not: they are whatever closed region was drawn,\r\n\t/// and fanning a concave one fills its notches in \u2014 draw a dart and the solid came back as a\r\n\t/// quadrilateral with the concave corner swallowed.\r\n\t///\r\n\t/// A TRIANGLE BYPASSES THE CLIPPER. An import is overwhelmingly triangles, and ear clipping a\r\n\t/// 3-corner face is still a Newell normal, a flatten, a signed-area and several allocations per\r\n\t/// face \u2014 millions of them on a dense import. A triangle is always itself, so it is emitted\r\n\t/// straight through.\r\n\t/// </summary>\r\n\tprivate static List<int> TriangulateIndices( PolyMesh mesh, List<int> faceIndices )\r\n\t{\r\n\t\tvar indices = new List<int>( faceIndices.Count * 6 );\r\n\t\tvar first = 0;\r\n\r\n\t\tforeach ( var fi in faceIndices )\r\n\t\t{\r\n\t\t\tvar face = mesh.Faces[fi];\r\n\r\n\t\t\tif ( face.Count == 3 )\r\n\t\t\t{\r\n\t\t\t\tindices.Add( first );\r\n\t\t\t\tindices.Add( first + 1 );\r\n\t\t\t\tindices.Add( first + 2 );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tvar polygon = new List<Vec3>( face.Count );\r\n\r\n\t\t\t\tfor ( var k = 0; k < face.Count; k++ )\r\n\t\t\t\t\tpolygon.Add( mesh.Positions[face.Indices[k]] );\r\n\r\n\t\t\t\tforeach ( var (a, b, cc) in Triangulate.Face( polygon ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tindices.Add( first + a );\r\n\t\t\t\t\tindices.Add( first + b );\r\n\t\t\t\t\tindices.Add( first + cc );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tfirst += face.Count;\r\n\t\t}\r\n\r\n\t\treturn indices;\r\n\t}\r\n\r\n\tprivate static BBox BoundsOf( PolyMesh mesh )\r\n\t{\r\n\t\tvar min = new Vector3( float.MaxValue );\r\n\t\tvar max = new Vector3( float.MinValue );\r\n\r\n\t\tforeach ( var p in mesh.Positions )\r\n\t\t{\r\n\t\t\tvar v = new Vector3( p.x, p.y, p.z );\r\n\t\t\tmin = Vector3.Min( min, v );\r\n\t\t\tmax = Vector3.Max( max, v );\r\n\t\t}\r\n\r\n\t\treturn new BBox( min, max );\r\n\t}\r\n}\r\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigySketchGridBar.cs",
"FileName": "EffigySketchGridBar.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing System;\n\nnamespace Marionette.EditorTools;\n\n/// <summary>\n/// The grid switch and its spacing, floating in the corner of the viewport while a sketch is open.\n///\n/// WHY IT IS NOT ONLY IN SETTINGS. Both controls already live in Edit > Settings, and that is the\n/// right home for them when you are setting up how the tool behaves. It is the wrong home for them\n/// while you are drawing: the grid is the paper, changing paper is part of drawing, and a dialog\n/// three menus away is not somewhere you go mid-sketch. Every sketcher worth using puts these two\n/// on the canvas.\n///\n/// NOT A SECOND COPY OF THE VALUE. This reads and writes the viewport's own properties - the same\n/// two the settings window sets - and re-reads them every time it is shown, so the two controls can\n/// never disagree about what is true. The spacing list itself is EffigySettingsWindow's, for the\n/// same reason: two lists drift, and a value that exists on one dropdown and not the other reads as\n/// the setting having been lost.\n///\n/// AT THE END OF THE TOOL ROW, not floating on the model. It belongs to the mode you are in, the\n/// way the Line and Rectangle buttons beside it do, and chrome is where a mode's controls go - a\n/// panel sitting on the part is one more thing between you and the thing you are drawing. Flush\n/// right, so the tools growing and shrinking as you change stage never move it.\n/// </summary>\ninternal sealed class EffigySketchGridBar : Widget\n{\n\t/// <summary>The tool buttons' own height, so the two read as one row of controls rather than\n\t/// as a panel parked next to them.</summary>\n\tpublic const float BarHeight = EffigyToolChrome.ButtonHeight;\n\tpublic const float BarWidth = 210f;\n\n\tprivate readonly Button _toggle;\n\tprivate readonly ComboBox _spacing;\n\n\t/// <summary>Set while Refresh writes the controls. Both of them fire their change callbacks on\n\t/// assignment exactly as a click does, and without this a refresh would turn straight round and\n\t/// write the value it had only just read - harmless for the toggle, and for the dropdown a way\n\t/// to reset the spacing to whatever happened to be first.</summary>\n\tprivate bool _syncing;\n\n\t/// <summary>Raised after either control has changed the viewport, so the window can save the\n\t/// setting the same way the settings dialog's own callback does. The viewport is already\n\t/// updated by the time this fires.</summary>\n\tpublic Action Changed { get; set; }\n\n\tprivate readonly EffigyViewport _viewport;\n\n\tpublic EffigySketchGridBar( Widget parent, EffigyViewport viewport ) : base( parent )\n\t{\n\t\t_viewport = viewport;\n\n\t\t// The same two flags every floating widget in this tool sets \u2014 a plain Widget paints the\n\t\t// system background, which is a white slab on the 3D view.\n\t\tTranslucentBackground = true;\n\t\tNoSystemBackground = true;\n\n\t\tVisible = false;\n\t\tFixedHeight = BarHeight;\n\t\tFixedWidth = BarWidth;\n\n\t\tLayout = Layout.Row();\n\t\tLayout.Spacing = 6;\n\t\tLayout.Margin = new Sandbox.UI.Margin( 6, 2, 6, 2 );\n\n\t\t_toggle = new Button( \"Grid\", \"grid_on\", this )\n\t\t{\n\t\t\tToolTip = \"Draw the grid on the face you are sketching on. The lines are the intervals \"\n\t\t\t\t+ \"the cursor snaps to.\",\n\t\t\tFixedWidth = 74f,\n\t\t};\n\n\t\t_toggle.Clicked = ToggleGrid;\n\n\t\tLayout.Add( _toggle );\n\n\t\t_spacing = new ComboBox( this )\n\t\t{\n\t\t\tToolTip = \"How far apart the grid lines sit, in sketch units. Automatic fits the step \"\n\t\t\t\t+ \"to the face you are on and to how close the camera is.\",\n\t\t};\n\n\t\tforeach ( var value in EffigySettingsWindow.Spacings )\n\t\t{\n\t\t\tvar step = value;\n\n\t\t\t_spacing.AddItem( EffigySettingsWindow.Describe( step ), onSelected: () => SetSpacing( step ) );\n\t\t}\n\n\t\tLayout.Add( _spacing, 1 );\n\n\t\tRefresh();\n\t}\n\n\t/// <summary>The tool row's background, so the gaps between the two controls disappear into the\n\t/// bar rather than showing as a panel laid on it. A widget cannot simply decline to paint: a\n\t/// rect it leaves alone keeps whatever the previous frame put there.</summary>\n\tpublic Color GapColor { get; set; } = Theme.ControlBackground;\n\n\tprotected override void OnPaint()\n\t{\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( GapColor );\n\t\tPaint.DrawRect( LocalRect );\n\t}\n\n\t/// <summary>Point both controls at what the viewport actually holds. Called on every show, and\n\t/// again whenever the settings window has been the one to change it.</summary>\n\tpublic void Refresh()\n\t{\n\t\tif ( !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_syncing = true;\n\n\t\ttry\n\t\t{\n\t\t\tvar on = _viewport.ShowPlaneGrid;\n\n\t\t\t_toggle.Icon = on ? \"grid_on\" : \"grid_off\";\n\n\t\t\t// Tinted rather than labelled on and off. The button says \"Grid\" either way, so the\n\t\t\t// only thing that has to change is whether it reads as engaged - and a label that\n\t\t\t// alternates between two words is a control you have to read to use.\n\t\t\t_toggle.Tint = on ? Theme.Blue : Theme.TextControl.WithAlpha( 0.5f );\n\n\t\t\tvar spacing = _viewport.GridSpacing;\n\t\t\tvar index = 0;\n\n\t\t\tfor ( var i = 0; i < EffigySettingsWindow.Spacings.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( MathF.Abs( EffigySettingsWindow.Spacings[i] - spacing ) < 0.0001f )\n\t\t\t\t{\n\t\t\t\t\tindex = i;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_spacing.CurrentIndex = index;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_syncing = false;\n\t\t}\n\t}\n\n\tprivate void ToggleGrid()\n\t{\n\t\tif ( _syncing || !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_viewport.ShowPlaneGrid = !_viewport.ShowPlaneGrid;\n\n\t\tRefresh();\n\t\tChanged?.Invoke();\n\t}\n\n\tprivate void SetSpacing( float step )\n\t{\n\t\tif ( _syncing || !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_viewport.GridSpacing = step;\n\n\t\t// Choosing a spacing while the grid is off is asking to see it. Turning it on is what was\n\t\t// meant, and leaving it off would make the dropdown look broken.\n\t\tif ( !_viewport.ShowPlaneGrid )\n\t\t\t_viewport.ShowPlaneGrid = true;\n\n\t\tRefresh();\n\t\tChanged?.Invoke();\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigyViewport.MaterialBrush.cs",
"FileName": "EffigyViewport.MaterialBrush.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing Effigy;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\n\nnamespace Marionette.EditorTools;\n\n/// <summary>\n/// Brushing an existing material onto faces.\n///\n/// THE SAME GESTURE AS THE COLOUR BRUSH AND A DIFFERENT RESULT. The ring, the hover, the\n/// press-drag-release are all copied from <see cref=\"PaintFrame\"/> deliberately: switching between\n/// the two should feel like changing what the brush is loaded with, not like learning a tool. What\n/// differs is what a dab does \u2014 colour blends into vertices here and now, a material dab is an\n/// edit to the feature history, so it has to go out to the window, change the document and come\n/// back as a rebuild.\n///\n/// WHICH IS WHY IT REPORTS RATHER THAN APPLIES. The viewport owns no studio and must not: it is\n/// handed a mesh to look at. So a dab raises <see cref=\"MaterialDabbed\"/> with the faces it\n/// covered and the window does the assignment through MaterialDrop.Brush. That keeps the undo\n/// story ordinary \u2014 a material dab is the same kind of edit as dropping a material, and undoes the\n/// same way \u2014 instead of inventing a second undo path that only the brush knows about.\n///\n/// A DAB PER FRAME, AN UNDO STEP PER STROKE. The faces under the ring change as it moves, and each\n/// batch has to reach the document while the drag is still happening or the model would not follow\n/// the brush \u2014 so dabs are frequent. What a person expects to take back is the GESTURE, though, so\n/// undo is recorded once, at the press, by <see cref=\"MaterialStrokeStarted\"/>. Recording it per\n/// dab instead would turn one sweep into fifty Ctrl+Z presses.\n/// </summary>\ninternal sealed partial class EffigyViewport\n{\n\tpublic MaterialBrushSession MaterialBrush { get; private set; }\n\n\tpublic bool IsMaterialBrushing => MaterialBrush is not null;\n\n\t/// <summary>Raised with the faces one dab covered. The window assigns them and rebuilds; the\n\t/// list is reused between dabs, so it must be consumed rather than kept.</summary>\n\tpublic Action<IReadOnlyList<int>> MaterialDabbed { get; set; }\n\n\t/// <summary>\n\t/// Raised once when a drag begins, BEFORE its first dab, so the window can record undo.\n\t///\n\t/// ONCE PER STROKE AND NOT PER DAB, which is the whole reason it is a separate signal. A dab\n\t/// fires every frame the brush moves, so recording undo there would make one sweep across a\n\t/// part into fifty Ctrl+Z presses; recording it nowhere - which is what this did at first -\n\t/// leaves the stroke unundoable and sends Ctrl+Z back past it to whatever came before. The\n\t/// gesture is the unit a person would expect to take back, so the gesture is what is recorded.\n\t/// </summary>\n\tpublic Action MaterialStrokeStarted { get; set; }\n\n\t/// <summary>\n\t/// Whether a material is actually chosen. The window sets it from the Materials browser.\n\t///\n\t/// THE RING HAS TO SAY SO. Without this the brush draws its ring, outlines the faces under it\n\t/// and then does nothing at all when dragged, because there is no material to lay down - which\n\t/// looks exactly like a broken tool rather than an unloaded one. Unloaded, the ring goes grey\n\t/// and stops outlining faces it is not going to take.\n\t/// </summary>\n\tpublic bool MaterialBrushLoaded { get; set; }\n\n\tprivate MeshHit? _materialCursor;\n\tprivate bool _materialStroking;\n\n\tpublic void BeginMaterialBrush( MaterialBrushSession session )\n\t{\n\t\tMaterialBrush = session ?? throw new ArgumentNullException( nameof( session ) );\n\t\t_materialStroking = false;\n\t}\n\n\tpublic void EndMaterialBrush()\n\t{\n\t\tMaterialBrush = null;\n\t\t_materialCursor = null;\n\t\t_materialStroking = false;\n\t}\n\n\tprivate void MaterialBrushFrame()\n\t{\n\t\tif ( MaterialBrush is null )\n\t\t\treturn;\n\n\t\t_materialCursor = null;\n\n\t\t// Leaving the canvas does not end the drag, the same rule paint and sculpt keep: dragging\n\t\t// off the model and back on is ordinary.\n\t\tif ( _canvasHasCursor )\n\t\t{\n\t\t\tvar ray = Gizmo.CurrentRay;\n\t\t\tvar origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );\n\t\t\tvar direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );\n\n\t\t\t_materialCursor = MaterialBrush.Hover( origin, direction );\n\n\t\t\t// Before the first dab, so the snapshot is the state the stroke is about to change.\n\t\t\tif ( Gizmo.WasLeftMousePressed && !_materialStroking )\n\t\t\t{\n\t\t\t\t_materialStroking = true;\n\t\t\t\tMaterialStrokeStarted?.Invoke();\n\t\t\t}\n\n\t\t\tif ( _materialStroking && Gizmo.IsLeftMouseDown && _materialCursor is { } hit )\n\t\t\t{\n\t\t\t\tvar faces = MaterialBrush.FacesAt( hit );\n\n\t\t\t\tif ( faces.Count > 0 )\n\t\t\t\t\tMaterialDabbed?.Invoke( faces );\n\t\t\t}\n\t\t}\n\n\t\tif ( _materialStroking && !Gizmo.IsLeftMouseDown )\n\t\t\t_materialStroking = false;\n\n\t\tDrawMaterialCursor();\n\t}\n\n\t/// <summary>\n\t/// The ring, plus the faces it is about to take.\n\t///\n\t/// HIGHLIGHTING THE FACES IS THE WHOLE POINT HERE, in a way it is not for colour. A material dab\n\t/// covers whole faces, so what the ring encloses and what the dab takes are not the same shape \u2014\n\t/// on a coarse box a small ring still paints an entire side. Showing the faces means the tool\n\t/// says what it is about to do rather than letting the first click explain it.\n\t/// </summary>\n\tprivate void DrawMaterialCursor()\n\t{\n\t\tif ( _materialCursor is not { } hit )\n\t\t\treturn;\n\n\t\tvar mesh = MaterialBrush.Mesh;\n\t\tvar colour = MaterialBrushLoaded ? MaterialCursorColor : UnloadedCursorColor;\n\n\t\tGizmo.Draw.IgnoreDepth = true;\n\t\tGizmo.Draw.Color = colour.WithAlpha( 0.5f );\n\t\tGizmo.Draw.LineThickness = 1.5f;\n\n\t\t// Only when there is something to lay down: outlining faces the brush will not touch is a\n\t\t// promise it cannot keep.\n\t\tforeach ( var faceIndex in MaterialBrushLoaded ? MaterialBrush.FacesAt( hit ) : NoFaces )\n\t\t{\n\t\t\tif ( faceIndex < 0 || faceIndex >= mesh.Faces.Count )\n\t\t\t\tcontinue;\n\n\t\t\tvar face = mesh.Faces[faceIndex];\n\n\t\t\tfor ( var c = 0; c < face.Count; c++ )\n\t\t\t{\n\t\t\t\tvar a = mesh.Positions[face.Indices[c]];\n\t\t\t\tvar b = mesh.Positions[face.Indices[(c + 1) % face.Count]];\n\n\t\t\t\tGizmo.Draw.Line( new Vector3( a.x, a.y, a.z ), new Vector3( b.x, b.y, b.z ) );\n\t\t\t}\n\t\t}\n\n\t\tvar normal = new Vector3( hit.Normal.x, hit.Normal.y, hit.Normal.z ).Normal;\n\t\tvar centre = new Vector3( hit.Point.x, hit.Point.y, hit.Point.z );\n\n\t\tvar reference = MathF.Abs( normal.z ) > 0.9f ? new Vector3( 1f, 0f, 0f ) : new Vector3( 0f, 0f, 1f );\n\t\tvar right = Vector3.Cross( normal, reference ).Normal;\n\t\tvar up = Vector3.Cross( normal, right ).Normal;\n\n\t\tvar radius = MaterialBrush.Radius;\n\n\t\tGizmo.Draw.Color = colour;\n\n\t\tvar lift = normal * (radius * 0.01f);\n\t\tconst int Segments = 40;\n\t\tvar previous = centre + right * radius + lift;\n\n\t\tfor ( var i = 1; i <= Segments; i++ )\n\t\t{\n\t\t\tvar angle = i / (float)Segments * MathF.PI * 2f;\n\t\t\tvar point = centre + (right * MathF.Cos( angle ) + up * MathF.Sin( angle )) * radius + lift;\n\n\t\t\tGizmo.Draw.Line( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// <summary>Amber, so it is neither sculpt's blue nor paint's pink \u2014 the three brushes are told\n\t/// apart by the colour of their ring before anything else.</summary>\n\tprivate static readonly Color MaterialCursorColor = new( 1f, 0.75f, 0.25f, 0.9f );\n\n\t/// <summary>Grey, for a brush with nothing loaded - the ring still tracks the surface so the\n\t/// tool is visibly alive, it just does not claim it is about to paint anything.</summary>\n\tprivate static readonly Color UnloadedCursorColor = new( 0.6f, 0.6f, 0.6f, 0.7f );\n\n\tprivate static readonly IReadOnlyList<int> NoFaces = new List<int>();\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigyViewport.Selection.cs",
"FileName": "EffigyViewport.Selection.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing Effigy;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\n\nnamespace Marionette.EditorTools;\n\n/// <summary>\n/// Idle geometry selection \u2014 click a face or a part, then use a tool on it.\n///\n/// Separate from FacePickMode / BodyPickMode. Those are a dialog asking a question; this is the\n/// selection that exists when nobody is asking. Onshape works the same way: you point at a face,\n/// it lights up, you click it, then Draft/Hole/Sketch consume what you already picked instead of\n/// making you pick it again inside a dialog.\n/// </summary>\ninternal sealed partial class EffigyViewport\n{\n\t/// <summary>Faces currently selected while idle. Empty when the last click was a whole part\n\t/// in the Parts list, or nothing.</summary>\n\tpublic IReadOnlyList<FaceRef> IdleFaces => _idleFaces;\n\n\t/// <summary>Edges currently selected while idle. Empty unless the last click landed near a\n\t/// rim rather than in the middle of a face.</summary>\n\tpublic IReadOnlyList<EdgeRef> IdleEdges => _idleEdges;\n\n\t/// <summary>Bodies currently selected while idle. A face click names its owning body so the\n\t/// Parts list can highlight the same row; a Parts-list click names the body and no faces.\n\t/// </summary>\n\tpublic IReadOnlyList<string> IdleBodyIds => _idleBodyIds;\n\n\t/// <summary>Committed sketch currently selected while idle, or null. Extrude/Revolve read\n\t/// this instead of waiting for a second click.</summary>\n\tpublic string IdleSketchFeatureId => _idleSketchFeatureId;\n\n\t/// <summary>Region seeds on <see cref=\"IdleSketchFeatureId\"/>. Empty means the whole sketch.\n\t/// </summary>\n\tpublic IReadOnlyList<Vec2> IdleRegionSeeds => _idleRegionSeeds;\n\n\tpublic bool HasIdleSelection =>\n\t\t_idleFaces.Count > 0 || _idleEdges.Count > 0 || _idleBodyIds.Count > 0\n\t\t|| _idleSketchFeatureId is not null;\n\n\t/// <summary>Raised after a click in the viewport changes the idle selection, so the Parts\n\t/// list can stay in step. Not raised when the list itself drove the change.</summary>\n\tpublic Action IdleSelectionChanged { get; set; }\n\n\tprivate readonly List<FaceRef> _idleFaces = new();\n\tprivate readonly List<EdgeRef> _idleEdges = new();\n\tprivate readonly List<string> _idleBodyIds = new();\n\tprivate string _idleSketchFeatureId;\n\tprivate readonly List<Vec2> _idleRegionSeeds = new();\n\n\t/// <summary>How close a click must be to a rim, in screen pixels, before it counts as an\n\t/// edge rather than the face. Wide enough to grab, tight enough that a click in the middle\n\t/// of a face stays a face.</summary>\n\tprivate const float EdgePickPixels = 10f;\n\n\t/// <summary>Whether a left click on a face is a selection rather than an answer to a dialog.\n\t/// Anything else with a click of its own owns the mouse while it is armed.</summary>\n\tprivate bool IdlePickingAllowed =>\n\t\t!IsSketching && !IsSplineEditing && !IsSculpting && !IsMeshEditing && !IsPainting && !IsMaterialBrushing && !IsNoting && !IsWeightPainting\n\t\t&& !PlanePickMode && !SketchPickMode && !FacePickMode && !EdgePickMode && !BodyPickMode\n\t\t&& !BoneToolActive\n\t\t&& !_draggingOrigin && !_draggingLight && !_draggingFace;\n\n\t/// <summary>\n\t/// Replace the idle selection with whole bodies, clearing any face picks.\n\t///\n\t/// This is what the Parts list does: clicking a row selects that part, not one of its faces.\n\t/// Fillet, shell, transform then act on the part; Sketch/Draft still need a face, so they\n\t/// open asking for one.\n\t/// </summary>\n\tpublic void SelectBodies( IReadOnlyList<string> bodyIds )\n\t{\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\tClearIdleSketch();\n\n\t\tif ( bodyIds is not null )\n\t\t{\n\t\t\tforeach ( var id in bodyIds )\n\t\t\t{\n\t\t\t\tif ( !string.IsNullOrEmpty( id ) && !_idleBodyIds.Contains( id ) )\n\t\t\t\t\t_idleBodyIds.Add( id );\n\t\t\t}\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tpublic void ClearIdleSelection()\n\t{\n\t\tif ( !HasIdleSelection )\n\t\t\treturn;\n\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\tClearIdleSketch();\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tvoid ClearIdleSketch()\n\t{\n\t\t_idleSketchFeatureId = null;\n\t\t_idleRegionSeeds.Clear();\n\t}\n\n\t/// <summary>Select a committed sketch, optionally a single closed region of it.</summary>\n\tpublic void SelectIdleSketch( string featureId, Vec2? seed, bool add = false )\n\t{\n\t\tif ( string.IsNullOrEmpty( featureId ) )\n\t\t{\n\t\t\tif ( _idleSketchFeatureId is null )\n\t\t\t\treturn;\n\n\t\t\tClearIdleSketch();\n\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( add && _idleSketchFeatureId == featureId && seed is { } extra )\n\t\t{\n\t\t\tfor ( var i = 0; i < _idleRegionSeeds.Count; i++ )\n\t\t\t{\n\t\t\t\tvar existing = _idleRegionSeeds[i];\n\n\t\t\t\tif ( MathF.Abs( existing.x - extra.x ) > 1e-4f || MathF.Abs( existing.y - extra.y ) > 1e-4f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleRegionSeeds.RemoveAt( i );\n\n\t\t\t\tif ( _idleRegionSeeds.Count == 0 )\n\t\t\t\t\t_idleSketchFeatureId = null;\n\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleRegionSeeds.Add( extra );\n\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\treturn;\n\t\t}\n\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\t_idleSketchFeatureId = featureId;\n\t\t_idleRegionSeeds.Clear();\n\n\t\tif ( seed is { } one )\n\t\t\t_idleRegionSeeds.Add( one );\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\t/// <summary>Drop faces and parts whose bodies no longer exist after a rebuild. Called from\n\t/// SetDisplayBodies so a deleted part cannot stay selected against nothing.</summary>\n\tprivate void PruneIdleSelection()\n\t{\n\t\tif ( !HasIdleSelection )\n\t\t\treturn;\n\n\t\tvar ids = new HashSet<string>();\n\n\t\tforeach ( var body in _displayBodies )\n\t\t{\n\t\t\tif ( body?.Id is { } id )\n\t\t\t\tids.Add( id );\n\t\t}\n\n\t\tvar changed = _idleFaces.RemoveAll( f => !ids.Contains( f.BodyId ) ) > 0\n\t\t\t| _idleEdges.RemoveAll( e => !ids.Contains( e.BodyId ) ) > 0\n\t\t\t| _idleBodyIds.RemoveAll( id => !ids.Contains( id ) ) > 0;\n\n\t\tif ( _idleSketchFeatureId is not null )\n\t\t{\n\t\t\tvar found = false;\n\n\t\t\tforeach ( var pickable in _pickableSketches )\n\t\t\t{\n\t\t\t\tif ( pickable.FeatureId == _idleSketchFeatureId )\n\t\t\t\t{\n\t\t\t\t\tfound = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( !found )\n\t\t\t{\n\t\t\t\tClearIdleSketch();\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\n\t\tif ( changed )\n\t\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\t/// <summary>\n\t/// Hover the face under the cursor, keep already-picked faces and parts lit, and take a click\n\t/// as a selection when nothing else owns the mouse.\n\t///\n\t/// Runs after the origin handle so a click on the origin is not also a click on the face that\n\t/// happens to sit behind it. Gizmo.HasHovered covers the origin, the plane-corner handles and\n\t/// anything else that registered a hitbox this frame.\n\t/// </summary>\n\tprivate void IdleSelectionFrame()\n\t{\n\t\tDrawIdleSelection();\n\n\t\tif ( !IdlePickingAllowed )\n\t\t\treturn;\n\n\t\tif ( !_canvasHasCursor )\n\t\t\treturn;\n\n\t\t// A bone under the cursor is a bone click, even when the mesh is in front of it.\n\t\t// Taking the part instead is why clicking a 3D bone did nothing and Assign stayed grey.\n\t\tif ( RigMode && TryPickBoneUnderCursor( out _ ) )\n\t\t\treturn;\n\n\t\t// Sketches are a CAD question. In the rig workspace a click names a part (or a bone),\n\t\t// and a profile lighting up under the cursor would steal the body you meant to pin.\n\t\tif ( !RigMode\n\t\t\t&& TryResolveSketchHover( out var sketchId, out var sketchSeed, out var sketchDistance )\n\t\t\t&& SketchBeatsFace( sketchDistance ) )\n\t\t{\n\t\t\t_hoveredFaceBodyId = sketchId;\n\t\t\tDrawSketchPickHighlight( sketchId, sketchSeed );\n\n\t\t\tif ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )\n\t\t\t{\n\t\t\t\tif ( OriginSelected )\n\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\tSelectIdleSketch( sketchId, sketchSeed, Gizmo.IsShiftPressed );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( TryPickFaceUnderCursor( out var hit ) )\n\t\t{\n\t\t\t_hoveredFaceBodyId = hit.Body.Id;\n\n\t\t\t// Rigging assigns WHOLE parts. A face or an edge under the cursor is still the part,\n\t\t\t// and lighting a single face would look like CAD selection in a workspace that has\n\t\t\t// no face tools.\n\t\t\tif ( RigMode )\n\t\t\t{\n\t\t\t\tDrawBodyHighlight( hit.Body, BodyPickHoverColor );\n\n\t\t\t\tif ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )\n\t\t\t\t\tSelectIdleBody( hit.Body.Id, Gizmo.IsShiftPressed );\n\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif ( TryIdleEdge( hit, out var edge, out var key ) )\n\t\t\t{\n\t\t\t\tDrawEdge( hit.Body, key, FaceHighlightColor );\n\n\t\t\t\tif ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )\n\t\t\t\t{\n\t\t\t\t\tif ( OriginSelected )\n\t\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\t\tSelectIdleEdge( hit.Body, edge, Gizmo.IsShiftPressed );\n\t\t\t\t}\n\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tDrawHoveredFace( hit.Body, hit.FaceIndex );\n\n\t\t\tif ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )\n\t\t\t{\n\t\t\t\tif ( OriginSelected )\n\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\tSelectIdleFace( hit, Gizmo.IsShiftPressed );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && !Gizmo.IsHovered )\n\t\t\tClearIdleSelection();\n\t}\n\n\t/// <summary>\n\t/// Chosen idle geometry, in the same amber the dialog uses for a committed pick.\n\t///\n\t/// Skipped when a dialog is already drawing its own SelectedFaces / SelectedBodyIds, so the\n\t/// two never paint the same face twice in slightly different states.\n\t/// </summary>\n\t/// <summary>A sketch on a face sits at the same depth as that face. Prefer the sketch when\n\t/// they tie; prefer a solid that is genuinely in front of the sketch plane.</summary>\n\tprivate bool SketchBeatsFace( float sketchDistance )\n\t{\n\t\tif ( !TryPickFaceUnderCursor( out var hit ) )\n\t\t\treturn true;\n\n\t\tvar faceDistance = (hit.Reference.Point - _cursorRayOrigin).Length;\n\n\t\treturn sketchDistance <= faceDistance + 0.05f;\n\t}\n\n\tprivate void DrawIdleSelection()\n\t{\n\t\t// A BRUSH WANTS THE SURFACE, NOT THE SELECTION. An amber wash and wireframe over the part\n\t\t// you are painting hides the very colour you are laying down. The selection survives \u2014 it\n\t\t// comes back when the brush is put away \u2014 only the drawing of it stops. The shell is cleared\n\t\t// too: it is a cached scene object, and a frame that simply stops drawing leaves it lit.\n\t\tif ( IsPainting || IsSculpting || IsMeshEditing || IsMaterialBrushing || IsWeightPainting )\n\t\t{\n\t\t\tSyncBodyHighlight( null, BodySelectedColor );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( (SelectedSketchFeatureId is null || SelectedSketchFeatureId.Length == 0)\n\t\t\t&& _idleSketchFeatureId is not null )\n\t\t{\n\t\t\tvar seed = _idleRegionSeeds.Count == 1 ? _idleRegionSeeds[0] : (Vec2?)null;\n\n\t\t\tif ( _idleRegionSeeds.Count <= 1 )\n\t\t\t\tDrawSketchPickHighlight( _idleSketchFeatureId, seed );\n\t\t\telse\n\t\t\t{\n\t\t\t\tforeach ( var region in _idleRegionSeeds )\n\t\t\t\t\tDrawSketchPickHighlight( _idleSketchFeatureId, region );\n\t\t\t}\n\t\t}\n\n\t\tvar drawFaces = SelectedFaces is null || SelectedFaces.Count == 0;\n\t\tvar drawEdges = SelectedEdges is null || SelectedEdges.Count == 0;\n\t\tvar drawBodies = SelectedBodyIds is null || SelectedBodyIds.Count == 0;\n\n\t\tif ( drawEdges && _idleEdges.Count > 0 )\n\t\t{\n\t\t\tforeach ( var edge in _idleEdges )\n\t\t\t{\n\t\t\t\tif ( FacePlane.TryResolveEdge( _displayBodies, edge, out var body, out var key ) )\n\t\t\t\t\tDrawEdge( body, key, FaceSelectedColor );\n\t\t\t}\n\t\t}\n\n\t\tif ( drawFaces && _idleFaces.Count > 0 )\n\t\t{\n\t\t\tforeach ( var face in _idleFaces )\n\t\t\t{\n\t\t\t\tif ( FacePlane.TryResolveFace( _displayBodies, face, out var body, out var index ) )\n\t\t\t\t\tDrawFace( body, index, FaceSelectedColor );\n\t\t\t}\n\n\t\t\t// A face pick names its owning body, so _idleBodyIds is not empty here \u2014 but the thing\n\t\t\t// selected is the face, and shelling the whole part around it would bury the pick.\n\t\t\tSyncBodyHighlight( null, BodySelectedColor );\n\t\t\treturn;\n\t\t}\n\n\t\t// A DIALOG OWNS THE SHELL WHILE ONE IS PICKING BODIES. DrawChosenBodies drives the same\n\t\t// cache from the other side, and clearing it here \u2014 which is what \"no idle selection\" would\n\t\t// otherwise mean \u2014 would blank the picker's own highlight every frame.\n\t\tif ( !drawBodies )\n\t\t\treturn;\n\n\t\t// EVERY OTHER PATH OUT OF HERE ANSWERS THIS, including the ones with nothing to light. The\n\t\t// shell is a cached scene object rather than lines drawn this frame (see\n\t\t// EffigyViewport.Highlight), so a frame that simply stops drawing it leaves it on screen.\n\t\t_highlightBodies.Clear();\n\n\t\tforeach ( var body in _displayBodies )\n\t\t{\n\t\t\tif ( body?.Id is not { } id || !_idleBodyIds.Contains( id ) )\n\t\t\t\tcontinue;\n\n\t\t\t// THE SAME DRAW A VIEWPORT CLICK USES, face for face.\n\t\t\t//\n\t\t\t// Not a similar one, not a body-shaped variant of one \u2014 DrawFace with FaceSelectedColor,\n\t\t\t// which is literally the call that lights a face up when you click the solid. Clicking a\n\t\t\t// row in the Parts list means \"that whole part\", so it runs over every face of the body\n\t\t\t// and the part comes up in the amber you already know. Every attempt at this that drew\n\t\t\t// something of its own instead \u2014 the wash-and-outline body highlight, the cached shell \u2014\n\t\t\t// failed in a way that looked from the outside like the click doing nothing at all.\n\t\t\t//\n\t\t\t// The shell stays for the case that earns it: an import too dense to draw a face at a\n\t\t\t// time, where this loop is hundreds of thousands of gizmo calls a frame.\n\t\t\t// ONE PASS, NOT ONE PER FACE. Calling DrawFace in a loop was the obvious way to say \"the\n\t\t\t// same amber a face pick gives you\" and it is quadratic: DrawFace resolves the COPLANAR\n\t\t\t// SURFACE the face belongs to and draws every face in it, so a surface of forty faces is\n\t\t\t// drawn forty times, and a part with a few thousand faces spends the frame redrawing\n\t\t\t// itself. DrawBodyHighlight paints each face exactly once in the colour it is handed,\n\t\t\t// which is the same picture for a fraction of the work.\n\t\t\tif ( (body.Mesh?.FaceCount ?? 0) <= HoverHighlightFaceBudget )\n\t\t\t\tDrawBodyHighlight( body, FaceSelectedColor );\n\t\t\telse\n\t\t\t\t_highlightBodies.Add( body );\n\t\t}\n\n\t\tSyncBodyHighlight( _highlightBodies, BodySelectedColor );\n\t}\n\n\t/// <summary>Scratch for the line above \u2014 reused rather than allocated, since it is rebuilt every\n\t/// frame and thrown away on almost all of them.</summary>\n\tprivate readonly List<Body> _highlightBodies = new();\n\n\t/// <summary>\n\t/// Select one face outright, from something other than a click \u2014 the right-click menu, which\n\t/// has already resolved the face under the cursor and now wants a tool pointed at it.\n\t///\n\t/// Replaces the selection rather than adding to it: the face you just right-clicked is the face\n\t/// you meant, and quietly bundling it with whatever was lit a moment ago would hand the tool\n\t/// more than you asked for.\n\t/// </summary>\n\tpublic void SelectFace( EffigyFaceHit hit ) => SelectIdleFace( hit, add: false );\n\n\t/// <summary>Click a part in the rig workspace: the whole body, never one of its faces.</summary>\n\tprivate void SelectIdleBody( string bodyId, bool add )\n\t{\n\t\tif ( string.IsNullOrEmpty( bodyId ) )\n\t\t\treturn;\n\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\tClearIdleSketch();\n\n\t\tif ( add )\n\t\t{\n\t\t\tif ( !_idleBodyIds.Remove( bodyId ) )\n\t\t\t\t_idleBodyIds.Add( bodyId );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_idleBodyIds.Clear();\n\t\t\t_idleBodyIds.Add( bodyId );\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\t/// <summary>Click a face: replace the selection, or Shift-click to toggle it in.</summary>\n\tprivate void SelectIdleFace( EffigyFaceHit hit, bool add )\n\t{\n\t\tvar face = hit.Reference;\n\n\t\tif ( add )\n\t\t{\n\t\t\tfor ( var i = 0; i < _idleFaces.Count; i++ )\n\t\t\t{\n\t\t\t\tif ( !SameIdleFace( _idleFaces[i], face ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleFaces.RemoveAt( i );\n\t\t\t\tRebuildIdleBodyIdsFromFaces();\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleEdges.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleFaces.Add( face );\n\n\t\t\tif ( !_idleBodyIds.Contains( hit.Body.Id ) )\n\t\t\t\t_idleBodyIds.Add( hit.Body.Id );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_idleFaces.Clear();\n\t\t\t_idleEdges.Clear();\n\t\t\t_idleBodyIds.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleFaces.Add( face );\n\t\t\t_idleBodyIds.Add( hit.Body.Id );\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tprivate void SelectIdleEdge( Body body, EdgeRef edge, bool add )\n\t{\n\t\tif ( add )\n\t\t{\n\t\t\tfor ( var i = 0; i < _idleEdges.Count; i++ )\n\t\t\t{\n\t\t\t\tif ( !SameIdleEdge( _idleEdges[i], edge ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleEdges.RemoveAt( i );\n\t\t\t\tRebuildIdleBodyIdsFromFaces();\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleFaces.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleEdges.Add( edge );\n\n\t\t\tif ( !_idleBodyIds.Contains( body.Id ) )\n\t\t\t\t_idleBodyIds.Add( body.Id );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_idleFaces.Clear();\n\t\t\t_idleEdges.Clear();\n\t\t\t_idleBodyIds.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleEdges.Add( edge );\n\t\t\t_idleBodyIds.Add( body.Id );\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tprivate bool TryIdleEdge( EffigyFaceHit hit, out EdgeRef edge, out EdgeKey key )\n\t{\n\t\tedge = default;\n\t\tkey = default;\n\n\t\tif ( !TryClosestEdge( hit.Body.Mesh, hit.FaceIndex, hit.Reference.Point, out key, out var distance ) )\n\t\t\treturn false;\n\n\t\tvar point = hit.Reference.Point;\n\t\tvar threshold = WorldRadiusAt( new Vector3( point.x, point.y, point.z ), EdgePickPixels );\n\n\t\tif ( distance > threshold )\n\t\t\treturn false;\n\n\t\tedge = FacePlane.Capture( hit.Body, key );\n\t\treturn true;\n\t}\n\n\tprivate void RebuildIdleBodyIdsFromFaces()\n\t{\n\t\t_idleBodyIds.Clear();\n\n\t\tforeach ( var face in _idleFaces )\n\t\t{\n\t\t\tif ( !_idleBodyIds.Contains( face.BodyId ) )\n\t\t\t\t_idleBodyIds.Add( face.BodyId );\n\t\t}\n\n\t\tforeach ( var edge in _idleEdges )\n\t\t{\n\t\t\tif ( !_idleBodyIds.Contains( edge.BodyId ) )\n\t\t\t\t_idleBodyIds.Add( edge.BodyId );\n\t\t}\n\t}\n\n\tprivate bool SameIdleEdge( EdgeRef a, EdgeRef b )\n\t{\n\t\tif ( !FacePlane.TryResolveEdge( _displayBodies, a, out var bodyA, out var keyA )\n\t\t\t|| !FacePlane.TryResolveEdge( _displayBodies, b, out var bodyB, out var keyB ) )\n\t\t\treturn false;\n\n\t\treturn bodyA.Id == bodyB.Id && keyA.Equals( keyB );\n\t}\n\n\t/// <summary>Match by resolved face, not by stored FaceRef equality \u2014 two clicks on the same\n\t/// face produce two references with slightly different hit points.</summary>\n\tprivate bool SameIdleFace( FaceRef a, FaceRef b )\n\t{\n\t\tif ( !FacePlane.TryResolveFace( _displayBodies, a, out var bodyA, out var indexA )\n\t\t\t|| !FacePlane.TryResolveFace( _displayBodies, b, out var bodyB, out var indexB ) )\n\t\t\treturn false;\n\n\t\treturn bodyA.Id == bodyB.Id && indexA == indexB;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/EffigyEditor/EffigyViewport.Splines.cs",
"FileName": "EffigyViewport.Splines.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing Effigy;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Marionette.EditorTools;\n\n/// <summary>\n/// Drawing a spline: click to drop points, drag a point to move it, and the curve follows.\n///\n/// A CLICK LANDS ON THE MODEL WHEN THERE IS ONE UNDER IT, and on a plane through the last point\n/// facing the camera otherwise \u2014 so a cable can be clicked straight onto a body's surface, and\n/// the run between two bodies is clicked out in the air at the height you were already at. The\n/// first point with nothing under it lands on the ground plane, which is where a path usually\n/// starts. A drag moves the point in the plane facing the camera through it: the point goes\n/// where the mouse goes, and depth is left alone.\n///\n/// The feature owns the points; this only edits them and says so. The curve drawn here is\n/// sampled from the feature directly, so it follows the mouse without waiting on a rebuild,\n/// and the rebuild that follows an edit is what turns it into the tube.\n/// </summary>\ninternal sealed partial class EffigyViewport\n{\n\tpublic SplineFeature SplineFeature { get; private set; }\n\tpublic bool IsSplineEditing => SplineFeature is not null;\n\n\t/// <summary>A point was added, moved or removed. The window marks the feature dirty and rebuilds.</summary>\n\tpublic Action SplineEdited { get; set; }\n\n\t/// <summary>Enter, or a double-click: the drawing is done.</summary>\n\tpublic Action SplineFinishRequested { get; set; }\n\n\tprivate int _splineHover = -1;\n\tprivate int _splineDrag = -1;\n\tprivate bool _splineDragMoved;\n\tprivate Vector3 _splineDragNormal;\n\tprivate Vec3 _splineDragOrigin;\n\n\tprivate static readonly Color SplineColor = new( 0.94f, 0.63f, 0.23f );\n\tprivate static readonly Color SplinePointColor = new( 0.96f, 0.96f, 0.98f );\n\tprivate static readonly Color SplineHoverColor = new( 0.36f, 0.58f, 1f );\n\n\tpublic void BeginSplineEdit( SplineFeature feature )\n\t{\n\t\tSplineFeature = feature;\n\t\t_splineHover = -1;\n\t\t_splineDrag = -1;\n\t\tUpdate();\n\t}\n\n\tpublic void EndSplineEdit()\n\t{\n\t\tSplineFeature = null;\n\t\t_splineHover = -1;\n\t\t_splineDrag = -1;\n\t\tUpdate();\n\t}\n\n\t/// <summary>Remove the last point \u2014 Backspace while drawing.</summary>\n\tpublic bool RemoveLastSplinePoint()\n\t{\n\t\tif ( SplineFeature is not { } feature || feature.Points.Count == 0 )\n\t\t\treturn false;\n\n\t\tfeature.Points.RemoveAt( feature.Points.Count - 1 );\n\t\t_splineHover = -1;\n\t\tSplineEdited?.Invoke();\n\t\treturn true;\n\t}\n\n\tprivate void SplineFrame()\n\t{\n\t\tif ( SplineFeature is not { } feature )\n\t\t\treturn;\n\n\t\tvar points = feature.Points;\n\t\tvar ray = Gizmo.CurrentRay;\n\t\tvar origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );\n\t\tvar direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );\n\t\tvar cameraForward = Gizmo.CameraTransform.Rotation.Forward;\n\n\t\t// --- the drag, first, so a held button keeps its point even off the handle ---\n\t\tif ( _splineDrag >= 0 )\n\t\t{\n\t\t\tif ( Gizmo.IsLeftMouseDown && _splineDrag < points.Count )\n\t\t\t{\n\t\t\t\tif ( RayPlane( origin, direction, _splineDragOrigin, new Vec3( _splineDragNormal.x, _splineDragNormal.y, _splineDragNormal.z ), out var moved ) )\n\t\t\t\t{\n\t\t\t\t\tif ( (moved - points[_splineDrag]).LengthSquared > 1e-10f )\n\t\t\t\t\t{\n\t\t\t\t\t\tpoints[_splineDrag] = moved;\n\t\t\t\t\t\t_splineDragMoved = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tvar moved = _splineDragMoved;\n\t\t\t\t_splineDrag = -1;\n\t\t\t\t_splineDragMoved = false;\n\n\t\t\t\tif ( moved )\n\t\t\t\t\tSplineEdited?.Invoke();\n\t\t\t}\n\t\t}\n\n\t\t// --- hover: the nearest point within a handle's width of the cursor ray ---\n\t\t_splineHover = -1;\n\n\t\tif ( _canvasHasCursor && _splineDrag < 0 )\n\t\t{\n\t\t\tvar best = float.MaxValue;\n\n\t\t\tfor ( var i = 0; i < points.Count; i++ )\n\t\t\t{\n\t\t\t\tvar toPoint = points[i] - origin;\n\t\t\t\tvar along = Vec3.Dot( toPoint, direction );\n\n\t\t\t\tif ( along <= 0f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar perpendicular = (toPoint - direction * along).Length;\n\t\t\t\tvar handle = HandleRadius( along ) * 1.6f;\n\n\t\t\t\tif ( perpendicular < handle && along < best )\n\t\t\t\t{\n\t\t\t\t\tbest = along;\n\t\t\t\t\t_splineHover = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// --- where a click would land ---\n\t\tVec3? landing = null;\n\n\t\tif ( _canvasHasCursor && _splineHover < 0 && _splineDrag < 0 )\n\t\t\tlanding = SplineLanding( origin, direction, points, cameraForward );\n\n\t\t// --- input ---\n\t\tif ( _canvasHasCursor && Gizmo.WasLeftMousePressed && !Gizmo.HasHovered && _splineDrag < 0 )\n\t\t{\n\t\t\tif ( _splineHover >= 0 )\n\t\t\t{\n\t\t\t\t_splineDrag = _splineHover;\n\t\t\t\t_splineDragMoved = false;\n\t\t\t\t_splineDragNormal = cameraForward;\n\t\t\t\t_splineDragOrigin = points[_splineHover];\n\t\t\t}\n\t\t\telse if ( landing is { } at )\n\t\t\t{\n\t\t\t\tpoints.Add( at );\n\t\t\t\tSplineEdited?.Invoke();\n\t\t\t}\n\t\t}\n\n\t\t// --- drawing ---\n\t\tvar spline = feature.ToSpline();\n\t\tvar path = spline.Sample( 10 );\n\n\t\tGizmo.Draw.IgnoreDepth = true;\n\t\tGizmo.Draw.Color = SplineColor;\n\t\tGizmo.Draw.LineThickness = 2.5f;\n\n\t\tfor ( var i = 0; i + 1 < path.Count; i++ )\n\t\t\tGizmo.Draw.Line( ToWorldDir( path[i] ), ToWorldDir( path[i + 1] ) );\n\n\t\tif ( spline.Closed && path.Count > 2 )\n\t\t\tGizmo.Draw.Line( ToWorldDir( path[^1] ), ToWorldDir( path[0] ) );\n\n\t\t// The straight polyline between control points, faint, so the curve's relationship to\n\t\t// the clicks stays readable on a tight bend.\n\t\tif ( spline.Smooth && points.Count > 2 )\n\t\t{\n\t\t\tGizmo.Draw.Color = SplineColor.WithAlpha( 0.25f );\n\t\t\tGizmo.Draw.LineThickness = 1f;\n\n\t\t\tfor ( var i = 0; i + 1 < points.Count; i++ )\n\t\t\t\tGizmo.Draw.Line( ToWorldDir( points[i] ), ToWorldDir( points[i + 1] ) );\n\t\t}\n\n\t\tfor ( var i = 0; i < points.Count; i++ )\n\t\t{\n\t\t\tvar world = ToWorldDir( points[i] );\n\t\t\tvar distance = (points[i] - origin).Length;\n\t\t\tvar radius = HandleRadius( distance );\n\t\t\tvar lit = i == _splineHover || i == _splineDrag;\n\n\t\t\tGizmo.Draw.Color = lit ? SplineHoverColor : i == points.Count - 1 ? SplineColor : SplinePointColor;\n\t\t\tGizmo.Draw.SolidSphere( world, lit ? radius * 1.3f : radius, 8, 8 );\n\t\t}\n\n\t\tif ( landing is { } ghost )\n\t\t{\n\t\t\tvar world = ToWorldDir( ghost );\n\t\t\tvar radius = HandleRadius( (ghost - origin).Length );\n\n\t\t\tGizmo.Draw.Color = SplineColor.WithAlpha( 0.55f );\n\t\t\tGizmo.Draw.LineSphere( new Sphere( world, radius ), 8 );\n\n\t\t\tif ( points.Count > 0 )\n\t\t\t{\n\t\t\t\tGizmo.Draw.Color = SplineColor.WithAlpha( 0.35f );\n\t\t\t\tGizmo.Draw.LineThickness = 1f;\n\t\t\t\tGizmo.Draw.Line( ToWorldDir( points[^1] ), world );\n\t\t\t}\n\t\t}\n\n\t\tGizmo.Draw.LineThickness = 1f;\n\t\tGizmo.Draw.IgnoreDepth = false;\n\t}\n\n\t/// <summary>A handle that stays the same size on screen: a fraction of its distance from the eye.</summary>\n\tprivate static float HandleRadius( float distance ) => MathF.Max( distance * 0.008f, 0.02f );\n\n\t/// <summary>\n\t/// Where a click lands: the model's surface under the cursor, else a plane facing the camera\n\t/// through the last point, else the ground.\n\t/// </summary>\n\tprivate Vec3? SplineLanding( Vec3 origin, Vec3 direction, List<Vec3> points, Vector3 cameraForward )\n\t{\n\t\tvar visible = _displayBodies.Where( b => b?.Mesh is not null && b.Visible ).ToList();\n\n\t\tif ( visible.Count > 0 && MeshRaycast.Raycast( visible, origin, direction, PickTreeFor ) is { } hit )\n\t\t\treturn hit.Hit.Point;\n\n\t\tif ( points.Count > 0 )\n\t\t{\n\t\t\tvar normal = new Vec3( cameraForward.x, cameraForward.y, cameraForward.z );\n\n\t\t\tif ( RayPlane( origin, direction, points[^1], normal, out var onView ) )\n\t\t\t\treturn onView;\n\t\t}\n\n\t\tif ( RayPlane( origin, direction, Vec3.Zero, new Vec3( 0, 0, 1 ), out var onGround ) )\n\t\t\treturn onGround;\n\n\t\treturn null;\n\t}\n\n\tprivate static bool RayPlane( Vec3 origin, Vec3 direction, Vec3 planePoint, Vec3 planeNormal, out Vec3 hit )\n\t{\n\t\thit = default;\n\n\t\tvar denominator = Vec3.Dot( direction, planeNormal );\n\n\t\tif ( MathF.Abs( denominator ) < 1e-6f )\n\t\t\treturn false;\n\n\t\tvar t = Vec3.Dot( planePoint - origin, planeNormal ) / denominator;\n\n\t\tif ( t <= 0f )\n\t\t\treturn false;\n\n\t\thit = origin + direction * t;\n\t\treturn true;\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Editor/RigControlEditor/RigHelpBox.cs",
"FileName": "RigHelpBox.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using Editor;\nusing Sandbox;\n\nnamespace Marionette.Tools;\n\n/// <summary>\n/// The \"Documentation\" style collapsible note used throughout the stock Inspector (a chevron, a\n/// circled ? badge, a bold title, a blue accent line under the whole block) - reused here so each\n/// tab opens with the same look instead of a hand-rolled one-off.\n/// </summary>\ninternal static class RigHelpBox\n{\n\t/// <summary>One labelled chunk of the note - \"Model\", \"IK Constraint\", whatever the reader\n\t/// needs to look up on its own rather than hunt for inside one wall of text.</summary>\n\tpublic readonly record struct Section( string Heading, string Body );\n\n\tpublic static Section S( string heading, string body ) => new( heading, body );\n\n\t/// <summary>\n\t/// intro: a plain, unlabelled paragraph at the top - what this tab is, in one or two\n\t/// sentences. sections: each gets its own bold heading and paragraph below it, so a reader\n\t/// can scan headings instead of parsing a block of text to find the one field they care about.\n\t///\n\t/// COLLAPSED BY DEFAULT. Expanded, three tabs of reference text is the first thing a new user\n\t/// sees, before they've done anything for it to be about - which reads as homework and gets\n\t/// skipped wholesale, taking the useful parts with it. The status bar's tutorial handles\n\t/// \"what do I do now\" one step at a time; this is the reference you open once you have a\n\t/// specific question, and the header line says which question it answers.\n\t/// </summary>\n\tpublic static Widget Create( Widget parent, string intro, Section[] sections, bool startExpanded = false )\n\t{\n\t\tvar expanded = startExpanded;\n\n\t\tvar box = new Widget( parent ) { Layout = Layout.Column() };\n\t\tbox.Layout.Margin = 0;\n\n\t\tbox.OnPaintOverride = () =>\n\t\t{\n\t\t\t// Accent line along the bottom, the one clearly-visible border the reference has.\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.Blue );\n\t\t\tPaint.DrawRect( new Rect( 0, box.Height - 2, box.Width, 2 ) );\n\t\t\treturn false;\n\t\t};\n\n\t\tvar header = new HeaderWidget( box ) { FixedHeight = 28 };\n\t\tbox.Layout.Add( header );\n\n\t\tvar body = new Widget( box ) { Layout = Layout.Column() };\n\t\tbody.Layout.Margin = new Sandbox.UI.Margin( 32, 0, 12, 12 );\n\t\tbody.Layout.Spacing = 10;\n\n\t\tif ( !string.IsNullOrWhiteSpace( intro ) )\n\t\t\tbody.Layout.Add( new Editor.Label( intro ) { WordWrap = true } );\n\n\t\tforeach ( var section in sections )\n\t\t{\n\t\t\tvar block = body.Layout.AddColumn();\n\t\t\tblock.Spacing = 2;\n\n\t\t\tvar heading = new Editor.Label( section.Heading ) { Color = Theme.Blue };\n\t\t\theading.SetStyles( \"font-weight: 600;\" );\n\t\t\tblock.Add( heading );\n\n\t\t\tblock.Add( new Editor.Label( section.Body ) { WordWrap = true } );\n\t\t}\n\n\t\tbox.Layout.Add( body );\n\n\t\tvoid UpdateState()\n\t\t{\n\t\t\theader.Expanded = expanded;\n\t\t\tbody.Visible = expanded;\n\t\t}\n\n\t\tUpdateState();\n\n\t\theader.Clicked = () =>\n\t\t{\n\t\t\texpanded = !expanded;\n\t\t\tUpdateState();\n\t\t};\n\n\t\treturn box;\n\t}\n\n\t/// <summary>Chevron + circled \"?\" + bold \"Documentation\" title, hand-painted so it actually\n\t/// matches the reference instead of approximating it with stock Label/Button widgets.</summary>\n\tprivate sealed class HeaderWidget : Widget\n\t{\n\t\tpublic bool Expanded { get; set; } = true;\n\t\tpublic System.Action Clicked { get; set; }\n\n\t\tpublic HeaderWidget( Widget parent ) : base( parent )\n\t\t{\n\t\t\tCursor = CursorShape.Finger;\n\t\t}\n\n\t\tprotected override void OnPaint()\n\t\t{\n\t\t\tvar rect = LocalRect;\n\n\t\t\tPaint.Antialiasing = true;\n\n\t\t\t// Chevron\n\t\t\tPaint.ClearBrush();\n\t\t\tPaint.SetPen( Theme.Blue );\n\t\t\tPaint.DrawIcon( new Rect( 6, 0, 16, rect.Height ), Expanded ? \"expand_more\" : \"chevron_right\", 16, TextFlag.Center );\n\n\t\t\t// Circled \"?\" badge - DrawCircle takes a Rect (confirmed from existing usage\n\t\t\t// elsewhere in this project), not a center+radius pair.\n\t\t\tvar badgeCenter = new Vector2( 34, rect.Center.y );\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.Blue );\n\t\t\tPaint.DrawCircle( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ) );\n\n\t\t\tPaint.SetPen( Theme.WindowBackground );\n\t\t\tPaint.SetDefaultFont( 8, 700 );\n\t\t\tPaint.DrawText( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ), \"?\", TextFlag.Center );\n\n\t\t\t// Title\n\t\t\tPaint.SetPen( Theme.Blue );\n\t\t\tPaint.SetDefaultFont( 8, 600 );\n\t\t\tPaint.DrawText( new Rect( 50, 0, rect.Width - 58, rect.Height ), \"Documentation\", TextFlag.LeftCenter );\n\t\t}\n\n\t\tprotected override void OnMouseClick( MouseEvent e )\n\t\t{\n\t\t\tbase.OnMouseClick( e );\n\t\t\tClicked?.Invoke();\n\t\t}\n\t}\n}\n"
},
{
"Ident": "pooh.geppetto",
"Path": "Effigy.Tests/BoneFromBodyTests.cs",
"FileName": "BoneFromBodyTests.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 411088,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing Effigy;\n\nnamespace Effigy.Tests;\n\n/// <summary>\n/// Deriving a bone from the shape of the body it will move.\n///\n/// The theme here is that the answers must be STABLE, not merely plausible. A derived bone that is\n/// a little off is easy to nudge; a derived bone that comes out differently on two identical\n/// fingers, or flips end-for-end when the model is rebuilt, is worse than no feature at all,\n/// because the modeller has to check every one by eye before trusting any of them. So most of what\n/// follows measures repeatability and orientation rather than precision.\n/// </summary>\npublic static class BoneFromBodyTests\n{\n\tpublic static void Run()\n\t{\n\t\tSection( \"a bone spans the body's longest axis\" );\n\t\tTestLongestAxis();\n\n\t\tSection( \"roll follows the widest direction across the bone\" );\n\t\tTestRoll();\n\n\t\tSection( \"an anchor decides which end is the head\" );\n\t\tTestAnchor();\n\n\t\tSection( \"shapes with no answer are refused rather than guessed\" );\n\t\tTestDegenerate();\n\n\t\tSection( \"the same shape always derives the same bone\" );\n\t\tTestRepeatable();\n\n\t\tSection( \"a derived bone feeds Skeleton unchanged\" );\n\t\tTestFeedsSkeleton();\n\t}\n\n\t/// <summary>The eight corners of an axis-aligned box from the origin to (sx, sy, sz).</summary>\n\tstatic List<Vec3> Box( float sx, float sy, float sz )\n\t{\n\t\tvar points = new List<Vec3>();\n\n\t\tfor ( var i = 0; i < 8; i++ )\n\t\t{\n\t\t\tpoints.Add( new Vec3(\n\t\t\t\t(i & 1) == 0 ? 0 : sx,\n\t\t\t\t(i & 2) == 0 ? 0 : sy,\n\t\t\t\t(i & 4) == 0 ? 0 : sz ) );\n\t\t}\n\n\t\treturn points;\n\t}\n\n\tstatic void TestLongestAxis()\n\t{\n\t\t// Four long, two wide, one thick \u2014 a finger, near enough, and unambiguous about which axis\n\t\t// is which.\n\t\tvar ok = BoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out _ );\n\n\t\tCheck( \"a box derives a bone\", ok );\n\t\tCheck( \"the bone aims down the long axis\",\n\t\t\tNear( (tail - head).Normal, new Vec3( 1, 0, 0 ) ), (tail - head).Normal.ToString() );\n\t\tCheck( \"it spans the full extent\", Near( (tail - head).Length, 4f ), (tail - head).Length.ToString() );\n\t\tCheck( \"it sits on the body's centre line\", Near( head, new Vec3( 0, 0.5f, 1 ) ), head.ToString() );\n\n\t\t// Rotated onto a diagonal: nothing about the method is axis-aligned, and a body built on a\n\t\t// sketch plane that is not one of the three standard ones is the normal case, not the\n\t\t// exotic one.\n\t\tvar diagonal = new List<Vec3>();\n\t\tvar along = new Vec3( 1, 1, 0 ).Normal;\n\n\t\tforeach ( var p in Box( 4, 1, 2 ) )\n\t\t{\n\t\t\t// Re-express the box about the diagonal: x runs along it, y across it in plane.\n\t\t\tdiagonal.Add( along * p.x + new Vec3( -along.y, along.x, 0 ) * p.y + new Vec3( 0, 0, 1 ) * p.z );\n\t\t}\n\n\t\tBoneFromBody.TryDerive( diagonal, out var dHead, out var dTail, out _ );\n\n\t\tCheck( \"a diagonal body aims down its own long axis\",\n\t\t\tNear( (dTail - dHead).Normal, along ), (dTail - dHead).Normal.ToString() );\n\t\tCheck( \"and still spans four units\", Near( (dTail - dHead).Length, 4f ),\n\t\t\t(dTail - dHead).Length.ToString() );\n\t}\n\n\tstatic void TestRoll()\n\t{\n\t\t// Long in x, thin in y, wide in z. The roll should name z, because that is the direction\n\t\t// the section actually has width in \u2014 it is what an animator would call the flat of it.\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out _, out var up );\n\n\t\tCheck( \"roll takes the wide direction, not the thin one\",\n\t\t\tNear( up, new Vec3( 0, 0, 1 ) ) || Near( up, new Vec3( 0, 0, -1 ) ), up.ToString() );\n\n\t\t// Swap which of the two cross directions is wider and the roll has to follow. This is the\n\t\t// check that would fail if the axes were simply being read off in a fixed order.\n\t\tBoneFromBody.TryDerive( Box( 4, 2, 1 ), out _, out _, out var swapped );\n\n\t\tCheck( \"widening the other way rolls the bone\",\n\t\t\tNear( swapped, new Vec3( 0, 1, 0 ) ) || Near( swapped, new Vec3( 0, -1, 0 ) ),\n\t\t\tswapped.ToString() );\n\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out _, out var tail, out var perpendicular );\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out _, out _ );\n\n\t\tCheck( \"the hint is already perpendicular to the aim\",\n\t\t\tMathF.Abs( Vec3.Dot( perpendicular, (tail - head).Normal ) ) < 1e-4f,\n\t\t\tVec3.Dot( perpendicular, (tail - head).Normal ).ToString() );\n\t\tCheck( \"and is unit length\", Near( perpendicular.Length, 1f ), perpendicular.Length.ToString() );\n\t}\n\n\tstatic void TestAnchor()\n\t{\n\t\tvar box = Box( 4, 1, 2 );\n\n\t\t// No anchor: canonical, and the canon is +x, so the head is the low end.\n\t\tBoneFromBody.TryDerive( box, out var head, out var tail, out _ );\n\t\tCheck( \"unanchored, the head is the canonical end\", head.x < tail.x, $\"{head} -> {tail}\" );\n\n\t\t// Anchored past the far end, the bone has to turn around \u2014 that end is where it joins the\n\t\t// rest of the model, and a bone growing back toward its own parent is the bug this stops.\n\t\tBoneFromBody.TryDerive( box, out var flipped, out var flippedTail, out _, new Vec3( 9, 0, 0 ) );\n\n\t\tCheck( \"an anchor at the far end swaps the ends\", flipped.x > flippedTail.x,\n\t\t\t$\"{flipped} -> {flippedTail}\" );\n\t\tCheck( \"swapping moves neither point\", Near( flipped, tail ) && Near( flippedTail, head ),\n\t\t\t$\"{flipped} / {flippedTail}\" );\n\n\t\t// An anchor on the side it already pointed at must change nothing at all.\n\t\tBoneFromBody.TryDerive( box, out var kept, out var keptTail, out _, new Vec3( -9, 0, 0 ) );\n\n\t\tCheck( \"an anchor at the near end leaves it alone\",\n\t\t\tNear( kept, head ) && Near( keptTail, tail ), $\"{kept} -> {keptTail}\" );\n\t}\n\n\tstatic void TestDegenerate()\n\t{\n\t\tCheck( \"no points is refused\",\n\t\t\t!BoneFromBody.TryDerive( new List<Vec3>(), out _, out _, out _ ) );\n\t\tCheck( \"one point is refused\",\n\t\t\t!BoneFromBody.TryDerive( new List<Vec3> { Vec3.Zero }, out _, out _, out _ ) );\n\t\tCheck( \"a null list is refused\",\n\t\t\t!BoneFromBody.TryDerive( (IReadOnlyList<Vec3>)null, out _, out _, out _ ) );\n\t\tCheck( \"a null mesh is refused\",\n\t\t\t!BoneFromBody.TryDerive( (PolyMesh)null, out _, out _, out _ ) );\n\n\t\t// Every point in the same place has no axis and no length. Refusing beats returning a\n\t\t// zero-length bone, which Skeleton would throw on anyway, further from the cause.\n\t\tvar stacked = new List<Vec3> { new( 1, 1, 1 ), new( 1, 1, 1 ), new( 1, 1, 1 ) };\n\t\tCheck( \"a body with no extent is refused\",\n\t\t\t!BoneFromBody.TryDerive( stacked, out _, out _, out _ ) );\n\n\t\t// A CUBE HAS NO LONGEST AXIS and that is not an error \u2014 every direction is a legitimate\n\t\t// principal axis, so there is nothing to be right about. It must still produce a usable\n\t\t// bone rather than a NaN or a refusal.\n\t\tvar cube = BoneFromBody.TryDerive( Box( 2, 2, 2 ), out var head, out var tail, out var up );\n\n\t\tCheck( \"a cube still derives a bone\", cube );\n\t\tCheck( \"the cube's bone has real length\",\n\t\t\tcube && (tail - head).Length > 1e-3f && !float.IsNaN( (tail - head).Length ),\n\t\t\tcube ? (tail - head).Length.ToString() : \"refused\" );\n\t\tCheck( \"and a real up-hint\", cube && Near( up.Length, 1f ), up.Length.ToString() );\n\t}\n\n\tstatic void TestRepeatable()\n\t{\n\t\t// The property the whole feature leans on: a patterned row of eight fingers has to come out\n\t\t// as eight bones pointing the same way. Same shape in, same bone out, every time.\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headA, out var tailA, out var upA );\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out var headB, out var tailB, out var upB );\n\n\t\tCheck( \"two identical bodies derive identical bones\",\n\t\t\tNear( headA, headB ) && Near( tailA, tailB ) && Near( upA, upB ) );\n\n\t\t// Order of the vertices is not part of the shape, and a rebuild is free to change it.\n\t\tvar shuffled = Box( 4, 1, 2 );\n\t\tshuffled.Reverse();\n\n\t\tBoneFromBody.TryDerive( shuffled, out var headC, out var tailC, out var upC );\n\n\t\tCheck( \"vertex order does not change the answer\",\n\t\t\tNear( headA, headC ) && Near( tailA, tailC ) && Near( upA, upC ),\n\t\t\t$\"{headC} -> {tailC}, up {upC}\" );\n\n\t\t// Translated bodies are the same shape somewhere else, so the bone must simply move with\n\t\t// them \u2014 a pattern is a translation, and this is that case.\n\t\tvar moved = new List<Vec3>();\n\t\tvar shift = new Vec3( 10, -3, 7 );\n\n\t\tforeach ( var p in Box( 4, 1, 2 ) )\n\t\t\tmoved.Add( p + shift );\n\n\t\tBoneFromBody.TryDerive( moved, out var headD, out var tailD, out var upD );\n\n\t\tCheck( \"a translated body derives a translated bone\",\n\t\t\tNear( headD, headA + shift ) && Near( tailD, tailA + shift ) && Near( upD, upA ),\n\t\t\t$\"{headD} -> {tailD}\" );\n\t}\n\n\tstatic void TestFeedsSkeleton()\n\t{\n\t\t// The output exists to be handed straight to AddBoneFromPoints, up-hint and all, so check\n\t\t// the bone that actually lands rather than only the numbers on the way in.\n\t\tBoneFromBody.TryDerive( Box( 4, 1, 2 ), out var head, out var tail, out var up );\n\n\t\tvar skeleton = new Skeleton();\n\t\tskeleton.AddBoneFromPoints( \"finger\", -1, head, tail, up );\n\n\t\tvar world = skeleton.WorldBind( 0 );\n\n\t\tCheck( \"the bone's +Y runs down the body\", Near( world.Y, new Vec3( 1, 0, 0 ) ), world.Y.ToString() );\n\t\tCheck( \"the bone's +Z took the derived roll\", Near( world.Z, up ), world.Z.ToString() );\n\t\tCheck( \"the bone starts at the derived head\", Near( world.Origin, head ), world.Origin.ToString() );\n\t\tCheck( \"its length is the body's extent\", Near( skeleton.Bones[0].Length, 4f ),\n\t\t\tskeleton.Bones[0].Length.ToString() );\n\t}\n\n\tstatic bool Near( Vec3 a, Vec3 b, float tolerance = 1e-4f ) => (a - b).Length < tolerance;\n\n\tstatic bool Near( float a, float b, float tolerance = 1e-4f ) => MathF.Abs( a - b ) < tolerance;\n\n\tstatic void Section( string title ) => Report.Section( title );\n\n\tstatic void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );\n}\n"
}
]
}