A Razor UI panel component for s&box that provides a transform tweaking UI for ITweakTarget objects. It renders tabs for targets, draggable sliders and steppers for local position/rotation/scale, supports copying a bake line to clipboard, exporting scene data, resetting to seed, and toggling visibility via key or convar.
@using Sandbox
@using Sandbox.UI
@using System
@using System.Collections.Generic
@namespace FieldGuide.Placement
@inherits PanelComponent
@attribute [StyleSheet]
@*
A live transform tuner for objects hanging off something else: an accessory on a character mount, a
part on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;
the active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and
uniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.
Each row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a
small +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or
multiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets
character-scale sliders and a scene prop gets scene-scale ones.
Copy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).
Export writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.
Reset restores the transform captured when the target was registered, never zero.
Rows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,
and add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,
never an F key), the 42px x in the header, or the `placement_panel` console convar. Starts closed
unless OpenOnStart is set; see the boot block in OnUpdate.
*@
<root>
@if ( PanelOpen )
{
<div class="tp-card">
<div class="tp-hdr">
<span class="tp-title">TRANSFORM TWEAK</span>
<div class="tp-hr">
<span class="tp-key">P</span>
<div class="tp-x" onclick=@ClosePanel>×</div>
</div>
</div>
@if ( TargetList.Count == 0 )
{
<div class="tp-empty">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>
}
else
{
@* ---- one tab per target ---- *@
<div class="tp-tabs">
@for ( int i = 0; i < TargetList.Count; i++ )
{
var idx = i;
string tab = TargetList[idx].DisplayName;
<div class="tp-tab @(idx == ActiveIndex ? "on" : "")" onclick=@(() => SelectTab( idx ))>@tab</div>
}
</div>
<div class="tp-sub">
<span class="tp-item">@ActiveName</span>
<span class="tp-note">@FrameNote</span>
</div>
@* ---- step-size toggle ---- *@
<div class="tp-steprow">
<span class="tp-sl">Step</span>
<div class="tp-seg @(_step == StepSize.XFine ? "on" : "")" onclick=@(() => _step = StepSize.XFine)>xfine ÷10</div>
<div class="tp-seg @(_step == StepSize.Fine ? "on" : "")" onclick=@(() => _step = StepSize.Fine)>fine</div>
<div class="tp-seg @(_step == StepSize.Coarse ? "on" : "")" onclick=@(() => _step = StepSize.Coarse)>coarse ×10</div>
</div>
@* ---- editable rows (draggable slider + stepper) ---- *@
@foreach ( var r in Rows )
{
var row = r;
float cur = Get( ActiveTarget, row.field );
string val = cur.ToString( row.fmt );
string lab = row.label; // plain local before interpolating: an inline field read can render blank
int fillPct = (int)( Frac( cur, row ) * 100f );
<div class="tp-row @(row.header ? "hdr" : "")">
<div class="tp-rlab">
<span class="tp-rl">@lab</span>
<span class="tp-rv">@val</span>
</div>
<div class="tp-slider">
<span class="tp-stp" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>−</span>
<div class="tp-track"
onmousedown=@(e => TrackPointer( e, row, true ))
onmousemove=@(e => TrackPointer( e, row, false ))>
<div class="tp-fill" style="width: @(fillPct)%;"></div>
</div>
<span class="tp-stp" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>
</div>
</div>
}
@* ---- actions ---- *@
<div class="tp-btns">
<div class="tp-btn" onclick=@ResetActive>Reset</div>
<div class="tp-btn" onclick=@CopyActive>@_copyLabel</div>
</div>
<div class="tp-btns">
<div class="tp-btn wide" onclick=@ExportNow>Export all to Data</div>
</div>
}
</div>
}
</root>
@code
{
// ---- toggle state (P raw key + `placement_panel` convar fallback) ----
static bool _open;
/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>
[ConVar( "placement_panel" )]
public static bool PanelOpen { get => _open; set => _open = value; }
/// <summary>
/// Whether this panel starts open. Off by default: a tuning panel that appears unbidden over a
/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the
/// way the kit's own demo does.
///
/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot
/// block in OnUpdate for why that matters.
/// </summary>
[Property] public bool OpenOnStart { get; set; }
int _activeIndex;
string _copyLabel = "Copy";
bool _wasOpen;
bool _booted;
/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for
/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>
enum StepSize { XFine, Fine, Coarse }
StepSize _step = StepSize.Fine;
IReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();
int ActiveIndex
{
get => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );
set => _activeIndex = value;
}
// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited
// bool Active, and a razor-generated member named Active would hide it (CS0108).
ITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];
/// <summary>The active target's label, resolved to a single-identifier property so the markup never
/// interpolates a chained member read (which renders blank in several razor cases).</summary>
string ActiveName => ActiveTarget?.DisplayName ?? "";
/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one
/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>
string FrameNote
{
get
{
var frame = ActiveTarget?.FrameName;
return string.IsNullOrEmpty( frame ) ? "local offset · edited live" : $"{frame} · edited live";
}
}
void SelectTab( int idx )
{
ActiveIndex = idx;
_copyLabel = "Copy"; // a stale "Copied!" on a different target reads as a lie
}
void ClosePanel()
{
PanelOpen = false;
_copyLabel = "Copy";
}
// ---- field model ----
public enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }
struct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }
/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read
/// rather than held in a static array, because an accessory and a scene prop want very different
/// ranges out of the same three sliders.</summary>
List<Row> Rows
{
get
{
var g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;
float p = MathF.Max( g.PositionRange, 0.001f );
return new List<Row>
{
new Row { field = Field.PosX, label = "Pos X", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosY, label = "Pos Y", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.PosZ, label = "Pos Z", fmt = "0.###", step = g.PositionStep, min = -p, max = p },
new Row { field = Field.Pitch, label = "Pitch °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f, header = true },
new Row { field = Field.Yaw, label = "Yaw °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Roll, label = "Roll °", fmt = "0.#", step = g.RotationStep, min = -180f, max = 180f },
new Row { field = Field.Scale, label = "Scale", fmt = "0.###", step = g.ScaleStep, min = g.ScaleMin, max = g.ScaleMax, header = true },
};
}
}
float StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };
static float Frac( float value, Row row )
=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );
// ---- read/write the active target's LOCAL transform ----
static float Get( ITweakTarget t, Field f )
{
var go = t?.Target;
if ( go is null || !go.IsValid() ) return 0f;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
return f switch
{
Field.PosX => p.x,
Field.PosY => p.y,
Field.PosZ => p.z,
Field.Pitch => a.pitch,
Field.Yaw => a.yaw,
Field.Roll => a.roll,
Field.Scale => go.LocalScale.x,
_ => 0f,
};
}
void Nudge( Field f, float delta )
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
var p = go.LocalPosition;
var a = go.LocalRotation.Angles();
switch ( f )
{
case Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;
case Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;
case Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;
case Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;
case Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;
case Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;
case Field.Scale:
float s = MathF.Max( 0.01f, go.LocalScale.x + delta );
go.LocalScale = new Vector3( s, s, s );
break;
}
}
/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove
/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same
/// write path runs whether you drag or step.</summary>
void TrackPointer( PanelEvent ev, Row row, bool jump )
{
if ( ev is not MousePanelEvent e ) return;
var track = e.This;
if ( track is null ) return;
if ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;
float w = track.Box.Rect.Width;
if ( w <= 0f ) return;
float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
float target = row.min + frac * (row.max - row.min);
if ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;
target = Math.Clamp( target, row.min, row.max );
Nudge( row.field, target - Get( ActiveTarget, row.field ) );
}
/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted
/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>
void ResetActive()
{
var go = ActiveTarget?.Target;
if ( go is null || !go.IsValid() ) return;
if ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;
// Never registered here (a hand-built target list, say): identity is the only baseline we have.
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
go.LocalScale = Vector3.One;
}
/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side
/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>
void CopyActive()
{
var t = ActiveTarget;
if ( t is null ) return;
var line = PlacementExport.BakeLine( t );
if ( string.IsNullOrEmpty( line ) ) return;
Sandbox.UI.Clipboard.SetText( line );
_copyLabel = "Copied!";
}
void ExportNow()
{
if ( Scene is not null )
PlacementExport.WriteAll( Scene );
}
// ---- boot state, P toggle, cursor while open ----
protected override void OnUpdate()
{
// BOOT. `placement_panel` is a convar and s&box PERSISTS convars across sessions, so a session can
// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule
// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value
// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene
// that wants the panel up says so explicitly.
//
// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the
// component that created it, and doing this in OnStart would race that assignment: whichever ran
// first would win. The first update is after every OnStart in the frame, so the setting is always
// read, never half-applied.
if ( !_booted )
{
_booted = true;
if ( PanelOpen && !OpenOnStart )
Log.Info( "[placement] tweak panel was OPEN at session start (persisted convar), forcing closed" );
PanelOpen = OpenOnStart;
}
if ( Input.Keyboard.Pressed( "P" ) )
PanelOpen = !PanelOpen;
if ( PanelOpen )
{
Mouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel
_wasOpen = true;
}
else if ( _wasOpen )
{
_wasOpen = false;
_copyLabel = "Copy"; // closing clears the flash, so a reopen never claims a copy that was not made
}
}
// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so
// readouts update the instant a value is nudged. Miss one and the number freezes on screen.
protected override int BuildHash()
{
int h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );
var a = ActiveTarget;
if ( a is not null )
{
h = HashCode.Combine( h, a.FrameName );
foreach ( var r in Rows )
h = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );
}
return h;
}
}