A Razor UI component for an in-editor weapon part tuner. It displays sliders and text entries to adjust per-piece position, rotation and scale, controls whole-weapon offsets, copies overrides JSON to the clipboard, and exposes console commands to print, revert, hide, and manipulate parts and aim pieces.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@using System.Globalization;
@using NZombies;
@using SWB.Base;
@inherits PanelComponent
@*
SCK PART EDITOR — `nz_parts`. One row per element, sliders for where it sits.
⛔ IT EXISTS BECAUSE THE PORT COULD NOT BE JUDGED ON PAPER. The Prisma's chain was verified
against the lua to zero error, the manifest against the raw table, the meshes against Crowbar
— and the gun was still visibly wrong in hand. Every one of those checks answers "does this
match the source", and none answers "is this the gun". Sliders answer the second one.
⚠️ IT EDITS THE LIVE RIG, NOT A COPY. `SckPartsRig` re-resolves the whole chain every frame
from the same arrays these sliders write into, so a child follows its parent as you drag it —
which is the only way the `rel` chain can be tuned at all.
⚠️ PRINT EMITS `--overrides` JSON, NOT A REPORT. The numbers land in the exact shape
`sck_bake.py --overrides` already reads, so tuning ends in a paste rather than a transcription.
⚠️ SAME SHAPE AS RecoilTuner AND SoulBoxTuner — a command that ensures its own host, a static
Visible, a copy button, and a BuildHash carrying every displayed number.
*@
<root class="tuner @(Visible ? "" : "hidden")">
<div class="panel">
<div class="head">
<div class="title">WEAPON PARTS</div>
<div class="close" onclick=@(() => Hide())>×</div>
</div>
@* ⛔ THE STATE LINE IS NOT DECORATION. "no viewmodel", "manifest unreadable", "bone
missing" and "no model loaded" all look identical from outside — nothing happens — and
the first version of this panel showed none of them, so a rig that was failing and a rig
that was not running were the same picture. *@
<div class="state @(SckPartsRig.MissingModels > 0 ? "bad" : "")">@SckPartsRig.Status</div>
@* ⛔ THE FIRST CONTROL ON THE PANEL, because it decides which pieces the rest of the panel
is even talking about. The weapon draws ten placements from the hip and sixteen down the
sights, and the six extra ones are the sight itself — there is no way to place a ring you
look through except from behind it. *@
<div class="group">POSE — which pieces the weapon draws</div>
<div class="buttons">
<div class="btn @(SckPartsRig.Ads ? "" : "on")" onclick=@(() => Pose( false ))>Hip</div>
<div class="btn @(SckPartsRig.Ads ? "on" : "")" onclick=@(() => Pose( true ))>Aiming</div>
<div class="btn @(SckPartsRig.AimHold ? "on" : "")"
onclick=@(() => SckPartsRig.AimHold = !SckPartsRig.AimHold)>
@(SckPartsRig.AimHold ? "Held at aim" : "Viewmodel free")
</div>
</div>
@if ( Rig is null )
{
<div class="warn">No rig — run nz_parts again to build one.</div>
}
else if ( !Rig.Parts.Any() )
{
<div class="warn">
Manifest is empty or unreadable. Expected @Rig.ManifestPath under Assets/.
</div>
}
else
{
@* ⚠️ ABOVE THE PIECE LIST BECAUSE IT IS NOT A PIECE. It multiplies every size at once,
so it belongs with the weapon rather than inside the per-piece section — and putting
it below would have it read as a property of whichever piece is selected. *@
<div class="group">WHOLE WEAPON — 0 = the weapon's own size</div>
<div class="row">
<div class="label">Size</div>
<SliderControl class="s" Value:bind=@Scale Min="@(-0.95f)" Max="@(5f)" Step="@(0.01f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "scale", Scale, "0.####" )
OnTextEdited=@( ( string v ) => Typed( "scale", v, f => Scale = f ) ) />
</div>
@* ⛔ ABOUT THE HAND, NOT THE GUN'S OWN CENTRE. "The gun is rotated wrong in my
hand" means rotate it about the hand — and a bone pivot needs no measuring, where
a centroid one has to be reproduced offline and has already been a bug twice. *@
@* ⚠️ A SEPARATE CONTROL FROM THE GUN ROWS, even though the relative result is the
same. Moving the gun changes where the GUN sits on screen; this changes where the
HANDS sit. Once the weapon is placed, fixing the grip has to move the other one. *@
<div class="group">HANDS — relative to the weapon</div>
<div class="state">@SckPartsRig.HandsStatus</div>
<div class="row">
<div class="label">X fwd</div>
<SliderControl class="s" Value:bind=@HandX Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hx", HandX, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "hx", v, f => HandX = f ) ) />
</div>
<div class="row">
<div class="label">Y left</div>
<SliderControl class="s" Value:bind=@HandY Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hy", HandY, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "hy", v, f => HandY = f ) ) />
</div>
<div class="row">
<div class="label">Z up</div>
<SliderControl class="s" Value:bind=@HandZ Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hz", HandZ, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "hz", v, f => HandZ = f ) ) />
</div>
<div class="row">
<div class="label">Pitch</div>
<SliderControl class="s" Value:bind=@HandP Min="@(-90f)" Max="@(90f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hp", HandP, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "hp", v, f => HandP = f ) ) />
</div>
<div class="row">
<div class="label">Yaw</div>
<SliderControl class="s" Value:bind=@HandYw Min="@(-90f)" Max="@(90f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hyw", HandYw, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "hyw", v, f => HandYw = f ) ) />
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@HandR Min="@(-90f)" Max="@(90f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "hr", HandR, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "hr", v, f => HandR = f ) ) />
</div>
<div class="group">WHOLE GUN — turned and moved about the hand</div>
<div class="row">
<div class="label">Pitch</div>
<SliderControl class="s" Value:bind=@GunP Min="@(-180f)" Max="@(180f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gp", GunP, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "gp", v, f => GunP = f ) ) />
</div>
<div class="row">
<div class="label">Yaw</div>
<SliderControl class="s" Value:bind=@GunY Min="@(-180f)" Max="@(180f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gy", GunY, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "gy", v, f => GunY = f ) ) />
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@GunR Min="@(-180f)" Max="@(180f)" Step="@(0.25f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gr", GunR, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "gr", v, f => GunR = f ) ) />
</div>
<div class="row">
<div class="label">X fwd</div>
<SliderControl class="s" Value:bind=@GunX Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gx", GunX, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "gx", v, f => GunX = f ) ) />
</div>
<div class="row">
<div class="label">Y left</div>
<SliderControl class="s" Value:bind=@GunMY Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gmy", GunMY, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "gmy", v, f => GunMY = f ) ) />
</div>
<div class="row">
<div class="label">Z up</div>
<SliderControl class="s" Value:bind=@GunZ Min="@(-20f)" Max="@(20f)" Step="@(0.05f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "gz", GunZ, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "gz", v, f => GunZ = f ) ) />
</div>
<div class="group">
PIECES — @Rig.Parts.Count( SckPartsRig.Shown ) drawn@(SckPartsRig.Ads ? ", aiming" : ", from the hip")
</div>
<div class="list">
@foreach ( var p in Rig.Parts.Where( SckPartsRig.Shown ) )
{
<div class="item @(p.Name == Selected ? "sel" : "")"
onclick=@(() => Select( p.Name ))>
<div class="nm">@p.Name</div>
<div class="rel">@(p.AdsOnly ? "sight" : string.IsNullOrEmpty( p.Rel ) ? "bone" : "→ " + p.Rel)</div>
@if ( Moved( p ) )
{
<div class="dot">●</div>
}
</div>
}
</div>
@if ( Sel is null )
{
<div class="warn">Pick a piece above.</div>
}
else
{
<div class="group">POSITION — offset along the parent's forward / left / up</div>
<div class="row">
<div class="label">X fwd</div>
<SliderControl class="s" Value:bind=@PX Min="@(-15f)" Max="@(15f)" Step="@(0.01f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "px", PX, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "px", v, f => PX = f ) ) />
</div>
<div class="row">
<div class="label">Y left</div>
<SliderControl class="s" Value:bind=@PY Min="@(-15f)" Max="@(15f)" Step="@(0.01f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "py", PY, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "py", v, f => PY = f ) ) />
</div>
<div class="row">
<div class="label">Z up</div>
<SliderControl class="s" Value:bind=@PZ Min="@(-15f)" Max="@(15f)" Step="@(0.01f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "pz", PZ, "0.###" )
OnTextEdited=@( ( string v ) => Typed( "pz", v, f => PZ = f ) ) />
</div>
@* ⚠️ PITCH IS LISTED FIRST BECAUSE THE TABLE WRITES IT FIRST — `Angle( p, y, r )`.
Reordering them to the more familiar yaw-pitch-roll would make the panel
disagree with every number in the lua it exists to reproduce. *@
<div class="group">ANGLE — offset in pitch, yaw, roll, the table's own order</div>
<div class="row">
<div class="label">Pitch</div>
<SliderControl class="s" Value:bind=@AP Min="@(-180f)" Max="@(180f)" Step="@(0.1f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "ap", AP, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "ap", v, f => AP = f ) ) />
</div>
<div class="row">
<div class="label">Yaw</div>
<SliderControl class="s" Value:bind=@AY Min="@(-180f)" Max="@(180f)" Step="@(0.1f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "ay", AY, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "ay", v, f => AY = f ) ) />
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@AR Min="@(-180f)" Max="@(180f)" Step="@(0.1f)"></SliderControl>
<TextEntry class="entry" Value=@Text( "ar", AR, "0.##" )
OnTextEdited=@( ( string v ) => Typed( "ar", v, f => AR = f ) ) />
</div>
@* ⚠️ THE RANGE HAS TO REACH 0.004 AND 1.18 AT ONCE — this weapon uses both. A linear
slider over that span gives the small parts almost no travel, so Fine rescales
the top end instead of adding a second control. *@
<div class="group">SIZE — offset per axis@(Fine ? ", fine" : "")</div>
<div class="row">
<div class="label">X</div>
<SliderControl class="s" Value:bind=@SX Min="@(-SizeMax)" Max="@(SizeMax)" Step="@(SizeStep)"></SliderControl>
<TextEntry class="entry" Value=@Text( "sx", SX, "0.####" )
OnTextEdited=@( ( string v ) => Typed( "sx", v, f => SX = f ) ) />
</div>
<div class="row">
<div class="label">Y</div>
<SliderControl class="s" Value:bind=@SY Min="@(-SizeMax)" Max="@(SizeMax)" Step="@(SizeStep)"></SliderControl>
<TextEntry class="entry" Value=@Text( "sy", SY, "0.####" )
OnTextEdited=@( ( string v ) => Typed( "sy", v, f => SY = f ) ) />
</div>
<div class="row">
<div class="label">Z</div>
<SliderControl class="s" Value:bind=@SZ Min="@(-SizeMax)" Max="@(SizeMax)" Step="@(SizeStep)"></SliderControl>
<TextEntry class="entry" Value=@Text( "sz", SZ, "0.####" )
OnTextEdited=@( ( string v ) => Typed( "sz", v, f => SZ = f ) ) />
</div>
@* ⚠️ ABSOLUTE, unlike every control above it. This is the line that gets pasted
into the overrides file, and `--overrides` REPLACES values rather than adding to
them — so an offset here would be silently wrong in the bake. *@
<div class="out">@Line</div>
}
<div class="buttons">
<div class="btn copy" onclick=@(() => Copy())>@CopyLabel</div>
<div class="btn" onclick=@(() => Print())>Print</div>
<div class="btn @(Fine ? "on" : "")" onclick=@(() => Fine = !Fine)>
@(Fine ? "Fine size" : "Coarse size")
</div>
<div class="btn @(Rig.HideBaked ? "on" : "")" onclick=@(() => Rig.HideBaked = !Rig.HideBaked)>
@(Rig.HideBaked ? "Baked hidden" : "Baked shown")
</div>
<div class="btn" onclick=@(() => Zero())>Zero piece</div>
<div class="btn" onclick=@(() => Revert())>Revert all</div>
</div>
}
</div>
</root>
@code {
/// <summary>Is the editor on screen. Driven by `nz_parts`.</summary>
public static bool Visible { get; set; }
/// <summary>Which piece the sliders are pointed at.</summary>
public static string Selected { get; set; } = "";
/// <summary>
/// Switch the weapon between the hip pose and the aiming one.
/// </summary>
///
/// ⚠️ IT MOVES THE SELECTION TOO. Entering the aiming pose with a barrel still selected puts
/// the sliders on a piece that was finished days ago while the six that need placing sit
/// further down the list — so the first sight piece is picked, which is the actual work.
/// Leaving it alone would also point the sliders at a piece that is no longer drawn.
static void Pose( bool ads )
{
SckPartsRig.AdsCmd( ads ? 1 : 0 );
var parts = Rig?.Parts;
if ( parts is null ) return;
if ( ads )
{
var first = parts.FirstOrDefault( p => p.AdsOnly && p.Visible );
if ( first is not null ) Selected = first.Name;
}
else if ( parts.FirstOrDefault( p => p.Name == Selected ) is { AdsOnly: true } )
{
Selected = parts.FirstOrDefault( SckPartsRig.Shown )?.Name ?? "";
}
}
/// <summary>Rescale the size sliders for the sub-0.2 parts.</summary>
static bool Fine { get; set; }
static string CopyLabel = "Copy";
static SckPartsRig Rig => SckPartsRig.Current;
SckPart Sel => Rig?.Parts?.FirstOrDefault( p => p.Name == Selected );
/// <summary>
/// One multiplier over every part's size.
/// </summary>
///
/// ⚠️ IT DOES NOT TOUCH THE PER-PART SIZES, which stay exactly as the lua wrote them. That is
/// what keeps "what the weapon says" and "what I corrected" separable after the fact.
/// ⚠️ ZERO MEANS x1, for the same reason everything else zeroes: the default has to read as
/// untouched. The stored value is still a multiplier — only the number on screen is shifted —
/// and it is floored just above zero, because a multiplier of 0 collapses the gun to a point
/// and there is no way back from that with a slider.
static float Scale
{
get => ( Rig?.SizeScale ?? 1f ) - 1f;
set { if ( Rig is not null ) Rig.SizeScale = MathF.Max( 0.01f, 1f + value ); }
}
// ⚠️ Angles and Vector3 are STRUCTS, so a field cannot be assigned through the property —
// read the whole value, change one component, write the whole value back. Assigning
// `SckPartsRig.GunTurn.pitch` directly does not compile, and a local copy silently would.
static float GunP
{
get => SckPartsRig.GunTurn.pitch;
set { var a = SckPartsRig.GunTurn; a.pitch = value; SckPartsRig.GunTurn = a; }
}
static float GunY
{
get => SckPartsRig.GunTurn.yaw;
set { var a = SckPartsRig.GunTurn; a.yaw = value; SckPartsRig.GunTurn = a; }
}
static float GunR
{
get => SckPartsRig.GunTurn.roll;
set { var a = SckPartsRig.GunTurn; a.roll = value; SckPartsRig.GunTurn = a; }
}
static float GunX
{
get => SckPartsRig.GunMove.x;
set { var v = SckPartsRig.GunMove; v.x = value; SckPartsRig.GunMove = v; }
}
static float GunMY
{
get => SckPartsRig.GunMove.y;
set { var v = SckPartsRig.GunMove; v.y = value; SckPartsRig.GunMove = v; }
}
static float GunZ
{
get => SckPartsRig.GunMove.z;
set { var v = SckPartsRig.GunMove; v.z = value; SckPartsRig.GunMove = v; }
}
// ⚠️ Vector3 and Angles are STRUCTS — read whole, change one component, write whole back.
static float HandX { get => SckPartsRig.HandsMove.x; set { var v = SckPartsRig.HandsMove; v.x = value; SckPartsRig.HandsMove = v; } }
static float HandY { get => SckPartsRig.HandsMove.y; set { var v = SckPartsRig.HandsMove; v.y = value; SckPartsRig.HandsMove = v; } }
static float HandZ { get => SckPartsRig.HandsMove.z; set { var v = SckPartsRig.HandsMove; v.z = value; SckPartsRig.HandsMove = v; } }
static float HandP { get => SckPartsRig.HandsTurn.pitch; set { var a = SckPartsRig.HandsTurn; a.pitch = value; SckPartsRig.HandsTurn = a; } }
static float HandYw { get => SckPartsRig.HandsTurn.yaw; set { var a = SckPartsRig.HandsTurn; a.yaw = value; SckPartsRig.HandsTurn = a; } }
static float HandR { get => SckPartsRig.HandsTurn.roll; set { var a = SckPartsRig.HandsTurn; a.roll = value; SckPartsRig.HandsTurn = a; } }
static float SizeMax => Fine ? 0.2f : 1.5f;
static float SizeStep => Fine ? 0.0005f : 0.005f;
/// <summary>
/// What is half-typed in each box, keyed by field.
/// </summary>
///
/// ⛔ WITHOUT THIS YOU CANNOT TYPE A NEGATIVE NUMBER. The box is refilled from the value on
/// every rebuild, and the rebuild happens on every keystroke because the value is in BuildHash
/// — so "-" parses as nothing, the value does not change, and the minus sign is wiped before
/// the second character arrives. Same for the "." in "1.5" and a trailing "0" in "0.004".
static readonly Dictionary<string, string> _typing = new();
/// <summary>What a box should show: the half-typed text if there is one, else the value.</summary>
static string Text( string key, float value, string fmt )
{
if ( _typing.TryGetValue( key, out var t ) )
{
// Mid-edit and not yet a number — "-", "1.", "" — so leave it exactly as typed.
if ( !float.TryParse( t, NumberStyles.Float, CultureInfo.InvariantCulture, out var f ) )
return t;
// ⚠️ THE BUFFER IS DROPPED THE MOMENT THE VALUE MOVES ON ITS OWN. Otherwise dragging
// the slider would leave the box showing whatever was last typed, and the two controls
// for one number would disagree — which is worse than either alone.
if ( MathF.Abs( f - value ) < 0.0005f ) return t;
_typing.Remove( key );
}
return value.ToString( fmt, CultureInfo.InvariantCulture );
}
/// <summary>Take a keystroke: always remember it, apply it only once it is a number.</summary>
static void Typed( string key, string raw, Action<float> apply )
{
_typing[key] = raw;
// ⚠️ AN UNPARSEABLE BOX LEAVES THE PART ALONE rather than snapping it to zero. Clearing
// the field to retype it would otherwise fling the piece across the screen on the way.
if ( float.TryParse( raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var f ) )
apply( f );
}
void Select( string name )
{
// The boxes belong to the piece that was selected, not to the panel.
_typing.Clear();
Selected = name;
// ⚠️ THE RIG TINTS THE SELECTION. Seventeen near-identical white shards are impossible to
// tell apart on screen, so "which one am I dragging" has to be answered in the world and
// not just in the list.
SckPartsRig.Highlight = name;
}
/// <summary>The selected piece as the manifest on disk has it — the zero point.</summary>
SckPart Home => Rig?.Pristine?.Parts?.FirstOrDefault( p => p.Name == Selected );
// ⛔ EVERY CONTROL SHOWS AN OFFSET, NOT AN ABSOLUTE, AND ZERO MEANS "AS THE LUA WROTE IT".
// An absolute readout of 94.843 tells you nothing about whether you have moved it; the same
// piece reading 0.0 says it is untouched, and -3.5 says exactly how far you have dragged it.
// It also makes undo a number you can type, per piece and per axis, instead of a whole revert.
//
// ⚠️ THE STORED VALUES ARE STILL ABSOLUTE and are never rewritten by this — the offset lives
// only in the presentation. Print and the paste line below still emit absolutes, because that
// is what `--overrides` replaces and what the lua is written in.
//
// ⚠️ BOUND STRAIGHT TO THE PART'S ARRAYS, never to local copies — RecoilTuner's note. A copy
// would fight `nz_part` and lose whatever the console just set.
float Off( float[] now, float[] home, int i, float fallback )
=> now is null ? 0f : now[i] - ( home is null ? fallback : home[i] );
void SetOff( float[] now, float[] home, int i, float v, float fallback )
{
if ( now is null ) return;
now[i] = ( home is null ? fallback : home[i] ) + v;
}
float PX { get => Off( Sel?.Pos, Home?.Pos, 0, 0f ); set => SetOff( Sel?.Pos, Home?.Pos, 0, value, 0f ); }
float PY { get => Off( Sel?.Pos, Home?.Pos, 1, 0f ); set => SetOff( Sel?.Pos, Home?.Pos, 1, value, 0f ); }
float PZ { get => Off( Sel?.Pos, Home?.Pos, 2, 0f ); set => SetOff( Sel?.Pos, Home?.Pos, 2, value, 0f ); }
float AP { get => Off( Sel?.Ang, Home?.Ang, 0, 0f ); set => SetOff( Sel?.Ang, Home?.Ang, 0, value, 0f ); }
float AY { get => Off( Sel?.Ang, Home?.Ang, 1, 0f ); set => SetOff( Sel?.Ang, Home?.Ang, 1, value, 0f ); }
float AR { get => Off( Sel?.Ang, Home?.Ang, 2, 0f ); set => SetOff( Sel?.Ang, Home?.Ang, 2, value, 0f ); }
float SX { get => Off( Sel?.Size, Home?.Size, 0, 1f ); set => SetOff( Sel?.Size, Home?.Size, 0, value, 1f ); }
float SY { get => Off( Sel?.Size, Home?.Size, 1, 1f ); set => SetOff( Sel?.Size, Home?.Size, 1, value, 1f ); }
float SZ { get => Off( Sel?.Size, Home?.Size, 2, 1f ); set => SetOff( Sel?.Size, Home?.Size, 2, value, 1f ); }
/// <summary>Put this piece back exactly where the manifest has it.</summary>
void Zero()
{
if ( Sel is null || Home is null ) return;
for ( var i = 0; i < 3; i++ )
{
Sel.Pos[i] = Home.Pos[i];
Sel.Ang[i] = Home.Ang[i];
Sel.Size[i] = Home.Size[i];
}
_typing.Clear();
Log.Info( $"[nz-parts] {Sel.Name} back to the manifest" );
}
/// <summary>Has this part been moved away from what is on disk.</summary>
static bool Moved( SckPart p )
{
var was = Rig?.Pristine?.Parts?.FirstOrDefault( q => q.Name == p.Name );
if ( was is null ) return false;
for ( var i = 0; i < 3; i++ )
{
if ( MathF.Abs( p.Pos[i] - was.Pos[i] ) > 0.0005f ) return true;
if ( MathF.Abs( p.Ang[i] - was.Ang[i] ) > 0.0005f ) return true;
if ( MathF.Abs( p.Size[i] - was.Size[i] ) > 0.0005f ) return true;
}
return false;
}
/// <summary>The selected part as one overrides line, ready to paste.</summary>
string Line
{
get
{
var p = Sel;
if ( p is null ) return "";
// ⛔ THE PART'S OWN ARRAYS, NOT PX/PY/PZ. Those are OFFSETS now, and `--overrides`
// REPLACES a value rather than adding to it — so pasting an offset here would put the
// piece at the offset instead of at the tuned position, silently, and only in the bake.
return $"\"{p.Name}\": {{ \"pos\": [{p.Pos[0]:0.###}, {p.Pos[1]:0.###}, {p.Pos[2]:0.###}],"
+ $" \"ang\": [{p.Ang[0]:0.###}, {p.Ang[1]:0.###}, {p.Ang[2]:0.###}],"
+ $" \"size\": [{p.Size[0]:0.####}, {p.Size[1]:0.####}, {p.Size[2]:0.####}] }}";
}
}
/// <summary>
/// The whole-weapon transform, in the shape the offline tool folds in.
/// </summary>
///
/// ⛔ NAMED `_bone`, DELIBERATELY. The rotator page also exports `gun_turn`, but ITS pivot is
/// the assembly's bounding-box centroid while this one is the anchor bone. Two different
/// rotations that would silently substitute for one another under the same key.
static string GunBlock()
{
var t = SckPartsRig.GunTurn;
var m = SckPartsRig.GunMove;
var turned = MathF.Abs( t.pitch ) > 0.005f || MathF.Abs( t.yaw ) > 0.005f
|| MathF.Abs( t.roll ) > 0.005f;
var moved = m.Length > 0.0005f;
if ( !turned && !moved ) return "";
var parts = new List<string>();
if ( turned )
parts.Add( $" \"gun_turn_bone\": [{t.pitch:0.###}, {t.yaw:0.###}, {t.roll:0.###}]" );
if ( moved )
parts.Add( $" \"gun_move_bone\": [{m.x:0.###}, {m.y:0.###}, {m.z:0.###}]" );
return string.Join( ",\n", parts );
}
void Copy()
{
var gun = GunBlock();
var text = ( gun.Length > 0 ? "{\n" + gun + "\n}\n" : "" ) + ( Rig?.Dump( Rig.Pristine ) ?? "" );
Clipboard.SetText( text );
CopyLabel = "Copied";
Log.Info( "[nz-parts] copied the overrides block" );
}
/// <summary>
/// `nz_parts_print` — the whole tuned set, in `--overrides` shape.
/// </summary>
[ConCmd( "nz_parts_print" )]
public static void Print()
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
// ⛔ ONE LOG CALL PER LINE. The console keeps a message's FIRST line and drops the rest, so
// every multi-line print here has been showing a header and nothing else — a Print that
// reads as "it produced nothing" rather than "the console ate it".
var gun = GunBlock();
if ( gun.Length > 0 )
{
Log.Info( "[nz-parts] whole weapon, about the bone:" );
Log.Info( "{" );
foreach ( var line in gun.Split( '\n' ) )
Log.Info( line );
Log.Info( "}" );
}
Log.Info( "[nz-parts] per-piece placements:" );
foreach ( var line in Rig.Dump( Rig.Pristine ).Split( '\n' ) )
Log.Info( line );
}
/// <summary>`nz_parts_reload` — throw away every change and re-read the manifest.</summary>
[ConCmd( "nz_parts_reload" )]
public static void Revert()
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
Rig.Load();
_typing.Clear();
CopyLabel = "Copy";
Log.Info( "[nz-parts] reverted to the manifest on disk" );
}
/// <summary>`nz_parts_hide [0/1]` — show or hide the weapon's own baked mesh.</summary>
[ConCmd( "nz_parts_hide" )]
public static void HideCmd( int on = -1 )
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
Rig.HideBaked = on < 0 ? !Rig.HideBaked : on != 0;
Log.Info( $"[nz-parts] baked mesh {(Rig.HideBaked ? "hidden" : "shown")}" );
}
/// <summary>
/// `nz_part <name> [px py pz] [pitch yaw roll]` — set one piece from the console.
///
/// ⚠️ EVERY CONTROL IN THIS PANEL HAS ONE, per the project rule, and this one also prints the
/// current values when called with a name alone — which is how you read a piece without
/// hunting for it in the list.
/// </summary>
[ConCmd( "nz_part" )]
public static void PartCmd( string name = "", float px = float.NaN, float py = 0f, float pz = 0f,
float pitch = float.NaN, float yaw = 0f, float roll = 0f )
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
if ( string.IsNullOrEmpty( name ) )
{
Log.Info( "[nz-parts] " + string.Join( ", ", Rig.Parts.Select( p => p.Name ) ) );
return;
}
var part = Rig.Parts.FirstOrDefault( p => p.Name == name );
if ( part is null ) { Log.Warning( $"[nz-parts] no piece called '{name}'" ); return; }
if ( !float.IsNaN( px ) ) { part.Pos[0] = px; part.Pos[1] = py; part.Pos[2] = pz; }
if ( !float.IsNaN( pitch ) ) { part.Ang[0] = pitch; part.Ang[1] = yaw; part.Ang[2] = roll; }
Selected = name;
SckPartsRig.Highlight = name;
Log.Info( $"[nz-parts] {name} pos {part.Pos[0]:0.###},{part.Pos[1]:0.###},{part.Pos[2]:0.###}"
+ $" ang {part.Ang[0]:0.##},{part.Ang[1]:0.##},{part.Ang[2]:0.##}"
+ $" size {part.Size[0]:0.####},{part.Size[1]:0.####},{part.Size[2]:0.####}" );
}
/// <summary>
/// `nz_ads_forward [units]` — slide every aiming-only piece along the gun, together.
/// </summary>
///
/// ⛔ ALL SIX AT ONCE, BECAUSE THEY ARE ONE ASSEMBLY. The sight pieces are placed relative to
/// each other and moving one is almost never what is wanted; `nz_part` is still there for the
/// times it is.
///
/// ⚠️ RELATIVE, NOT ABSOLUTE, so calling it again adds to what is already there — which is
/// how "a bit more forward" is actually found. `nz_ads_forward -2` walks it back.
///
/// ⚠️ POS[0] IS FORWARD. `SckPartsRig.PlaceMatrix` measures the offset along
/// Forward/Right/Up of the hand bone, so the first component runs down the barrel — positive
/// toward the muzzle. It is NOT a world axis and does not care where the player is looking.
///
/// ⚠️ IT PRINTS MANIFEST-READY LINES because nothing here writes the file. The panel's own
/// SAVE is a copy-out, and so is this.
[ConCmd( "nz_ads_forward" )]
public static void AdsForwardCmd( float units = float.NaN )
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
var ads = Rig.Parts.Where( p => p.AdsOnly ).ToList();
if ( ads.Count == 0 ) { Log.Warning( "[nz-parts] no ads_only pieces" ); return; }
if ( !float.IsNaN( units ) )
foreach ( var p in ads )
p.Pos[0] += units;
Log.Info( $"[nz-parts] {ads.Count} ads piece(s)"
+ (float.IsNaN( units ) ? "" : $" moved {units:+0.##;-0.##}u along the barrel") );
foreach ( var p in ads )
Log.Info( $"[nz-parts] \"{p.Name}\": pos [{p.Pos[0]:0.####}, "
+ $"{p.Pos[1]:0.####}, {p.Pos[2]:0.####}]" );
}
/// <summary>
/// `nz_ads_legacy [0|1]` — show or hide the six aiming pieces that came with the pack.
/// </summary>
///
/// ⛔ THE CUSTOM SIGHT AND THE PACK'S OWN AIM GEOMETRY BOTH DRAW, AND THAT IS THE POINT OF
/// HAVING A SWITCH. The Prisma shows ten placements from the hip and sixteen down the sights;
/// six of those sixteen are the pack's aiming additions, and a purpose-built front post and
/// rear aperture sitting among them is a cluttered sight picture. Neither set is deleted —
/// this flips `Visible` on the pack's six so the two can be looked at side by side.
///
/// ⚠️ IT TELLS THEM APART BY NAME. The custom pieces are `ads_front` and `ads_rear`;
/// everything else flagged `ads_only` is the pack's. A third custom piece should follow the
/// same `ads_` prefix or it will get hidden with them.
[ConCmd( "nz_ads_legacy" )]
public static void AdsLegacyCmd( int on = -1 )
{
if ( Rig is null ) { Log.Warning( "[nz-parts] no rig — run nz_parts" ); return; }
var pack = Rig.Parts
.Where( p => p.AdsOnly && !p.Name.StartsWith( "ads_" ) )
.ToList();
if ( pack.Count == 0 ) { Log.Warning( "[nz-parts] no pack aim pieces" ); return; }
var show = on < 0 ? !pack[0].Visible : on != 0;
foreach ( var p in pack ) p.Visible = show;
Log.Info( $"[nz-parts] the pack's {pack.Count} aim piece(s) {(show ? "shown" : "hidden")}"
+ $" — {string.Join( ", ", pack.Select( p => p.Name ) )}" );
}
void Hide()
{
Visible = false;
SckPartsRig.Active = false;
Mouse.Visibility = MouseVisibility.Hidden;
}
/// <summary>
/// `nz_parts [0/1]` — open the part editor.
///
/// ⚠️ IT TAKES THE CURSOR, like the other tuners — a slider cannot be dragged without one, and
/// Noclip keys off Mouse.Visibility, so leaving it hidden would have V toggling noclip under
/// the player mid-drag.
/// </summary>
[ConCmd( "nz_parts" )]
public static void PartsCmd( int on = -1 )
{
var fresh = EnsureHost();
// ⛔ A PANEL THAT WAS JUST BUILT OPENS, IT DOES NOT TOGGLE. `Visible` is a static, so it
// survives a hotload while the GameObject holding the panel does not — and the first run
// after a hotload then built the panel and immediately hid it, logging "editor closed" on
// what looked like a first open. Toggling is only meaningful once something is on screen.
Visible = fresh || (on < 0 ? !Visible : on != 0);
// ⛔ THE PANEL AND THE RIG GO TOGETHER. Closing the editor while the rig kept drawing left
// the loose placements on screen and the baked gun hidden, with no way back short of a
// restart and nothing saying so. Open means tuning; closed means the shipped model.
SckPartsRig.Active = Visible;
Mouse.Visibility = Visible ? MouseVisibility.Visible : MouseVisibility.Hidden;
// ⚠️ IT SAYS THE WEAPON IS BACK, the way the sight editor's close does. Closing used to
// be able to leave the gun held at the sights, and the line that would have made that
// obvious — or its absence — is the difference between a one-command fix and a puzzle.
Log.Info( $"[nz-parts] editor {(Visible ? "open" : "closed")}"
+ $" — {Rig?.Parts?.Count ?? 0} piece(s)"
+ (Visible ? "" : ", weapon back to the hip") );
}
static GameObject _host;
/// <summary>
/// Make sure something is drawing the panel, AND that a rig exists to edit.
///
/// ⛔ THE PANEL HAS NO HOME IN THE SCENE. Without this the razor compiles and never renders:
/// the command reports "open" and nothing appears.
///
/// ⚠️ REBUILT WHENEVER THE OBJECT IS GONE, not once — a GameObject created from code does not
/// survive a hotload.
/// </summary>
static bool EnsureHost()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return false;
var built = false;
if ( !_host.IsValid() )
{
built = true;
_host = scene.CreateObject();
_host.Name = "Part Editor UI";
_host.Flags |= GameObjectFlags.NotSaved;
var screen = _host.Components.Create<ScreenPanel>();
screen.ZIndex = 80;
_host.Components.Create<SckPartEditor>();
Log.Info( "[nz-parts] created the editor's screen panel" );
}
if ( !SckPartsRig.Current.IsValid() )
{
_host.Components.Create<SckPartsRig>();
Log.Info( "[nz-parts] created the part rig" );
}
return built;
}
// ⚠️ EVERY DISPLAYED NUMBER IS IN THE HASH. Leave one out and it freezes on screen while the
// value behind it moves, which reads as the panel being broken rather than stale.
protected override int BuildHash()
=> HashCode.Combine(
HashCode.Combine( Visible, Selected, CopyLabel, Fine ),
HashCode.Combine( PX, PY, PZ ),
HashCode.Combine( AP, AY, AR ),
HashCode.Combine( SX, SY, SZ ),
HashCode.Combine( Rig?.Parts?.Count ?? 0, Rig?.HideBaked ?? false ),
HashCode.Combine( SckPartsRig.Status, SckPartsRig.Drawn, SckPartsRig.MissingModels ),
HashCode.Combine( Scale, SckPartsRig.GunTurn, SckPartsRig.GunMove ),
HashCode.Combine( SckPartsRig.HandsMove, SckPartsRig.HandsTurn, SckPartsRig.HandsStatus,
SckPartsRig.Ads, SckPartsRig.AimHold ) );
}