Data model for the weapon animator document and related types. Defines enums and classes that represent the document schema, source model settings, rig definition, calibration, clips, tracks, workspace state, generation manifest and helper naming/repair utilities.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Sandbox;
namespace SboxWeaponAnimator;
public enum WeaponAnimatorStage
{
Calibrate = 1,
Animate = 2
}
public enum WeaponBoneClassification
{
WeaponRoot,
Animatable,
Structural,
Ignored
}
public enum WeaponBoneInclusion
{
Included,
StructuralBridge,
Excluded
}
public enum MeasurementUnit
{
Inches,
Centimetres
}
public enum WeaponUpAxis
{
PositiveZ,
NegativeZ,
PositiveY,
NegativeY
}
public enum ClipReadiness
{
NotStarted,
Draft,
Ready,
Warning
}
public enum WeaponClipRole
{
Custom,
Idle,
Deploy,
Fire,
FireDry,
Reload,
ReloadEmpty,
Holster,
Inspect,
Sprint,
Jump,
Lower,
Ironsights,
GrabStance,
GrabGestureOne,
GrabGestureTwo,
GrabGestureThree,
GrabGestureFour,
ReloadEnter,
FirstShell,
InsertShell,
ReloadExit
}
public enum TrackInterpolation
{
Stepped,
Linear,
Cubic
}
public enum CurveEditorMode
{
Speed,
Channels
}
[Flags]
public enum TransformCurveChannel
{
None = 0,
PositionX = 1 << 0,
PositionY = 1 << 1,
PositionZ = 1 << 2,
RotationX = 1 << 3,
RotationY = 1 << 4,
RotationZ = 1 << 5,
ScaleX = 1 << 6,
ScaleY = 1 << 7,
ScaleZ = 1 << 8,
Position = PositionX | PositionY | PositionZ,
Rotation = RotationX | RotationY | RotationZ,
Scale = ScaleX | ScaleY | ScaleZ,
All = Position | Rotation | Scale
}
public enum CurveHandleMode
{
Aligned,
Free
}
public enum AnimationTagKind
{
Point,
Range
}
public enum ReloadProfile
{
Magazine,
Incremental
}
public enum GripConfiguration
{
OneHanded,
TwoHanded
}
public enum AnchorKind
{
Grip,
RearBore,
FrontBore,
Muzzle,
Eject,
Custom
}
public enum RigControlKind
{
Arm,
Weapon,
Camera
}
public enum VisibilityRenderMode
{
BoneBranch,
BodyGroup
}
public enum WeaponTextureChannel
{
BaseColor,
Normal,
Roughness,
Metalness,
AmbientOcclusion,
PackedOrm
}
public sealed class WeaponAnimationDocument
{
public const int CurrentSchemaVersion = 4;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
public Guid DocumentId { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "New Weapon";
public WeaponAnimatorStage ActiveStage { get; set; } = WeaponAnimatorStage.Calibrate;
public SourceModelSettings Source { get; set; } = new();
public WeaponRigDefinition Rig { get; set; } = new();
public WeaponCalibration Calibration { get; set; } = new();
public ArmBindingDefinition Binding { get; set; } = new();
public List<WeaponAnimationClip> Clips { get; set; } = [];
public AnimGraphSettings Graph { get; set; } = new();
public OutputSettings Output { get; set; } = new();
public WorkspaceState Workspace { get; set; } = new();
// Ownership is persisted beside generated assets. Keeping file-like strings out of the
// GameResource prevents the asset compiler from treating manifest entries as dependencies.
[JsonIgnore]
public GenerationManifest Manifest { get; set; } = new();
public static WeaponAnimationDocument CreateDefault( string name = "New Weapon" )
{
var document = new WeaponAnimationDocument
{
Name = name,
Output = new OutputSettings
{
AssetName = Slugify( name )
}
};
document.Clips = StandardClips()
.Select( role => WeaponAnimationClip.Create( role ) )
.ToList();
document.Workspace.SelectedClipId = document.Clips
.First( x => x.Role == WeaponClipRole.Idle ).Id;
return document;
}
public WeaponAnimationClip? GetSelectedClip()
{
return Clips.FirstOrDefault( x => x.Id == Workspace.SelectedClipId )
?? Clips.FirstOrDefault();
}
public WeaponAnimationClip EnsureClip( WeaponClipRole role )
{
var clip = Clips.FirstOrDefault( x => x.Role == role );
if ( clip is not null )
return clip;
clip = WeaponAnimationClip.Create( role );
Clips.Add( clip );
return clip;
}
public static IReadOnlyList<WeaponClipRole> StandardClips() =>
[
WeaponClipRole.Idle,
WeaponClipRole.Deploy,
WeaponClipRole.Fire,
WeaponClipRole.FireDry,
WeaponClipRole.Reload,
WeaponClipRole.ReloadEmpty,
WeaponClipRole.Holster,
WeaponClipRole.Inspect,
WeaponClipRole.Sprint,
WeaponClipRole.Jump,
WeaponClipRole.Lower,
WeaponClipRole.Ironsights,
WeaponClipRole.GrabStance,
WeaponClipRole.GrabGestureOne,
WeaponClipRole.GrabGestureTwo,
WeaponClipRole.GrabGestureThree,
WeaponClipRole.GrabGestureFour,
WeaponClipRole.ReloadEnter,
WeaponClipRole.FirstShell,
WeaponClipRole.InsertShell,
WeaponClipRole.ReloadExit
];
public static string Slugify( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return "weapon";
var chars = value.Trim().ToLowerInvariant()
.Select( c => char.IsLetterOrDigit( c ) ? c : '_' )
.ToArray();
return string.Join( "_", new string( chars )
.Split( '_', StringSplitOptions.RemoveEmptyEntries ) );
}
}
public sealed class SourceModelSettings
{
public string OriginalSourcePath { get; set; } = "";
public string SourcePath { get; set; } = "";
public string CompiledModelPath { get; set; } = "";
public string PreviewHostPath { get; set; } = "";
public string SourceHash { get; set; } = "";
public string SourceRootBoneName { get; set; } = "";
public Vector3 OriginalModelDimensions { get; set; }
public bool NeedsModelDocWrapper { get; set; }
public bool Compiled { get; set; }
public bool PreviewHostCompiled { get; set; }
public DateTime LastImportedUtc { get; set; }
public List<SourceMaterialBinding> Materials { get; set; } = [];
}
public sealed class SourceMaterialBinding
{
// Stored without a resource extension so the .wepanim compiler does not treat an
// imported FBX slot label as a project asset dependency.
public string SourceMaterialPath { get; set; } = "";
public string Name { get; set; } = "";
public string OutputName { get; set; } = "";
[JsonIgnore]
public string PreviewMaterialPath { get; set; } = "";
public List<SourceTextureMap> Textures { get; set; } = [];
public SourceTextureMap? FindTexture( WeaponTextureChannel channel ) =>
Textures.FirstOrDefault( texture => texture.Channel == channel );
public bool HasUsableTextures =>
Textures.Any( texture => texture.Channel != WeaponTextureChannel.PackedOrm
&& !string.IsNullOrWhiteSpace( texture.AssetPath ) );
}
public sealed class SourceTextureMap
{
public WeaponTextureChannel Channel { get; set; }
[JsonIgnore]
public string OriginalPath { get; set; } = "";
public string AssetPath { get; set; } = "";
public string Sha256 { get; set; } = "";
}
public sealed class WeaponRigDefinition
{
public string RootBone { get; set; } = "";
public string SourceSkeletonRootId { get; set; } = "";
public string WeaponSubtreeRootId { get; set; } = "";
public List<WeaponBoneDefinition> Bones { get; set; } = [];
public List<WeaponVisibilityPart> VisibilityParts { get; set; } = [];
public List<RigAuditIssue> AuditIssues { get; set; } = [];
public string ProfileHash { get; set; } = "";
public bool ReviewRequired { get; set; }
public bool FilteredPreviewConfirmed { get; set; }
public WeaponBoneDefinition? FindBone( string idOrName ) =>
Bones.FirstOrDefault( x =>
string.Equals( x.Id, idOrName, StringComparison.OrdinalIgnoreCase )
|| string.Equals( x.Name, idOrName, StringComparison.OrdinalIgnoreCase ) );
public IEnumerable<WeaponBoneDefinition> RetainedBones() =>
Bones.Where( x => x.Inclusion != WeaponBoneInclusion.Excluded
&& x.Classification != WeaponBoneClassification.Ignored );
}
public sealed class WeaponVisibilityPart
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "Visible Part";
public string BoneId { get; set; } = "";
public string BoneName { get; set; } = "";
public bool DefaultVisible { get; set; } = true;
public VisibilityRenderMode RenderMode { get; set; } = VisibilityRenderMode.BoneBranch;
public string BodyGroupName { get; set; } = "";
public int VisibleBodyGroupValue { get; set; } = 1;
public int HiddenBodyGroupValue { get; set; }
}
public sealed class WeaponBoneDefinition
{
public string Id { get; set; } = "";
public string ParentId { get; set; } = "";
public string HierarchyPath { get; set; } = "";
public string Name { get; set; } = "";
public string ParentName { get; set; } = "";
public string OriginalName { get; set; } = "";
public string OriginalParentName { get; set; } = "";
public WeaponBoneClassification Classification { get; set; } = WeaponBoneClassification.Animatable;
public WeaponBoneInclusion Inclusion { get; set; } = WeaponBoneInclusion.Included;
// BindTransform is retained for loading version 2 projects.
public Transform BindTransform { get; set; } = Transform.Zero;
public Transform BindModelTransform { get; set; } = Transform.Zero;
public Transform BindLocalTransform { get; set; } = Transform.Zero;
public bool HasSkinInfluence { get; set; }
}
public sealed class RigAuditIssue
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
public ValidationSeverity Severity { get; set; } = ValidationSeverity.Warning;
public string BoneName { get; set; } = "";
}
public sealed class WeaponCalibration
{
public float UniformScale { get; set; } = 1.0f;
public Transform PhysicalTransform { get; set; } = Transform.Zero;
public Transform FramingTransform { get; set; } = Transform.Zero;
public ScaleMeasurement Measurement { get; set; } = new();
public List<WeaponAnchor> Anchors { get; set; } = [];
public WeaponUpAxis UpAxis { get; set; } = WeaponUpAxis.PositiveZ;
public float HorizontalFov { get; set; } = 80.0f;
public string AspectGuide { get; set; } = "16:9";
public bool ShowSafeArea { get; set; } = true;
public bool ShowCrosshair { get; set; } = true;
public bool Confirmed { get; set; }
public int Revision { get; set; }
public CalibrationSnapshot? Snapshot { get; set; }
/// <summary>
/// Resolves the single anchor of a fixed kind. Custom anchors are identified by id instead,
/// because a weapon may carry several of them.
/// </summary>
public WeaponAnchor? GetAnchor( AnchorKind kind ) =>
Anchors.FirstOrDefault( x => x.Kind == kind );
public WeaponAnchor? FindAnchor( Guid id ) =>
Anchors.FirstOrDefault( x => x.Id == id );
public IEnumerable<WeaponAnchor> CustomAnchors() =>
Anchors.Where( x => x.Kind == AnchorKind.Custom );
public void SetAnchor( WeaponAnchor anchor )
{
var existing = anchor.Kind == AnchorKind.Custom
? FindAnchor( anchor.Id )
: GetAnchor( anchor.Kind );
if ( existing is null )
Anchors.Add( anchor );
else
{
existing.Name = anchor.Name;
existing.BoneName = anchor.BoneName;
existing.LocalPosition = anchor.LocalPosition;
existing.LocalRotation = anchor.LocalRotation;
}
}
}
public sealed class ScaleMeasurement
{
public bool HasFirstPoint { get; set; }
public bool HasSecondPoint { get; set; }
public Vector3 FirstPoint { get; set; }
public Vector3 SecondPoint { get; set; }
public string FirstBone { get; set; } = "";
public string SecondBone { get; set; } = "";
public float KnownDistance { get; set; }
public MeasurementUnit Unit { get; set; } = MeasurementUnit.Inches;
public float PreviewScale { get; set; } = 1.0f;
public bool HasPendingScale { get; set; }
public Vector3 OriginalDimensions { get; set; }
public Vector3 ResultingDimensions { get; set; }
}
public sealed class WeaponAnchor
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
/// <summary>
/// Attachment name emitted for custom anchors. Stored rather than derived so renaming the
/// anchor cannot silently rename an attachment that game code already references.
/// </summary>
public string GeneratedAttachmentName { get; set; } = "";
public AnchorKind Kind { get; set; }
public string BoneName { get; set; } = "";
public Vector3 LocalPosition { get; set; }
public Rotation LocalRotation { get; set; } = Rotation.Identity;
}
public sealed class CalibrationSnapshot
{
public int Revision { get; set; }
public string SourceHash { get; set; } = "";
public string RigHash { get; set; } = "";
public float UniformScale { get; set; } = 1.0f;
public Transform PhysicalTransform { get; set; } = Transform.Zero;
public Transform FramingTransform { get; set; } = Transform.Zero;
public List<WeaponAnchor> Anchors { get; set; } = [];
public DateTime ConfirmedUtc { get; set; }
}
public sealed class ArmBindingDefinition
{
public string Profile { get; set; } = "FacepunchHumanV1";
public string ArmsModel { get; set; } = "models/first_person/v_first_person_arms_human.vmdl";
public GripConfiguration Configuration { get; set; } = GripConfiguration.TwoHanded;
public RigTarget PrimaryHand { get; set; } = RigTarget.Create( "Primary Hand", true );
public RigTarget SupportHand { get; set; } = RigTarget.Create( "Support Hand", false );
public RigTarget PrimaryElbowPole { get; set; } = RigTarget.CreatePole( "Primary Elbow" );
public RigTarget SupportElbowPole { get; set; } = RigTarget.CreatePole( "Support Elbow" );
public List<GripPose> GripPoses { get; set; } = [];
public Guid DefaultGripPoseId { get; set; }
public bool ChecklistDismissed { get; set; }
public List<string> CompletedChecklistItems { get; set; } = [];
}
public sealed class RigTarget
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public RigControlKind Kind { get; set; } = RigControlKind.Arm;
public Transform Transform { get; set; } = Transform.Zero;
public string AttachedBone { get; set; } = "";
public bool IsPrimary { get; set; }
public bool IsBound { get; set; }
public bool Reachable { get; set; } = true;
public static RigTarget Create( string name, bool primary ) => new()
{
Name = name,
IsPrimary = primary,
Transform = new Transform( new Vector3( 12, primary ? -3 : 3, -2 ) )
};
public static RigTarget CreatePole( string name ) => new()
{
Name = name,
Transform = new Transform( new Vector3( 5, name.Contains( "Primary" ) ? -12 : 12, -5 ) )
};
}
public sealed class GripPose
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "Default Grip";
public List<BonePose> Bones { get; set; } = [];
}
public sealed class BonePose
{
public string BoneName { get; set; } = "";
public Transform LocalTransform { get; set; } = Transform.Zero;
}
public sealed class WeaponAnimationClip
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "Custom";
public WeaponClipRole Role { get; set; }
// Generated Idle bind poses can follow calibration changes until deliberately authored.
public bool IsBindPoseSeed { get; set; }
public ClipReadiness Readiness { get; set; } = ClipReadiness.NotStarted;
public float Duration { get; set; } = 1.0f;
public float SampleRate { get; set; } = 30.0f;
public bool AllowSubframeKeys { get; set; }
public bool Loop { get; set; }
public string GeneratedSequenceName { get; set; } = "";
public List<TransformTrack> Tracks { get; set; } = [];
public List<VisibilityTrack> VisibilityTracks { get; set; } = [];
public List<TimedConstraint> Constraints { get; set; } = [];
public List<AnimationTag> Tags { get; set; } = [];
public List<ClipParameterEvent> ParameterEvents { get; set; } = [];
public string ImportedSequence { get; set; } = "";
public static WeaponAnimationClip Create( WeaponClipRole role ) => new()
{
Name = WeaponAnimationNames.DisplayName( role ),
Role = role,
Loop = role is WeaponClipRole.Idle or WeaponClipRole.Sprint
};
public TransformTrack EnsureTrack( string target )
{
var track = Tracks.FirstOrDefault( x => x.Target == target );
if ( track is not null )
return track;
track = new TransformTrack { Target = target };
Tracks.Add( track );
return track;
}
public VisibilityTrack EnsureVisibilityTrack( Guid partId )
{
var track = VisibilityTracks.FirstOrDefault( x => x.PartId == partId );
if ( track is not null )
return track;
track = new VisibilityTrack { PartId = partId };
VisibilityTracks.Add( track );
return track;
}
}
public sealed class VisibilityTrack
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PartId { get; set; }
public List<VisibilityKey> Keys { get; set; } = [];
public bool Muted { get; set; }
}
public sealed class VisibilityKey
{
public Guid Id { get; set; } = Guid.NewGuid();
public float Time { get; set; }
public bool Visible { get; set; } = true;
}
public sealed class TransformTrack
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Target { get; set; } = "";
public RigControlKind Kind { get; set; } = RigControlKind.Weapon;
public TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Cubic;
public List<TransformKey> Keys { get; set; } = [];
public List<TransformCurveSpan> CurveSpans { get; set; } = [];
public bool Muted { get; set; }
public TransformCurveSpan? FindCurveSpan( Guid startKeyId, Guid endKeyId ) =>
CurveSpans.FirstOrDefault( x =>
x.StartKeyId == startKeyId && x.EndKeyId == endKeyId );
public TransformCurveSpan EnsureCurveSpan( Guid startKeyId, Guid endKeyId )
{
var span = FindCurveSpan( startKeyId, endKeyId );
if ( span is not null )
return span;
span = new TransformCurveSpan
{
StartKeyId = startKeyId,
EndKeyId = endKeyId
};
CurveSpans.Add( span );
return span;
}
}
public sealed class TransformKey
{
public Guid Id { get; set; } = Guid.NewGuid();
public float Time { get; set; }
public Vector3 Position { get; set; }
public Rotation Rotation { get; set; } = Rotation.Identity;
public Vector3 Scale { get; set; } = Vector3.One;
// Retained for schema-v3 compatibility; migrated into CurveTangents.
public Vector3 InTangent { get; set; }
public Vector3 OutTangent { get; set; }
public TransformCurveTangents CurveTangents { get; set; } = new();
}
public sealed class TransformCurveSpan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StartKeyId { get; set; }
public Guid EndKeyId { get; set; }
public bool HasSpeedCurve { get; set; }
public MotionRateCurve Speed { get; set; } = new();
public bool HasInterpolationOverride { get; set; }
public TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Linear;
public TransformCurveChannel CustomChannels { get; set; }
}
public sealed class MotionRateCurve
{
public float StartRate { get; set; } = 1.0f;
public float EndRate { get; set; } = 1.0f;
public float StartSlope { get; set; }
public float EndSlope { get; set; }
public CurveHandleMode StartHandleMode { get; set; } = CurveHandleMode.Aligned;
public CurveHandleMode EndHandleMode { get; set; } = CurveHandleMode.Aligned;
}
public sealed class TransformCurveTangents
{
public Vector3 PositionIn { get; set; }
public Vector3 PositionOut { get; set; }
public Vector3 RotationIn { get; set; }
public Vector3 RotationOut { get; set; }
public Vector3 ScaleIn { get; set; }
public Vector3 ScaleOut { get; set; }
public TransformCurveChannel FreeHandles { get; set; }
}
public sealed class TimedConstraint
{
public Guid Id { get; set; } = Guid.NewGuid();
public string SourceControl { get; set; } = "";
public string TargetBone { get; set; } = "";
public float StartTime { get; set; }
public float EndTime { get; set; } = 1.0f;
public float Weight { get; set; } = 1.0f;
public bool MaintainOffset { get; set; } = true;
}
public sealed class AnimationTag
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public AnimationTagKind Kind { get; set; }
public float StartTime { get; set; }
public float EndTime { get; set; }
}
public sealed class ClipParameterEvent
{
public string Name { get; set; } = "";
public float Time { get; set; }
public float Value { get; set; }
}
public sealed class AnimGraphSettings
{
public bool GenerateGraph { get; set; } = true;
public string ParameterProfile { get; set; } = "FacepunchHumanV1";
public ReloadProfile ReloadProfile { get; set; } = ReloadProfile.Magazine;
public bool FirearmProfile { get; set; } = true;
public Dictionary<string, float> PreviewFloats { get; set; } = [];
public Dictionary<string, bool> PreviewBools { get; set; } = [];
}
public sealed class OutputSettings
{
public string AssetName { get; set; } = "weapon";
public string OutputFolder { get; set; } = "";
public bool GeneratePrefab { get; set; } = true;
public bool GenerateGraph { get; set; } = true;
public bool IncludeDebugSkeleton { get; set; }
public string GetDefaultRelativeFolder()
{
var slug = WeaponAnimationDocument.Slugify( AssetName );
return $"weapons/{slug}/viewmodel";
}
}
public sealed class WorkspaceState
{
public Guid SelectedClipId { get; set; }
public float TimelineTime { get; set; }
public string SelectedBone { get; set; } = "";
public string SelectedControl { get; set; } = "";
public string ConstraintTargetBone { get; set; } = "";
public bool FirstPersonPreview { get; set; }
public bool ShowGuides { get; set; }
public bool ShowSkeleton { get; set; } = true;
public bool XRaySkeleton { get; set; } = true;
public bool BoneOcclusionEnabled { get; set; } = true;
public bool ShowIkBones { get; set; }
public bool ShowOnionSkins { get; set; }
public float GridOpacity { get; set; } = 0.10f;
public float GridLineThickness { get; set; } = 0.65f;
public bool RimLightEnabled { get; set; } = true;
public float RimLightIntensity { get; set; } = 4.0f;
public bool AutoKey { get; set; } = true;
public bool LocalGizmos { get; set; } = true;
public bool SnapPosition { get; set; } = true;
public bool SnapRotation { get; set; } = true;
public float RotationSnapDegrees { get; set; } = 15.0f;
public bool CurveEditorVisible { get; set; }
public List<WorkingPoseOverride> WorkingPoseOverrides { get; set; } = [];
public List<TimelineViewState> TimelineViews { get; set; } = [];
public List<CurveViewState> CurveViews { get; set; } = [];
public Vector3 CameraFocus { get; set; }
public Angles CameraAngles { get; set; } = new( 12, 180, 0 );
public float CameraDistance { get; set; } = 48.0f;
public bool FreeLookCamera { get; set; }
public Vector3 CameraPosition { get; set; }
public float CameraMoveSpeed { get; set; } = 1.0f;
public bool FullBrightViewport { get; set; }
public string CalibrationSplitterState { get; set; } = "";
public string AnimationSplitterState { get; set; } = "";
public string CalibrationVerticalSplitterState { get; set; } = "";
public string AnimationVerticalSplitterState { get; set; } = "";
public string AnimationTimelineSplitterState { get; set; } = "";
public string AnimationRightSplitterState { get; set; } = "";
public string AnimationMainSplitterState { get; set; } = "";
public string AnimationOuterSplitterState { get; set; } = "";
public TimelineViewState? GetTimelineView( Guid clipId ) =>
TimelineViews.FirstOrDefault( x => x.ClipId == clipId );
public TimelineViewState EnsureTimelineView( Guid clipId, float duration )
{
var existing = GetTimelineView( clipId );
if ( existing is not null )
return existing;
existing = new TimelineViewState
{
ClipId = clipId,
VisibleEnd = MathF.Max( duration, 0 )
};
TimelineViews.Add( existing );
return existing;
}
public CurveViewState? GetCurveView( Guid clipId ) =>
CurveViews.FirstOrDefault( x => x.ClipId == clipId );
public CurveViewState EnsureCurveView( Guid clipId )
{
var existing = GetCurveView( clipId );
if ( existing is not null )
return existing;
existing = new CurveViewState { ClipId = clipId };
CurveViews.Add( existing );
return existing;
}
public WorkingPoseOverride? GetWorkingPose( Guid clipId, string target ) =>
WorkingPoseOverrides.FirstOrDefault( x =>
x.ClipId == clipId
&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) );
public void SetWorkingPose(
Guid clipId,
string target,
RigControlKind kind,
Transform transform )
{
var existing = GetWorkingPose( clipId, target );
if ( existing is null )
{
WorkingPoseOverrides.Add( new WorkingPoseOverride
{
ClipId = clipId,
Target = target,
Kind = kind,
Transform = transform
} );
return;
}
existing.Kind = kind;
existing.Transform = transform;
}
public bool RemoveWorkingPose( Guid clipId, string target ) =>
WorkingPoseOverrides.RemoveAll( x =>
x.ClipId == clipId
&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) ) > 0;
public void ClearWorkingPoses( Guid clipId ) =>
WorkingPoseOverrides.RemoveAll( x => x.ClipId == clipId );
}
public sealed class TimelineViewState
{
public Guid ClipId { get; set; }
public float VisibleStart { get; set; }
public float VisibleEnd { get; set; }
public float VerticalScroll { get; set; }
}
public sealed class CurveViewState
{
public Guid ClipId { get; set; }
public Guid SelectedTrackId { get; set; }
public CurveEditorMode Mode { get; set; }
public TransformCurveChannel VisibleChannels { get; set; }
public string Search { get; set; } = "";
public float TrackScroll { get; set; }
public bool HasVerticalRange { get; set; }
public float VerticalMinimum { get; set; }
public float VerticalMaximum { get; set; } = 2.0f;
}
public sealed class WorkingPoseOverride
{
public Guid ClipId { get; set; }
public string Target { get; set; } = "";
public RigControlKind Kind { get; set; }
public Transform Transform { get; set; } = Transform.Zero;
}
public sealed class GenerationManifest
{
public string GeneratorVersion { get; set; } = "";
public DateTime GeneratedUtc { get; set; }
public string InputHash { get; set; } = "";
public List<GeneratedFileRecord> Files { get; set; } = [];
public List<GenerationDiagnostic> Diagnostics { get; set; } = [];
}
public sealed class GeneratedFileRecord
{
public string RelativePath { get; set; } = "";
public string Sha256 { get; set; } = "";
public string Kind { get; set; } = "";
}
public sealed class GenerationDiagnostic
{
public ValidationSeverity Severity { get; set; }
public string Code { get; set; } = "";
public string Message { get; set; } = "";
public string AssetPath { get; set; } = "";
}
public static class WeaponAnimationNames
{
public static string DisplayName( WeaponClipRole role ) => role switch
{
WeaponClipRole.FireDry => "Fire Dry",
WeaponClipRole.ReloadEmpty => "Reload Empty",
WeaponClipRole.GrabStance => "Grab Stance",
WeaponClipRole.GrabGestureOne => "Grab Gesture 1",
WeaponClipRole.GrabGestureTwo => "Grab Gesture 2",
WeaponClipRole.GrabGestureThree => "Grab Gesture 3",
WeaponClipRole.GrabGestureFour => "Grab Gesture 4",
WeaponClipRole.ReloadEnter => "Reload Enter",
WeaponClipRole.FirstShell => "First Shell",
WeaponClipRole.InsertShell => "Insert Shell",
WeaponClipRole.ReloadExit => "Reload Exit",
_ => role.ToString()
};
public static string SequenceName( WeaponClipRole role ) =>
WeaponAnimationDocument.Slugify( DisplayName( role ) );
public static string SequenceName( WeaponAnimationClip clip ) =>
clip.Role == WeaponClipRole.Custom
? !string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )
? clip.GeneratedSequenceName
: ShortCustomSequenceName(
WeaponAnimationDocument.Slugify( clip.Name ),
clip.Id )
: SequenceName( clip.Role );
public static bool RepairCustomSequenceNames( WeaponAnimationDocument document )
{
var changed = false;
var used = document.Clips
.Where( clip => clip.Role != WeaponClipRole.Custom )
.Select( clip => SequenceName( clip.Role ) )
.ToHashSet( StringComparer.OrdinalIgnoreCase );
foreach ( var clip in document.Clips.Where( clip =>
clip.Role == WeaponClipRole.Custom ) )
{
var existing = string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )
? ""
: WeaponAnimationDocument.Slugify( clip.GeneratedSequenceName );
if ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )
{
if ( clip.GeneratedSequenceName != existing )
{
clip.GeneratedSequenceName = existing;
changed = true;
}
continue;
}
var stem = WeaponAnimationDocument.Slugify( clip.Name );
if ( string.IsNullOrWhiteSpace( stem ) )
stem = "custom";
var candidate = stem;
if ( !used.Add( candidate ) )
{
var id = clip.Id.ToString( "N" );
var assigned = false;
for ( var suffixLength = 8;
suffixLength <= id.Length;
suffixLength += 4 )
{
candidate = $"{stem}_{id[..suffixLength]}";
if ( !used.Add( candidate ) )
continue;
assigned = true;
break;
}
for ( var collision = 2; !assigned; collision++ )
{
candidate = $"{stem}_{id}_{collision}";
assigned = used.Add( candidate );
}
}
if ( clip.GeneratedSequenceName == candidate )
continue;
clip.GeneratedSequenceName = candidate;
changed = true;
}
return changed;
}
private static string ShortCustomSequenceName( string stem, Guid id ) =>
$"{stem}_{id:N}"[..(stem.Length + 9)];
/// <summary>
/// Attachment names reserved by the fixed anchor kinds that reach generation. The calibration-only
/// kinds (grip, bore markers) are never exported, so they reserve nothing.
/// </summary>
private static readonly string[] ReservedAttachmentNames = ["muzzle", "eject"];
public static string AttachmentName( WeaponAnchor anchor ) => anchor.Kind switch
{
AnchorKind.Muzzle => "muzzle",
AnchorKind.Eject => "eject",
_ => !string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )
? anchor.GeneratedAttachmentName
: WeaponAnimationDocument.Slugify( anchor.Name )
};
/// <summary>
/// Assigns each custom anchor a stable, unique attachment name. Mirrors
/// <see cref="RepairCustomSequenceNames"/>: an existing stored name is kept whenever it is still
/// unique, so generated output stays stable across renames.
/// </summary>
public static bool RepairCustomAnchorNames( WeaponAnimationDocument document )
{
var changed = false;
var used = ReservedAttachmentNames.ToHashSet( StringComparer.OrdinalIgnoreCase );
foreach ( var anchor in document.Calibration.CustomAnchors() )
{
var existing = string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )
? ""
: WeaponAnimationDocument.Slugify( anchor.GeneratedAttachmentName );
if ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )
{
if ( anchor.GeneratedAttachmentName != existing )
{
anchor.GeneratedAttachmentName = existing;
changed = true;
}
continue;
}
var stem = WeaponAnimationDocument.Slugify( anchor.Name );
if ( string.IsNullOrWhiteSpace( stem ) )
stem = "anchor";
var candidate = stem;
if ( !used.Add( candidate ) )
{
var id = anchor.Id.ToString( "N" );
var assigned = false;
for ( var suffixLength = 8; suffixLength <= id.Length; suffixLength += 4 )
{
candidate = $"{stem}_{id[..suffixLength]}";
if ( !used.Add( candidate ) )
continue;
assigned = true;
break;
}
for ( var collision = 2; !assigned; collision++ )
{
candidate = $"{stem}_{id}_{collision}";
assigned = used.Add( candidate );
}
}
if ( anchor.GeneratedAttachmentName == candidate )
continue;
anchor.GeneratedAttachmentName = candidate;
changed = true;
}
return changed;
}
}