A Razor UI component for the sight editor in a game. It renders a full-screen drag canvas and a side panel to position aiming-only parts and the weapon aim pose, allows dragging and slider edits, copying/printing placements, saving/restoring weapon aim, and manages editor state and mouse visibility.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@using NZombies;
@using SWB.Base;
@inherits PanelComponent
@*
SIGHT EDITOR — `nz_sight`. Opens AT THE SIGHTS and puts the six aiming-only pieces on one at a
time, wherever you drag them.
⛔ IT IS A SECOND PANEL RATHER THAN A THIRD SECTION OF `nz_parts`, AND THE REASON IS THE SCREEN.
The sight sits in the MIDDLE of the view when the weapon is up — that is what a sight is — and
the part editor is a 440px column of sliders that grew a pose row, a hands row, a whole-gun row
and a seventeen-row list. Placing a half-unit ring means looking at the middle of the screen
with as little as possible in front of it.
⚠️ AND IT OPENS INTO THE POSE, WHICH IS THE WHOLE POINT. User: *"create a new ui that makes me
ads when i open it"*. A sight can only be judged from behind it, so the pose is not a setting on
this panel — it is the state the panel exists in, taken on open and dropped on close.
*@
<root class="sight @(Visible ? "" : "hidden")">
@* ⚠️ BEHIND THE PANEL AND ACROSS THE WHOLE SCREEN, because the thing you drag is in the middle
of it. The dot marks where the sight has to end up: dead centre. *@
<div class="canvas" onmousedown=@(() => Grab( true )) onmouseup=@(() => Grab( false ))>
<div class="dot @(Dragging ? "live" : "")"></div>
</div>
<div class="panel">
<div class="head">
<div class="title">SIGHT</div>
<div class="close" onclick=@(() => Close())>×</div>
</div>
@* ⛔ THE STATE LINE IS THE FEATURE, NOT DECORATION. "The panel says AIMING and the weapon is
at the hip" is exactly the failure this tool was built after, and every way it can happen
— no viewmodel, no aim pose authored, the hold not taking — looks identical from outside.
This says which one. *@
<div class="state @(Ready ? "" : "bad")">@State</div>
@if ( Rig is null || !Sight.Any() )
{
<div class="warn">
No aiming-only pieces in @(Rig?.ManifestPath ?? "the manifest"). Run nz_parts first.
</div>
}
else
{
@* ⛔ THE WEAPON IS A TARGET TOO, AND IT HAS TO BE. Aiming does not ADD to the hip
placement, it REPLACES it — so a weapon whose aim offset was never dialled in jumps to
a pose nobody measured the instant it aims, which is exactly what the Prisma does with
the number the batch port gave it. There is no placing a sight against a weapon that
is itself in the wrong place. *@
<div class="group">TARGET</div>
<div class="buttons">
<div class="btn @(GunTarget ? "on" : "")" onclick=@(() => GunTarget = true)>Whole weapon</div>
<div class="btn @(GunTarget ? "" : "on")" onclick=@(() => GunTarget = false)>
@(Sel is null ? "A piece" : "Piece " + Sel.Name)
</div>
</div>
<div class="group">PIECES — click to put one on</div>
<div class="list">
@foreach ( var p in Sight )
{
<div class="item @(!GunTarget && p.Name == Selected ? "sel" : "") @(Held( p ) ? "off" : "")"
onclick=@(() => Pick( p ))>
<div class="box">@(Held( p ) ? "+" : "✓")</div>
<div class="nm">@p.Name</div>
<div class="at">@Where( p )</div>
</div>
}
</div>
@if ( !GunTarget && Sel is null )
{
<div class="warn">Click a piece to put it on the weapon.</div>
}
else
{
@* ⚠️ DRAG FIRST, SLIDERS SECOND. The numbers are bone-space and nobody thinks in
them; you place a ring by looking through it. The sliders are here for the last
hundredth of a unit, and for when a drag axis reads backwards. *@
<div class="group">DRAG — @(AngleMode ? "yaw / pitch" : "left / up")</div>
<div class="buttons">
<div class="btn @(Dragging ? "on" : "")" onclick=@(() => Grab( !Dragging ))>
@(Dragging ? "Drop" : "Grab")
</div>
<div class="btn @(AngleMode ? "on" : "")" onclick=@(() => AngleMode = !AngleMode)>
@(AngleMode ? "Turning" : "Moving")
</div>
<div class="btn @(Fine ? "on" : "")" onclick=@(() => Fine = !Fine)>
@(Fine ? "Fine" : "Coarse")
</div>
</div>
<div class="group">
@(GunTarget ? "WEAPON — where the gun sits while aiming" : "POSITION — bone axes, forward / left / up")
</div>
<div class="row">
<div class="label">X fwd</div>
<SliderControl class="s" Value:bind=@PX Min="@(-20f)" Max="@(20f)" Step="@(0.005f)"></SliderControl>
<div class="num">@PX.ToString( "0.###" )</div>
</div>
<div class="row">
<div class="label">Y left</div>
<SliderControl class="s" Value:bind=@PY Min="@(-20f)" Max="@(20f)" Step="@(0.005f)"></SliderControl>
<div class="num">@PY.ToString( "0.###" )</div>
</div>
<div class="row">
<div class="label">Z up</div>
<SliderControl class="s" Value:bind=@PZ Min="@(-20f)" Max="@(20f)" Step="@(0.005f)"></SliderControl>
<div class="num">@PZ.ToString( "0.###" )</div>
</div>
<div class="group">ANGLE — 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>
<div class="num">@AP.ToString( "0.##" )</div>
</div>
<div class="row">
<div class="label">Yaw</div>
<SliderControl class="s" Value:bind=@AY Min="@(-180f)" Max="@(180f)" Step="@(0.1f)"></SliderControl>
<div class="num">@AY.ToString( "0.##" )</div>
</div>
<div class="row">
<div class="label">Roll</div>
<SliderControl class="s" Value:bind=@AR Min="@(-180f)" Max="@(180f)" Step="@(0.1f)"></SliderControl>
<div class="num">@AR.ToString( "0.##" )</div>
</div>
<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" onclick=@(() => All( true ))>All on</div>
<div class="btn" onclick=@(() => All( false ))>All off</div>
</div>
@* ⚠️ THE WEAPON'S AIM PLACEMENT PERSISTS SEPARATELY FROM THE PIECES. The pieces live in
the parts manifest and come back through a re-bake; this one is a weapon property, and
`WeaponPlacement` is the same store the offset editor's SAVE writes to — so a sight
dialled in here survives a respawn without anyone editing a prefab. *@
<div class="buttons">
<div class="btn copy" onclick=@(() => SaveAim())>Save weapon pose</div>
<div class="btn" onclick=@(() => RestoreAim())>Restore weapon pose</div>
</div>
}
</div>
</root>
@code {
/// <summary>Is the sight editor on screen. Driven by `nz_sight`.</summary>
public static bool Visible { get; set; }
/// <summary>Which sight piece the drag and the sliders are pointed at.</summary>
public static string Selected { get; set; } = "";
/// <summary>Is the mouse currently carrying the selected piece.</summary>
public static bool Dragging { get; private set; }
/// <summary>
/// Are the controls pointed at the whole weapon rather than at one piece.
/// </summary>
///
/// ⚠️ IT OPENS ON THE WEAPON, not on a piece. The first question when the panel appears is
/// always "is the gun in the right place", because nothing placed against a weapon in the wrong
/// position stays placed once the weapon moves.
static bool GunTarget { get; set; } = true;
/// <summary>Does a drag turn the piece instead of moving it.</summary>
static bool AngleMode { get; set; }
/// <summary>Smaller steps, for the last hundredth of a unit.</summary>
static bool Fine { get; set; }
static string CopyLabel = "Copy";
static GameObject _host;
/// <summary>
/// Has this session ever opened the panel.
/// </summary>
///
/// ⚠️ THE FIRST OPEN CLEARS THE SIGHT AND LATER ONES DO NOT. Building it up one piece at a time
/// is the workflow, so the panel starts empty — but closing it to look at something and coming
/// back should not throw away which pieces are already on.
static bool _opened;
Vector2 _lastMouse;
static SckPartsRig Rig => SckPartsRig.Current;
IEnumerable<SckPart> Sight => Rig?.Parts?.Where( p => p.AdsOnly ) ?? Enumerable.Empty<SckPart>();
SckPart Sel => Rig?.Parts?.FirstOrDefault( p => p.Name == Selected && p.AdsOnly );
static bool Held( SckPart p ) => SckPartsRig.Held.Contains( p.Name );
/// <summary>
/// The local viewmodel's handler, which is the only thing that knows whether the aim pose took.
/// </summary>
///
/// ⚠️ FOUND BY COMPONENT, NOT BY PLAYER. A viewmodel exists only on the machine that owns it, so
/// there is no "which player" question here and no lookup across players to get wrong.
static ViewModelHandler Handler => Game.ActiveScene?
.GetAllComponents<ViewModelHandler>()
.FirstOrDefault( h => h.IsValid() && h.ViewModelRenderer.IsValid() );
/// <summary>Is the weapon actually at the sights right now.</summary>
static bool Ready => Handler is { } h && h.AimOwnsPose;
static string State
{
get
{
var h = Handler;
if ( h is null ) return "no viewmodel — hold a weapon in first person";
var w = h.Weapon;
if ( !w.IsValid() ) return "the viewmodel has no weapon";
// ⛔ THE ONE CONDITION THE HOLD CANNOT OVERRIDE, so it is the one worth naming. A weapon
// with no aim pose authored has nothing to hold it at, and forcing one would drop the
// hip offset as well and throw the gun to the model origin.
if ( w.AimAnimData == AngPos.Zero )
return "this weapon has no aim pose (AimAnimData is zero) — nothing to hold it at";
if ( h.AimOwnsPose )
return $"at the sights · {SckPartsRig.Drawn} piece(s) drawing";
return $"NOT at the sights — aiming {w.IsAiming}, reloading {w.IsReloading}, "
+ $"hold {SckPartsRig.AimHold}";
}
}
/// <summary>Where a piece is, short enough to sit in a list row.</summary>
static string Where( SckPart p )
=> SckPartsRig.Held.Contains( p.Name )
? "off"
: $"{p.Pos[0]:0.##}, {p.Pos[1]:0.##}, {p.Pos[2]:0.##}";
/// <summary>The weapon whose aim placement this panel edits.</summary>
static Weapon Gun => Handler?.Weapon;
/// <summary>
/// The aim placement, read and written a field at a time.
/// </summary>
///
/// ⚠️ `AngPos` AND `Vector3` ARE BOTH STRUCTS, so `w.AimAnimData.Pos.x = v` is a write to a copy
/// that the compiler will not even accept. Every edit is read-modify-write, which is why these
/// go through one pair of helpers instead of six inline setters.
static float AimPos( int i )
=> Gun is { } w ? (i == 0 ? w.AimAnimData.Pos.x : i == 1 ? w.AimAnimData.Pos.y : w.AimAnimData.Pos.z) : 0f;
static void AimPos( int i, float v )
{
if ( Gun is not { } w ) return;
var a = w.AimAnimData;
var p = a.Pos;
if ( i == 0 ) p.x = v;
else if ( i == 1 ) p.y = v;
else p.z = v;
a.Pos = p;
w.AimAnimData = a;
}
static float AimAng( int i )
=> Gun is { } w ? (i == 0 ? w.AimAnimData.Angle.pitch : i == 1 ? w.AimAnimData.Angle.yaw : w.AimAnimData.Angle.roll) : 0f;
static void AimAng( int i, float v )
{
if ( Gun is not { } w ) return;
var a = w.AimAnimData;
var g = a.Angle;
if ( i == 0 ) g.pitch = v;
else if ( i == 1 ) g.yaw = v;
else g.roll = v;
a.Angle = g;
w.AimAnimData = a;
}
float PX { get => GunTarget ? AimPos( 0 ) : Sel?.Pos[0] ?? 0f; set { if ( GunTarget ) AimPos( 0, value ); else if ( Sel is { } s ) s.Pos[0] = value; } }
float PY { get => GunTarget ? AimPos( 1 ) : Sel?.Pos[1] ?? 0f; set { if ( GunTarget ) AimPos( 1, value ); else if ( Sel is { } s ) s.Pos[1] = value; } }
float PZ { get => GunTarget ? AimPos( 2 ) : Sel?.Pos[2] ?? 0f; set { if ( GunTarget ) AimPos( 2, value ); else if ( Sel is { } s ) s.Pos[2] = value; } }
float AP { get => GunTarget ? AimAng( 0 ) : Sel?.Ang[0] ?? 0f; set { if ( GunTarget ) AimAng( 0, value ); else if ( Sel is { } s ) s.Ang[0] = value; } }
float AY { get => GunTarget ? AimAng( 1 ) : Sel?.Ang[1] ?? 0f; set { if ( GunTarget ) AimAng( 1, value ); else if ( Sel is { } s ) s.Ang[1] = value; } }
float AR { get => GunTarget ? AimAng( 2 ) : Sel?.Ang[2] ?? 0f; set { if ( GunTarget ) AimAng( 2, value ); else if ( Sel is { } s ) s.Ang[2] = value; } }
/// <summary>
/// The aim placement the weapon had when the panel first opened, kept so Restore means something.
/// </summary>
///
/// ⚠️ NOT NECESSARILY THE PREFAB'S. `WeaponPlacement.Apply` puts a saved placement on at spawn,
/// so this is whatever was in force — the first run seeded over `-0.66,-1.509,-5.04` where the
/// prefab says `-3.67,-2,1.99`. Calling it "the prefab's" would send someone looking for it in a
/// file that does not contain it.
static AngPos? _wasAim;
/// <summary>
/// Stop the weapon jumping the moment the panel opens.
/// </summary>
///
/// ⛔ THE AIM POSE REPLACES THE HIP POSE, IT DOES NOT ADD TO IT. Both offsets are measured
/// absolutely, so a weapon whose aim placement was never dialled in snaps somewhere nobody chose
/// as soon as it aims — the Prisma's came out of the batch port and is exactly that. Seeding the
/// aim slot from the hip placement means the panel opens with the gun where it already looks
/// right, and moving it to the sights becomes an edit you make instead of one you undo.
///
/// ⚠️ ONCE PER SESSION, NOT ONCE PER OPEN. Re-seeding on every open would throw away the aim
/// placement the last open dialled in, which is the one thing here that cannot be recovered by
/// re-reading a file.
static void SeedAim()
{
if ( Gun is not { } w || _wasAim is not null ) return;
_wasAim = w.AimAnimData;
w.AimAnimData = w.ViewModelOffset;
Log.Info( $"[nz-sight] aim placement seeded from the hip pose (was {_wasAim.Value.Pos})" );
}
/// <summary>Persist the weapon's aim placement, the same way the offset editor does.</summary>
void SaveAim()
{
if ( Gun is not { } w ) return;
WeaponPlacement.Save( w, WeaponPlacement.Aim, w.AimAnimData );
Log.Info( $"[nz-sight] saved aim placement {w.AimAnimData.Pos} / {w.AimAnimData.Angle}" );
}
/// <summary>Put back the aim placement the weapon had before the panel touched it.</summary>
void RestoreAim()
{
if ( Gun is not { } w || _wasAim is null ) return;
w.AimAnimData = _wasAim.Value;
Log.Info( $"[nz-sight] aim placement restored to {_wasAim.Value.Pos} — what the weapon had "
+ "when this panel opened, saved placement included" );
}
/// <summary>The selected piece in the manifest's own shape, ready to paste.</summary>
string Line
{
get
{
if ( GunTarget )
return Gun is { } w
? $"AimAnimData: Pos = {w.AimAnimData.Pos.x:0.###}f, {w.AimAnimData.Pos.y:0.###}f, "
+ $"{w.AimAnimData.Pos.z:0.###}f Angle = {w.AimAnimData.Angle.pitch:0.###}f, "
+ $"{w.AimAnimData.Angle.yaw:0.###}f, {w.AimAnimData.Angle.roll:0.###}f"
: "";
return Sel is { } s
? $"\"{s.Name}\": {{ \"pos\": [{s.Pos[0]:0.###}, {s.Pos[1]:0.###}, {s.Pos[2]:0.###}], "
+ $"\"ang\": [{s.Ang[0]:0.###}, {s.Ang[1]:0.###}, {s.Ang[2]:0.###}] }}"
: "";
}
}
/// <summary>Put a piece on if it is off, or point the controls at it if it is already on.</summary>
void Pick( SckPart p )
{
if ( SckPartsRig.Held.Remove( p.Name ) )
Log.Info( $"[nz-sight] {p.Name} on" );
Selected = p.Name;
GunTarget = false;
SckPartsRig.Highlight = p.Name;
}
void All( bool on )
{
foreach ( var p in Sight )
{
if ( on ) SckPartsRig.Held.Remove( p.Name );
else SckPartsRig.Held.Add( p.Name );
}
}
/// <summary>
/// Take hold of the selected piece, or let it go.
/// </summary>
///
/// ⚠️ IT IS A MODE, NOT A HELD BUTTON, and that is on purpose. These rings are a third of a unit
/// across: placing one takes small careful movements over several seconds, and a drag that ends
/// the moment a finger lifts turns every one of them into a race.
void Grab( bool on )
{
if ( on && !GunTarget && Sel is null ) return;
Dragging = on;
_lastMouse = Mouse.Position;
}
protected override void OnUpdate()
{
if ( !Visible || !Dragging )
{
_lastMouse = Mouse.Position;
return;
}
var d = Mouse.Position - _lastMouse;
_lastMouse = Mouse.Position;
if ( d.Length < 0.01f ) return;
var move = Fine ? 0.002f : 0.012f;
var turn = Fine ? 0.02f : 0.12f;
if ( GunTarget )
{
// ⚠️ THE SAME AXES AND THE SAME INVERSIONS THE OFFSET EDITOR USES, read from its own
// fields rather than copied. The viewmodel is deliberately spun a quarter turn
// (`nz_vm_yaw`), so which offset axis reads as "horizontal on screen" is not obvious —
// that mapping was found in game once, and `nz_editor_invert` still flips it for both.
if ( AngleMode )
{
AimAng( 1, AimAng( 1 ) + SWB.Editor.OffsetEditor.InvYaw * d.x * turn );
AimAng( 0, AimAng( 0 ) - SWB.Editor.OffsetEditor.InvPitch * d.y * turn );
}
else
{
AimPos( 1, AimPos( 1 ) + SWB.Editor.OffsetEditor.InvY * d.x * move );
AimPos( 2, AimPos( 2 ) - SWB.Editor.OffsetEditor.InvZ * d.y * move );
}
return;
}
if ( Sel is not { } s ) return;
if ( AngleMode )
{
// Horizontal is yaw and vertical is pitch, which is how a gun turns when you push it.
s.Ang[1] += d.x * turn;
s.Ang[0] -= d.y * turn;
}
else
{
// ⛔ BOTH SIGNS ARE NEGATIVE AND NEITHER IS A GUESS. The bone's +Y is LEFT and its +Z is
// UP, while the mouse's +x is right and its +y is DOWN — so a drag to the right is −Y
// and a drag downward is −Z. Getting this backwards reads as the tool being broken
// rather than inverted, because a ring this small leaves the screen before you can tell.
s.Pos[1] -= d.x * move;
s.Pos[2] -= d.y * move;
}
}
void Copy()
{
Clipboard.SetText( Block() );
CopyLabel = "Copied";
Log.Info( "[nz-sight] copied" );
}
/// <summary>
/// Print the placements, ONE LOG CALL PER LINE.
/// </summary>
///
/// ⛔ THE CONSOLE KEEPS THE FIRST LINE OF A MESSAGE AND DROPS THE REST. `Log.Info(header + "\n"
/// + body)` therefore prints the header and looks like it produced nothing at all — which is
/// exactly what it did look like: *"[nz-sight] sight placements:"* followed by silence. The
/// clipboard has no such limit, so Copy was never affected and this only ever hit Print.
void Print()
{
Log.Info( "[nz-sight] sight placements:" );
foreach ( var line in Block().Split( '\n' ) )
Log.Info( line );
// ⚠️ THE WEAPON'S OWN PLACEMENT GOES WITH THEM. The pieces are positioned relative to a gun
// that this panel can also move, so a dump of the pieces alone does not describe the pose
// anybody was looking at.
if ( Gun is { } w )
Log.Info( $"[nz-sight] weapon aim placement: Pos = {w.AimAnimData.Pos.x:0.###}f, "
+ $"{w.AimAnimData.Pos.y:0.###}f, {w.AimAnimData.Pos.z:0.###}f "
+ $"Angle = {w.AimAnimData.Angle.pitch:0.###}f, {w.AimAnimData.Angle.yaw:0.###}f, "
+ $"{w.AimAnimData.Angle.roll:0.###}f" );
}
/// <summary>Every sight piece, in the manifest's shape.</summary>
///
/// ⚠️ ALL SIX, NOT ONLY THE ONES THAT MOVED. They are placed as a group — a ring is positioned
/// against the ring in front of it — so a dump of half of them cannot be pasted back as a state.
string Block()
{
var lines = Sight.Select( s =>
$" \"{s.Name}\": {{ \"pos\": [{s.Pos[0]:0.###}, {s.Pos[1]:0.###}, {s.Pos[2]:0.###}], "
+ $"\"ang\": [{s.Ang[0]:0.###}, {s.Ang[1]:0.###}, {s.Ang[2]:0.###}] }}" );
return "{\n" + string.Join( ",\n", lines ) + "\n}";
}
void Close() => Shut();
static void Shut()
{
Visible = false;
Dragging = false;
// ⚠️ THE POSE AND THE HELD-BACK PIECES BOTH BELONG TO THE PANEL, so both go when it does.
// Leaving the weapon stuck at the sights, or leaving half the sight switched off inside the
// part editor, would be this panel editing the game after it closed.
SckPartsRig.AimHold = false;
SckPartsRig.Held.Clear();
SckPartsRig.Ads = false;
SckPartsRig.Active = false;
Mouse.Visibility = MouseVisibility.Hidden;
Log.Info( "[nz-sight] closed — weapon back to the hip" );
}
/// <summary>
/// `nz_sight [0/1]` — the sight editor: opens at the sights, pieces go on one at a time.
/// </summary>
[ConCmd( "nz_sight" )]
public static void SightCmd( int on = -1 )
{
var fresh = EnsureHost();
// ⛔ A PANEL THAT WAS JUST BUILT OPENS, IT DOES NOT TOGGLE — `Visible` is a static and
// survives a hotload while the GameObject holding the panel does not, so the first run after
// one would otherwise build the panel and immediately hide it.
Visible = fresh || (on < 0 ? !Visible : on != 0);
if ( !Visible )
{
Shut();
return;
}
SckPartsRig.Ads = true;
SckPartsRig.AimHold = true;
SckPartsRig.Active = true;
SeedAim();
if ( !_opened )
{
_opened = true;
foreach ( var p in Rig?.Parts?.Where( q => q.AdsOnly ) ?? Enumerable.Empty<SckPart>() )
SckPartsRig.Held.Add( p.Name );
}
Mouse.Visibility = MouseVisibility.Visible;
Log.Info( $"[nz-sight] open — {State}" );
}
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 = "Sight Editor UI";
_host.Flags |= GameObjectFlags.NotSaved;
// ⛔ BELOW THE PART EDITOR (80), NOT ABOVE IT. This panel carries a full-screen drag
// surface that takes the mouse, and above the part editor that surface would swallow
// every click meant for its sliders — with both panels open and only one of them
// working, for no visible reason. They sit in opposite corners, so nothing overlaps.
var screen = _host.Components.Create<ScreenPanel>();
screen.ZIndex = 79;
_host.Components.Create<SightEditor>();
Log.Info( "[nz-sight] created the sight editor's screen panel" );
}
// ⚠️ THE RIG IS THE THING THAT DRAWS THE PIECES, so this panel is useless without one — and
// `nz_sight` should not require remembering to run `nz_parts` first.
if ( !SckPartsRig.Current.IsValid() )
{
_host.Components.Create<SckPartsRig>();
Log.Info( "[nz-sight] created the part rig" );
}
return built;
}
// ⚠️ EVERY DISPLAYED VALUE IS IN THE HASH, or it freezes on screen while the thing behind it
// moves — which reads as a broken panel rather than a stale one.
protected override int BuildHash()
=> HashCode.Combine(
HashCode.Combine( Visible, Selected, CopyLabel, Dragging, AngleMode, Fine, GunTarget ),
HashCode.Combine( PX, PY, PZ ),
HashCode.Combine( AP, AY, AR ),
HashCode.Combine( State, SckPartsRig.Held.Count, Rig?.Parts?.Count ?? 0 ) );
}