🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=fieldguide.placement&take=20
Showing code results for query:
*
(38 total matches found)
Game
library
using Sandbox;
namespace FieldGuide.Placement;
/// <summary>
/// A tweak target whose frame label comes from a live <see cref="CharacterAttachPoint"/> rather than a
/// string fixed at registration. Use this for anything hanging off a character.
///
/// Why it exists: an attach point resolves LATE (the model has to pose before its attachment objects
/// and bones can be read), so a frame string captured at registration would say "citizen/root" forever
/// even after the hand attachment came good. Reading it through the mount means the panel sub-line, the
/// export, and the bake comment all name the frame the accessory actually ended up on.
/// </summary>
public sealed class AttachedTweakTarget : ITweakTarget
{
public string DisplayName { get; set; }
/// <summary>The accessory whose LOCAL transform the panel edits. Parent it under
/// <see cref="Mount"/> before registering, or its offsets mean nothing.</summary>
public GameObject Target { get; set; }
/// <summary>The mount the offset is measured from. Its resolved frame is read live.</summary>
public CharacterAttachPoint Mount { get; set; }
/// <summary>The id the bake line names. Leave null to derive it from <see cref="DisplayName"/>.</summary>
public string Symbol { get; set; }
/// <summary>Slider bounds and steps. Accessory scale by default, which is the point of this type.</summary>
public TweakRanges Ranges { get; set; } = TweakRanges.Accessory;
string ITweakTarget.FrameName => Mount.IsValid() ? Mount.FrameName : BakeFormat.LocalFrame;
string ITweakTarget.BakeSymbol => string.IsNullOrEmpty( Symbol ) ? BakeFormat.SymbolFrom( DisplayName ) : Symbol;
TweakRanges ITweakTarget.Ranges => Ranges ?? TweakRanges.Accessory;
public AttachedTweakTarget( GameObject accessory, CharacterAttachPoint mount, string name = null )
{
Target = accessory;
Mount = mount;
DisplayName = string.IsNullOrEmpty( name ) ? (accessory?.Name ?? "Accessory") : name;
}
}
Game
library
using Sandbox;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// Wires the demo scene in code so the whole kit is exercised from one component.
///
/// The scene it builds is the kit's hero case: a dressed stock citizen standing in the middle, with
/// three accessories parented to mounts on its skeleton (a tool in the right hand, a hat on the head, a
/// pack on the spine). Each is registered with the <see cref="TweakSession"/>, so pressing P gives you
/// three tabs of sliders that move the accessory RELATIVE to the bone it hangs from, and Copy hands you
/// the paste-ready offset for your own game code. Everything ships with the engine: the citizen model,
/// its clothing, and the dev primitives standing in for your accessories.
///
/// TWO-LEVEL ACCESSORY SHAPE. Each accessory is a bare root GameObject holding one or more child parts
/// that carry the models. The split is load-bearing: the tweak panel's Scale row writes a UNIFORM
/// LocalScale on the root, so any non-uniform proportions put there would be flattened the first time
/// someone touched the slider. Proportions live on the children, authored directly in engine units; the
/// root's scale stays a clean multiplier that reads 1 at the authored size.
///
/// The ghost-placement flow is still here as the second beat: press B and drop boxes on the ground, and
/// they export as world-space placements alongside the accessory offsets.
///
/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
/// </summary>
[Title( "Placement Demo Bootstrap" )]
[Category( "Field Guide · Placement" )]
[Icon( "auto_awesome" )]
public sealed class DemoBootstrap : Component
{
/// <summary>Radius (engine units) inside which the demo validity seam reports a spot as valid, to show
/// the ghost's green/red tint switching. Set to 0 or negative to allow placement anywhere.</summary>
[Property] public float DemoValidRadius { get; set; } = 512f;
/// <summary>Yaw the demo citizen faces. 225 puts its front toward the orbit camera's starting angle.</summary>
[Property] public float CitizenYaw { get; set; } = 225f;
const string CitizenModel = "models/citizen/citizen.vmdl";
const string FallbackMaterial = "materials/default.vmat";
/// <summary>A plain outfit from the shipped citizen clothing resources. Each item is null-checked, so a
/// missing asset degrades to a barer citizen rather than a broken spawn.</summary>
static readonly string[] Outfit =
{
"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing",
"models/citizen_clothes/shoes/Trainers/trainers.clothing",
};
/// <summary>Accessories waiting for their mount to resolve before their starting transform is applied.
/// See <see cref="SeedPending"/>.</summary>
readonly List<Pending> _pending = new();
/// <summary>One accessory's starting transform, with two paths. <see cref="TunedFrame"/> plus
/// <see cref="TunedPosition"/> are the values the owner dialled in on the stock citizen and are applied
/// verbatim when the mount resolves to exactly that bone. <see cref="BodySeed"/> is the generic
/// fallback for every other outcome, because bone-local numbers mean nothing on a rig they were not
/// measured on.</summary>
readonly record struct Pending(
GameObject Accessory,
CharacterAttachPoint Mount,
string TunedFrame,
Vector3 TunedPosition,
Angles TunedAngles,
float TunedScale,
Vector3 BodySeed );
/// <summary>One primitive making up an accessory's silhouette. <see cref="SizeUnits"/> is the part's
/// intended size in ENGINE UNITS per axis; the builder divides it by the model's own bounds to get the
/// scale, so the numbers below read as real dimensions rather than model-relative multipliers.</summary>
readonly record struct Part( string ModelPath, Vector3 SizeUnits, Vector3 LocalPosition );
GameObject _citizen;
protected override void OnStart()
{
BuildCatalog();
_citizen = BuildCitizen();
BuildAccessories( _citizen );
BuildUi();
Log.Info( "[placement] demo ready. The tweak panel is open, P toggles it, right mouse orbits, B enters ghost placement." );
}
protected override void OnUpdate() => SeedPending();
// ---- the ghost-placement catalog (the second beat) ----
void BuildCatalog()
{
var cat = PlacementCatalog.Instance ?? Components.GetOrCreate<PlacementCatalog>();
cat.Entries.Clear();
cat.Entries.Add( new PlaceableEntry( "box", "Box", "models/dev/box.vmdl" ) );
cat.Entries.Add( new PlaceableEntry( "sphere", "Sphere", "models/dev/sphere.vmdl" ) );
cat.Entries.Add( new PlaceableEntry( "plane", "Plane", "models/dev/plane.vmdl" ) );
// Demo validity seam: only allow placement within DemoValidRadius of the origin (illustrates the
// green/red ghost tint). Replace or clear this in your own project.
if ( DemoValidRadius > 0f )
cat.ValidityCheck = ( pos, rot ) => pos.WithZ( 0f ).Length <= DemoValidRadius;
}
// ---- the character ----
GameObject BuildCitizen()
{
var go = Scene.CreateObject();
go.Name = "Citizen";
go.WorldPosition = Vector3.Zero;
go.WorldRotation = Rotation.FromYaw( CitizenYaw );
var renderer = go.Components.Create<SkinnedModelRenderer>();
var model = Model.Load( CitizenModel );
if ( model is null || model.IsError )
{
Log.Warning( $"[placement] demo citizen model '{CitizenModel}' did not load; the mounts will fall back to the root." );
return go;
}
renderer.Model = model;
Dress( renderer );
// Standing idle straight off the citizen animgraph: grounded with no move input is its rest state,
// which breathes and shifts weight a little. That subtle motion is the point here, because an
// accessory that only looks right on a frozen T-pose is not actually fitted.
renderer.Set( "b_grounded", true );
// holdtype 6 is Swing on the citizen animgraph: a one-handed closed fist, so the hand actually grips
// the tool instead of leaving a flat open palm under it. holdtype_handedness 1 is the right hand.
renderer.Set( "holdtype", 6 );
renderer.Set( "holdtype_handedness", 1 );
return go;
}
static void Dress( SkinnedModelRenderer renderer )
{
var outfit = new ClothingContainer();
bool any = false;
foreach ( var path in Outfit )
{
var item = ResourceLibrary.Get<Clothing>( path );
if ( item is null )
{
Log.Warning( $"[placement] demo clothing '{path}' did not resolve, skipping that slot." );
continue;
}
outfit.Add( item );
any = true;
}
if ( any ) outfit.Apply( renderer );
}
// ---- the three accessories ----
void BuildAccessories( GameObject citizen )
{
var session = TweakSession.Instance ?? Components.GetOrCreate<TweakSession>();
// OBSERVED ON THE SHIPPED CITIZEN (2026-07-30): none of the attachment names below resolved, and all
// three mounts landed on their bone backstop (hand_R, head, spine_2). The attachment candidates stay
// in the list because they are correct on rigs that do expose them and they exercise the
// attachment-first path, but the bones are what actually carries this demo.
// Right hand. hold_R is the usual weapon-hold attachment name; the casing varies between models.
var hand = BuildMount( citizen, "Hand Mount",
attachments: new List<string> { "hold_R", "hold_r" },
bones: new List<string> { "hand_R", "hand_r" } );
// Head. "hat" is the usual head-top attachment point; the head bone is the backstop that resolves here.
var head = BuildMount( citizen, "Head Mount",
attachments: new List<string> { "hat", "head" },
bones: new List<string> { "head" } );
// Upper spine. No attachment here, so this one exercises the bone path: the mount is re-pinned to
// spine_2 every frame, which is what makes a back-worn item bob with the animation instead of
// sliding along the root.
var back = BuildMount( citizen, "Back Mount",
attachments: new List<string>(),
bones: new List<string> { "spine_2", "spine_1", "spine_0", "spine" } );
// SILHOUETTES, and the axis they are built along. All three tuned offsets came back with their large
// component on local X (hand +5.5, head +15, spine +1.6 with the big move on Y), which is the bone
// chain running +X down its length: out past the fingers, up out of the skull, up the spine. So each
// accessory is authored with its long axis on X. If a rig ever disagrees, the panel's rotation rows
// fix it in one drag; nothing here depends on the guess being right.
//
// SIZES are the ones the owner settled on, in engine units on the longest axis: tool 5, hat 7,
// pack 14. See BuildPart for why they are written as dimensions rather than scale factors.
// Tool: a handle with a heavier head on the end, reading along +X (out past the fingers).
Register( session, hand, "Hand Tool",
tunedFrame: "citizen/hand_R",
tunedPosition: new Vector3( 4.25f, 0.5f, -2.5f ),
tunedAngles: new Angles( 5f, 91f, 5f ),
tunedScale: 1.9f,
bodySeed: new Vector3( 5f, 0f, -3f ),
tint: new Color( 1f, 0.62f, 0.25f ),
parts: new[]
{
new Part( "models/dev/box.vmdl", new Vector3( 3.4f, 1f, 1f ), new Vector3( -0.8f, 0f, 0f ) ),
new Part( "models/dev/box.vmdl", new Vector3( 1.6f, 1.7f, 1.7f ), new Vector3( 1.7f, 0f, 0f ) ),
} );
// Hat: a flat brim disk with a dome crown above it. No cylinder ships in models/dev, so both are
// squashed spheres; the brim is thin on X (the up-out-of-the-skull axis) and round across Y and Z.
Register( session, head, "Hat",
tunedFrame: "citizen/head",
tunedPosition: new Vector3( 15f, 1.901744f, 0.000369f ),
tunedAngles: new Angles( 0f, 0f, 0f ),
tunedScale: 1f,
bodySeed: new Vector3( 2f, 0f, 6f ),
tint: new Color( 0.42f, 0.86f, 1f ),
parts: new[]
{
new Part( "models/dev/sphere.vmdl", new Vector3( 1.6f, 7f, 7f ), new Vector3( 0f, 0f, 0f ) ),
new Part( "models/dev/sphere.vmdl", new Vector3( 2.6f, 4.2f, 4.2f ), new Vector3( 2f, 0f, 0f ) ),
} );
// Pack: the box was close, so this is only a proportion change, taller up the spine than it is deep
// off the back. Kept chunky rather than slab-thin so it still reads as a pack under any axis order.
Register( session, back, "Back Pack",
tunedFrame: "citizen/spine_2",
tunedPosition: new Vector3( 1.625522f, -9.075111f, -0.00035f ),
tunedAngles: new Angles( 0f, 0f, 0f ),
tunedScale: 1f,
bodySeed: new Vector3( -9f, 0f, 2f ),
tint: new Color( 0.85f, 0.45f, 0.95f ),
parts: new[]
{
new Part( "models/dev/box.vmdl", new Vector3( 14f, 7f, 11f ), new Vector3( 0f, 0f, 0f ) ),
} );
}
CharacterAttachPoint BuildMount( GameObject citizen, string name, List<string> attachments, List<string> bones )
{
var go = Scene.CreateObject();
go.Name = name;
go.SetParent( citizen, false );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
var mount = go.Components.Create<CharacterAttachPoint>();
mount.AttachmentNames = attachments;
mount.BoneNames = bones;
mount.CharacterName = "citizen";
return mount;
}
/// <summary>
/// Build one accessory under a mount and register it for tweaking. The root is a bare GameObject: it
/// owns the offset the panel edits and a uniform scale that starts at 1, and nothing else. The parts
/// hang under it carrying the models and the proportions.
///
/// The starting transform is applied later, once the mount resolves. See <see cref="SeedPending"/>.
/// </summary>
void Register( TweakSession session, CharacterAttachPoint mount, string label,
string tunedFrame, Vector3 tunedPosition, Angles tunedAngles, float tunedScale, Vector3 bodySeed,
Color tint, Part[] parts )
{
var go = Scene.CreateObject();
go.Name = label;
go.SetParent( mount.GameObject, false );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
go.LocalScale = Vector3.One; // the panel's Scale row is a multiplier on the authored size, so 1 is "as built"
for ( int i = 0; i < parts.Length; i++ )
BuildPart( go, $"{label} part {i + 1}", parts[i], tint );
session.Add( new AttachedTweakTarget( go, mount, label ) );
_pending.Add( new Pending( go, mount, tunedFrame, tunedPosition, tunedAngles, tunedScale, bodySeed ) );
}
/// <summary>
/// One primitive under an accessory root, sized in engine units.
///
/// The scale is the requested size divided by the model's OWN bounds, per axis, rather than a
/// hand-tuned multiplier. That keeps the part table readable as real dimensions and survives the engine
/// changing what a dev primitive measures: the shipped box is 50 units and the sphere is 64, and
/// nothing here has to know that.
/// </summary>
void BuildPart( GameObject parent, string name, Part part, Color tint )
{
var go = Scene.CreateObject();
go.Name = name;
go.SetParent( parent, false );
go.LocalPosition = part.LocalPosition;
go.LocalRotation = Rotation.Identity;
var renderer = go.Components.Create<ModelRenderer>();
var model = Model.Load( part.ModelPath );
if ( model is null || model.IsError )
{
Log.Warning( $"[placement] demo part model '{part.ModelPath}' did not load; '{name}' will be invisible." );
return;
}
renderer.Model = model;
var bounds = model.Bounds.Size;
go.LocalScale = new Vector3(
bounds.x > 0.001f ? part.SizeUnits.x / bounds.x : 1f,
bounds.y > 0.001f ? part.SizeUnits.y / bounds.y : 1f,
bounds.z > 0.001f ? part.SizeUnits.z / bounds.z : 1f );
// The engine's models/dev primitives render as missing-material magenta unless a real material is
// forced on, which would swallow the tint that tells the three accessories apart.
var mat = Material.Load( FallbackMaterial );
if ( mat is not null ) renderer.MaterialOverride = mat;
renderer.Tint = tint;
}
/// <summary>
/// Apply each accessory's starting transform once its mount has resolved, then re-baseline it as the
/// authored default so the panel's Reset comes back here.
///
/// Two paths, and which one runs depends on what the rig gave us:
///
/// - The mount landed on exactly the bone the demo values were measured against: apply them verbatim.
/// These are real numbers, dialled in on the stock citizen, so the demo opens already fitted and the
/// first thing you see is the finished result rather than three primitives in a heap.
/// - Anything else, including the character-root fallback and any renamed or substituted bone: fall
/// back to a seed expressed in the CHARACTER's frame (x forward, y left, z up) and let the engine
/// convert it to local. Bone-local numbers are only valid on the rig they were measured on; reusing
/// them elsewhere buries an accessory in the chest on one rig and flings it into orbit on the next.
/// The character frame is not accurate, but it is always visible, which is all a fallback owes you.
///
/// The frame test requires a BONE mount, not just a matching name. An attachment that happens to be
/// called "head" is a different transform from the head bone, and the tuned numbers would be wrong on it.
/// </summary>
void SeedPending()
{
if ( _pending.Count == 0 ) return;
var session = TweakSession.Instance;
var bodyRot = _citizen.IsValid() ? _citizen.WorldRotation : Rotation.Identity;
for ( int i = _pending.Count - 1; i >= 0; i-- )
{
var p = _pending[i];
if ( !p.Accessory.IsValid() || !p.Mount.IsValid() ) { _pending.RemoveAt( i ); continue; }
if ( p.Mount.Kind == CharacterAttachPoint.MountKind.Unresolved ) continue;
bool tuned = p.Mount.Kind == CharacterAttachPoint.MountKind.Bone
&& p.Mount.FrameName == p.TunedFrame;
if ( tuned )
{
p.Accessory.LocalPosition = p.TunedPosition;
p.Accessory.LocalRotation = p.TunedAngles.ToRotation();
p.Accessory.LocalScale = new Vector3( p.TunedScale, p.TunedScale, p.TunedScale );
Log.Info( $"[placement] '{p.Accessory.Name}' seated at the tuned offset for {p.TunedFrame}" );
}
else
{
p.Accessory.WorldPosition = p.Mount.WorldPosition + bodyRot * p.BodySeed;
Log.Info( $"[placement] '{p.Accessory.Name}' mounted on {p.Mount.FrameName}, not the tuned "
+ $"{p.TunedFrame}; using the generic character-frame seed instead. Fit it with P." );
}
session?.CaptureSeed( p.Accessory );
_pending.RemoveAt( i );
}
}
// ---- screen UI ----
void BuildUi()
{
// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene
// needs no razor wiring.
var panelHost = Scene.CreateObject();
panelHost.Name = "Placement UI";
panelHost.Components.Create<ScreenPanel>();
// Open on arrival. Fitting the accessories is what this scene is FOR, so making the visitor find the
// key first is a toll booth on the way to the point. P and the header x still close it. The panel
// reads this on its first update, after every OnStart in the frame, so setting it here always lands.
var panel = panelHost.Components.Create<TweakPanel>();
panel.OpenOnStart = true;
var hintHost = Scene.CreateObject();
hintHost.Name = "Placement Hint";
hintHost.Components.Create<ScreenPanel>();
hintHost.Components.Create<DemoHintCard>();
}
}
Game
library
using Sandbox;
namespace FieldGuide.Placement;
/// <summary>
/// Tags a placed object with the catalog id it came from, so <see cref="PlacementExport"/> can collect
/// every placed object in the scene and record which entry produced it. <see cref="GhostPlacer"/> adds
/// this on place; you can also add it by hand (or in a prefab) to make an object show up in exports.
/// </summary>
[Title( "Placed Instance" )]
[Category( "Field Guide · Placement" )]
[Icon( "place" )]
public sealed class PlacedInstance : Component
{
/// <summary>The <see cref="PlaceableEntry.Id"/> this object was placed from.</summary>
[Property] public string CatalogId { get; set; }
}
Game
library
using Sandbox;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// Wires the demo scene in code so the whole kit is exercised from one component.
///
/// The scene it builds is the kit's hero case: a dressed stock citizen standing in the middle, with
/// three accessories parented to mounts on its skeleton (a tool in the right hand, a hat on the head, a
/// pack on the spine). Each is registered with the <see cref="TweakSession"/>, so pressing P gives you
/// three tabs of sliders that move the accessory RELATIVE to the bone it hangs from, and Copy hands you
/// the paste-ready offset for your own game code. Everything ships with the engine: the citizen model,
/// its clothing, and the dev primitives standing in for your accessories.
///
/// TWO-LEVEL ACCESSORY SHAPE. Each accessory is a bare root GameObject holding one or more child parts
/// that carry the models. The split is load-bearing: the tweak panel's Scale row writes a UNIFORM
/// LocalScale on the root, so any non-uniform proportions put there would be flattened the first time
/// someone touched the slider. Proportions live on the children, authored directly in engine units; the
/// root's scale stays a clean multiplier that reads 1 at the authored size.
///
/// The ghost-placement flow is still here as the second beat: press B and drop boxes on the ground, and
/// they export as world-space placements alongside the accessory offsets.
///
/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
/// </summary>
[Title( "Placement Demo Bootstrap" )]
[Category( "Field Guide · Placement" )]
[Icon( "auto_awesome" )]
public sealed class DemoBootstrap : Component
{
/// <summary>Radius (engine units) inside which the demo validity seam reports a spot as valid, to show
/// the ghost's green/red tint switching. Set to 0 or negative to allow placement anywhere.</summary>
[Property] public float DemoValidRadius { get; set; } = 512f;
/// <summary>Yaw the demo citizen faces. 225 puts its front toward the orbit camera's starting angle.</summary>
[Property] public float CitizenYaw { get; set; } = 225f;
const string CitizenModel = "models/citizen/citizen.vmdl";
const string FallbackMaterial = "materials/default.vmat";
/// <summary>A plain outfit from the shipped citizen clothing resources. Each item is null-checked, so a
/// missing asset degrades to a barer citizen rather than a broken spawn.</summary>
static readonly string[] Outfit =
{
"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing",
"models/citizen_clothes/shoes/Trainers/trainers.clothing",
};
/// <summary>Accessories waiting for their mount to resolve before their starting transform is applied.
/// See <see cref="SeedPending"/>.</summary>
readonly List<Pending> _pending = new();
/// <summary>One accessory's starting transform, with two paths. <see cref="TunedFrame"/> plus
/// <see cref="TunedPosition"/> are the values the owner dialled in on the stock citizen and are applied
/// verbatim when the mount resolves to exactly that bone. <see cref="BodySeed"/> is the generic
/// fallback for every other outcome, because bone-local numbers mean nothing on a rig they were not
/// measured on.</summary>
readonly record struct Pending(
GameObject Accessory,
CharacterAttachPoint Mount,
string TunedFrame,
Vector3 TunedPosition,
Angles TunedAngles,
float TunedScale,
Vector3 BodySeed );
/// <summary>One primitive making up an accessory's silhouette. <see cref="SizeUnits"/> is the part's
/// intended size in ENGINE UNITS per axis; the builder divides it by the model's own bounds to get the
/// scale, so the numbers below read as real dimensions rather than model-relative multipliers.</summary>
readonly record struct Part( string ModelPath, Vector3 SizeUnits, Vector3 LocalPosition );
GameObject _citizen;
protected override void OnStart()
{
BuildCatalog();
_citizen = BuildCitizen();
BuildAccessories( _citizen );
BuildUi();
Log.Info( "[placement] demo ready. The tweak panel is open, P toggles it, right mouse orbits, B enters ghost placement." );
}
protected override void OnUpdate() => SeedPending();
// ---- the ghost-placement catalog (the second beat) ----
void BuildCatalog()
{
var cat = PlacementCatalog.Instance ?? Components.GetOrCreate<PlacementCatalog>();
cat.Entries.Clear();
cat.Entries.Add( new PlaceableEntry( "box", "Box", "models/dev/box.vmdl" ) );
cat.Entries.Add( new PlaceableEntry( "sphere", "Sphere", "models/dev/sphere.vmdl" ) );
cat.Entries.Add( new PlaceableEntry( "plane", "Plane", "models/dev/plane.vmdl" ) );
// Demo validity seam: only allow placement within DemoValidRadius of the origin (illustrates the
// green/red ghost tint). Replace or clear this in your own project.
if ( DemoValidRadius > 0f )
cat.ValidityCheck = ( pos, rot ) => pos.WithZ( 0f ).Length <= DemoValidRadius;
}
// ---- the character ----
GameObject BuildCitizen()
{
var go = Scene.CreateObject();
go.Name = "Citizen";
go.WorldPosition = Vector3.Zero;
go.WorldRotation = Rotation.FromYaw( CitizenYaw );
var renderer = go.Components.Create<SkinnedModelRenderer>();
var model = Model.Load( CitizenModel );
if ( model is null || model.IsError )
{
Log.Warning( $"[placement] demo citizen model '{CitizenModel}' did not load; the mounts will fall back to the root." );
return go;
}
renderer.Model = model;
Dress( renderer );
// Standing idle straight off the citizen animgraph: grounded with no move input is its rest state,
// which breathes and shifts weight a little. That subtle motion is the point here, because an
// accessory that only looks right on a frozen T-pose is not actually fitted.
renderer.Set( "b_grounded", true );
// holdtype 6 is Swing on the citizen animgraph: a one-handed closed fist, so the hand actually grips
// the tool instead of leaving a flat open palm under it. holdtype_handedness 1 is the right hand.
renderer.Set( "holdtype", 6 );
renderer.Set( "holdtype_handedness", 1 );
return go;
}
static void Dress( SkinnedModelRenderer renderer )
{
var outfit = new ClothingContainer();
bool any = false;
foreach ( var path in Outfit )
{
var item = ResourceLibrary.Get<Clothing>( path );
if ( item is null )
{
Log.Warning( $"[placement] demo clothing '{path}' did not resolve, skipping that slot." );
continue;
}
outfit.Add( item );
any = true;
}
if ( any ) outfit.Apply( renderer );
}
// ---- the three accessories ----
void BuildAccessories( GameObject citizen )
{
var session = TweakSession.Instance ?? Components.GetOrCreate<TweakSession>();
// OBSERVED ON THE SHIPPED CITIZEN (2026-07-30): none of the attachment names below resolved, and all
// three mounts landed on their bone backstop (hand_R, head, spine_2). The attachment candidates stay
// in the list because they are correct on rigs that do expose them and they exercise the
// attachment-first path, but the bones are what actually carries this demo.
// Right hand. hold_R is the usual weapon-hold attachment name; the casing varies between models.
var hand = BuildMount( citizen, "Hand Mount",
attachments: new List<string> { "hold_R", "hold_r" },
bones: new List<string> { "hand_R", "hand_r" } );
// Head. "hat" is the usual head-top attachment point; the head bone is the backstop that resolves here.
var head = BuildMount( citizen, "Head Mount",
attachments: new List<string> { "hat", "head" },
bones: new List<string> { "head" } );
// Upper spine. No attachment here, so this one exercises the bone path: the mount is re-pinned to
// spine_2 every frame, which is what makes a back-worn item bob with the animation instead of
// sliding along the root.
var back = BuildMount( citizen, "Back Mount",
attachments: new List<string>(),
bones: new List<string> { "spine_2", "spine_1", "spine_0", "spine" } );
// SILHOUETTES, and the axis they are built along. All three tuned offsets came back with their large
// component on local X (hand +5.5, head +15, spine +1.6 with the big move on Y), which is the bone
// chain running +X down its length: out past the fingers, up out of the skull, up the spine. So each
// accessory is authored with its long axis on X. If a rig ever disagrees, the panel's rotation rows
// fix it in one drag; nothing here depends on the guess being right.
//
// SIZES are the ones the owner settled on, in engine units on the longest axis: tool 5, hat 7,
// pack 14. See BuildPart for why they are written as dimensions rather than scale factors.
// Tool: a handle with a heavier head on the end, reading along +X (out past the fingers).
Register( session, hand, "Hand Tool",
tunedFrame: "citizen/hand_R",
tunedPosition: new Vector3( 4.25f, 0.5f, -2.5f ),
tunedAngles: new Angles( 5f, 91f, 5f ),
tunedScale: 1.9f,
bodySeed: new Vector3( 5f, 0f, -3f ),
tint: new Color( 1f, 0.62f, 0.25f ),
parts: new[]
{
new Part( "models/dev/box.vmdl", new Vector3( 3.4f, 1f, 1f ), new Vector3( -0.8f, 0f, 0f ) ),
new Part( "models/dev/box.vmdl", new Vector3( 1.6f, 1.7f, 1.7f ), new Vector3( 1.7f, 0f, 0f ) ),
} );
// Hat: a flat brim disk with a dome crown above it. No cylinder ships in models/dev, so both are
// squashed spheres; the brim is thin on X (the up-out-of-the-skull axis) and round across Y and Z.
Register( session, head, "Hat",
tunedFrame: "citizen/head",
tunedPosition: new Vector3( 15f, 1.901744f, 0.000369f ),
tunedAngles: new Angles( 0f, 0f, 0f ),
tunedScale: 1f,
bodySeed: new Vector3( 2f, 0f, 6f ),
tint: new Color( 0.42f, 0.86f, 1f ),
parts: new[]
{
new Part( "models/dev/sphere.vmdl", new Vector3( 1.6f, 7f, 7f ), new Vector3( 0f, 0f, 0f ) ),
new Part( "models/dev/sphere.vmdl", new Vector3( 2.6f, 4.2f, 4.2f ), new Vector3( 2f, 0f, 0f ) ),
} );
// Pack: the box was close, so this is only a proportion change, taller up the spine than it is deep
// off the back. Kept chunky rather than slab-thin so it still reads as a pack under any axis order.
Register( session, back, "Back Pack",
tunedFrame: "citizen/spine_2",
tunedPosition: new Vector3( 1.625522f, -9.075111f, -0.00035f ),
tunedAngles: new Angles( 0f, 0f, 0f ),
tunedScale: 1f,
bodySeed: new Vector3( -9f, 0f, 2f ),
tint: new Color( 0.85f, 0.45f, 0.95f ),
parts: new[]
{
new Part( "models/dev/box.vmdl", new Vector3( 14f, 7f, 11f ), new Vector3( 0f, 0f, 0f ) ),
} );
}
CharacterAttachPoint BuildMount( GameObject citizen, string name, List<string> attachments, List<string> bones )
{
var go = Scene.CreateObject();
go.Name = name;
go.SetParent( citizen, false );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
var mount = go.Components.Create<CharacterAttachPoint>();
mount.AttachmentNames = attachments;
mount.BoneNames = bones;
mount.CharacterName = "citizen";
return mount;
}
/// <summary>
/// Build one accessory under a mount and register it for tweaking. The root is a bare GameObject: it
/// owns the offset the panel edits and a uniform scale that starts at 1, and nothing else. The parts
/// hang under it carrying the models and the proportions.
///
/// The starting transform is applied later, once the mount resolves. See <see cref="SeedPending"/>.
/// </summary>
void Register( TweakSession session, CharacterAttachPoint mount, string label,
string tunedFrame, Vector3 tunedPosition, Angles tunedAngles, float tunedScale, Vector3 bodySeed,
Color tint, Part[] parts )
{
var go = Scene.CreateObject();
go.Name = label;
go.SetParent( mount.GameObject, false );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
go.LocalScale = Vector3.One; // the panel's Scale row is a multiplier on the authored size, so 1 is "as built"
for ( int i = 0; i < parts.Length; i++ )
BuildPart( go, $"{label} part {i + 1}", parts[i], tint );
session.Add( new AttachedTweakTarget( go, mount, label ) );
_pending.Add( new Pending( go, mount, tunedFrame, tunedPosition, tunedAngles, tunedScale, bodySeed ) );
}
/// <summary>
/// One primitive under an accessory root, sized in engine units.
///
/// The scale is the requested size divided by the model's OWN bounds, per axis, rather than a
/// hand-tuned multiplier. That keeps the part table readable as real dimensions and survives the engine
/// changing what a dev primitive measures: the shipped box is 50 units and the sphere is 64, and
/// nothing here has to know that.
/// </summary>
void BuildPart( GameObject parent, string name, Part part, Color tint )
{
var go = Scene.CreateObject();
go.Name = name;
go.SetParent( parent, false );
go.LocalPosition = part.LocalPosition;
go.LocalRotation = Rotation.Identity;
var renderer = go.Components.Create<ModelRenderer>();
var model = Model.Load( part.ModelPath );
if ( model is null || model.IsError )
{
Log.Warning( $"[placement] demo part model '{part.ModelPath}' did not load; '{name}' will be invisible." );
return;
}
renderer.Model = model;
var bounds = model.Bounds.Size;
go.LocalScale = new Vector3(
bounds.x > 0.001f ? part.SizeUnits.x / bounds.x : 1f,
bounds.y > 0.001f ? part.SizeUnits.y / bounds.y : 1f,
bounds.z > 0.001f ? part.SizeUnits.z / bounds.z : 1f );
// The engine's models/dev primitives render as missing-material magenta unless a real material is
// forced on, which would swallow the tint that tells the three accessories apart.
var mat = Material.Load( FallbackMaterial );
if ( mat is not null ) renderer.MaterialOverride = mat;
renderer.Tint = tint;
}
/// <summary>
/// Apply each accessory's starting transform once its mount has resolved, then re-baseline it as the
/// authored default so the panel's Reset comes back here.
///
/// Two paths, and which one runs depends on what the rig gave us:
///
/// - The mount landed on exactly the bone the demo values were measured against: apply them verbatim.
/// These are real numbers, dialled in on the stock citizen, so the demo opens already fitted and the
/// first thing you see is the finished result rather than three primitives in a heap.
/// - Anything else, including the character-root fallback and any renamed or substituted bone: fall
/// back to a seed expressed in the CHARACTER's frame (x forward, y left, z up) and let the engine
/// convert it to local. Bone-local numbers are only valid on the rig they were measured on; reusing
/// them elsewhere buries an accessory in the chest on one rig and flings it into orbit on the next.
/// The character frame is not accurate, but it is always visible, which is all a fallback owes you.
///
/// The frame test requires a BONE mount, not just a matching name. An attachment that happens to be
/// called "head" is a different transform from the head bone, and the tuned numbers would be wrong on it.
/// </summary>
void SeedPending()
{
if ( _pending.Count == 0 ) return;
var session = TweakSession.Instance;
var bodyRot = _citizen.IsValid() ? _citizen.WorldRotation : Rotation.Identity;
for ( int i = _pending.Count - 1; i >= 0; i-- )
{
var p = _pending[i];
if ( !p.Accessory.IsValid() || !p.Mount.IsValid() ) { _pending.RemoveAt( i ); continue; }
if ( p.Mount.Kind == CharacterAttachPoint.MountKind.Unresolved ) continue;
bool tuned = p.Mount.Kind == CharacterAttachPoint.MountKind.Bone
&& p.Mount.FrameName == p.TunedFrame;
if ( tuned )
{
p.Accessory.LocalPosition = p.TunedPosition;
p.Accessory.LocalRotation = p.TunedAngles.ToRotation();
p.Accessory.LocalScale = new Vector3( p.TunedScale, p.TunedScale, p.TunedScale );
Log.Info( $"[placement] '{p.Accessory.Name}' seated at the tuned offset for {p.TunedFrame}" );
}
else
{
p.Accessory.WorldPosition = p.Mount.WorldPosition + bodyRot * p.BodySeed;
Log.Info( $"[placement] '{p.Accessory.Name}' mounted on {p.Mount.FrameName}, not the tuned "
+ $"{p.TunedFrame}; using the generic character-frame seed instead. Fit it with P." );
}
session?.CaptureSeed( p.Accessory );
_pending.RemoveAt( i );
}
}
// ---- screen UI ----
void BuildUi()
{
// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene
// needs no razor wiring.
var panelHost = Scene.CreateObject();
panelHost.Name = "Placement UI";
panelHost.Components.Create<ScreenPanel>();
// Open on arrival. Fitting the accessories is what this scene is FOR, so making the visitor find the
// key first is a toll booth on the way to the point. P and the header x still close it. The panel
// reads this on its first update, after every OnStart in the frame, so setting it here always lands.
var panel = panelHost.Components.Create<TweakPanel>();
panel.OpenOnStart = true;
var hintHost = Scene.CreateObject();
hintHost.Name = "Placement Hint";
hintHost.Components.Create<ScreenPanel>();
hintHost.Components.Create<DemoHintCard>();
}
}
Game
library
using Sandbox;
namespace FieldGuide.Placement;
/// <summary>
/// The seam the tweak panel edits. A target is a display name plus the GameObject whose LOCAL transform
/// (position, rotation as pitch/yaw/roll, uniform scale) the panel nudges.
///
/// Only <see cref="DisplayName"/> and <see cref="Target"/> are required. Everything below is
/// default-implemented, so an existing implementation keeps compiling and gets sensible behaviour:
/// accessory-scale sliders, a "local" frame label, a bake id derived from the display name, and the
/// kit's standard bake line. Override the ones you care about.
///
/// Implement this to expose your own objects to the panel, or use the built-in <see cref="TweakTarget"/>
/// wrapper around a plain GameObject.
/// </summary>
public interface ITweakTarget
{
/// <summary>The label shown on this target's tab in the panel.</summary>
string DisplayName { get; }
/// <summary>The object whose LocalPosition / LocalRotation / LocalScale the panel edits.</summary>
GameObject Target { get; }
/// <summary>
/// What the edited offset is measured against: the parent this object hangs from, named in words your
/// game code recognises. "citizen/hold_R", "citizen/spine_2", "turret/muzzle". It shows on the panel's
/// sub-line and goes into the export and the bake comment, so a pasted offset says what it is relative
/// to. Defaults to <see cref="BakeFormat.LocalFrame"/>, which just means "this object's parent".
/// </summary>
string FrameName => BakeFormat.LocalFrame;
/// <summary>
/// The identifier the bake line names, e.g. "hat" in <c>new PlacedOffset( "hat", ... )</c>. Defaults to
/// a code-safe squashing of <see cref="DisplayName"/>, so "Back Pack" bakes as "back_pack".
/// </summary>
string BakeSymbol => BakeFormat.SymbolFrom( DisplayName );
/// <summary>Slider bounds and steps for this target's rows. Defaults to
/// <see cref="TweakRanges.Accessory"/>; use <see cref="TweakRanges.Scene"/> for a placed prop.</summary>
TweakRanges Ranges => TweakRanges.Accessory;
/// <summary>
/// Optional shaping hook for the paste-ready bake line. Return null (the default) for the kit's
/// standard two-line form, or return your own text to bake straight into your project's real shape,
/// e.g. <c>ItemMounts.HatOffset = new Vector3( ... );</c>. The <paramref name="item"/> handed in
/// already carries this target's live local transform, its <see cref="BakeSymbol"/> as the id, and its
/// <see cref="FrameName"/>; format it with <see cref="BakeFormat.F(float)"/> to match the kit's
/// invariant-culture float style.
/// </summary>
string FormatBakeLine( PlacedItem item ) => null;
}
/// <summary>
/// A minimal <see cref="ITweakTarget"/> that wraps a GameObject, defaulting its label to the object's
/// name. Convenience for the common case where a target is just "this GameObject", with the optional
/// interface members lifted to settable properties so a caller can fill them at registration.
/// </summary>
public sealed class TweakTarget : ITweakTarget
{
public string DisplayName { get; set; }
public GameObject Target { get; set; }
/// <summary>What the offset is relative to, shown on the panel sub-line and written into the export.
/// Leave null for the "local" default.</summary>
public string Frame { get; set; }
/// <summary>The id the bake line names. Leave null to derive it from <see cref="DisplayName"/>.</summary>
public string Symbol { get; set; }
/// <summary>Slider bounds and steps. Defaults to accessory scale.</summary>
public TweakRanges Ranges { get; set; } = TweakRanges.Accessory;
string ITweakTarget.FrameName => string.IsNullOrEmpty( Frame ) ? BakeFormat.LocalFrame : Frame;
string ITweakTarget.BakeSymbol => string.IsNullOrEmpty( Symbol ) ? BakeFormat.SymbolFrom( DisplayName ) : Symbol;
TweakRanges ITweakTarget.Ranges => Ranges ?? TweakRanges.Accessory;
public TweakTarget( GameObject go, string name = null )
{
Target = go;
DisplayName = string.IsNullOrEmpty( name ) ? (go?.Name ?? "Object") : name;
}
}
Game
library
using Sandbox;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// A mount frame on a skinned character: this GameObject rides a named ATTACHMENT (a hand hold point,
/// a hat point) or, failing that, a named BONE, so anything parented under it inherits that frame and
/// can be positioned with a plain LocalPosition / LocalRotation.
///
/// This is the piece the accessory-fitting flow is built on. Put this component on an empty child of
/// the character, list the attachment and bone names you want in preference order, then parent your
/// model under it and register the model with the <see cref="TweakSession"/>. The offsets you drag in
/// the panel are then genuinely relative to the hand or the spine, which is what
/// <see cref="PlacedOffset"/> bakes and what your game code will reproduce.
///
/// Resolution is DEFERRED and RETRIED, not done once at start: a skinned model created this frame has no
/// attachment objects yet, and reading a bone before the first pose evaluates gives the bind pose. The
/// component keeps trying for <see cref="ResolveTimeout"/> seconds, then settles on the character root
/// and says so in the console. A renamed or missing attachment therefore degrades to a visible,
/// diagnosable fallback instead of a null reference.
/// </summary>
[Title( "Character Attach Point" )]
[Category( "Field Guide · Placement" )]
[Icon( "accessibility" )]
public sealed class CharacterAttachPoint : Component
{
/// <summary>How the mount ended up anchored, once resolution settles.</summary>
public enum MountKind
{
/// <summary>Still looking (the model may not have posed yet).</summary>
Unresolved,
/// <summary>Parented to a model attachment object; the engine drives it.</summary>
Attachment,
/// <summary>Re-pinned to a bone transform every frame before render.</summary>
Bone,
/// <summary>Nothing resolved; sitting on the character root so the accessory is still visible.</summary>
Root,
}
/// <summary>The skinned model to mount against. Left null, the component searches its ancestors and
/// then their descendants, which covers "empty child of the character GameObject".</summary>
[Property] public SkinnedModelRenderer Renderer { get; set; }
/// <summary>Attachment names to try, in order. Casing varies between models, so list both forms
/// (for example "hold_R" then "hold_r"). Tried before <see cref="BoneNames"/>.</summary>
[Property] public List<string> AttachmentNames { get; set; } = new();
/// <summary>Bone names to try if no attachment resolves. A bone mount is re-pinned every frame, so the
/// accessory bobs and leans with the animation instead of sliding with the root.</summary>
[Property] public List<string> BoneNames { get; set; } = new();
/// <summary>Label for the character in the frame string, e.g. "citizen" gives "citizen/hold_R". This is
/// what ends up in the export and the bake comment, so name it the way your code talks about it.</summary>
[Property] public string CharacterName { get; set; } = "character";
/// <summary>Seconds to keep retrying before giving up and falling back to the character root.</summary>
[Property] public float ResolveTimeout { get; set; } = 3f;
/// <summary>How this mount is anchored right now.</summary>
public MountKind Kind { get; private set; } = MountKind.Unresolved;
/// <summary>The attachment or bone name that actually resolved, or "root" on the fallback.</summary>
public string ResolvedName { get; private set; } = "root";
/// <summary>The frame string to hand <see cref="TweakSession.Add(GameObject, string, string, TweakRanges)"/>:
/// "citizen/hold_R", "citizen/spine_2", "citizen/root". Safe to read before resolution settles; it just
/// reports the fallback until then.</summary>
public string FrameName => $"{CharacterName}/{ResolvedName}";
float _elapsed;
bool _gaveUp;
protected override void OnStart()
{
Renderer ??= Components.GetInAncestorsOrSelf<SkinnedModelRenderer>()
?? Components.GetInDescendantsOrSelf<SkinnedModelRenderer>( false );
TryResolve();
}
protected override void OnUpdate()
{
if ( Kind is MountKind.Attachment or MountKind.Bone ) return;
_elapsed += Time.Delta;
if ( TryResolve() ) return;
if ( _gaveUp || _elapsed < ResolveTimeout ) return;
_gaveUp = true;
Kind = MountKind.Root;
ResolvedName = "root";
Log.Warning( $"[placement] attach point '{GameObject?.Name}' resolved no attachment {Names( AttachmentNames )} "
+ $"and no bone {Names( BoneNames )} on the model; falling back to the character root." );
}
/// <summary>A bone mount is re-pinned here rather than in OnUpdate: PreRender runs after the pose is
/// evaluated, so the accessory sits on the bone position that will actually be drawn this frame.</summary>
protected override void OnPreRender()
{
if ( Kind != MountKind.Bone || !Renderer.IsValid() ) return;
if ( !Renderer.TryGetBoneTransform( ResolvedName, out Transform t ) ) return;
WorldPosition = t.Position;
WorldRotation = t.Rotation;
}
/// <summary>One resolution attempt. Attachments win (the engine parents and drives them for free);
/// bones are the fallback that still tracks the animation. Returns true once anchored.</summary>
bool TryResolve()
{
if ( !Renderer.IsValid() ) return false;
foreach ( var name in AttachmentNames )
{
if ( string.IsNullOrEmpty( name ) ) continue;
var mount = Renderer.GetAttachmentObject( name );
if ( mount is null || !mount.IsValid() ) continue;
GameObject.SetParent( mount, false );
GameObject.LocalPosition = Vector3.Zero;
GameObject.LocalRotation = Rotation.Identity;
Kind = MountKind.Attachment;
ResolvedName = name;
Log.Info( $"[placement] attach point '{GameObject.Name}' mounted on attachment {FrameName}" );
return true;
}
foreach ( var name in BoneNames )
{
if ( string.IsNullOrEmpty( name ) ) continue;
if ( !Renderer.TryGetBoneTransform( name, out Transform t ) ) continue;
WorldPosition = t.Position;
WorldRotation = t.Rotation;
Kind = MountKind.Bone;
ResolvedName = name;
Log.Info( $"[placement] attach point '{GameObject.Name}' mounted on bone {FrameName} (re-pinned each frame)" );
return true;
}
return false;
}
static string Names( List<string> names )
=> names is null || names.Count == 0 ? "(none listed)" : "[" + string.Join( ", ", names ) + "]";
}
Game
library
namespace FieldGuide.Placement;
/// <summary>
/// Slider bounds and step sizes for one tweak target's rows, in ENGINE UNITS and degrees.
///
/// Why this is per-target and not a constant: fitting a hat to a head and laying out a building are the
/// same three sliders over wildly different ranges. A position row spanning +/-1024 units at step 1 (the
/// kit's original fixed range) cannot seat an accessory at all: one pixel of drag is several units, and
/// the value you want lives in the first half-percent of the track. The accessory default below spans
/// +/-48 units at step 0.25, so the xfine step (÷10) reaches 0.025 units and a full drag still covers a
/// character-sized volume.
/// </summary>
public sealed record TweakRanges(
float PositionRange = 48f,
float PositionStep = 0.25f,
float RotationStep = 1f,
float ScaleMin = 0.05f,
float ScaleMax = 4f,
float ScaleStep = 0.05f )
{
/// <summary>The default: character-accessory scale. +/-48 units at 0.25 (xfine reaches 0.025), rotation
/// at 1 degree, scale 0.05 to 4. This is what an unconfigured target gets.</summary>
public static readonly TweakRanges Accessory = new();
/// <summary>Scene-authoring scale, for tweaking a placed prop rather than a worn accessory:
/// +/-1024 units at step 1, scale up to 8.</summary>
public static readonly TweakRanges Scene = new( PositionRange: 1024f, PositionStep: 1f, ScaleMax: 8f );
/// <summary>Room-scale, between the two: +/-256 units at 0.5.</summary>
public static readonly TweakRanges Prop = new( PositionRange: 256f, PositionStep: 0.5f );
}
Game
library
using System.Globalization;
using System.Text;
namespace FieldGuide.Placement;
/// <summary>
/// The text rules every export path shares: how a float becomes a C# literal, how a string is escaped,
/// and how a display name becomes a code-safe id. One home for them so the JSON file, the C# snippet,
/// and the panel's Copy button cannot drift apart in formatting.
/// </summary>
public static class BakeFormat
{
/// <summary>The <see cref="ITweakTarget.FrameName"/> default: "this object's parent", nothing more
/// specific known.</summary>
public const string LocalFrame = "local";
/// <summary>A float as a C# literal body: up to six decimals, invariant culture so a decimal never
/// localises to a comma and breaks the pasted code. Callers append the "f" suffix.</summary>
public static string F( float v ) => v.ToString( "0.######", CultureInfo.InvariantCulture );
/// <summary>Escape a string for a C# / JSON double-quoted literal.</summary>
public static string Escape( string s ) => (s ?? "").Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
/// <summary>
/// Squash a display name into a code-safe id: lowercase, non-alphanumerics collapsed to single
/// underscores, no leading or trailing underscore. "Back Pack" becomes "back_pack", "Hat (left)"
/// becomes "hat_left". Falls back to "item" for an empty or fully-stripped name.
/// </summary>
public static string SymbolFrom( string displayName )
{
if ( string.IsNullOrWhiteSpace( displayName ) ) return "item";
var sb = new StringBuilder( displayName.Length );
bool pendingUnderscore = false;
foreach ( char c in displayName )
{
if ( char.IsLetterOrDigit( c ) )
{
if ( pendingUnderscore && sb.Length > 0 ) sb.Append( '_' );
pendingUnderscore = false;
sb.Append( char.ToLowerInvariant( c ) );
}
else
{
pendingUnderscore = true;
}
}
return sb.Length == 0 ? "item" : sb.ToString();
}
}
Game
library
// Demo hint card. Same visual language as the tweak panel: 3 grouped font sizes (13 / 11 / 10), no
// letter-spacing, borders via border-width + border-color only (never border-style, which aborts the
// whole stylesheet), no blurred box-shadow on rounded elements. Root is pointer-events:none so only the
// card takes clicks. Every text colour sits above the contrast floor: instructions are never dim gray.
//
// The close control is a 42px square, matching the tweak panel. No ESC badge anywhere.
DemoHintCard {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
.hc-card {
position: absolute;
top: 80px;
left: 24px;
width: 340px;
flex-direction: column;
pointer-events: all;
background-color: rgba( 14, 16, 20, 0.94 );
border-width: 1px;
border-color: rgba( 90, 220, 255, 0.55 );
border-radius: 8px;
padding: 12px;
font-family: "Roboto Mono", monospace;
color: #eaf6ff;
}
.hc-hdr {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.hc-title {
font-size: 13px;
color: #7fe6ff;
}
.hc-x {
font-size: 13px;
color: #ffffff;
width: 42px;
height: 42px;
justify-content: center;
align-items: center;
border-radius: 6px;
border-width: 1px;
border-color: rgba( 90, 220, 255, 0.45 );
background-color: rgba( 255, 255, 255, 0.06 );
pointer-events: all;
transition: all 0.1s ease;
&:hover {
background-color: rgba( 90, 220, 255, 0.28 );
border-color: rgba( 90, 220, 255, 0.9 );
}
}
.hc-lede {
font-size: 11px;
color: #ffffff;
margin-bottom: 10px;
}
.hc-row {
flex-direction: row;
align-items: center;
margin-bottom: 5px;
}
.hc-key {
font-size: 10px;
color: #ffffff;
font-weight: 700;
background-color: rgba( 90, 220, 255, 0.18 );
border-radius: 4px;
padding: 3px 7px;
margin-right: 8px;
width: 96px;
justify-content: center;
align-items: center;
}
.hc-what {
font-size: 11px;
color: #eaf6ff;
flex-grow: 1;
}
.hc-foot {
font-size: 10px;
color: #cfe0ea;
margin-top: 8px;
border-top-width: 1px;
border-top-color: rgba( 255, 255, 255, 0.12 );
padding-top: 8px;
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Placement Kit" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "placement" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "fieldguide" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "fieldguide.placement" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-31T00:57:51.8271982Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.121.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.121.0")]
Game
library
using Sandbox;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// A mount frame on a skinned character: this GameObject rides a named ATTACHMENT (a hand hold point,
/// a hat point) or, failing that, a named BONE, so anything parented under it inherits that frame and
/// can be positioned with a plain LocalPosition / LocalRotation.
///
/// This is the piece the accessory-fitting flow is built on. Put this component on an empty child of
/// the character, list the attachment and bone names you want in preference order, then parent your
/// model under it and register the model with the <see cref="TweakSession"/>. The offsets you drag in
/// the panel are then genuinely relative to the hand or the spine, which is what
/// <see cref="PlacedOffset"/> bakes and what your game code will reproduce.
///
/// Resolution is DEFERRED and RETRIED, not done once at start: a skinned model created this frame has no
/// attachment objects yet, and reading a bone before the first pose evaluates gives the bind pose. The
/// component keeps trying for <see cref="ResolveTimeout"/> seconds, then settles on the character root
/// and says so in the console. A renamed or missing attachment therefore degrades to a visible,
/// diagnosable fallback instead of a null reference.
/// </summary>
[Title( "Character Attach Point" )]
[Category( "Field Guide · Placement" )]
[Icon( "accessibility" )]
public sealed class CharacterAttachPoint : Component
{
/// <summary>How the mount ended up anchored, once resolution settles.</summary>
public enum MountKind
{
/// <summary>Still looking (the model may not have posed yet).</summary>
Unresolved,
/// <summary>Parented to a model attachment object; the engine drives it.</summary>
Attachment,
/// <summary>Re-pinned to a bone transform every frame before render.</summary>
Bone,
/// <summary>Nothing resolved; sitting on the character root so the accessory is still visible.</summary>
Root,
}
/// <summary>The skinned model to mount against. Left null, the component searches its ancestors and
/// then their descendants, which covers "empty child of the character GameObject".</summary>
[Property] public SkinnedModelRenderer Renderer { get; set; }
/// <summary>Attachment names to try, in order. Casing varies between models, so list both forms
/// (for example "hold_R" then "hold_r"). Tried before <see cref="BoneNames"/>.</summary>
[Property] public List<string> AttachmentNames { get; set; } = new();
/// <summary>Bone names to try if no attachment resolves. A bone mount is re-pinned every frame, so the
/// accessory bobs and leans with the animation instead of sliding with the root.</summary>
[Property] public List<string> BoneNames { get; set; } = new();
/// <summary>Label for the character in the frame string, e.g. "citizen" gives "citizen/hold_R". This is
/// what ends up in the export and the bake comment, so name it the way your code talks about it.</summary>
[Property] public string CharacterName { get; set; } = "character";
/// <summary>Seconds to keep retrying before giving up and falling back to the character root.</summary>
[Property] public float ResolveTimeout { get; set; } = 3f;
/// <summary>How this mount is anchored right now.</summary>
public MountKind Kind { get; private set; } = MountKind.Unresolved;
/// <summary>The attachment or bone name that actually resolved, or "root" on the fallback.</summary>
public string ResolvedName { get; private set; } = "root";
/// <summary>The frame string to hand <see cref="TweakSession.Add(GameObject, string, string, TweakRanges)"/>:
/// "citizen/hold_R", "citizen/spine_2", "citizen/root". Safe to read before resolution settles; it just
/// reports the fallback until then.</summary>
public string FrameName => $"{CharacterName}/{ResolvedName}";
float _elapsed;
bool _gaveUp;
protected override void OnStart()
{
Renderer ??= Components.GetInAncestorsOrSelf<SkinnedModelRenderer>()
?? Components.GetInDescendantsOrSelf<SkinnedModelRenderer>( false );
TryResolve();
}
protected override void OnUpdate()
{
if ( Kind is MountKind.Attachment or MountKind.Bone ) return;
_elapsed += Time.Delta;
if ( TryResolve() ) return;
if ( _gaveUp || _elapsed < ResolveTimeout ) return;
_gaveUp = true;
Kind = MountKind.Root;
ResolvedName = "root";
Log.Warning( $"[placement] attach point '{GameObject?.Name}' resolved no attachment {Names( AttachmentNames )} "
+ $"and no bone {Names( BoneNames )} on the model; falling back to the character root." );
}
/// <summary>A bone mount is re-pinned here rather than in OnUpdate: PreRender runs after the pose is
/// evaluated, so the accessory sits on the bone position that will actually be drawn this frame.</summary>
protected override void OnPreRender()
{
if ( Kind != MountKind.Bone || !Renderer.IsValid() ) return;
if ( !Renderer.TryGetBoneTransform( ResolvedName, out Transform t ) ) return;
WorldPosition = t.Position;
WorldRotation = t.Rotation;
}
/// <summary>One resolution attempt. Attachments win (the engine parents and drives them for free);
/// bones are the fallback that still tracks the animation. Returns true once anchored.</summary>
bool TryResolve()
{
if ( !Renderer.IsValid() ) return false;
foreach ( var name in AttachmentNames )
{
if ( string.IsNullOrEmpty( name ) ) continue;
var mount = Renderer.GetAttachmentObject( name );
if ( mount is null || !mount.IsValid() ) continue;
GameObject.SetParent( mount, false );
GameObject.LocalPosition = Vector3.Zero;
GameObject.LocalRotation = Rotation.Identity;
Kind = MountKind.Attachment;
ResolvedName = name;
Log.Info( $"[placement] attach point '{GameObject.Name}' mounted on attachment {FrameName}" );
return true;
}
foreach ( var name in BoneNames )
{
if ( string.IsNullOrEmpty( name ) ) continue;
if ( !Renderer.TryGetBoneTransform( name, out Transform t ) ) continue;
WorldPosition = t.Position;
WorldRotation = t.Rotation;
Kind = MountKind.Bone;
ResolvedName = name;
Log.Info( $"[placement] attach point '{GameObject.Name}' mounted on bone {FrameName} (re-pinned each frame)" );
return true;
}
return false;
}
static string Names( List<string> names )
=> names is null || names.Count == 0 ? "(none listed)" : "[" + string.Join( ", ", names ) + "]";
}
Game
library
using Sandbox;
using System;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// The consumer-supplied set of placeable things, plus the validity seam the ghost uses to tint valid
/// vs invalid spots. Populate <see cref="Entries"/> in code (e.g. from a bootstrap) and, optionally, set
/// <see cref="ValidityCheck"/> to gate where placement is allowed (default: anywhere the aim ray hits).
/// Publishes itself through a static <see cref="Instance"/> so <see cref="GhostPlacer"/> needs no handle.
/// </summary>
[Title( "Placement Catalog" )]
[Category( "Field Guide · Placement" )]
[Icon( "category" )]
public sealed class PlacementCatalog : Component
{
public static PlacementCatalog Instance { get; private set; }
/// <summary>The placeable entries, in cycle order. Filled by the consumer at runtime.</summary>
public List<PlaceableEntry> Entries { get; } = new();
/// <summary>
/// Seam: return true if an object may be placed at the given world position and rotation. Null means
/// "always valid" (the default). The ghost tints green when this returns true, red when false, and a
/// click only places on a valid spot.
/// </summary>
public Func<Vector3, Rotation, bool> ValidityCheck { get; set; }
public bool IsValidSpot( Vector3 worldPos, Rotation worldRot )
=> ValidityCheck is null || ValidityCheck( worldPos, worldRot );
protected override void OnEnabled() => Instance = this;
protected override void OnDisabled()
{
if ( Instance == this ) Instance = null;
}
}
Game
library
using Sandbox;
using System.Collections.Generic;
namespace FieldGuide.Placement;
/// <summary>
/// Holds the list of objects the <see cref="TweakPanel"/> can edit, and publishes itself through a
/// static <see cref="Instance"/> so the panel needs no component handle. Two ways to fill it:
///
/// 1. Wire GameObjects into <see cref="Objects"/> in the scene inspector (the simple path).
/// 2. Call <see cref="Add(ITweakTarget)"/> / <see cref="Add(GameObject, string)"/> at runtime for
/// code-supplied or custom targets. This is the path the accessory-fitting flow uses: parent your
/// model to a character mount, then register it with a frame name so the bake says what the offset
/// is relative to.
///
/// <see cref="Targets"/> merges both into the effective list the panel tabs across.
///
/// The session also remembers each target's AUTHORED transform, captured the moment it is registered, so
/// the panel's Reset restores what you started with rather than slamming the object to zero (which for a
/// hand-mounted accessory means "collapsed into the wrist", never what you wanted).
/// </summary>
[Title( "Tweak Session" )]
[Category( "Field Guide · Placement" )]
[Icon( "tune" )]
public sealed class TweakSession : Component
{
public static TweakSession Instance { get; private set; }
/// <summary>Scene-wired targets: drop GameObjects here in the inspector to make them tweakable.</summary>
[Property] public List<GameObject> Objects { get; set; } = new();
readonly List<ITweakTarget> _custom = new();
/// <summary>The authored local transform of each registered target, captured at registration. Keyed by
/// GameObject so a custom ITweakTarget and a wrapped GameObject share one entry.</summary>
readonly Dictionary<GameObject, Seed> _seeds = new();
readonly record struct Seed( Vector3 Position, Rotation Rotation, Vector3 Scale );
/// <summary>The effective, de-duplicated target list the panel tabs across: code-supplied targets
/// first, then the scene-wired <see cref="Objects"/> (skipping any invalid or already-listed ones).</summary>
public IReadOnlyList<ITweakTarget> Targets
{
get
{
var list = new List<ITweakTarget>();
var seen = new HashSet<GameObject>();
foreach ( var t in _custom )
{
if ( t?.Target is null || !t.Target.IsValid() || !seen.Add( t.Target ) ) continue;
list.Add( t );
}
foreach ( var go in Objects )
{
if ( go is null || !go.IsValid() || !seen.Add( go ) ) continue;
CaptureSeedIfNew( go ); // an object dropped into the list at runtime still gets an authored baseline
list.Add( new TweakTarget( go ) );
}
return list;
}
}
/// <summary>Register a custom target (your own <see cref="ITweakTarget"/> implementation).</summary>
public void Add( ITweakTarget target )
{
if ( target?.Target is null ) return;
_custom.Add( target );
CaptureSeedIfNew( target.Target );
}
/// <summary>Register a GameObject as a target, wrapped in a <see cref="TweakTarget"/>.</summary>
public void Add( GameObject go, string name = null )
{
if ( go is null ) return;
_custom.Add( new TweakTarget( go, name ) );
CaptureSeedIfNew( go );
}
/// <summary>Register a GameObject with the frame its offset is measured against and, optionally, the
/// slider ranges its rows should use. The frame name shows on the panel sub-line and lands in the
/// export and the bake comment, which is the whole reason a baked offset is readable later.</summary>
public TweakTarget Add( GameObject go, string name, string frame, TweakRanges ranges = null )
{
if ( go is null ) return null;
var t = new TweakTarget( go, name ) { Frame = frame, Ranges = ranges ?? TweakRanges.Accessory };
_custom.Add( t );
CaptureSeedIfNew( go );
return t;
}
/// <summary>Drop a previously-registered code-supplied target (does not affect scene-wired Objects).</summary>
public void Remove( GameObject go )
{
_custom.RemoveAll( t => t.Target == go );
_seeds.Remove( go );
}
// ---- authored-default baseline ----
/// <summary>Record this object's CURRENT local transform as its authored default, overwriting any
/// earlier capture. Call it after you finish positioning an object in code if you want Reset to come
/// back to that pose rather than the one it had at registration.</summary>
public void CaptureSeed( GameObject go )
{
if ( go is null || !go.IsValid() ) return;
_seeds[go] = new Seed( go.LocalPosition, go.LocalRotation, go.LocalScale );
}
void CaptureSeedIfNew( GameObject go )
{
if ( go is null || !go.IsValid() || _seeds.ContainsKey( go ) ) return;
_seeds[go] = new Seed( go.LocalPosition, go.LocalRotation, go.LocalScale );
}
/// <summary>Restore a target to the transform captured when it was registered. Returns false if the
/// object is invalid or was never registered here, so a caller can fall back if it wants to.</summary>
public bool ResetToSeed( GameObject go )
{
if ( go is null || !go.IsValid() || !_seeds.TryGetValue( go, out var seed ) ) return false;
go.LocalPosition = seed.Position;
go.LocalRotation = seed.Rotation;
go.LocalScale = seed.Scale;
return true;
}
protected override void OnEnabled() => Instance = this;
protected override void OnStart()
{
// Baseline the scene-wired objects at start, so their authored inspector transform is what Reset
// comes back to even if something moves them before the panel is first opened.
foreach ( var go in Objects )
CaptureSeedIfNew( go );
}
protected override void OnDisabled()
{
if ( Instance == this ) Instance = null;
}
}
Game
library
using Sandbox;
namespace FieldGuide.Placement;
/// <summary>
/// One entry in a <see cref="PlacementCatalog"/>: an id, a display name, and what to spawn. Supply
/// either a <see cref="ModelPath"/> (the object is built as a GameObject + ModelRenderer, the light
/// path the ghost preview also uses) or a <see cref="Prefab"/> template GameObject (cloned on place,
/// carrying all its components). If both are set, <see cref="Prefab"/> wins for the placed object.
///
/// <see cref="Id"/> is the stable key written into the export (JSON/C# snippet), so keep it terse and
/// unique within a catalog (e.g. "box", "sphere", "crate_a").
/// </summary>
public sealed class PlaceableEntry
{
/// <summary>Stable key written to the export. Unique within the catalog.</summary>
public string Id { get; set; }
/// <summary>Label shown in the placement HUD / logs.</summary>
public string DisplayName { get; set; }
/// <summary>Engine model path, e.g. "models/dev/box.vmdl". Used for the ghost preview and, when no
/// <see cref="Prefab"/> is set, for the placed object (a GameObject with a single ModelRenderer).</summary>
public string ModelPath { get; set; }
/// <summary>Optional template GameObject cloned on place (a disabled prefab instance in the scene works
/// well). Overrides <see cref="ModelPath"/> for the placed object; the ghost still uses ModelPath if set.</summary>
public GameObject Prefab { get; set; }
public PlaceableEntry() { }
public PlaceableEntry( string id, string displayName, string modelPath )
{
Id = id;
DisplayName = displayName;
ModelPath = modelPath;
}
}
Game
library
using Sandbox;
namespace FieldGuide.Placement;
/// <summary>
/// Tags a placed object with the catalog id it came from, so <see cref="PlacementExport"/> can collect
/// every placed object in the scene and record which entry produced it. <see cref="GhostPlacer"/> adds
/// this on place; you can also add it by hand (or in a prefab) to make an object show up in exports.
/// </summary>
[Title( "Placed Instance" )]
[Category( "Field Guide · Placement" )]
[Icon( "place" )]
public sealed class PlacedInstance : Component
{
/// <summary>The <see cref="PlaceableEntry.Id"/> this object was placed from.</summary>
[Property] public string CatalogId { get; set; }
}
Game
library
// Tweak panel styling. 3 grouped font sizes (13 / 11 / 10), no letter-spacing (glyph-corruption
// budget). Borders via border-width + border-color only (never border-style, which aborts the whole
// stylesheet). No blurred box-shadow on rounded elements (it renders unrounded); 0-blur rings only.
// Root is pointer-events:none; only the card takes clicks, so the cursor stays usable over the panel
// while the rest of the scene ignores it. Instructional text is bright, never dim gray.
//
// Slider line: minus stepper · draggable fill track · plus stepper. .tp-track position:relative +
// .tp-fill position:absolute so dragging fills the bar WITHOUT growing the whole row, and the fill is
// pointer-events:none so the drag position stays measured against the track.
//
// The close control is a 42px square (owner ruling: 34px is too small to hit). There is no ESC badge
// anywhere: the × is the close affordance.
TweakPanel {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
.tp-card {
position: absolute;
top: 80px;
right: 24px;
width: 300px;
flex-direction: column;
pointer-events: all;
background-color: rgba( 14, 16, 20, 0.94 );
border-width: 1px;
border-color: rgba( 90, 220, 255, 0.55 );
border-radius: 8px;
padding: 12px;
font-family: "Roboto Mono", monospace;
color: #eaf6ff;
}
.tp-hdr {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.tp-title {
font-size: 13px;
color: #7fe6ff;
}
.tp-hr {
flex-direction: row;
align-items: center;
}
.tp-key {
font-size: 10px;
color: #ffffff;
font-weight: 700;
background-color: rgba( 90, 220, 255, 0.14 );
border-radius: 4px;
padding: 2px 6px;
margin-right: 6px;
}
.tp-x {
font-size: 13px;
color: #ffffff;
width: 42px;
height: 42px;
justify-content: center;
align-items: center;
border-radius: 6px;
border-width: 1px;
border-color: rgba( 90, 220, 255, 0.45 );
background-color: rgba( 255, 255, 255, 0.06 );
pointer-events: all;
transition: all 0.1s ease;
&:hover {
background-color: rgba( 90, 220, 255, 0.28 );
border-color: rgba( 90, 220, 255, 0.9 );
}
}
.tp-empty {
font-size: 11px;
color: #ffffff;
padding: 6px 0px;
}
.tp-tabs {
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 8px;
}
.tp-tab {
font-size: 10px;
color: #eaf6ff;
background-color: rgba( 255, 255, 255, 0.06 );
border-radius: 4px;
padding: 4px 7px;
margin-right: 4px;
margin-bottom: 4px;
transition: all 0.1s ease;
&:hover { background-color: rgba( 255, 255, 255, 0.12 ); }
&.on {
background-color: rgba( 90, 220, 255, 0.22 );
color: #ffffff;
border-width: 1px;
border-color: rgba( 90, 220, 255, 0.7 );
}
}
.tp-sub {
flex-direction: column;
margin-bottom: 8px;
}
.tp-item { font-size: 11px; color: #ffffff; }
.tp-note { font-size: 10px; color: #cfe0ea; }
.tp-steprow {
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.tp-sl { font-size: 10px; color: #cfe0ea; margin-right: 6px; }
.tp-seg {
font-size: 10px;
color: #eaf6ff;
background-color: rgba( 255, 255, 255, 0.06 );
border-radius: 4px;
padding: 3px 7px;
margin-right: 4px;
transition: all 0.1s ease;
&:hover { background-color: rgba( 255, 255, 255, 0.12 ); }
&.on { background-color: rgba( 90, 220, 255, 0.22 ); color: #ffffff; }
}
.tp-row {
flex-direction: column;
margin-bottom: 6px;
&.hdr {
margin-top: 6px;
border-top-width: 1px;
border-top-color: rgba( 255, 255, 255, 0.1 );
padding-top: 8px;
}
}
.tp-rlab {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.tp-rl { font-size: 11px; color: #eaf6ff; }
.tp-rv {
font-size: 11px;
color: #ffffff;
justify-content: center;
text-align: center;
}
.tp-slider {
flex-direction: row;
align-items: center;
}
.tp-track {
position: relative;
flex-grow: 1;
height: 14px;
margin-left: 6px;
margin-right: 6px;
border-radius: 99px;
background-color: rgba( 255, 255, 255, 0.10 );
pointer-events: all;
&:hover { background-color: rgba( 255, 255, 255, 0.18 ); }
.tp-fill {
position: absolute;
left: 0px;
height: 100%;
border-radius: 99px;
background-image: linear-gradient( 90deg, #3AC4DE, #7fe6ff );
pointer-events: none;
}
}
.tp-stp {
font-size: 13px;
color: #eaf6ff;
background-color: rgba( 255, 255, 255, 0.08 );
border-radius: 4px;
width: 24px;
height: 22px;
justify-content: center;
align-items: center;
transition: all 0.1s ease;
&:hover { background-color: rgba( 90, 220, 255, 0.28 ); color: #ffffff; }
}
.tp-btns {
flex-direction: row;
margin-top: 8px;
}
.tp-btn {
font-size: 10px;
color: #eaf6ff;
background-color: rgba( 255, 255, 255, 0.08 );
border-radius: 4px;
padding: 7px 8px;
margin-right: 5px;
justify-content: center;
align-items: center;
flex-grow: 1;
transition: all 0.1s ease;
&:hover { background-color: rgba( 90, 220, 255, 0.24 ); color: #ffffff; }
&.wide { margin-right: 0px; }
}
}
Game
library
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace FieldGuide.Placement;
/// <summary>
/// Bakes what you tuned in-game into text you can paste into your own project. Two populations are
/// collected, because the kit serves two jobs and both end at "get these numbers into my code":
///
/// 1. TWEAK TARGETS (the hero case). Every <see cref="ITweakTarget"/> registered with the scene's
/// <see cref="TweakSession"/>, exported as a LOCAL offset tagged with the frame it hangs off
/// ("citizen/hold_R", "citizen/spine_2", ...). This is the accessory-fitting path: parent a custom
/// model to a character, drag it into place, bake the offset.
/// 2. PLACED INSTANCES (scene authoring). Every object carrying a <see cref="PlacedInstance"/>, exported
/// in WORLD space. This is the ghost-placer path: lay a scene out, bake the coordinates.
///
/// <see cref="WriteAll(Scene)"/> writes a JSON file and a C# file into FileSystem.Data (per-project,
/// writable) and logs their full paths. The panel's Copy button does the single-target version:
/// <see cref="BakeLine(ITweakTarget)"/> straight onto the clipboard.
/// </summary>
public static class PlacementExport
{
public const string JsonFileName = "placement_export.json";
public const string CSharpFileName = "placement_export.cs";
// ---- collection ----
/// <summary>Both populations in one list: tweak-session offsets first (ordered by frame then id), then
/// world-space placed instances. This is what the JSON and C# exports write.</summary>
public static List<PlacedItem> Collect( Scene scene )
{
var list = new List<PlacedItem>();
if ( scene is null ) return list;
list.AddRange( CollectOffsets( scene ) );
list.AddRange( CollectPlaced( scene ) );
return list;
}
/// <summary>The tweak session's targets as LOCAL offsets, each tagged with its frame. Empty when the
/// scene has no <see cref="TweakSession"/> or the session holds no valid targets.</summary>
public static List<PlacedItem> CollectOffsets( Scene scene )
{
var list = new List<PlacedItem>();
if ( scene is null ) return list;
foreach ( var session in scene.GetAllComponents<TweakSession>() )
{
if ( session is null || !session.IsValid() ) continue;
foreach ( var target in session.Targets )
{
var item = ToItem( target );
if ( item is not null ) list.Add( item );
}
}
return list
.OrderBy( i => i.Frame )
.ThenBy( i => i.Id )
.ToList();
}
/// <summary>Every object tagged with a <see cref="PlacedInstance"/>, in WORLD space, ordered by catalog
/// id then x for stable output.</summary>
public static List<PlacedItem> CollectPlaced( Scene scene )
{
var list = new List<PlacedItem>();
if ( scene is null ) return list;
foreach ( var inst in scene.GetAllComponents<PlacedInstance>() )
{
if ( inst is null || !inst.IsValid() ) continue;
var go = inst.GameObject;
list.Add( new PlacedItem(
string.IsNullOrEmpty( inst.CatalogId ) ? "item" : inst.CatalogId,
go.WorldPosition,
go.WorldRotation.Angles(),
go.WorldScale.x,
PlacedItem.WorldFrame ) );
}
return list
.OrderBy( i => i.Id )
.ThenBy( i => i.Position.x )
.ToList();
}
/// <summary>One tweak target's live LOCAL transform as a record: its bake id, its local position /
/// rotation / uniform scale, and the frame it hangs off. Null for an invalid target.</summary>
public static PlacedItem ToItem( ITweakTarget target )
{
var go = target?.Target;
if ( go is null || !go.IsValid() ) return null;
return new PlacedItem(
target.BakeSymbol,
go.LocalPosition,
go.LocalRotation.Angles(),
go.LocalScale.x,
target.FrameName );
}
// ---- the paste-ready line (what Copy puts on the clipboard) ----
/// <summary>
/// The paste-ready C# for ONE tweak target. A target that implements
/// <see cref="ITweakTarget.FormatBakeLine"/> shapes its own line (bake straight into your real data
/// shape); otherwise the kit's default form is used. Returns an empty string for an invalid target.
/// </summary>
public static string BakeLine( ITweakTarget target )
{
var item = ToItem( target );
if ( item is null ) return "";
var custom = target.FormatBakeLine( item );
return string.IsNullOrEmpty( custom ) ? DefaultBakeLine( item ) : custom;
}
/// <summary>
/// The kit's default bake line: a comment naming the id and the frame the offset is measured from,
/// then a <see cref="PlacedOffset"/> constructor with the live numbers. Two lines, because the comment
/// is what makes the numbers mean something once they are sitting in a file three weeks later.
/// <code>
/// // hat: offset from citizen/head
/// new PlacedOffset( "hat", new Vector3( 0.5f, 0f, 3.25f ), new Angles( 0f, 90f, 0f ), 1f ),
/// </code>
/// </summary>
public static string DefaultBakeLine( PlacedItem it )
{
if ( it is null ) return "";
string what = it.IsLocalOffset ? $"offset from {it.Frame}" : "world placement";
return $"// {it.Id}: {what}\n{OffsetCtor( it )},";
}
/// <summary>Just the constructor expression, no comment and no trailing comma. The building block the
/// default line and the C# file share.</summary>
public static string OffsetCtor( PlacedItem it )
=> $"new PlacedOffset( \"{BakeFormat.Escape( it.Id )}\", "
+ $"new Vector3( {BakeFormat.F( it.Position.x )}f, {BakeFormat.F( it.Position.y )}f, {BakeFormat.F( it.Position.z )}f ), "
+ $"new Angles( {BakeFormat.F( it.Rotation.pitch )}f, {BakeFormat.F( it.Rotation.yaw )}f, {BakeFormat.F( it.Rotation.roll )}f ), "
+ $"{BakeFormat.F( it.Scale )}f )";
// ---- JSON ----
/// <summary>An indented JSON array of { id, frame, position {x,y,z}, rotation {pitch,yaw,roll}, scale }.
/// "frame" is "world" for a placed instance and the target's frame name for a tuned offset.</summary>
public static string ToJson( IEnumerable<PlacedItem> items )
{
var sb = new StringBuilder();
sb.Append( "[\n" );
var arr = items.ToList();
for ( int i = 0; i < arr.Count; i++ )
{
var it = arr[i];
sb.Append( " {\n" );
sb.Append( $" \"id\": \"{BakeFormat.Escape( it.Id )}\",\n" );
sb.Append( $" \"frame\": \"{BakeFormat.Escape( it.Frame )}\",\n" );
sb.Append( $" \"position\": {{ \"x\": {BakeFormat.F( it.Position.x )}, \"y\": {BakeFormat.F( it.Position.y )}, \"z\": {BakeFormat.F( it.Position.z )} }},\n" );
sb.Append( $" \"rotation\": {{ \"pitch\": {BakeFormat.F( it.Rotation.pitch )}, \"yaw\": {BakeFormat.F( it.Rotation.yaw )}, \"roll\": {BakeFormat.F( it.Rotation.roll )} }},\n" );
sb.Append( $" \"scale\": {BakeFormat.F( it.Scale )}\n" );
sb.Append( i < arr.Count - 1 ? " },\n" : " }\n" );
}
sb.Append( "]\n" );
return sb.ToString();
}
// ---- C# snippet ----
/// <summary>
/// A paste-ready C# file with up to two blocks, each written only when it has entries:
/// a <c>PlacedOffset[]</c> of character-relative offsets (one commented line per accessory, naming the
/// frame it hangs off), and a <c>PlacedItem[]</c> of world-space placements. Both record types ship
/// with the kit, so the paste compiles as-is against <c>FieldGuide.Placement</c>.
/// </summary>
public static string ToCSharp( IEnumerable<PlacedItem> items )
{
var arr = items.ToList();
var offsets = arr.Where( i => i.IsLocalOffset ).ToList();
var placed = arr.Where( i => !i.IsLocalOffset ).ToList();
var sb = new StringBuilder();
sb.Append( "// Generated by FieldGuide.Placement. Paste into your project.\n" );
if ( offsets.Count > 0 )
{
sb.Append( "\n// Character-relative offsets. Each is LOCAL to the frame named above it: parent your\n" );
sb.Append( "// object to that mount, then call offset.ApplyTo( go ).\n" );
sb.Append( "static readonly PlacedOffset[] Offsets =\n{\n" );
foreach ( var it in offsets )
{
sb.Append( $"\t// {it.Id}: offset from {it.Frame}\n" );
sb.Append( $"\t{OffsetCtor( it )},\n" );
}
sb.Append( "};\n" );
}
if ( placed.Count > 0 )
{
sb.Append( "\n// World-space placements from the ghost placer.\n" );
sb.Append( "// PlacedItem( string Id, Vector3 Position, Angles Rotation, float Scale, string Frame )\n" );
sb.Append( "static readonly PlacedItem[] Placed =\n{\n" );
foreach ( var it in placed )
{
sb.Append( $"\tnew PlacedItem( \"{BakeFormat.Escape( it.Id )}\", " );
sb.Append( $"new Vector3( {BakeFormat.F( it.Position.x )}f, {BakeFormat.F( it.Position.y )}f, {BakeFormat.F( it.Position.z )}f ), " );
sb.Append( $"new Angles( {BakeFormat.F( it.Rotation.pitch )}f, {BakeFormat.F( it.Rotation.yaw )}f, {BakeFormat.F( it.Rotation.roll )}f ), " );
sb.Append( $"{BakeFormat.F( it.Scale )}f, \"{BakeFormat.Escape( it.Frame )}\" ),\n" );
}
sb.Append( "};\n" );
}
if ( offsets.Count == 0 && placed.Count == 0 )
sb.Append( "\n// Nothing to export: no TweakSession targets and no PlacedInstance objects in the scene.\n" );
return sb.ToString();
}
// ---- write both to FileSystem.Data ----
/// <summary>Collect both populations and write the JSON and C# exports to FileSystem.Data, logging the
/// paths and the per-population counts. Returns the total number of records written.</summary>
public static int WriteAll( Scene scene )
{
var items = Collect( scene );
int offsets = items.Count( i => i.IsLocalOffset );
int placed = items.Count - offsets;
FileSystem.Data.WriteAllText( JsonFileName, ToJson( items ) );
FileSystem.Data.WriteAllText( CSharpFileName, ToCSharp( items ) );
Log.Info( $"[placement] exported {offsets} tuned offset(s) + {placed} world placement(s):" );
Log.Info( $"[placement] JSON to {FileSystem.Data.GetFullPath( JsonFileName )}" );
Log.Info( $"[placement] C# to {FileSystem.Data.GetFullPath( CSharpFileName )}" );
return items.Count;
}
}
Game
library
using Sandbox;
using System;
using System.Linq;
namespace FieldGuide.Placement;
/// <summary>
/// Aim-follow ghost placement, pattern-rebuilt from World Builder's prop placer, minus all networking:
/// this is a single-player authoring driver. Toggle placement mode with B (or the `placement_place`
/// convar); a tinted, colliderless GHOST of the current catalog entry follows the aim ray (green =
/// valid spot, red = invalid, per <see cref="PlacementCatalog.ValidityCheck"/>). Bracket keys cycle
/// the catalog, R rotates the ghost, left mouse places, G deletes the placed object under the cursor.
///
/// Placed objects get a <see cref="PlacedInstance"/> tag so <see cref="PlacementExport"/> can find them.
/// Works in engine units throughout (no meter scaling), so it composes with any camera setup.
/// </summary>
[Title( "Ghost Placer" )]
[Category( "Field Guide · Placement" )]
[Icon( "add_location" )]
public sealed class GhostPlacer : Component
{
public static GhostPlacer Instance { get; private set; }
// ── HUD readback (static so a HUD razor needs no component handle) ──
public static bool PlaceModeActive { get; private set; }
public static string CurrentEntryName { get; private set; }
public static int PlacedCount { get; private set; }
// ── toggle state (B raw key + `placement_place` convar fallback) ──
static bool _placeConvar;
[ConVar( "placement_place", Help = "Arm or disarm ghost placement mode (same as the B key)" )]
public static bool PlaceModeConVar { get => _placeConvar; set => _placeConvar = value; }
/// <summary>Rotation step (degrees) applied by the R key.</summary>
[Property] public float RotateStepDeg { get; set; } = 45f;
/// <summary>How far the placed/ghost object sinks along -Z from the aim hit (engine units).</summary>
[Property] public float SinkDepth { get; set; } = 0f;
/// <summary>Max aim-ray length in engine units.</summary>
[Property] public float AimReach { get; set; } = 500_000f;
bool _placeMode;
int _catalogIndex;
float _ghostYaw;
GameObject _ghost;
string _ghostModel;
PlacementCatalog Catalog => PlacementCatalog.Instance;
PlaceableEntry CurrentEntry
{
get
{
var cat = Catalog;
if ( cat is null || cat.Entries.Count == 0 ) return null;
int n = cat.Entries.Count;
return cat.Entries[((_catalogIndex % n) + n) % n];
}
}
protected override void OnEnabled() => Instance = this;
protected override void OnDisabled()
{
if ( Instance == this ) Instance = null;
PlaceModeActive = false;
DestroyGhost();
}
protected override void OnUpdate()
{
HandleInput();
UpdateGhost();
PlaceModeActive = _placeMode;
CurrentEntryName = CurrentEntry?.DisplayName;
PlacedCount = CountPlaced();
}
// ── input ──
void HandleInput()
{
// B (raw key) or the `placement_place` convar toggles placement mode.
if ( Input.Keyboard.Pressed( "B" ) )
SetPlaceMode( !_placeMode );
else if ( _placeConvar != _placeMode )
SetPlaceMode( _placeConvar );
if ( !_placeMode ) return;
// cycle the catalog (bracket keys; the mouse wheel is the camera zoom, so it is not used here)
if ( Input.Keyboard.Pressed( "]" ) ) { _catalogIndex++; RebuildGhostModel(); }
if ( Input.Keyboard.Pressed( "[" ) ) { _catalogIndex--; RebuildGhostModel(); }
// rotate (R)
if ( Input.Keyboard.Pressed( "R" ) ) _ghostYaw = (_ghostYaw + RotateStepDeg) % 360f;
// place (left mouse)
if ( Input.Pressed( "attack1" ) )
TryPlaceAtAim();
// delete the placed object under the cursor (G)
if ( Input.Keyboard.Pressed( "G" ) )
TryDeleteAtAim();
}
void SetPlaceMode( bool on )
{
_placeMode = on;
_placeConvar = on;
if ( on )
{
RebuildGhostModel();
Mouse.Visibility = MouseVisibility.Visible;
Log.Info( $"[placement] mode ON, armed with {CurrentEntry?.DisplayName ?? "(empty catalog)"}" );
}
else
{
DestroyGhost();
Log.Info( "[placement] mode OFF" );
}
}
// ── aim ──
bool AimHit( out Vector3 worldPos )
{
worldPos = default;
var cam = Scene.Camera;
if ( !cam.IsValid() ) return false;
var ray = cam.ScreenPixelToRay( Mouse.Position );
var trace = Scene.Trace.Ray( ray, AimReach );
if ( _ghost.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( _ghost ); // never hit the ghost itself
var tr = trace.Run();
if ( !tr.Hit || tr.StartedSolid ) return false;
worldPos = tr.HitPosition;
return true;
}
void TryPlaceAtAim()
{
var entry = CurrentEntry;
if ( entry is null || !AimHit( out var hit ) ) return;
var pos = hit.WithZ( hit.z - SinkDepth );
var rot = Rotation.FromYaw( _ghostYaw );
if ( !(Catalog?.IsValidSpot( pos, rot ) ?? true) )
{
Log.Info( $"[placement] refused: invalid spot for {entry.DisplayName}" );
return;
}
var go = Spawn( entry, pos, rot );
if ( go is null )
{
Log.Info( $"[placement] could not spawn {entry.DisplayName} (no prefab or model)" );
return;
}
Log.Info( $"[placement] placed {entry.Id} at {pos} yaw={_ghostYaw:0} count={CountPlaced()}" );
}
GameObject Spawn( PlaceableEntry entry, Vector3 pos, Rotation rot )
{
GameObject go = null;
if ( entry.Prefab.IsValid() )
{
go = entry.Prefab.Clone( pos, rot );
}
else if ( !string.IsNullOrEmpty( entry.ModelPath ) )
{
go = Scene.CreateObject();
go.WorldPosition = pos;
go.WorldRotation = rot;
var r = go.Components.Create<ModelRenderer>();
var m = Model.Load( entry.ModelPath );
if ( m is not null && !m.IsError ) r.Model = m;
}
if ( go is null ) return null;
go.Name = $"placed_{entry.Id}";
go.Components.GetOrCreate<PlacedInstance>().CatalogId = entry.Id;
return go;
}
void TryDeleteAtAim()
{
var cam = Scene.Camera;
if ( !cam.IsValid() ) return;
var ray = cam.ScreenPixelToRay( Mouse.Position );
var trace = Scene.Trace.Ray( ray, AimReach );
if ( _ghost.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( _ghost );
var tr = trace.Run();
if ( !tr.Hit ) return;
var placed = tr.GameObject?.Components.GetInAncestorsOrSelf<PlacedInstance>();
if ( placed is null || !placed.IsValid() ) return;
Log.Info( $"[placement] deleted {placed.CatalogId}" );
placed.GameObject.Destroy();
}
// ── ghost preview (colliderless, tinted) ──
void RebuildGhostModel() => _ghostModel = null;
void UpdateGhost()
{
if ( !_placeMode ) { DestroyGhost(); return; }
var entry = CurrentEntry;
if ( entry is null || !AimHit( out var hit ) )
{
if ( _ghost.IsValid() ) _ghost.Enabled = false;
return;
}
EnsureGhost( entry );
if ( !_ghost.IsValid() ) return;
_ghost.Enabled = true;
var pos = hit.WithZ( hit.z - SinkDepth );
var rot = Rotation.FromYaw( _ghostYaw );
_ghost.WorldPosition = pos;
_ghost.WorldRotation = rot;
bool valid = Catalog?.IsValidSpot( pos, rot ) ?? true;
var r = _ghost.Components.Get<ModelRenderer>();
if ( r is not null )
r.Tint = valid ? new Color( 0.5f, 1f, 0.55f, 0.55f ) : new Color( 1f, 0.45f, 0.4f, 0.55f );
}
void EnsureGhost( PlaceableEntry entry )
{
string modelPath = entry.ModelPath;
if ( string.IsNullOrEmpty( modelPath ) ) { DestroyGhost(); return; } // prefab-only entries: no light ghost
if ( !_ghost.IsValid() )
{
_ghost = Scene.CreateObject();
_ghost.Name = "placement_ghost";
_ghost.Components.Create<ModelRenderer>();
_ghostModel = null;
}
if ( _ghostModel != modelPath )
{
var r = _ghost.Components.Get<ModelRenderer>();
var m = Model.Load( modelPath );
if ( m is not null && !m.IsError ) { r.Model = m; _ghostModel = modelPath; }
}
}
void DestroyGhost()
{
if ( _ghost.IsValid() ) { _ghost.Destroy(); _ghost = null; }
_ghostModel = null;
}
int CountPlaced() => Scene?.GetAllComponents<PlacedInstance>().Count() ?? 0;
}
Game
library
namespace FieldGuide.Placement;
/// <summary>
/// Slider bounds and step sizes for one tweak target's rows, in ENGINE UNITS and degrees.
///
/// Why this is per-target and not a constant: fitting a hat to a head and laying out a building are the
/// same three sliders over wildly different ranges. A position row spanning +/-1024 units at step 1 (the
/// kit's original fixed range) cannot seat an accessory at all: one pixel of drag is several units, and
/// the value you want lives in the first half-percent of the track. The accessory default below spans
/// +/-48 units at step 0.25, so the xfine step (÷10) reaches 0.025 units and a full drag still covers a
/// character-sized volume.
/// </summary>
public sealed record TweakRanges(
float PositionRange = 48f,
float PositionStep = 0.25f,
float RotationStep = 1f,
float ScaleMin = 0.05f,
float ScaleMax = 4f,
float ScaleStep = 0.05f )
{
/// <summary>The default: character-accessory scale. +/-48 units at 0.25 (xfine reaches 0.025), rotation
/// at 1 degree, scale 0.05 to 4. This is what an unconfigured target gets.</summary>
public static readonly TweakRanges Accessory = new();
/// <summary>Scene-authoring scale, for tweaking a placed prop rather than a worn accessory:
/// +/-1024 units at step 1, scale up to 8.</summary>
public static readonly TweakRanges Scene = new( PositionRange: 1024f, PositionStep: 1f, ScaleMax: 8f );
/// <summary>Room-scale, between the two: +/-256 units at 0.5.</summary>
public static readonly TweakRanges Prop = new( PositionRange: 256f, PositionStep: 0.5f );
}
Game
library
@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", Help = "Open or close the transform tweak panel (same as the P key)" )]
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;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 38,
"Files": [
{
"Ident": "fieldguide.placement",
"Path": "Code/Attach/AttachedTweakTarget.cs",
"FileName": "AttachedTweakTarget.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// A tweak target whose frame label comes from a live <see cref=\"CharacterAttachPoint\"/> rather than a\r\n/// string fixed at registration. Use this for anything hanging off a character.\r\n///\r\n/// Why it exists: an attach point resolves LATE (the model has to pose before its attachment objects\r\n/// and bones can be read), so a frame string captured at registration would say \"citizen/root\" forever\r\n/// even after the hand attachment came good. Reading it through the mount means the panel sub-line, the\r\n/// export, and the bake comment all name the frame the accessory actually ended up on.\r\n/// </summary>\r\npublic sealed class AttachedTweakTarget : ITweakTarget\r\n{\r\n\tpublic string DisplayName { get; set; }\r\n\r\n\t/// <summary>The accessory whose LOCAL transform the panel edits. Parent it under\r\n\t/// <see cref=\"Mount\"/> before registering, or its offsets mean nothing.</summary>\r\n\tpublic GameObject Target { get; set; }\r\n\r\n\t/// <summary>The mount the offset is measured from. Its resolved frame is read live.</summary>\r\n\tpublic CharacterAttachPoint Mount { get; set; }\r\n\r\n\t/// <summary>The id the bake line names. Leave null to derive it from <see cref=\"DisplayName\"/>.</summary>\r\n\tpublic string Symbol { get; set; }\r\n\r\n\t/// <summary>Slider bounds and steps. Accessory scale by default, which is the point of this type.</summary>\r\n\tpublic TweakRanges Ranges { get; set; } = TweakRanges.Accessory;\r\n\r\n\tstring ITweakTarget.FrameName => Mount.IsValid() ? Mount.FrameName : BakeFormat.LocalFrame;\r\n\tstring ITweakTarget.BakeSymbol => string.IsNullOrEmpty( Symbol ) ? BakeFormat.SymbolFrom( DisplayName ) : Symbol;\r\n\tTweakRanges ITweakTarget.Ranges => Ranges ?? TweakRanges.Accessory;\r\n\r\n\tpublic AttachedTweakTarget( GameObject accessory, CharacterAttachPoint mount, string name = null )\r\n\t{\r\n\t\tTarget = accessory;\r\n\t\tMount = mount;\r\n\t\tDisplayName = string.IsNullOrEmpty( name ) ? (accessory?.Name ?? \"Accessory\") : name;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Demo/DemoBootstrap.cs",
"FileName": "DemoBootstrap.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Wires the demo scene in code so the whole kit is exercised from one component.\r\n///\r\n/// The scene it builds is the kit's hero case: a dressed stock citizen standing in the middle, with\r\n/// three accessories parented to mounts on its skeleton (a tool in the right hand, a hat on the head, a\r\n/// pack on the spine). Each is registered with the <see cref=\"TweakSession\"/>, so pressing P gives you\r\n/// three tabs of sliders that move the accessory RELATIVE to the bone it hangs from, and Copy hands you\r\n/// the paste-ready offset for your own game code. Everything ships with the engine: the citizen model,\r\n/// its clothing, and the dev primitives standing in for your accessories.\r\n///\r\n/// TWO-LEVEL ACCESSORY SHAPE. Each accessory is a bare root GameObject holding one or more child parts\r\n/// that carry the models. The split is load-bearing: the tweak panel's Scale row writes a UNIFORM\r\n/// LocalScale on the root, so any non-uniform proportions put there would be flattened the first time\r\n/// someone touched the slider. Proportions live on the children, authored directly in engine units; the\r\n/// root's scale stays a clean multiplier that reads 1 at the authored size.\r\n///\r\n/// The ghost-placement flow is still here as the second beat: press B and drop boxes on the ground, and\r\n/// they export as world-space placements alongside the accessory offsets.\r\n///\r\n/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.\r\n/// </summary>\r\n[Title( \"Placement Demo Bootstrap\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"auto_awesome\" )]\r\npublic sealed class DemoBootstrap : Component\r\n{\r\n\t/// <summary>Radius (engine units) inside which the demo validity seam reports a spot as valid, to show\r\n\t/// the ghost's green/red tint switching. Set to 0 or negative to allow placement anywhere.</summary>\r\n\t[Property] public float DemoValidRadius { get; set; } = 512f;\r\n\r\n\t/// <summary>Yaw the demo citizen faces. 225 puts its front toward the orbit camera's starting angle.</summary>\r\n\t[Property] public float CitizenYaw { get; set; } = 225f;\r\n\r\n\tconst string CitizenModel = \"models/citizen/citizen.vmdl\";\r\n\tconst string FallbackMaterial = \"materials/default.vmat\";\r\n\r\n\t/// <summary>A plain outfit from the shipped citizen clothing resources. Each item is null-checked, so a\r\n\t/// missing asset degrades to a barer citizen rather than a broken spawn.</summary>\r\n\tstatic readonly string[] Outfit =\r\n\t{\r\n\t\t\"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing\",\r\n\t\t\"models/citizen_clothes/shoes/Trainers/trainers.clothing\",\r\n\t};\r\n\r\n\t/// <summary>Accessories waiting for their mount to resolve before their starting transform is applied.\r\n\t/// See <see cref=\"SeedPending\"/>.</summary>\r\n\treadonly List<Pending> _pending = new();\r\n\r\n\t/// <summary>One accessory's starting transform, with two paths. <see cref=\"TunedFrame\"/> plus\r\n\t/// <see cref=\"TunedPosition\"/> are the values the owner dialled in on the stock citizen and are applied\r\n\t/// verbatim when the mount resolves to exactly that bone. <see cref=\"BodySeed\"/> is the generic\r\n\t/// fallback for every other outcome, because bone-local numbers mean nothing on a rig they were not\r\n\t/// measured on.</summary>\r\n\treadonly record struct Pending(\r\n\t\tGameObject Accessory,\r\n\t\tCharacterAttachPoint Mount,\r\n\t\tstring TunedFrame,\r\n\t\tVector3 TunedPosition,\r\n\t\tAngles TunedAngles,\r\n\t\tfloat TunedScale,\r\n\t\tVector3 BodySeed );\r\n\r\n\t/// <summary>One primitive making up an accessory's silhouette. <see cref=\"SizeUnits\"/> is the part's\r\n\t/// intended size in ENGINE UNITS per axis; the builder divides it by the model's own bounds to get the\r\n\t/// scale, so the numbers below read as real dimensions rather than model-relative multipliers.</summary>\r\n\treadonly record struct Part( string ModelPath, Vector3 SizeUnits, Vector3 LocalPosition );\r\n\r\n\tGameObject _citizen;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tBuildCatalog();\r\n\t\t_citizen = BuildCitizen();\r\n\t\tBuildAccessories( _citizen );\r\n\t\tBuildUi();\r\n\r\n\t\tLog.Info( \"[placement] demo ready. The tweak panel is open, P toggles it, right mouse orbits, B enters ghost placement.\" );\r\n\t}\r\n\r\n\tprotected override void OnUpdate() => SeedPending();\r\n\r\n\t// ---- the ghost-placement catalog (the second beat) ----\r\n\r\n\tvoid BuildCatalog()\r\n\t{\r\n\t\tvar cat = PlacementCatalog.Instance ?? Components.GetOrCreate<PlacementCatalog>();\r\n\t\tcat.Entries.Clear();\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"box\", \"Box\", \"models/dev/box.vmdl\" ) );\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"sphere\", \"Sphere\", \"models/dev/sphere.vmdl\" ) );\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"plane\", \"Plane\", \"models/dev/plane.vmdl\" ) );\r\n\r\n\t\t// Demo validity seam: only allow placement within DemoValidRadius of the origin (illustrates the\r\n\t\t// green/red ghost tint). Replace or clear this in your own project.\r\n\t\tif ( DemoValidRadius > 0f )\r\n\t\t\tcat.ValidityCheck = ( pos, rot ) => pos.WithZ( 0f ).Length <= DemoValidRadius;\r\n\t}\r\n\r\n\t// ---- the character ----\r\n\r\n\tGameObject BuildCitizen()\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \"Citizen\";\r\n\t\tgo.WorldPosition = Vector3.Zero;\r\n\t\tgo.WorldRotation = Rotation.FromYaw( CitizenYaw );\r\n\r\n\t\tvar renderer = go.Components.Create<SkinnedModelRenderer>();\r\n\t\tvar model = Model.Load( CitizenModel );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[placement] demo citizen model '{CitizenModel}' did not load; the mounts will fall back to the root.\" );\r\n\t\t\treturn go;\r\n\t\t}\r\n\t\trenderer.Model = model;\r\n\t\tDress( renderer );\r\n\r\n\t\t// Standing idle straight off the citizen animgraph: grounded with no move input is its rest state,\r\n\t\t// which breathes and shifts weight a little. That subtle motion is the point here, because an\r\n\t\t// accessory that only looks right on a frozen T-pose is not actually fitted.\r\n\t\trenderer.Set( \"b_grounded\", true );\r\n\r\n\t\t// holdtype 6 is Swing on the citizen animgraph: a one-handed closed fist, so the hand actually grips\r\n\t\t// the tool instead of leaving a flat open palm under it. holdtype_handedness 1 is the right hand.\r\n\t\trenderer.Set( \"holdtype\", 6 );\r\n\t\trenderer.Set( \"holdtype_handedness\", 1 );\r\n\r\n\t\treturn go;\r\n\t}\r\n\r\n\tstatic void Dress( SkinnedModelRenderer renderer )\r\n\t{\r\n\t\tvar outfit = new ClothingContainer();\r\n\t\tbool any = false;\r\n\t\tforeach ( var path in Outfit )\r\n\t\t{\r\n\t\t\tvar item = ResourceLibrary.Get<Clothing>( path );\r\n\t\t\tif ( item is null )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\"[placement] demo clothing '{path}' did not resolve, skipping that slot.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\toutfit.Add( item );\r\n\t\t\tany = true;\r\n\t\t}\r\n\t\tif ( any ) outfit.Apply( renderer );\r\n\t}\r\n\r\n\t// ---- the three accessories ----\r\n\r\n\tvoid BuildAccessories( GameObject citizen )\r\n\t{\r\n\t\tvar session = TweakSession.Instance ?? Components.GetOrCreate<TweakSession>();\r\n\r\n\t\t// OBSERVED ON THE SHIPPED CITIZEN (2026-07-30): none of the attachment names below resolved, and all\r\n\t\t// three mounts landed on their bone backstop (hand_R, head, spine_2). The attachment candidates stay\r\n\t\t// in the list because they are correct on rigs that do expose them and they exercise the\r\n\t\t// attachment-first path, but the bones are what actually carries this demo.\r\n\r\n\t\t// Right hand. hold_R is the usual weapon-hold attachment name; the casing varies between models.\r\n\t\tvar hand = BuildMount( citizen, \"Hand Mount\",\r\n\t\t\tattachments: new List<string> { \"hold_R\", \"hold_r\" },\r\n\t\t\tbones: new List<string> { \"hand_R\", \"hand_r\" } );\r\n\r\n\t\t// Head. \"hat\" is the usual head-top attachment point; the head bone is the backstop that resolves here.\r\n\t\tvar head = BuildMount( citizen, \"Head Mount\",\r\n\t\t\tattachments: new List<string> { \"hat\", \"head\" },\r\n\t\t\tbones: new List<string> { \"head\" } );\r\n\r\n\t\t// Upper spine. No attachment here, so this one exercises the bone path: the mount is re-pinned to\r\n\t\t// spine_2 every frame, which is what makes a back-worn item bob with the animation instead of\r\n\t\t// sliding along the root.\r\n\t\tvar back = BuildMount( citizen, \"Back Mount\",\r\n\t\t\tattachments: new List<string>(),\r\n\t\t\tbones: new List<string> { \"spine_2\", \"spine_1\", \"spine_0\", \"spine\" } );\r\n\r\n\t\t// SILHOUETTES, and the axis they are built along. All three tuned offsets came back with their large\r\n\t\t// component on local X (hand +5.5, head +15, spine +1.6 with the big move on Y), which is the bone\r\n\t\t// chain running +X down its length: out past the fingers, up out of the skull, up the spine. So each\r\n\t\t// accessory is authored with its long axis on X. If a rig ever disagrees, the panel's rotation rows\r\n\t\t// fix it in one drag; nothing here depends on the guess being right.\r\n\t\t//\r\n\t\t// SIZES are the ones the owner settled on, in engine units on the longest axis: tool 5, hat 7,\r\n\t\t// pack 14. See BuildPart for why they are written as dimensions rather than scale factors.\r\n\r\n\t\t// Tool: a handle with a heavier head on the end, reading along +X (out past the fingers).\r\n\t\tRegister( session, hand, \"Hand Tool\",\r\n\t\t\ttunedFrame: \"citizen/hand_R\",\r\n\t\t\ttunedPosition: new Vector3( 4.25f, 0.5f, -2.5f ),\r\n\t\t\ttunedAngles: new Angles( 5f, 91f, 5f ),\r\n\t\t\ttunedScale: 1.9f,\r\n\t\t\tbodySeed: new Vector3( 5f, 0f, -3f ),\r\n\t\t\ttint: new Color( 1f, 0.62f, 0.25f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 3.4f, 1f, 1f ), new Vector3( -0.8f, 0f, 0f ) ),\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 1.6f, 1.7f, 1.7f ), new Vector3( 1.7f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\r\n\t\t// Hat: a flat brim disk with a dome crown above it. No cylinder ships in models/dev, so both are\r\n\t\t// squashed spheres; the brim is thin on X (the up-out-of-the-skull axis) and round across Y and Z.\r\n\t\tRegister( session, head, \"Hat\",\r\n\t\t\ttunedFrame: \"citizen/head\",\r\n\t\t\ttunedPosition: new Vector3( 15f, 1.901744f, 0.000369f ),\r\n\t\t\ttunedAngles: new Angles( 0f, 0f, 0f ),\r\n\t\t\ttunedScale: 1f,\r\n\t\t\tbodySeed: new Vector3( 2f, 0f, 6f ),\r\n\t\t\ttint: new Color( 0.42f, 0.86f, 1f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/sphere.vmdl\", new Vector3( 1.6f, 7f, 7f ), new Vector3( 0f, 0f, 0f ) ),\r\n\t\t\t\tnew Part( \"models/dev/sphere.vmdl\", new Vector3( 2.6f, 4.2f, 4.2f ), new Vector3( 2f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\r\n\t\t// Pack: the box was close, so this is only a proportion change, taller up the spine than it is deep\r\n\t\t// off the back. Kept chunky rather than slab-thin so it still reads as a pack under any axis order.\r\n\t\tRegister( session, back, \"Back Pack\",\r\n\t\t\ttunedFrame: \"citizen/spine_2\",\r\n\t\t\ttunedPosition: new Vector3( 1.625522f, -9.075111f, -0.00035f ),\r\n\t\t\ttunedAngles: new Angles( 0f, 0f, 0f ),\r\n\t\t\ttunedScale: 1f,\r\n\t\t\tbodySeed: new Vector3( -9f, 0f, 2f ),\r\n\t\t\ttint: new Color( 0.85f, 0.45f, 0.95f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 14f, 7f, 11f ), new Vector3( 0f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\t}\r\n\r\n\tCharacterAttachPoint BuildMount( GameObject citizen, string name, List<string> attachments, List<string> bones )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = name;\r\n\t\tgo.SetParent( citizen, false );\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar mount = go.Components.Create<CharacterAttachPoint>();\r\n\t\tmount.AttachmentNames = attachments;\r\n\t\tmount.BoneNames = bones;\r\n\t\tmount.CharacterName = \"citizen\";\r\n\t\treturn mount;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build one accessory under a mount and register it for tweaking. The root is a bare GameObject: it\r\n\t/// owns the offset the panel edits and a uniform scale that starts at 1, and nothing else. The parts\r\n\t/// hang under it carrying the models and the proportions.\r\n\t///\r\n\t/// The starting transform is applied later, once the mount resolves. See <see cref=\"SeedPending\"/>.\r\n\t/// </summary>\r\n\tvoid Register( TweakSession session, CharacterAttachPoint mount, string label,\r\n\t\tstring tunedFrame, Vector3 tunedPosition, Angles tunedAngles, float tunedScale, Vector3 bodySeed,\r\n\t\tColor tint, Part[] parts )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = label;\r\n\t\tgo.SetParent( mount.GameObject, false );\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\t\tgo.LocalScale = Vector3.One; // the panel's Scale row is a multiplier on the authored size, so 1 is \"as built\"\r\n\r\n\t\tfor ( int i = 0; i < parts.Length; i++ )\r\n\t\t\tBuildPart( go, $\"{label} part {i + 1}\", parts[i], tint );\r\n\r\n\t\tsession.Add( new AttachedTweakTarget( go, mount, label ) );\r\n\t\t_pending.Add( new Pending( go, mount, tunedFrame, tunedPosition, tunedAngles, tunedScale, bodySeed ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One primitive under an accessory root, sized in engine units.\r\n\t///\r\n\t/// The scale is the requested size divided by the model's OWN bounds, per axis, rather than a\r\n\t/// hand-tuned multiplier. That keeps the part table readable as real dimensions and survives the engine\r\n\t/// changing what a dev primitive measures: the shipped box is 50 units and the sphere is 64, and\r\n\t/// nothing here has to know that.\r\n\t/// </summary>\r\n\tvoid BuildPart( GameObject parent, string name, Part part, Color tint )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = name;\r\n\t\tgo.SetParent( parent, false );\r\n\t\tgo.LocalPosition = part.LocalPosition;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar renderer = go.Components.Create<ModelRenderer>();\r\n\t\tvar model = Model.Load( part.ModelPath );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[placement] demo part model '{part.ModelPath}' did not load; '{name}' will be invisible.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\trenderer.Model = model;\r\n\r\n\t\tvar bounds = model.Bounds.Size;\r\n\t\tgo.LocalScale = new Vector3(\r\n\t\t\tbounds.x > 0.001f ? part.SizeUnits.x / bounds.x : 1f,\r\n\t\t\tbounds.y > 0.001f ? part.SizeUnits.y / bounds.y : 1f,\r\n\t\t\tbounds.z > 0.001f ? part.SizeUnits.z / bounds.z : 1f );\r\n\r\n\t\t// The engine's models/dev primitives render as missing-material magenta unless a real material is\r\n\t\t// forced on, which would swallow the tint that tells the three accessories apart.\r\n\t\tvar mat = Material.Load( FallbackMaterial );\r\n\t\tif ( mat is not null ) renderer.MaterialOverride = mat;\r\n\t\trenderer.Tint = tint;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Apply each accessory's starting transform once its mount has resolved, then re-baseline it as the\r\n\t/// authored default so the panel's Reset comes back here.\r\n\t///\r\n\t/// Two paths, and which one runs depends on what the rig gave us:\r\n\t///\r\n\t/// - The mount landed on exactly the bone the demo values were measured against: apply them verbatim.\r\n\t/// These are real numbers, dialled in on the stock citizen, so the demo opens already fitted and the\r\n\t/// first thing you see is the finished result rather than three primitives in a heap.\r\n\t/// - Anything else, including the character-root fallback and any renamed or substituted bone: fall\r\n\t/// back to a seed expressed in the CHARACTER's frame (x forward, y left, z up) and let the engine\r\n\t/// convert it to local. Bone-local numbers are only valid on the rig they were measured on; reusing\r\n\t/// them elsewhere buries an accessory in the chest on one rig and flings it into orbit on the next.\r\n\t/// The character frame is not accurate, but it is always visible, which is all a fallback owes you.\r\n\t///\r\n\t/// The frame test requires a BONE mount, not just a matching name. An attachment that happens to be\r\n\t/// called \"head\" is a different transform from the head bone, and the tuned numbers would be wrong on it.\r\n\t/// </summary>\r\n\tvoid SeedPending()\r\n\t{\r\n\t\tif ( _pending.Count == 0 ) return;\r\n\r\n\t\tvar session = TweakSession.Instance;\r\n\t\tvar bodyRot = _citizen.IsValid() ? _citizen.WorldRotation : Rotation.Identity;\r\n\r\n\t\tfor ( int i = _pending.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar p = _pending[i];\r\n\t\t\tif ( !p.Accessory.IsValid() || !p.Mount.IsValid() ) { _pending.RemoveAt( i ); continue; }\r\n\t\t\tif ( p.Mount.Kind == CharacterAttachPoint.MountKind.Unresolved ) continue;\r\n\r\n\t\t\tbool tuned = p.Mount.Kind == CharacterAttachPoint.MountKind.Bone\r\n\t\t\t\t&& p.Mount.FrameName == p.TunedFrame;\r\n\r\n\t\t\tif ( tuned )\r\n\t\t\t{\r\n\t\t\t\tp.Accessory.LocalPosition = p.TunedPosition;\r\n\t\t\t\tp.Accessory.LocalRotation = p.TunedAngles.ToRotation();\r\n\t\t\t\tp.Accessory.LocalScale = new Vector3( p.TunedScale, p.TunedScale, p.TunedScale );\r\n\t\t\t\tLog.Info( $\"[placement] '{p.Accessory.Name}' seated at the tuned offset for {p.TunedFrame}\" );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tp.Accessory.WorldPosition = p.Mount.WorldPosition + bodyRot * p.BodySeed;\r\n\t\t\t\tLog.Info( $\"[placement] '{p.Accessory.Name}' mounted on {p.Mount.FrameName}, not the tuned \"\r\n\t\t\t\t\t+ $\"{p.TunedFrame}; using the generic character-frame seed instead. Fit it with P.\" );\r\n\t\t\t}\r\n\r\n\t\t\tsession?.CaptureSeed( p.Accessory );\r\n\t\t\t_pending.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- screen UI ----\r\n\r\n\tvoid BuildUi()\r\n\t{\r\n\t\t// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene\r\n\t\t// needs no razor wiring.\r\n\t\tvar panelHost = Scene.CreateObject();\r\n\t\tpanelHost.Name = \"Placement UI\";\r\n\t\tpanelHost.Components.Create<ScreenPanel>();\r\n\r\n\t\t// Open on arrival. Fitting the accessories is what this scene is FOR, so making the visitor find the\r\n\t\t// key first is a toll booth on the way to the point. P and the header x still close it. The panel\r\n\t\t// reads this on its first update, after every OnStart in the frame, so setting it here always lands.\r\n\t\tvar panel = panelHost.Components.Create<TweakPanel>();\r\n\t\tpanel.OpenOnStart = true;\r\n\r\n\t\tvar hintHost = Scene.CreateObject();\r\n\t\thintHost.Name = \"Placement Hint\";\r\n\t\thintHost.Components.Create<ScreenPanel>();\r\n\t\thintHost.Components.Create<DemoHintCard>();\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Placement/PlacedInstance.cs",
"FileName": "PlacedInstance.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Tags a placed object with the catalog id it came from, so <see cref=\"PlacementExport\"/> can collect\r\n/// every placed object in the scene and record which entry produced it. <see cref=\"GhostPlacer\"/> adds\r\n/// this on place; you can also add it by hand (or in a prefab) to make an object show up in exports.\r\n/// </summary>\r\n[Title( \"Placed Instance\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"place\" )]\r\npublic sealed class PlacedInstance : Component\r\n{\r\n\t/// <summary>The <see cref=\"PlaceableEntry.Id\"/> this object was placed from.</summary>\r\n\t[Property] public string CatalogId { get; set; }\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Demo/DemoBootstrap.cs",
"FileName": "DemoBootstrap.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Wires the demo scene in code so the whole kit is exercised from one component.\r\n///\r\n/// The scene it builds is the kit's hero case: a dressed stock citizen standing in the middle, with\r\n/// three accessories parented to mounts on its skeleton (a tool in the right hand, a hat on the head, a\r\n/// pack on the spine). Each is registered with the <see cref=\"TweakSession\"/>, so pressing P gives you\r\n/// three tabs of sliders that move the accessory RELATIVE to the bone it hangs from, and Copy hands you\r\n/// the paste-ready offset for your own game code. Everything ships with the engine: the citizen model,\r\n/// its clothing, and the dev primitives standing in for your accessories.\r\n///\r\n/// TWO-LEVEL ACCESSORY SHAPE. Each accessory is a bare root GameObject holding one or more child parts\r\n/// that carry the models. The split is load-bearing: the tweak panel's Scale row writes a UNIFORM\r\n/// LocalScale on the root, so any non-uniform proportions put there would be flattened the first time\r\n/// someone touched the slider. Proportions live on the children, authored directly in engine units; the\r\n/// root's scale stays a clean multiplier that reads 1 at the authored size.\r\n///\r\n/// The ghost-placement flow is still here as the second beat: press B and drop boxes on the ground, and\r\n/// they export as world-space placements alongside the accessory offsets.\r\n///\r\n/// Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.\r\n/// </summary>\r\n[Title( \"Placement Demo Bootstrap\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"auto_awesome\" )]\r\npublic sealed class DemoBootstrap : Component\r\n{\r\n\t/// <summary>Radius (engine units) inside which the demo validity seam reports a spot as valid, to show\r\n\t/// the ghost's green/red tint switching. Set to 0 or negative to allow placement anywhere.</summary>\r\n\t[Property] public float DemoValidRadius { get; set; } = 512f;\r\n\r\n\t/// <summary>Yaw the demo citizen faces. 225 puts its front toward the orbit camera's starting angle.</summary>\r\n\t[Property] public float CitizenYaw { get; set; } = 225f;\r\n\r\n\tconst string CitizenModel = \"models/citizen/citizen.vmdl\";\r\n\tconst string FallbackMaterial = \"materials/default.vmat\";\r\n\r\n\t/// <summary>A plain outfit from the shipped citizen clothing resources. Each item is null-checked, so a\r\n\t/// missing asset degrades to a barer citizen rather than a broken spawn.</summary>\r\n\tstatic readonly string[] Outfit =\r\n\t{\r\n\t\t\"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing\",\r\n\t\t\"models/citizen_clothes/shoes/Trainers/trainers.clothing\",\r\n\t};\r\n\r\n\t/// <summary>Accessories waiting for their mount to resolve before their starting transform is applied.\r\n\t/// See <see cref=\"SeedPending\"/>.</summary>\r\n\treadonly List<Pending> _pending = new();\r\n\r\n\t/// <summary>One accessory's starting transform, with two paths. <see cref=\"TunedFrame\"/> plus\r\n\t/// <see cref=\"TunedPosition\"/> are the values the owner dialled in on the stock citizen and are applied\r\n\t/// verbatim when the mount resolves to exactly that bone. <see cref=\"BodySeed\"/> is the generic\r\n\t/// fallback for every other outcome, because bone-local numbers mean nothing on a rig they were not\r\n\t/// measured on.</summary>\r\n\treadonly record struct Pending(\r\n\t\tGameObject Accessory,\r\n\t\tCharacterAttachPoint Mount,\r\n\t\tstring TunedFrame,\r\n\t\tVector3 TunedPosition,\r\n\t\tAngles TunedAngles,\r\n\t\tfloat TunedScale,\r\n\t\tVector3 BodySeed );\r\n\r\n\t/// <summary>One primitive making up an accessory's silhouette. <see cref=\"SizeUnits\"/> is the part's\r\n\t/// intended size in ENGINE UNITS per axis; the builder divides it by the model's own bounds to get the\r\n\t/// scale, so the numbers below read as real dimensions rather than model-relative multipliers.</summary>\r\n\treadonly record struct Part( string ModelPath, Vector3 SizeUnits, Vector3 LocalPosition );\r\n\r\n\tGameObject _citizen;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tBuildCatalog();\r\n\t\t_citizen = BuildCitizen();\r\n\t\tBuildAccessories( _citizen );\r\n\t\tBuildUi();\r\n\r\n\t\tLog.Info( \"[placement] demo ready. The tweak panel is open, P toggles it, right mouse orbits, B enters ghost placement.\" );\r\n\t}\r\n\r\n\tprotected override void OnUpdate() => SeedPending();\r\n\r\n\t// ---- the ghost-placement catalog (the second beat) ----\r\n\r\n\tvoid BuildCatalog()\r\n\t{\r\n\t\tvar cat = PlacementCatalog.Instance ?? Components.GetOrCreate<PlacementCatalog>();\r\n\t\tcat.Entries.Clear();\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"box\", \"Box\", \"models/dev/box.vmdl\" ) );\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"sphere\", \"Sphere\", \"models/dev/sphere.vmdl\" ) );\r\n\t\tcat.Entries.Add( new PlaceableEntry( \"plane\", \"Plane\", \"models/dev/plane.vmdl\" ) );\r\n\r\n\t\t// Demo validity seam: only allow placement within DemoValidRadius of the origin (illustrates the\r\n\t\t// green/red ghost tint). Replace or clear this in your own project.\r\n\t\tif ( DemoValidRadius > 0f )\r\n\t\t\tcat.ValidityCheck = ( pos, rot ) => pos.WithZ( 0f ).Length <= DemoValidRadius;\r\n\t}\r\n\r\n\t// ---- the character ----\r\n\r\n\tGameObject BuildCitizen()\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \"Citizen\";\r\n\t\tgo.WorldPosition = Vector3.Zero;\r\n\t\tgo.WorldRotation = Rotation.FromYaw( CitizenYaw );\r\n\r\n\t\tvar renderer = go.Components.Create<SkinnedModelRenderer>();\r\n\t\tvar model = Model.Load( CitizenModel );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[placement] demo citizen model '{CitizenModel}' did not load; the mounts will fall back to the root.\" );\r\n\t\t\treturn go;\r\n\t\t}\r\n\t\trenderer.Model = model;\r\n\t\tDress( renderer );\r\n\r\n\t\t// Standing idle straight off the citizen animgraph: grounded with no move input is its rest state,\r\n\t\t// which breathes and shifts weight a little. That subtle motion is the point here, because an\r\n\t\t// accessory that only looks right on a frozen T-pose is not actually fitted.\r\n\t\trenderer.Set( \"b_grounded\", true );\r\n\r\n\t\t// holdtype 6 is Swing on the citizen animgraph: a one-handed closed fist, so the hand actually grips\r\n\t\t// the tool instead of leaving a flat open palm under it. holdtype_handedness 1 is the right hand.\r\n\t\trenderer.Set( \"holdtype\", 6 );\r\n\t\trenderer.Set( \"holdtype_handedness\", 1 );\r\n\r\n\t\treturn go;\r\n\t}\r\n\r\n\tstatic void Dress( SkinnedModelRenderer renderer )\r\n\t{\r\n\t\tvar outfit = new ClothingContainer();\r\n\t\tbool any = false;\r\n\t\tforeach ( var path in Outfit )\r\n\t\t{\r\n\t\t\tvar item = ResourceLibrary.Get<Clothing>( path );\r\n\t\t\tif ( item is null )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\"[placement] demo clothing '{path}' did not resolve, skipping that slot.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t\toutfit.Add( item );\r\n\t\t\tany = true;\r\n\t\t}\r\n\t\tif ( any ) outfit.Apply( renderer );\r\n\t}\r\n\r\n\t// ---- the three accessories ----\r\n\r\n\tvoid BuildAccessories( GameObject citizen )\r\n\t{\r\n\t\tvar session = TweakSession.Instance ?? Components.GetOrCreate<TweakSession>();\r\n\r\n\t\t// OBSERVED ON THE SHIPPED CITIZEN (2026-07-30): none of the attachment names below resolved, and all\r\n\t\t// three mounts landed on their bone backstop (hand_R, head, spine_2). The attachment candidates stay\r\n\t\t// in the list because they are correct on rigs that do expose them and they exercise the\r\n\t\t// attachment-first path, but the bones are what actually carries this demo.\r\n\r\n\t\t// Right hand. hold_R is the usual weapon-hold attachment name; the casing varies between models.\r\n\t\tvar hand = BuildMount( citizen, \"Hand Mount\",\r\n\t\t\tattachments: new List<string> { \"hold_R\", \"hold_r\" },\r\n\t\t\tbones: new List<string> { \"hand_R\", \"hand_r\" } );\r\n\r\n\t\t// Head. \"hat\" is the usual head-top attachment point; the head bone is the backstop that resolves here.\r\n\t\tvar head = BuildMount( citizen, \"Head Mount\",\r\n\t\t\tattachments: new List<string> { \"hat\", \"head\" },\r\n\t\t\tbones: new List<string> { \"head\" } );\r\n\r\n\t\t// Upper spine. No attachment here, so this one exercises the bone path: the mount is re-pinned to\r\n\t\t// spine_2 every frame, which is what makes a back-worn item bob with the animation instead of\r\n\t\t// sliding along the root.\r\n\t\tvar back = BuildMount( citizen, \"Back Mount\",\r\n\t\t\tattachments: new List<string>(),\r\n\t\t\tbones: new List<string> { \"spine_2\", \"spine_1\", \"spine_0\", \"spine\" } );\r\n\r\n\t\t// SILHOUETTES, and the axis they are built along. All three tuned offsets came back with their large\r\n\t\t// component on local X (hand +5.5, head +15, spine +1.6 with the big move on Y), which is the bone\r\n\t\t// chain running +X down its length: out past the fingers, up out of the skull, up the spine. So each\r\n\t\t// accessory is authored with its long axis on X. If a rig ever disagrees, the panel's rotation rows\r\n\t\t// fix it in one drag; nothing here depends on the guess being right.\r\n\t\t//\r\n\t\t// SIZES are the ones the owner settled on, in engine units on the longest axis: tool 5, hat 7,\r\n\t\t// pack 14. See BuildPart for why they are written as dimensions rather than scale factors.\r\n\r\n\t\t// Tool: a handle with a heavier head on the end, reading along +X (out past the fingers).\r\n\t\tRegister( session, hand, \"Hand Tool\",\r\n\t\t\ttunedFrame: \"citizen/hand_R\",\r\n\t\t\ttunedPosition: new Vector3( 4.25f, 0.5f, -2.5f ),\r\n\t\t\ttunedAngles: new Angles( 5f, 91f, 5f ),\r\n\t\t\ttunedScale: 1.9f,\r\n\t\t\tbodySeed: new Vector3( 5f, 0f, -3f ),\r\n\t\t\ttint: new Color( 1f, 0.62f, 0.25f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 3.4f, 1f, 1f ), new Vector3( -0.8f, 0f, 0f ) ),\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 1.6f, 1.7f, 1.7f ), new Vector3( 1.7f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\r\n\t\t// Hat: a flat brim disk with a dome crown above it. No cylinder ships in models/dev, so both are\r\n\t\t// squashed spheres; the brim is thin on X (the up-out-of-the-skull axis) and round across Y and Z.\r\n\t\tRegister( session, head, \"Hat\",\r\n\t\t\ttunedFrame: \"citizen/head\",\r\n\t\t\ttunedPosition: new Vector3( 15f, 1.901744f, 0.000369f ),\r\n\t\t\ttunedAngles: new Angles( 0f, 0f, 0f ),\r\n\t\t\ttunedScale: 1f,\r\n\t\t\tbodySeed: new Vector3( 2f, 0f, 6f ),\r\n\t\t\ttint: new Color( 0.42f, 0.86f, 1f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/sphere.vmdl\", new Vector3( 1.6f, 7f, 7f ), new Vector3( 0f, 0f, 0f ) ),\r\n\t\t\t\tnew Part( \"models/dev/sphere.vmdl\", new Vector3( 2.6f, 4.2f, 4.2f ), new Vector3( 2f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\r\n\t\t// Pack: the box was close, so this is only a proportion change, taller up the spine than it is deep\r\n\t\t// off the back. Kept chunky rather than slab-thin so it still reads as a pack under any axis order.\r\n\t\tRegister( session, back, \"Back Pack\",\r\n\t\t\ttunedFrame: \"citizen/spine_2\",\r\n\t\t\ttunedPosition: new Vector3( 1.625522f, -9.075111f, -0.00035f ),\r\n\t\t\ttunedAngles: new Angles( 0f, 0f, 0f ),\r\n\t\t\ttunedScale: 1f,\r\n\t\t\tbodySeed: new Vector3( -9f, 0f, 2f ),\r\n\t\t\ttint: new Color( 0.85f, 0.45f, 0.95f ),\r\n\t\t\tparts: new[]\r\n\t\t\t{\r\n\t\t\t\tnew Part( \"models/dev/box.vmdl\", new Vector3( 14f, 7f, 11f ), new Vector3( 0f, 0f, 0f ) ),\r\n\t\t\t} );\r\n\t}\r\n\r\n\tCharacterAttachPoint BuildMount( GameObject citizen, string name, List<string> attachments, List<string> bones )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = name;\r\n\t\tgo.SetParent( citizen, false );\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar mount = go.Components.Create<CharacterAttachPoint>();\r\n\t\tmount.AttachmentNames = attachments;\r\n\t\tmount.BoneNames = bones;\r\n\t\tmount.CharacterName = \"citizen\";\r\n\t\treturn mount;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Build one accessory under a mount and register it for tweaking. The root is a bare GameObject: it\r\n\t/// owns the offset the panel edits and a uniform scale that starts at 1, and nothing else. The parts\r\n\t/// hang under it carrying the models and the proportions.\r\n\t///\r\n\t/// The starting transform is applied later, once the mount resolves. See <see cref=\"SeedPending\"/>.\r\n\t/// </summary>\r\n\tvoid Register( TweakSession session, CharacterAttachPoint mount, string label,\r\n\t\tstring tunedFrame, Vector3 tunedPosition, Angles tunedAngles, float tunedScale, Vector3 bodySeed,\r\n\t\tColor tint, Part[] parts )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = label;\r\n\t\tgo.SetParent( mount.GameObject, false );\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\t\tgo.LocalScale = Vector3.One; // the panel's Scale row is a multiplier on the authored size, so 1 is \"as built\"\r\n\r\n\t\tfor ( int i = 0; i < parts.Length; i++ )\r\n\t\t\tBuildPart( go, $\"{label} part {i + 1}\", parts[i], tint );\r\n\r\n\t\tsession.Add( new AttachedTweakTarget( go, mount, label ) );\r\n\t\t_pending.Add( new Pending( go, mount, tunedFrame, tunedPosition, tunedAngles, tunedScale, bodySeed ) );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One primitive under an accessory root, sized in engine units.\r\n\t///\r\n\t/// The scale is the requested size divided by the model's OWN bounds, per axis, rather than a\r\n\t/// hand-tuned multiplier. That keeps the part table readable as real dimensions and survives the engine\r\n\t/// changing what a dev primitive measures: the shipped box is 50 units and the sphere is 64, and\r\n\t/// nothing here has to know that.\r\n\t/// </summary>\r\n\tvoid BuildPart( GameObject parent, string name, Part part, Color tint )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = name;\r\n\t\tgo.SetParent( parent, false );\r\n\t\tgo.LocalPosition = part.LocalPosition;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar renderer = go.Components.Create<ModelRenderer>();\r\n\t\tvar model = Model.Load( part.ModelPath );\r\n\t\tif ( model is null || model.IsError )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[placement] demo part model '{part.ModelPath}' did not load; '{name}' will be invisible.\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\trenderer.Model = model;\r\n\r\n\t\tvar bounds = model.Bounds.Size;\r\n\t\tgo.LocalScale = new Vector3(\r\n\t\t\tbounds.x > 0.001f ? part.SizeUnits.x / bounds.x : 1f,\r\n\t\t\tbounds.y > 0.001f ? part.SizeUnits.y / bounds.y : 1f,\r\n\t\t\tbounds.z > 0.001f ? part.SizeUnits.z / bounds.z : 1f );\r\n\r\n\t\t// The engine's models/dev primitives render as missing-material magenta unless a real material is\r\n\t\t// forced on, which would swallow the tint that tells the three accessories apart.\r\n\t\tvar mat = Material.Load( FallbackMaterial );\r\n\t\tif ( mat is not null ) renderer.MaterialOverride = mat;\r\n\t\trenderer.Tint = tint;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Apply each accessory's starting transform once its mount has resolved, then re-baseline it as the\r\n\t/// authored default so the panel's Reset comes back here.\r\n\t///\r\n\t/// Two paths, and which one runs depends on what the rig gave us:\r\n\t///\r\n\t/// - The mount landed on exactly the bone the demo values were measured against: apply them verbatim.\r\n\t/// These are real numbers, dialled in on the stock citizen, so the demo opens already fitted and the\r\n\t/// first thing you see is the finished result rather than three primitives in a heap.\r\n\t/// - Anything else, including the character-root fallback and any renamed or substituted bone: fall\r\n\t/// back to a seed expressed in the CHARACTER's frame (x forward, y left, z up) and let the engine\r\n\t/// convert it to local. Bone-local numbers are only valid on the rig they were measured on; reusing\r\n\t/// them elsewhere buries an accessory in the chest on one rig and flings it into orbit on the next.\r\n\t/// The character frame is not accurate, but it is always visible, which is all a fallback owes you.\r\n\t///\r\n\t/// The frame test requires a BONE mount, not just a matching name. An attachment that happens to be\r\n\t/// called \"head\" is a different transform from the head bone, and the tuned numbers would be wrong on it.\r\n\t/// </summary>\r\n\tvoid SeedPending()\r\n\t{\r\n\t\tif ( _pending.Count == 0 ) return;\r\n\r\n\t\tvar session = TweakSession.Instance;\r\n\t\tvar bodyRot = _citizen.IsValid() ? _citizen.WorldRotation : Rotation.Identity;\r\n\r\n\t\tfor ( int i = _pending.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tvar p = _pending[i];\r\n\t\t\tif ( !p.Accessory.IsValid() || !p.Mount.IsValid() ) { _pending.RemoveAt( i ); continue; }\r\n\t\t\tif ( p.Mount.Kind == CharacterAttachPoint.MountKind.Unresolved ) continue;\r\n\r\n\t\t\tbool tuned = p.Mount.Kind == CharacterAttachPoint.MountKind.Bone\r\n\t\t\t\t&& p.Mount.FrameName == p.TunedFrame;\r\n\r\n\t\t\tif ( tuned )\r\n\t\t\t{\r\n\t\t\t\tp.Accessory.LocalPosition = p.TunedPosition;\r\n\t\t\t\tp.Accessory.LocalRotation = p.TunedAngles.ToRotation();\r\n\t\t\t\tp.Accessory.LocalScale = new Vector3( p.TunedScale, p.TunedScale, p.TunedScale );\r\n\t\t\t\tLog.Info( $\"[placement] '{p.Accessory.Name}' seated at the tuned offset for {p.TunedFrame}\" );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tp.Accessory.WorldPosition = p.Mount.WorldPosition + bodyRot * p.BodySeed;\r\n\t\t\t\tLog.Info( $\"[placement] '{p.Accessory.Name}' mounted on {p.Mount.FrameName}, not the tuned \"\r\n\t\t\t\t\t+ $\"{p.TunedFrame}; using the generic character-frame seed instead. Fit it with P.\" );\r\n\t\t\t}\r\n\r\n\t\t\tsession?.CaptureSeed( p.Accessory );\r\n\t\t\t_pending.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- screen UI ----\r\n\r\n\tvoid BuildUi()\r\n\t{\r\n\t\t// One ScreenPanel per PanelComponent (the World Builder UI idiom). Built in code so the demo scene\r\n\t\t// needs no razor wiring.\r\n\t\tvar panelHost = Scene.CreateObject();\r\n\t\tpanelHost.Name = \"Placement UI\";\r\n\t\tpanelHost.Components.Create<ScreenPanel>();\r\n\r\n\t\t// Open on arrival. Fitting the accessories is what this scene is FOR, so making the visitor find the\r\n\t\t// key first is a toll booth on the way to the point. P and the header x still close it. The panel\r\n\t\t// reads this on its first update, after every OnStart in the frame, so setting it here always lands.\r\n\t\tvar panel = panelHost.Components.Create<TweakPanel>();\r\n\t\tpanel.OpenOnStart = true;\r\n\r\n\t\tvar hintHost = Scene.CreateObject();\r\n\t\thintHost.Name = \"Placement Hint\";\r\n\t\thintHost.Components.Create<ScreenPanel>();\r\n\t\thintHost.Components.Create<DemoHintCard>();\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Tweak/ITweakTarget.cs",
"FileName": "ITweakTarget.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// The seam the tweak panel edits. A target is a display name plus the GameObject whose LOCAL transform\r\n/// (position, rotation as pitch/yaw/roll, uniform scale) the panel nudges.\r\n///\r\n/// Only <see cref=\"DisplayName\"/> and <see cref=\"Target\"/> are required. Everything below is\r\n/// default-implemented, so an existing implementation keeps compiling and gets sensible behaviour:\r\n/// accessory-scale sliders, a \"local\" frame label, a bake id derived from the display name, and the\r\n/// kit's standard bake line. Override the ones you care about.\r\n///\r\n/// Implement this to expose your own objects to the panel, or use the built-in <see cref=\"TweakTarget\"/>\r\n/// wrapper around a plain GameObject.\r\n/// </summary>\r\npublic interface ITweakTarget\r\n{\r\n\t/// <summary>The label shown on this target's tab in the panel.</summary>\r\n\tstring DisplayName { get; }\r\n\r\n\t/// <summary>The object whose LocalPosition / LocalRotation / LocalScale the panel edits.</summary>\r\n\tGameObject Target { get; }\r\n\r\n\t/// <summary>\r\n\t/// What the edited offset is measured against: the parent this object hangs from, named in words your\r\n\t/// game code recognises. \"citizen/hold_R\", \"citizen/spine_2\", \"turret/muzzle\". It shows on the panel's\r\n\t/// sub-line and goes into the export and the bake comment, so a pasted offset says what it is relative\r\n\t/// to. Defaults to <see cref=\"BakeFormat.LocalFrame\"/>, which just means \"this object's parent\".\r\n\t/// </summary>\r\n\tstring FrameName => BakeFormat.LocalFrame;\r\n\r\n\t/// <summary>\r\n\t/// The identifier the bake line names, e.g. \"hat\" in <c>new PlacedOffset( \"hat\", ... )</c>. Defaults to\r\n\t/// a code-safe squashing of <see cref=\"DisplayName\"/>, so \"Back Pack\" bakes as \"back_pack\".\r\n\t/// </summary>\r\n\tstring BakeSymbol => BakeFormat.SymbolFrom( DisplayName );\r\n\r\n\t/// <summary>Slider bounds and steps for this target's rows. Defaults to\r\n\t/// <see cref=\"TweakRanges.Accessory\"/>; use <see cref=\"TweakRanges.Scene\"/> for a placed prop.</summary>\r\n\tTweakRanges Ranges => TweakRanges.Accessory;\r\n\r\n\t/// <summary>\r\n\t/// Optional shaping hook for the paste-ready bake line. Return null (the default) for the kit's\r\n\t/// standard two-line form, or return your own text to bake straight into your project's real shape,\r\n\t/// e.g. <c>ItemMounts.HatOffset = new Vector3( ... );</c>. The <paramref name=\"item\"/> handed in\r\n\t/// already carries this target's live local transform, its <see cref=\"BakeSymbol\"/> as the id, and its\r\n\t/// <see cref=\"FrameName\"/>; format it with <see cref=\"BakeFormat.F(float)\"/> to match the kit's\r\n\t/// invariant-culture float style.\r\n\t/// </summary>\r\n\tstring FormatBakeLine( PlacedItem item ) => null;\r\n}\r\n\r\n/// <summary>\r\n/// A minimal <see cref=\"ITweakTarget\"/> that wraps a GameObject, defaulting its label to the object's\r\n/// name. Convenience for the common case where a target is just \"this GameObject\", with the optional\r\n/// interface members lifted to settable properties so a caller can fill them at registration.\r\n/// </summary>\r\npublic sealed class TweakTarget : ITweakTarget\r\n{\r\n\tpublic string DisplayName { get; set; }\r\n\tpublic GameObject Target { get; set; }\r\n\r\n\t/// <summary>What the offset is relative to, shown on the panel sub-line and written into the export.\r\n\t/// Leave null for the \"local\" default.</summary>\r\n\tpublic string Frame { get; set; }\r\n\r\n\t/// <summary>The id the bake line names. Leave null to derive it from <see cref=\"DisplayName\"/>.</summary>\r\n\tpublic string Symbol { get; set; }\r\n\r\n\t/// <summary>Slider bounds and steps. Defaults to accessory scale.</summary>\r\n\tpublic TweakRanges Ranges { get; set; } = TweakRanges.Accessory;\r\n\r\n\tstring ITweakTarget.FrameName => string.IsNullOrEmpty( Frame ) ? BakeFormat.LocalFrame : Frame;\r\n\tstring ITweakTarget.BakeSymbol => string.IsNullOrEmpty( Symbol ) ? BakeFormat.SymbolFrom( DisplayName ) : Symbol;\r\n\tTweakRanges ITweakTarget.Ranges => Ranges ?? TweakRanges.Accessory;\r\n\r\n\tpublic TweakTarget( GameObject go, string name = null )\r\n\t{\r\n\t\tTarget = go;\r\n\t\tDisplayName = string.IsNullOrEmpty( name ) ? (go?.Name ?? \"Object\") : name;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Attach/CharacterAttachPoint.cs",
"FileName": "CharacterAttachPoint.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// A mount frame on a skinned character: this GameObject rides a named ATTACHMENT (a hand hold point,\r\n/// a hat point) or, failing that, a named BONE, so anything parented under it inherits that frame and\r\n/// can be positioned with a plain LocalPosition / LocalRotation.\r\n///\r\n/// This is the piece the accessory-fitting flow is built on. Put this component on an empty child of\r\n/// the character, list the attachment and bone names you want in preference order, then parent your\r\n/// model under it and register the model with the <see cref=\"TweakSession\"/>. The offsets you drag in\r\n/// the panel are then genuinely relative to the hand or the spine, which is what\r\n/// <see cref=\"PlacedOffset\"/> bakes and what your game code will reproduce.\r\n///\r\n/// Resolution is DEFERRED and RETRIED, not done once at start: a skinned model created this frame has no\r\n/// attachment objects yet, and reading a bone before the first pose evaluates gives the bind pose. The\r\n/// component keeps trying for <see cref=\"ResolveTimeout\"/> seconds, then settles on the character root\r\n/// and says so in the console. A renamed or missing attachment therefore degrades to a visible,\r\n/// diagnosable fallback instead of a null reference.\r\n/// </summary>\r\n[Title( \"Character Attach Point\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"accessibility\" )]\r\npublic sealed class CharacterAttachPoint : Component\r\n{\r\n\t/// <summary>How the mount ended up anchored, once resolution settles.</summary>\r\n\tpublic enum MountKind\r\n\t{\r\n\t\t/// <summary>Still looking (the model may not have posed yet).</summary>\r\n\t\tUnresolved,\r\n\t\t/// <summary>Parented to a model attachment object; the engine drives it.</summary>\r\n\t\tAttachment,\r\n\t\t/// <summary>Re-pinned to a bone transform every frame before render.</summary>\r\n\t\tBone,\r\n\t\t/// <summary>Nothing resolved; sitting on the character root so the accessory is still visible.</summary>\r\n\t\tRoot,\r\n\t}\r\n\r\n\t/// <summary>The skinned model to mount against. Left null, the component searches its ancestors and\r\n\t/// then their descendants, which covers \"empty child of the character GameObject\".</summary>\r\n\t[Property] public SkinnedModelRenderer Renderer { get; set; }\r\n\r\n\t/// <summary>Attachment names to try, in order. Casing varies between models, so list both forms\r\n\t/// (for example \"hold_R\" then \"hold_r\"). Tried before <see cref=\"BoneNames\"/>.</summary>\r\n\t[Property] public List<string> AttachmentNames { get; set; } = new();\r\n\r\n\t/// <summary>Bone names to try if no attachment resolves. A bone mount is re-pinned every frame, so the\r\n\t/// accessory bobs and leans with the animation instead of sliding with the root.</summary>\r\n\t[Property] public List<string> BoneNames { get; set; } = new();\r\n\r\n\t/// <summary>Label for the character in the frame string, e.g. \"citizen\" gives \"citizen/hold_R\". This is\r\n\t/// what ends up in the export and the bake comment, so name it the way your code talks about it.</summary>\r\n\t[Property] public string CharacterName { get; set; } = \"character\";\r\n\r\n\t/// <summary>Seconds to keep retrying before giving up and falling back to the character root.</summary>\r\n\t[Property] public float ResolveTimeout { get; set; } = 3f;\r\n\r\n\t/// <summary>How this mount is anchored right now.</summary>\r\n\tpublic MountKind Kind { get; private set; } = MountKind.Unresolved;\r\n\r\n\t/// <summary>The attachment or bone name that actually resolved, or \"root\" on the fallback.</summary>\r\n\tpublic string ResolvedName { get; private set; } = \"root\";\r\n\r\n\t/// <summary>The frame string to hand <see cref=\"TweakSession.Add(GameObject, string, string, TweakRanges)\"/>:\r\n\t/// \"citizen/hold_R\", \"citizen/spine_2\", \"citizen/root\". Safe to read before resolution settles; it just\r\n\t/// reports the fallback until then.</summary>\r\n\tpublic string FrameName => $\"{CharacterName}/{ResolvedName}\";\r\n\r\n\tfloat _elapsed;\r\n\tbool _gaveUp;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tRenderer ??= Components.GetInAncestorsOrSelf<SkinnedModelRenderer>()\r\n\t\t\t?? Components.GetInDescendantsOrSelf<SkinnedModelRenderer>( false );\r\n\t\tTryResolve();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Kind is MountKind.Attachment or MountKind.Bone ) return;\r\n\r\n\t\t_elapsed += Time.Delta;\r\n\t\tif ( TryResolve() ) return;\r\n\r\n\t\tif ( _gaveUp || _elapsed < ResolveTimeout ) return;\r\n\r\n\t\t_gaveUp = true;\r\n\t\tKind = MountKind.Root;\r\n\t\tResolvedName = \"root\";\r\n\t\tLog.Warning( $\"[placement] attach point '{GameObject?.Name}' resolved no attachment {Names( AttachmentNames )} \"\r\n\t\t\t+ $\"and no bone {Names( BoneNames )} on the model; falling back to the character root.\" );\r\n\t}\r\n\r\n\t/// <summary>A bone mount is re-pinned here rather than in OnUpdate: PreRender runs after the pose is\r\n\t/// evaluated, so the accessory sits on the bone position that will actually be drawn this frame.</summary>\r\n\tprotected override void OnPreRender()\r\n\t{\r\n\t\tif ( Kind != MountKind.Bone || !Renderer.IsValid() ) return;\r\n\t\tif ( !Renderer.TryGetBoneTransform( ResolvedName, out Transform t ) ) return;\r\n\t\tWorldPosition = t.Position;\r\n\t\tWorldRotation = t.Rotation;\r\n\t}\r\n\r\n\t/// <summary>One resolution attempt. Attachments win (the engine parents and drives them for free);\r\n\t/// bones are the fallback that still tracks the animation. Returns true once anchored.</summary>\r\n\tbool TryResolve()\r\n\t{\r\n\t\tif ( !Renderer.IsValid() ) return false;\r\n\r\n\t\tforeach ( var name in AttachmentNames )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( name ) ) continue;\r\n\t\t\tvar mount = Renderer.GetAttachmentObject( name );\r\n\t\t\tif ( mount is null || !mount.IsValid() ) continue;\r\n\r\n\t\t\tGameObject.SetParent( mount, false );\r\n\t\t\tGameObject.LocalPosition = Vector3.Zero;\r\n\t\t\tGameObject.LocalRotation = Rotation.Identity;\r\n\t\t\tKind = MountKind.Attachment;\r\n\t\t\tResolvedName = name;\r\n\t\t\tLog.Info( $\"[placement] attach point '{GameObject.Name}' mounted on attachment {FrameName}\" );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tforeach ( var name in BoneNames )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( name ) ) continue;\r\n\t\t\tif ( !Renderer.TryGetBoneTransform( name, out Transform t ) ) continue;\r\n\r\n\t\t\tWorldPosition = t.Position;\r\n\t\t\tWorldRotation = t.Rotation;\r\n\t\t\tKind = MountKind.Bone;\r\n\t\t\tResolvedName = name;\r\n\t\t\tLog.Info( $\"[placement] attach point '{GameObject.Name}' mounted on bone {FrameName} (re-pinned each frame)\" );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic string Names( List<string> names )\r\n\t\t=> names is null || names.Count == 0 ? \"(none listed)\" : \"[\" + string.Join( \", \", names ) + \"]\";\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Tweak/TweakRanges.cs",
"FileName": "TweakRanges.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "namespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Slider bounds and step sizes for one tweak target's rows, in ENGINE UNITS and degrees.\r\n///\r\n/// Why this is per-target and not a constant: fitting a hat to a head and laying out a building are the\r\n/// same three sliders over wildly different ranges. A position row spanning +/-1024 units at step 1 (the\r\n/// kit's original fixed range) cannot seat an accessory at all: one pixel of drag is several units, and\r\n/// the value you want lives in the first half-percent of the track. The accessory default below spans\r\n/// +/-48 units at step 0.25, so the xfine step (\u00f710) reaches 0.025 units and a full drag still covers a\r\n/// character-sized volume.\r\n/// </summary>\r\npublic sealed record TweakRanges(\r\n\tfloat PositionRange = 48f,\r\n\tfloat PositionStep = 0.25f,\r\n\tfloat RotationStep = 1f,\r\n\tfloat ScaleMin = 0.05f,\r\n\tfloat ScaleMax = 4f,\r\n\tfloat ScaleStep = 0.05f )\r\n{\r\n\t/// <summary>The default: character-accessory scale. +/-48 units at 0.25 (xfine reaches 0.025), rotation\r\n\t/// at 1 degree, scale 0.05 to 4. This is what an unconfigured target gets.</summary>\r\n\tpublic static readonly TweakRanges Accessory = new();\r\n\r\n\t/// <summary>Scene-authoring scale, for tweaking a placed prop rather than a worn accessory:\r\n\t/// +/-1024 units at step 1, scale up to 8.</summary>\r\n\tpublic static readonly TweakRanges Scene = new( PositionRange: 1024f, PositionStep: 1f, ScaleMax: 8f );\r\n\r\n\t/// <summary>Room-scale, between the two: +/-256 units at 0.5.</summary>\r\n\tpublic static readonly TweakRanges Prop = new( PositionRange: 256f, PositionStep: 0.5f );\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Export/BakeFormat.cs",
"FileName": "BakeFormat.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using System.Globalization;\r\nusing System.Text;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// The text rules every export path shares: how a float becomes a C# literal, how a string is escaped,\r\n/// and how a display name becomes a code-safe id. One home for them so the JSON file, the C# snippet,\r\n/// and the panel's Copy button cannot drift apart in formatting.\r\n/// </summary>\r\npublic static class BakeFormat\r\n{\r\n\t/// <summary>The <see cref=\"ITweakTarget.FrameName\"/> default: \"this object's parent\", nothing more\r\n\t/// specific known.</summary>\r\n\tpublic const string LocalFrame = \"local\";\r\n\r\n\t/// <summary>A float as a C# literal body: up to six decimals, invariant culture so a decimal never\r\n\t/// localises to a comma and breaks the pasted code. Callers append the \"f\" suffix.</summary>\r\n\tpublic static string F( float v ) => v.ToString( \"0.######\", CultureInfo.InvariantCulture );\r\n\r\n\t/// <summary>Escape a string for a C# / JSON double-quoted literal.</summary>\r\n\tpublic static string Escape( string s ) => (s ?? \"\").Replace( \"\\\\\", \"\\\\\\\\\" ).Replace( \"\\\"\", \"\\\\\\\"\" );\r\n\r\n\t/// <summary>\r\n\t/// Squash a display name into a code-safe id: lowercase, non-alphanumerics collapsed to single\r\n\t/// underscores, no leading or trailing underscore. \"Back Pack\" becomes \"back_pack\", \"Hat (left)\"\r\n\t/// becomes \"hat_left\". Falls back to \"item\" for an empty or fully-stripped name.\r\n\t/// </summary>\r\n\tpublic static string SymbolFrom( string displayName )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( displayName ) ) return \"item\";\r\n\r\n\t\tvar sb = new StringBuilder( displayName.Length );\r\n\t\tbool pendingUnderscore = false;\r\n\t\tforeach ( char c in displayName )\r\n\t\t{\r\n\t\t\tif ( char.IsLetterOrDigit( c ) )\r\n\t\t\t{\r\n\t\t\t\tif ( pendingUnderscore && sb.Length > 0 ) sb.Append( '_' );\r\n\t\t\t\tpendingUnderscore = false;\r\n\t\t\t\tsb.Append( char.ToLowerInvariant( c ) );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tpendingUnderscore = true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn sb.Length == 0 ? \"item\" : sb.ToString();\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Demo/DemoHintCard.razor.scss",
"FileName": "DemoHintCard.razor.scss",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "// Demo hint card. Same visual language as the tweak panel: 3 grouped font sizes (13 / 11 / 10), no\r\n// letter-spacing, borders via border-width + border-color only (never border-style, which aborts the\r\n// whole stylesheet), no blurred box-shadow on rounded elements. Root is pointer-events:none so only the\r\n// card takes clicks. Every text colour sits above the contrast floor: instructions are never dim gray.\r\n//\r\n// The close control is a 42px square, matching the tweak panel. No ESC badge anywhere.\r\n\r\nDemoHintCard {\r\n\tposition: absolute;\r\n\ttop: 0;\r\n\tleft: 0;\r\n\twidth: 100%;\r\n\theight: 100%;\r\n\tpointer-events: none;\r\n\r\n\t.hc-card {\r\n\t\tposition: absolute;\r\n\t\ttop: 80px;\r\n\t\tleft: 24px;\r\n\t\twidth: 340px;\r\n\t\tflex-direction: column;\r\n\t\tpointer-events: all;\r\n\t\tbackground-color: rgba( 14, 16, 20, 0.94 );\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: rgba( 90, 220, 255, 0.55 );\r\n\t\tborder-radius: 8px;\r\n\t\tpadding: 12px;\r\n\t\tfont-family: \"Roboto Mono\", monospace;\r\n\t\tcolor: #eaf6ff;\r\n\t}\r\n\r\n\t.hc-hdr {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t\tmargin-bottom: 6px;\r\n\t}\r\n\t.hc-title {\r\n\t\tfont-size: 13px;\r\n\t\tcolor: #7fe6ff;\r\n\t}\r\n\t.hc-x {\r\n\t\tfont-size: 13px;\r\n\t\tcolor: #ffffff;\r\n\t\twidth: 42px;\r\n\t\theight: 42px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tborder-radius: 6px;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: rgba( 90, 220, 255, 0.45 );\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\tpointer-events: all;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover {\r\n\t\t\tbackground-color: rgba( 90, 220, 255, 0.28 );\r\n\t\t\tborder-color: rgba( 90, 220, 255, 0.9 );\r\n\t\t}\r\n\t}\r\n\r\n\t.hc-lede {\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #ffffff;\r\n\t\tmargin-bottom: 10px;\r\n\t}\r\n\r\n\t.hc-row {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tmargin-bottom: 5px;\r\n\t}\r\n\t.hc-key {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #ffffff;\r\n\t\tfont-weight: 700;\r\n\t\tbackground-color: rgba( 90, 220, 255, 0.18 );\r\n\t\tborder-radius: 4px;\r\n\t\tpadding: 3px 7px;\r\n\t\tmargin-right: 8px;\r\n\t\twidth: 96px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t}\r\n\t.hc-what {\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #eaf6ff;\r\n\t\tflex-grow: 1;\r\n\t}\r\n\r\n\t.hc-foot {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #cfe0ea;\r\n\t\tmargin-top: 8px;\r\n\t\tborder-top-width: 1px;\r\n\t\tborder-top-color: rgba( 255, 255, 255, 0.12 );\r\n\t\tpadding-top: 8px;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Placement Kit\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"placement\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"fieldguide\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"fieldguide.placement\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-07-31T00:57:51.8271982Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.121.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.121.0\")]"
},
{
"Ident": "fieldguide.placement",
"Path": "Attach/CharacterAttachPoint.cs",
"FileName": "CharacterAttachPoint.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// A mount frame on a skinned character: this GameObject rides a named ATTACHMENT (a hand hold point,\r\n/// a hat point) or, failing that, a named BONE, so anything parented under it inherits that frame and\r\n/// can be positioned with a plain LocalPosition / LocalRotation.\r\n///\r\n/// This is the piece the accessory-fitting flow is built on. Put this component on an empty child of\r\n/// the character, list the attachment and bone names you want in preference order, then parent your\r\n/// model under it and register the model with the <see cref=\"TweakSession\"/>. The offsets you drag in\r\n/// the panel are then genuinely relative to the hand or the spine, which is what\r\n/// <see cref=\"PlacedOffset\"/> bakes and what your game code will reproduce.\r\n///\r\n/// Resolution is DEFERRED and RETRIED, not done once at start: a skinned model created this frame has no\r\n/// attachment objects yet, and reading a bone before the first pose evaluates gives the bind pose. The\r\n/// component keeps trying for <see cref=\"ResolveTimeout\"/> seconds, then settles on the character root\r\n/// and says so in the console. A renamed or missing attachment therefore degrades to a visible,\r\n/// diagnosable fallback instead of a null reference.\r\n/// </summary>\r\n[Title( \"Character Attach Point\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"accessibility\" )]\r\npublic sealed class CharacterAttachPoint : Component\r\n{\r\n\t/// <summary>How the mount ended up anchored, once resolution settles.</summary>\r\n\tpublic enum MountKind\r\n\t{\r\n\t\t/// <summary>Still looking (the model may not have posed yet).</summary>\r\n\t\tUnresolved,\r\n\t\t/// <summary>Parented to a model attachment object; the engine drives it.</summary>\r\n\t\tAttachment,\r\n\t\t/// <summary>Re-pinned to a bone transform every frame before render.</summary>\r\n\t\tBone,\r\n\t\t/// <summary>Nothing resolved; sitting on the character root so the accessory is still visible.</summary>\r\n\t\tRoot,\r\n\t}\r\n\r\n\t/// <summary>The skinned model to mount against. Left null, the component searches its ancestors and\r\n\t/// then their descendants, which covers \"empty child of the character GameObject\".</summary>\r\n\t[Property] public SkinnedModelRenderer Renderer { get; set; }\r\n\r\n\t/// <summary>Attachment names to try, in order. Casing varies between models, so list both forms\r\n\t/// (for example \"hold_R\" then \"hold_r\"). Tried before <see cref=\"BoneNames\"/>.</summary>\r\n\t[Property] public List<string> AttachmentNames { get; set; } = new();\r\n\r\n\t/// <summary>Bone names to try if no attachment resolves. A bone mount is re-pinned every frame, so the\r\n\t/// accessory bobs and leans with the animation instead of sliding with the root.</summary>\r\n\t[Property] public List<string> BoneNames { get; set; } = new();\r\n\r\n\t/// <summary>Label for the character in the frame string, e.g. \"citizen\" gives \"citizen/hold_R\". This is\r\n\t/// what ends up in the export and the bake comment, so name it the way your code talks about it.</summary>\r\n\t[Property] public string CharacterName { get; set; } = \"character\";\r\n\r\n\t/// <summary>Seconds to keep retrying before giving up and falling back to the character root.</summary>\r\n\t[Property] public float ResolveTimeout { get; set; } = 3f;\r\n\r\n\t/// <summary>How this mount is anchored right now.</summary>\r\n\tpublic MountKind Kind { get; private set; } = MountKind.Unresolved;\r\n\r\n\t/// <summary>The attachment or bone name that actually resolved, or \"root\" on the fallback.</summary>\r\n\tpublic string ResolvedName { get; private set; } = \"root\";\r\n\r\n\t/// <summary>The frame string to hand <see cref=\"TweakSession.Add(GameObject, string, string, TweakRanges)\"/>:\r\n\t/// \"citizen/hold_R\", \"citizen/spine_2\", \"citizen/root\". Safe to read before resolution settles; it just\r\n\t/// reports the fallback until then.</summary>\r\n\tpublic string FrameName => $\"{CharacterName}/{ResolvedName}\";\r\n\r\n\tfloat _elapsed;\r\n\tbool _gaveUp;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tRenderer ??= Components.GetInAncestorsOrSelf<SkinnedModelRenderer>()\r\n\t\t\t?? Components.GetInDescendantsOrSelf<SkinnedModelRenderer>( false );\r\n\t\tTryResolve();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Kind is MountKind.Attachment or MountKind.Bone ) return;\r\n\r\n\t\t_elapsed += Time.Delta;\r\n\t\tif ( TryResolve() ) return;\r\n\r\n\t\tif ( _gaveUp || _elapsed < ResolveTimeout ) return;\r\n\r\n\t\t_gaveUp = true;\r\n\t\tKind = MountKind.Root;\r\n\t\tResolvedName = \"root\";\r\n\t\tLog.Warning( $\"[placement] attach point '{GameObject?.Name}' resolved no attachment {Names( AttachmentNames )} \"\r\n\t\t\t+ $\"and no bone {Names( BoneNames )} on the model; falling back to the character root.\" );\r\n\t}\r\n\r\n\t/// <summary>A bone mount is re-pinned here rather than in OnUpdate: PreRender runs after the pose is\r\n\t/// evaluated, so the accessory sits on the bone position that will actually be drawn this frame.</summary>\r\n\tprotected override void OnPreRender()\r\n\t{\r\n\t\tif ( Kind != MountKind.Bone || !Renderer.IsValid() ) return;\r\n\t\tif ( !Renderer.TryGetBoneTransform( ResolvedName, out Transform t ) ) return;\r\n\t\tWorldPosition = t.Position;\r\n\t\tWorldRotation = t.Rotation;\r\n\t}\r\n\r\n\t/// <summary>One resolution attempt. Attachments win (the engine parents and drives them for free);\r\n\t/// bones are the fallback that still tracks the animation. Returns true once anchored.</summary>\r\n\tbool TryResolve()\r\n\t{\r\n\t\tif ( !Renderer.IsValid() ) return false;\r\n\r\n\t\tforeach ( var name in AttachmentNames )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( name ) ) continue;\r\n\t\t\tvar mount = Renderer.GetAttachmentObject( name );\r\n\t\t\tif ( mount is null || !mount.IsValid() ) continue;\r\n\r\n\t\t\tGameObject.SetParent( mount, false );\r\n\t\t\tGameObject.LocalPosition = Vector3.Zero;\r\n\t\t\tGameObject.LocalRotation = Rotation.Identity;\r\n\t\t\tKind = MountKind.Attachment;\r\n\t\t\tResolvedName = name;\r\n\t\t\tLog.Info( $\"[placement] attach point '{GameObject.Name}' mounted on attachment {FrameName}\" );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tforeach ( var name in BoneNames )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( name ) ) continue;\r\n\t\t\tif ( !Renderer.TryGetBoneTransform( name, out Transform t ) ) continue;\r\n\r\n\t\t\tWorldPosition = t.Position;\r\n\t\t\tWorldRotation = t.Rotation;\r\n\t\t\tKind = MountKind.Bone;\r\n\t\t\tResolvedName = name;\r\n\t\t\tLog.Info( $\"[placement] attach point '{GameObject.Name}' mounted on bone {FrameName} (re-pinned each frame)\" );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tstatic string Names( List<string> names )\r\n\t\t=> names is null || names.Count == 0 ? \"(none listed)\" : \"[\" + string.Join( \", \", names ) + \"]\";\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Placement/PlacementCatalog.cs",
"FileName": "PlacementCatalog.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// The consumer-supplied set of placeable things, plus the validity seam the ghost uses to tint valid\r\n/// vs invalid spots. Populate <see cref=\"Entries\"/> in code (e.g. from a bootstrap) and, optionally, set\r\n/// <see cref=\"ValidityCheck\"/> to gate where placement is allowed (default: anywhere the aim ray hits).\r\n/// Publishes itself through a static <see cref=\"Instance\"/> so <see cref=\"GhostPlacer\"/> needs no handle.\r\n/// </summary>\r\n[Title( \"Placement Catalog\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"category\" )]\r\npublic sealed class PlacementCatalog : Component\r\n{\r\n\tpublic static PlacementCatalog Instance { get; private set; }\r\n\r\n\t/// <summary>The placeable entries, in cycle order. Filled by the consumer at runtime.</summary>\r\n\tpublic List<PlaceableEntry> Entries { get; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Seam: return true if an object may be placed at the given world position and rotation. Null means\r\n\t/// \"always valid\" (the default). The ghost tints green when this returns true, red when false, and a\r\n\t/// click only places on a valid spot.\r\n\t/// </summary>\r\n\tpublic Func<Vector3, Rotation, bool> ValidityCheck { get; set; }\r\n\r\n\tpublic bool IsValidSpot( Vector3 worldPos, Rotation worldRot )\r\n\t\t=> ValidityCheck is null || ValidityCheck( worldPos, worldRot );\r\n\r\n\tprotected override void OnEnabled() => Instance = this;\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( Instance == this ) Instance = null;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Tweak/TweakSession.cs",
"FileName": "TweakSession.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Holds the list of objects the <see cref=\"TweakPanel\"/> can edit, and publishes itself through a\r\n/// static <see cref=\"Instance\"/> so the panel needs no component handle. Two ways to fill it:\r\n///\r\n/// 1. Wire GameObjects into <see cref=\"Objects\"/> in the scene inspector (the simple path).\r\n/// 2. Call <see cref=\"Add(ITweakTarget)\"/> / <see cref=\"Add(GameObject, string)\"/> at runtime for\r\n/// code-supplied or custom targets. This is the path the accessory-fitting flow uses: parent your\r\n/// model to a character mount, then register it with a frame name so the bake says what the offset\r\n/// is relative to.\r\n///\r\n/// <see cref=\"Targets\"/> merges both into the effective list the panel tabs across.\r\n///\r\n/// The session also remembers each target's AUTHORED transform, captured the moment it is registered, so\r\n/// the panel's Reset restores what you started with rather than slamming the object to zero (which for a\r\n/// hand-mounted accessory means \"collapsed into the wrist\", never what you wanted).\r\n/// </summary>\r\n[Title( \"Tweak Session\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"tune\" )]\r\npublic sealed class TweakSession : Component\r\n{\r\n\tpublic static TweakSession Instance { get; private set; }\r\n\r\n\t/// <summary>Scene-wired targets: drop GameObjects here in the inspector to make them tweakable.</summary>\r\n\t[Property] public List<GameObject> Objects { get; set; } = new();\r\n\r\n\treadonly List<ITweakTarget> _custom = new();\r\n\r\n\t/// <summary>The authored local transform of each registered target, captured at registration. Keyed by\r\n\t/// GameObject so a custom ITweakTarget and a wrapped GameObject share one entry.</summary>\r\n\treadonly Dictionary<GameObject, Seed> _seeds = new();\r\n\r\n\treadonly record struct Seed( Vector3 Position, Rotation Rotation, Vector3 Scale );\r\n\r\n\t/// <summary>The effective, de-duplicated target list the panel tabs across: code-supplied targets\r\n\t/// first, then the scene-wired <see cref=\"Objects\"/> (skipping any invalid or already-listed ones).</summary>\r\n\tpublic IReadOnlyList<ITweakTarget> Targets\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar list = new List<ITweakTarget>();\r\n\t\t\tvar seen = new HashSet<GameObject>();\r\n\t\t\tforeach ( var t in _custom )\r\n\t\t\t{\r\n\t\t\t\tif ( t?.Target is null || !t.Target.IsValid() || !seen.Add( t.Target ) ) continue;\r\n\t\t\t\tlist.Add( t );\r\n\t\t\t}\r\n\t\t\tforeach ( var go in Objects )\r\n\t\t\t{\r\n\t\t\t\tif ( go is null || !go.IsValid() || !seen.Add( go ) ) continue;\r\n\t\t\t\tCaptureSeedIfNew( go ); // an object dropped into the list at runtime still gets an authored baseline\r\n\t\t\t\tlist.Add( new TweakTarget( go ) );\r\n\t\t\t}\r\n\t\t\treturn list;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Register a custom target (your own <see cref=\"ITweakTarget\"/> implementation).</summary>\r\n\tpublic void Add( ITweakTarget target )\r\n\t{\r\n\t\tif ( target?.Target is null ) return;\r\n\t\t_custom.Add( target );\r\n\t\tCaptureSeedIfNew( target.Target );\r\n\t}\r\n\r\n\t/// <summary>Register a GameObject as a target, wrapped in a <see cref=\"TweakTarget\"/>.</summary>\r\n\tpublic void Add( GameObject go, string name = null )\r\n\t{\r\n\t\tif ( go is null ) return;\r\n\t\t_custom.Add( new TweakTarget( go, name ) );\r\n\t\tCaptureSeedIfNew( go );\r\n\t}\r\n\r\n\t/// <summary>Register a GameObject with the frame its offset is measured against and, optionally, the\r\n\t/// slider ranges its rows should use. The frame name shows on the panel sub-line and lands in the\r\n\t/// export and the bake comment, which is the whole reason a baked offset is readable later.</summary>\r\n\tpublic TweakTarget Add( GameObject go, string name, string frame, TweakRanges ranges = null )\r\n\t{\r\n\t\tif ( go is null ) return null;\r\n\t\tvar t = new TweakTarget( go, name ) { Frame = frame, Ranges = ranges ?? TweakRanges.Accessory };\r\n\t\t_custom.Add( t );\r\n\t\tCaptureSeedIfNew( go );\r\n\t\treturn t;\r\n\t}\r\n\r\n\t/// <summary>Drop a previously-registered code-supplied target (does not affect scene-wired Objects).</summary>\r\n\tpublic void Remove( GameObject go )\r\n\t{\r\n\t\t_custom.RemoveAll( t => t.Target == go );\r\n\t\t_seeds.Remove( go );\r\n\t}\r\n\r\n\t// ---- authored-default baseline ----\r\n\r\n\t/// <summary>Record this object's CURRENT local transform as its authored default, overwriting any\r\n\t/// earlier capture. Call it after you finish positioning an object in code if you want Reset to come\r\n\t/// back to that pose rather than the one it had at registration.</summary>\r\n\tpublic void CaptureSeed( GameObject go )\r\n\t{\r\n\t\tif ( go is null || !go.IsValid() ) return;\r\n\t\t_seeds[go] = new Seed( go.LocalPosition, go.LocalRotation, go.LocalScale );\r\n\t}\r\n\r\n\tvoid CaptureSeedIfNew( GameObject go )\r\n\t{\r\n\t\tif ( go is null || !go.IsValid() || _seeds.ContainsKey( go ) ) return;\r\n\t\t_seeds[go] = new Seed( go.LocalPosition, go.LocalRotation, go.LocalScale );\r\n\t}\r\n\r\n\t/// <summary>Restore a target to the transform captured when it was registered. Returns false if the\r\n\t/// object is invalid or was never registered here, so a caller can fall back if it wants to.</summary>\r\n\tpublic bool ResetToSeed( GameObject go )\r\n\t{\r\n\t\tif ( go is null || !go.IsValid() || !_seeds.TryGetValue( go, out var seed ) ) return false;\r\n\t\tgo.LocalPosition = seed.Position;\r\n\t\tgo.LocalRotation = seed.Rotation;\r\n\t\tgo.LocalScale = seed.Scale;\r\n\t\treturn true;\r\n\t}\r\n\r\n\tprotected override void OnEnabled() => Instance = this;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t// Baseline the scene-wired objects at start, so their authored inspector transform is what Reset\r\n\t\t// comes back to even if something moves them before the panel is first opened.\r\n\t\tforeach ( var go in Objects )\r\n\t\t\tCaptureSeedIfNew( go );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( Instance == this ) Instance = null;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Placement/PlaceableEntry.cs",
"FileName": "PlaceableEntry.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// One entry in a <see cref=\"PlacementCatalog\"/>: an id, a display name, and what to spawn. Supply\r\n/// either a <see cref=\"ModelPath\"/> (the object is built as a GameObject + ModelRenderer, the light\r\n/// path the ghost preview also uses) or a <see cref=\"Prefab\"/> template GameObject (cloned on place,\r\n/// carrying all its components). If both are set, <see cref=\"Prefab\"/> wins for the placed object.\r\n///\r\n/// <see cref=\"Id\"/> is the stable key written into the export (JSON/C# snippet), so keep it terse and\r\n/// unique within a catalog (e.g. \"box\", \"sphere\", \"crate_a\").\r\n/// </summary>\r\npublic sealed class PlaceableEntry\r\n{\r\n\t/// <summary>Stable key written to the export. Unique within the catalog.</summary>\r\n\tpublic string Id { get; set; }\r\n\r\n\t/// <summary>Label shown in the placement HUD / logs.</summary>\r\n\tpublic string DisplayName { get; set; }\r\n\r\n\t/// <summary>Engine model path, e.g. \"models/dev/box.vmdl\". Used for the ghost preview and, when no\r\n\t/// <see cref=\"Prefab\"/> is set, for the placed object (a GameObject with a single ModelRenderer).</summary>\r\n\tpublic string ModelPath { get; set; }\r\n\r\n\t/// <summary>Optional template GameObject cloned on place (a disabled prefab instance in the scene works\r\n\t/// well). Overrides <see cref=\"ModelPath\"/> for the placed object; the ghost still uses ModelPath if set.</summary>\r\n\tpublic GameObject Prefab { get; set; }\r\n\r\n\tpublic PlaceableEntry() { }\r\n\r\n\tpublic PlaceableEntry( string id, string displayName, string modelPath )\r\n\t{\r\n\t\tId = id;\r\n\t\tDisplayName = displayName;\r\n\t\tModelPath = modelPath;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Placement/PlacedInstance.cs",
"FileName": "PlacedInstance.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Tags a placed object with the catalog id it came from, so <see cref=\"PlacementExport\"/> can collect\r\n/// every placed object in the scene and record which entry produced it. <see cref=\"GhostPlacer\"/> adds\r\n/// this on place; you can also add it by hand (or in a prefab) to make an object show up in exports.\r\n/// </summary>\r\n[Title( \"Placed Instance\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"place\" )]\r\npublic sealed class PlacedInstance : Component\r\n{\r\n\t/// <summary>The <see cref=\"PlaceableEntry.Id\"/> this object was placed from.</summary>\r\n\t[Property] public string CatalogId { get; set; }\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Tweak/TweakPanel.razor.scss",
"FileName": "TweakPanel.razor.scss",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "// Tweak panel styling. 3 grouped font sizes (13 / 11 / 10), no letter-spacing (glyph-corruption\r\n// budget). Borders via border-width + border-color only (never border-style, which aborts the whole\r\n// stylesheet). No blurred box-shadow on rounded elements (it renders unrounded); 0-blur rings only.\r\n// Root is pointer-events:none; only the card takes clicks, so the cursor stays usable over the panel\r\n// while the rest of the scene ignores it. Instructional text is bright, never dim gray.\r\n//\r\n// Slider line: minus stepper \u00b7 draggable fill track \u00b7 plus stepper. .tp-track position:relative +\r\n// .tp-fill position:absolute so dragging fills the bar WITHOUT growing the whole row, and the fill is\r\n// pointer-events:none so the drag position stays measured against the track.\r\n//\r\n// The close control is a 42px square (owner ruling: 34px is too small to hit). There is no ESC badge\r\n// anywhere: the \u00d7 is the close affordance.\r\n\r\nTweakPanel {\r\n\tposition: absolute;\r\n\ttop: 0;\r\n\tleft: 0;\r\n\twidth: 100%;\r\n\theight: 100%;\r\n\tpointer-events: none;\r\n\r\n\t.tp-card {\r\n\t\tposition: absolute;\r\n\t\ttop: 80px;\r\n\t\tright: 24px;\r\n\t\twidth: 300px;\r\n\t\tflex-direction: column;\r\n\t\tpointer-events: all;\r\n\t\tbackground-color: rgba( 14, 16, 20, 0.94 );\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: rgba( 90, 220, 255, 0.55 );\r\n\t\tborder-radius: 8px;\r\n\t\tpadding: 12px;\r\n\t\tfont-family: \"Roboto Mono\", monospace;\r\n\t\tcolor: #eaf6ff;\r\n\t}\r\n\r\n\t.tp-hdr {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t\tmargin-bottom: 6px;\r\n\t}\r\n\t.tp-title {\r\n\t\tfont-size: 13px;\r\n\t\tcolor: #7fe6ff;\r\n\t}\r\n\t.tp-hr {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t}\r\n\t.tp-key {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #ffffff;\r\n\t\tfont-weight: 700;\r\n\t\tbackground-color: rgba( 90, 220, 255, 0.14 );\r\n\t\tborder-radius: 4px;\r\n\t\tpadding: 2px 6px;\r\n\t\tmargin-right: 6px;\r\n\t}\r\n\t.tp-x {\r\n\t\tfont-size: 13px;\r\n\t\tcolor: #ffffff;\r\n\t\twidth: 42px;\r\n\t\theight: 42px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tborder-radius: 6px;\r\n\t\tborder-width: 1px;\r\n\t\tborder-color: rgba( 90, 220, 255, 0.45 );\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\tpointer-events: all;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover {\r\n\t\t\tbackground-color: rgba( 90, 220, 255, 0.28 );\r\n\t\t\tborder-color: rgba( 90, 220, 255, 0.9 );\r\n\t\t}\r\n\t}\r\n\r\n\t.tp-empty {\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #ffffff;\r\n\t\tpadding: 6px 0px;\r\n\t}\r\n\r\n\t.tp-tabs {\r\n\t\tflex-direction: row;\r\n\t\tflex-wrap: wrap;\r\n\t\tmargin-bottom: 8px;\r\n\t}\r\n\t.tp-tab {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #eaf6ff;\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\tborder-radius: 4px;\r\n\t\tpadding: 4px 7px;\r\n\t\tmargin-right: 4px;\r\n\t\tmargin-bottom: 4px;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover { background-color: rgba( 255, 255, 255, 0.12 ); }\r\n\t\t&.on {\r\n\t\t\tbackground-color: rgba( 90, 220, 255, 0.22 );\r\n\t\t\tcolor: #ffffff;\r\n\t\t\tborder-width: 1px;\r\n\t\t\tborder-color: rgba( 90, 220, 255, 0.7 );\r\n\t\t}\r\n\t}\r\n\r\n\t.tp-sub {\r\n\t\tflex-direction: column;\r\n\t\tmargin-bottom: 8px;\r\n\t}\r\n\t.tp-item { font-size: 11px; color: #ffffff; }\r\n\t.tp-note { font-size: 10px; color: #cfe0ea; }\r\n\r\n\t.tp-steprow {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t\tmargin-bottom: 8px;\r\n\t}\r\n\t.tp-sl { font-size: 10px; color: #cfe0ea; margin-right: 6px; }\r\n\t.tp-seg {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #eaf6ff;\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.06 );\r\n\t\tborder-radius: 4px;\r\n\t\tpadding: 3px 7px;\r\n\t\tmargin-right: 4px;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover { background-color: rgba( 255, 255, 255, 0.12 ); }\r\n\t\t&.on { background-color: rgba( 90, 220, 255, 0.22 ); color: #ffffff; }\r\n\t}\r\n\r\n\t.tp-row {\r\n\t\tflex-direction: column;\r\n\t\tmargin-bottom: 6px;\r\n\t\t&.hdr {\r\n\t\t\tmargin-top: 6px;\r\n\t\t\tborder-top-width: 1px;\r\n\t\t\tborder-top-color: rgba( 255, 255, 255, 0.1 );\r\n\t\t\tpadding-top: 8px;\r\n\t\t}\r\n\t}\r\n\t.tp-rlab {\r\n\t\tflex-direction: row;\r\n\t\tjustify-content: space-between;\r\n\t\talign-items: center;\r\n\t\tmargin-bottom: 4px;\r\n\t}\r\n\t.tp-rl { font-size: 11px; color: #eaf6ff; }\r\n\t.tp-rv {\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #ffffff;\r\n\t\tjustify-content: center;\r\n\t\ttext-align: center;\r\n\t}\r\n\r\n\t.tp-slider {\r\n\t\tflex-direction: row;\r\n\t\talign-items: center;\r\n\t}\r\n\t.tp-track {\r\n\t\tposition: relative;\r\n\t\tflex-grow: 1;\r\n\t\theight: 14px;\r\n\t\tmargin-left: 6px;\r\n\t\tmargin-right: 6px;\r\n\t\tborder-radius: 99px;\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.10 );\r\n\t\tpointer-events: all;\r\n\t\t&:hover { background-color: rgba( 255, 255, 255, 0.18 ); }\r\n\t\t.tp-fill {\r\n\t\t\tposition: absolute;\r\n\t\t\tleft: 0px;\r\n\t\t\theight: 100%;\r\n\t\t\tborder-radius: 99px;\r\n\t\t\tbackground-image: linear-gradient( 90deg, #3AC4DE, #7fe6ff );\r\n\t\t\tpointer-events: none;\r\n\t\t}\r\n\t}\r\n\t.tp-stp {\r\n\t\tfont-size: 13px;\r\n\t\tcolor: #eaf6ff;\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.08 );\r\n\t\tborder-radius: 4px;\r\n\t\twidth: 24px;\r\n\t\theight: 22px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover { background-color: rgba( 90, 220, 255, 0.28 ); color: #ffffff; }\r\n\t}\r\n\r\n\t.tp-btns {\r\n\t\tflex-direction: row;\r\n\t\tmargin-top: 8px;\r\n\t}\r\n\t.tp-btn {\r\n\t\tfont-size: 10px;\r\n\t\tcolor: #eaf6ff;\r\n\t\tbackground-color: rgba( 255, 255, 255, 0.08 );\r\n\t\tborder-radius: 4px;\r\n\t\tpadding: 7px 8px;\r\n\t\tmargin-right: 5px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tflex-grow: 1;\r\n\t\ttransition: all 0.1s ease;\r\n\t\t&:hover { background-color: rgba( 90, 220, 255, 0.24 ); color: #ffffff; }\r\n\t\t&.wide { margin-right: 0px; }\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Code/Export/PlacementExport.cs",
"FileName": "PlacementExport.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Bakes what you tuned in-game into text you can paste into your own project. Two populations are\r\n/// collected, because the kit serves two jobs and both end at \"get these numbers into my code\":\r\n///\r\n/// 1. TWEAK TARGETS (the hero case). Every <see cref=\"ITweakTarget\"/> registered with the scene's\r\n/// <see cref=\"TweakSession\"/>, exported as a LOCAL offset tagged with the frame it hangs off\r\n/// (\"citizen/hold_R\", \"citizen/spine_2\", ...). This is the accessory-fitting path: parent a custom\r\n/// model to a character, drag it into place, bake the offset.\r\n/// 2. PLACED INSTANCES (scene authoring). Every object carrying a <see cref=\"PlacedInstance\"/>, exported\r\n/// in WORLD space. This is the ghost-placer path: lay a scene out, bake the coordinates.\r\n///\r\n/// <see cref=\"WriteAll(Scene)\"/> writes a JSON file and a C# file into FileSystem.Data (per-project,\r\n/// writable) and logs their full paths. The panel's Copy button does the single-target version:\r\n/// <see cref=\"BakeLine(ITweakTarget)\"/> straight onto the clipboard.\r\n/// </summary>\r\npublic static class PlacementExport\r\n{\r\n\tpublic const string JsonFileName = \"placement_export.json\";\r\n\tpublic const string CSharpFileName = \"placement_export.cs\";\r\n\r\n\t// ---- collection ----\r\n\r\n\t/// <summary>Both populations in one list: tweak-session offsets first (ordered by frame then id), then\r\n\t/// world-space placed instances. This is what the JSON and C# exports write.</summary>\r\n\tpublic static List<PlacedItem> Collect( Scene scene )\r\n\t{\r\n\t\tvar list = new List<PlacedItem>();\r\n\t\tif ( scene is null ) return list;\r\n\r\n\t\tlist.AddRange( CollectOffsets( scene ) );\r\n\t\tlist.AddRange( CollectPlaced( scene ) );\r\n\t\treturn list;\r\n\t}\r\n\r\n\t/// <summary>The tweak session's targets as LOCAL offsets, each tagged with its frame. Empty when the\r\n\t/// scene has no <see cref=\"TweakSession\"/> or the session holds no valid targets.</summary>\r\n\tpublic static List<PlacedItem> CollectOffsets( Scene scene )\r\n\t{\r\n\t\tvar list = new List<PlacedItem>();\r\n\t\tif ( scene is null ) return list;\r\n\r\n\t\tforeach ( var session in scene.GetAllComponents<TweakSession>() )\r\n\t\t{\r\n\t\t\tif ( session is null || !session.IsValid() ) continue;\r\n\t\t\tforeach ( var target in session.Targets )\r\n\t\t\t{\r\n\t\t\t\tvar item = ToItem( target );\r\n\t\t\t\tif ( item is not null ) list.Add( item );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn list\r\n\t\t\t.OrderBy( i => i.Frame )\r\n\t\t\t.ThenBy( i => i.Id )\r\n\t\t\t.ToList();\r\n\t}\r\n\r\n\t/// <summary>Every object tagged with a <see cref=\"PlacedInstance\"/>, in WORLD space, ordered by catalog\r\n\t/// id then x for stable output.</summary>\r\n\tpublic static List<PlacedItem> CollectPlaced( Scene scene )\r\n\t{\r\n\t\tvar list = new List<PlacedItem>();\r\n\t\tif ( scene is null ) return list;\r\n\r\n\t\tforeach ( var inst in scene.GetAllComponents<PlacedInstance>() )\r\n\t\t{\r\n\t\t\tif ( inst is null || !inst.IsValid() ) continue;\r\n\t\t\tvar go = inst.GameObject;\r\n\t\t\tlist.Add( new PlacedItem(\r\n\t\t\t\tstring.IsNullOrEmpty( inst.CatalogId ) ? \"item\" : inst.CatalogId,\r\n\t\t\t\tgo.WorldPosition,\r\n\t\t\t\tgo.WorldRotation.Angles(),\r\n\t\t\t\tgo.WorldScale.x,\r\n\t\t\t\tPlacedItem.WorldFrame ) );\r\n\t\t}\r\n\r\n\t\treturn list\r\n\t\t\t.OrderBy( i => i.Id )\r\n\t\t\t.ThenBy( i => i.Position.x )\r\n\t\t\t.ToList();\r\n\t}\r\n\r\n\t/// <summary>One tweak target's live LOCAL transform as a record: its bake id, its local position /\r\n\t/// rotation / uniform scale, and the frame it hangs off. Null for an invalid target.</summary>\r\n\tpublic static PlacedItem ToItem( ITweakTarget target )\r\n\t{\r\n\t\tvar go = target?.Target;\r\n\t\tif ( go is null || !go.IsValid() ) return null;\r\n\r\n\t\treturn new PlacedItem(\r\n\t\t\ttarget.BakeSymbol,\r\n\t\t\tgo.LocalPosition,\r\n\t\t\tgo.LocalRotation.Angles(),\r\n\t\t\tgo.LocalScale.x,\r\n\t\t\ttarget.FrameName );\r\n\t}\r\n\r\n\t// ---- the paste-ready line (what Copy puts on the clipboard) ----\r\n\r\n\t/// <summary>\r\n\t/// The paste-ready C# for ONE tweak target. A target that implements\r\n\t/// <see cref=\"ITweakTarget.FormatBakeLine\"/> shapes its own line (bake straight into your real data\r\n\t/// shape); otherwise the kit's default form is used. Returns an empty string for an invalid target.\r\n\t/// </summary>\r\n\tpublic static string BakeLine( ITweakTarget target )\r\n\t{\r\n\t\tvar item = ToItem( target );\r\n\t\tif ( item is null ) return \"\";\r\n\r\n\t\tvar custom = target.FormatBakeLine( item );\r\n\t\treturn string.IsNullOrEmpty( custom ) ? DefaultBakeLine( item ) : custom;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The kit's default bake line: a comment naming the id and the frame the offset is measured from,\r\n\t/// then a <see cref=\"PlacedOffset\"/> constructor with the live numbers. Two lines, because the comment\r\n\t/// is what makes the numbers mean something once they are sitting in a file three weeks later.\r\n\t/// <code>\r\n\t/// // hat: offset from citizen/head\r\n\t/// new PlacedOffset( \"hat\", new Vector3( 0.5f, 0f, 3.25f ), new Angles( 0f, 90f, 0f ), 1f ),\r\n\t/// </code>\r\n\t/// </summary>\r\n\tpublic static string DefaultBakeLine( PlacedItem it )\r\n\t{\r\n\t\tif ( it is null ) return \"\";\r\n\t\tstring what = it.IsLocalOffset ? $\"offset from {it.Frame}\" : \"world placement\";\r\n\t\treturn $\"// {it.Id}: {what}\\n{OffsetCtor( it )},\";\r\n\t}\r\n\r\n\t/// <summary>Just the constructor expression, no comment and no trailing comma. The building block the\r\n\t/// default line and the C# file share.</summary>\r\n\tpublic static string OffsetCtor( PlacedItem it )\r\n\t\t=> $\"new PlacedOffset( \\\"{BakeFormat.Escape( it.Id )}\\\", \"\r\n\t\t + $\"new Vector3( {BakeFormat.F( it.Position.x )}f, {BakeFormat.F( it.Position.y )}f, {BakeFormat.F( it.Position.z )}f ), \"\r\n\t\t + $\"new Angles( {BakeFormat.F( it.Rotation.pitch )}f, {BakeFormat.F( it.Rotation.yaw )}f, {BakeFormat.F( it.Rotation.roll )}f ), \"\r\n\t\t + $\"{BakeFormat.F( it.Scale )}f )\";\r\n\r\n\t// ---- JSON ----\r\n\r\n\t/// <summary>An indented JSON array of { id, frame, position {x,y,z}, rotation {pitch,yaw,roll}, scale }.\r\n\t/// \"frame\" is \"world\" for a placed instance and the target's frame name for a tuned offset.</summary>\r\n\tpublic static string ToJson( IEnumerable<PlacedItem> items )\r\n\t{\r\n\t\tvar sb = new StringBuilder();\r\n\t\tsb.Append( \"[\\n\" );\r\n\t\tvar arr = items.ToList();\r\n\t\tfor ( int i = 0; i < arr.Count; i++ )\r\n\t\t{\r\n\t\t\tvar it = arr[i];\r\n\t\t\tsb.Append( \" {\\n\" );\r\n\t\t\tsb.Append( $\" \\\"id\\\": \\\"{BakeFormat.Escape( it.Id )}\\\",\\n\" );\r\n\t\t\tsb.Append( $\" \\\"frame\\\": \\\"{BakeFormat.Escape( it.Frame )}\\\",\\n\" );\r\n\t\t\tsb.Append( $\" \\\"position\\\": {{ \\\"x\\\": {BakeFormat.F( it.Position.x )}, \\\"y\\\": {BakeFormat.F( it.Position.y )}, \\\"z\\\": {BakeFormat.F( it.Position.z )} }},\\n\" );\r\n\t\t\tsb.Append( $\" \\\"rotation\\\": {{ \\\"pitch\\\": {BakeFormat.F( it.Rotation.pitch )}, \\\"yaw\\\": {BakeFormat.F( it.Rotation.yaw )}, \\\"roll\\\": {BakeFormat.F( it.Rotation.roll )} }},\\n\" );\r\n\t\t\tsb.Append( $\" \\\"scale\\\": {BakeFormat.F( it.Scale )}\\n\" );\r\n\t\t\tsb.Append( i < arr.Count - 1 ? \" },\\n\" : \" }\\n\" );\r\n\t\t}\r\n\t\tsb.Append( \"]\\n\" );\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\t// ---- C# snippet ----\r\n\r\n\t/// <summary>\r\n\t/// A paste-ready C# file with up to two blocks, each written only when it has entries:\r\n\t/// a <c>PlacedOffset[]</c> of character-relative offsets (one commented line per accessory, naming the\r\n\t/// frame it hangs off), and a <c>PlacedItem[]</c> of world-space placements. Both record types ship\r\n\t/// with the kit, so the paste compiles as-is against <c>FieldGuide.Placement</c>.\r\n\t/// </summary>\r\n\tpublic static string ToCSharp( IEnumerable<PlacedItem> items )\r\n\t{\r\n\t\tvar arr = items.ToList();\r\n\t\tvar offsets = arr.Where( i => i.IsLocalOffset ).ToList();\r\n\t\tvar placed = arr.Where( i => !i.IsLocalOffset ).ToList();\r\n\r\n\t\tvar sb = new StringBuilder();\r\n\t\tsb.Append( \"// Generated by FieldGuide.Placement. Paste into your project.\\n\" );\r\n\r\n\t\tif ( offsets.Count > 0 )\r\n\t\t{\r\n\t\t\tsb.Append( \"\\n// Character-relative offsets. Each is LOCAL to the frame named above it: parent your\\n\" );\r\n\t\t\tsb.Append( \"// object to that mount, then call offset.ApplyTo( go ).\\n\" );\r\n\t\t\tsb.Append( \"static readonly PlacedOffset[] Offsets =\\n{\\n\" );\r\n\t\t\tforeach ( var it in offsets )\r\n\t\t\t{\r\n\t\t\t\tsb.Append( $\"\\t// {it.Id}: offset from {it.Frame}\\n\" );\r\n\t\t\t\tsb.Append( $\"\\t{OffsetCtor( it )},\\n\" );\r\n\t\t\t}\r\n\t\t\tsb.Append( \"};\\n\" );\r\n\t\t}\r\n\r\n\t\tif ( placed.Count > 0 )\r\n\t\t{\r\n\t\t\tsb.Append( \"\\n// World-space placements from the ghost placer.\\n\" );\r\n\t\t\tsb.Append( \"// PlacedItem( string Id, Vector3 Position, Angles Rotation, float Scale, string Frame )\\n\" );\r\n\t\t\tsb.Append( \"static readonly PlacedItem[] Placed =\\n{\\n\" );\r\n\t\t\tforeach ( var it in placed )\r\n\t\t\t{\r\n\t\t\t\tsb.Append( $\"\\tnew PlacedItem( \\\"{BakeFormat.Escape( it.Id )}\\\", \" );\r\n\t\t\t\tsb.Append( $\"new Vector3( {BakeFormat.F( it.Position.x )}f, {BakeFormat.F( it.Position.y )}f, {BakeFormat.F( it.Position.z )}f ), \" );\r\n\t\t\t\tsb.Append( $\"new Angles( {BakeFormat.F( it.Rotation.pitch )}f, {BakeFormat.F( it.Rotation.yaw )}f, {BakeFormat.F( it.Rotation.roll )}f ), \" );\r\n\t\t\t\tsb.Append( $\"{BakeFormat.F( it.Scale )}f, \\\"{BakeFormat.Escape( it.Frame )}\\\" ),\\n\" );\r\n\t\t\t}\r\n\t\t\tsb.Append( \"};\\n\" );\r\n\t\t}\r\n\r\n\t\tif ( offsets.Count == 0 && placed.Count == 0 )\r\n\t\t\tsb.Append( \"\\n// Nothing to export: no TweakSession targets and no PlacedInstance objects in the scene.\\n\" );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\t// ---- write both to FileSystem.Data ----\r\n\r\n\t/// <summary>Collect both populations and write the JSON and C# exports to FileSystem.Data, logging the\r\n\t/// paths and the per-population counts. Returns the total number of records written.</summary>\r\n\tpublic static int WriteAll( Scene scene )\r\n\t{\r\n\t\tvar items = Collect( scene );\r\n\t\tint offsets = items.Count( i => i.IsLocalOffset );\r\n\t\tint placed = items.Count - offsets;\r\n\r\n\t\tFileSystem.Data.WriteAllText( JsonFileName, ToJson( items ) );\r\n\t\tFileSystem.Data.WriteAllText( CSharpFileName, ToCSharp( items ) );\r\n\r\n\t\tLog.Info( $\"[placement] exported {offsets} tuned offset(s) + {placed} world placement(s):\" );\r\n\t\tLog.Info( $\"[placement] JSON to {FileSystem.Data.GetFullPath( JsonFileName )}\" );\r\n\t\tLog.Info( $\"[placement] C# to {FileSystem.Data.GetFullPath( CSharpFileName )}\" );\r\n\t\treturn items.Count;\r\n\t}\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Placement/GhostPlacer.cs",
"FileName": "GhostPlacer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\nnamespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Aim-follow ghost placement, pattern-rebuilt from World Builder's prop placer, minus all networking:\r\n/// this is a single-player authoring driver. Toggle placement mode with B (or the `placement_place`\r\n/// convar); a tinted, colliderless GHOST of the current catalog entry follows the aim ray (green =\r\n/// valid spot, red = invalid, per <see cref=\"PlacementCatalog.ValidityCheck\"/>). Bracket keys cycle\r\n/// the catalog, R rotates the ghost, left mouse places, G deletes the placed object under the cursor.\r\n///\r\n/// Placed objects get a <see cref=\"PlacedInstance\"/> tag so <see cref=\"PlacementExport\"/> can find them.\r\n/// Works in engine units throughout (no meter scaling), so it composes with any camera setup.\r\n/// </summary>\r\n[Title( \"Ghost Placer\" )]\r\n[Category( \"Field Guide \u00b7 Placement\" )]\r\n[Icon( \"add_location\" )]\r\npublic sealed class GhostPlacer : Component\r\n{\r\n\tpublic static GhostPlacer Instance { get; private set; }\r\n\r\n\t// \u2500\u2500 HUD readback (static so a HUD razor needs no component handle) \u2500\u2500\r\n\tpublic static bool PlaceModeActive { get; private set; }\r\n\tpublic static string CurrentEntryName { get; private set; }\r\n\tpublic static int PlacedCount { get; private set; }\r\n\r\n\t// \u2500\u2500 toggle state (B raw key + `placement_place` convar fallback) \u2500\u2500\r\n\tstatic bool _placeConvar;\r\n\t[ConVar( \"placement_place\", Help = \"Arm or disarm ghost placement mode (same as the B key)\" )]\r\n\tpublic static bool PlaceModeConVar { get => _placeConvar; set => _placeConvar = value; }\r\n\r\n\t/// <summary>Rotation step (degrees) applied by the R key.</summary>\r\n\t[Property] public float RotateStepDeg { get; set; } = 45f;\r\n\r\n\t/// <summary>How far the placed/ghost object sinks along -Z from the aim hit (engine units).</summary>\r\n\t[Property] public float SinkDepth { get; set; } = 0f;\r\n\r\n\t/// <summary>Max aim-ray length in engine units.</summary>\r\n\t[Property] public float AimReach { get; set; } = 500_000f;\r\n\r\n\tbool _placeMode;\r\n\tint _catalogIndex;\r\n\tfloat _ghostYaw;\r\n\tGameObject _ghost;\r\n\tstring _ghostModel;\r\n\r\n\tPlacementCatalog Catalog => PlacementCatalog.Instance;\r\n\r\n\tPlaceableEntry CurrentEntry\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar cat = Catalog;\r\n\t\t\tif ( cat is null || cat.Entries.Count == 0 ) return null;\r\n\t\t\tint n = cat.Entries.Count;\r\n\t\t\treturn cat.Entries[((_catalogIndex % n) + n) % n];\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnEnabled() => Instance = this;\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( Instance == this ) Instance = null;\r\n\t\tPlaceModeActive = false;\r\n\t\tDestroyGhost();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tHandleInput();\r\n\t\tUpdateGhost();\r\n\r\n\t\tPlaceModeActive = _placeMode;\r\n\t\tCurrentEntryName = CurrentEntry?.DisplayName;\r\n\t\tPlacedCount = CountPlaced();\r\n\t}\r\n\r\n\t// \u2500\u2500 input \u2500\u2500\r\n\r\n\tvoid HandleInput()\r\n\t{\r\n\t\t// B (raw key) or the `placement_place` convar toggles placement mode.\r\n\t\tif ( Input.Keyboard.Pressed( \"B\" ) )\r\n\t\t\tSetPlaceMode( !_placeMode );\r\n\t\telse if ( _placeConvar != _placeMode )\r\n\t\t\tSetPlaceMode( _placeConvar );\r\n\r\n\t\tif ( !_placeMode ) return;\r\n\r\n\t\t// cycle the catalog (bracket keys; the mouse wheel is the camera zoom, so it is not used here)\r\n\t\tif ( Input.Keyboard.Pressed( \"]\" ) ) { _catalogIndex++; RebuildGhostModel(); }\r\n\t\tif ( Input.Keyboard.Pressed( \"[\" ) ) { _catalogIndex--; RebuildGhostModel(); }\r\n\r\n\t\t// rotate (R)\r\n\t\tif ( Input.Keyboard.Pressed( \"R\" ) ) _ghostYaw = (_ghostYaw + RotateStepDeg) % 360f;\r\n\r\n\t\t// place (left mouse)\r\n\t\tif ( Input.Pressed( \"attack1\" ) )\r\n\t\t\tTryPlaceAtAim();\r\n\r\n\t\t// delete the placed object under the cursor (G)\r\n\t\tif ( Input.Keyboard.Pressed( \"G\" ) )\r\n\t\t\tTryDeleteAtAim();\r\n\t}\r\n\r\n\tvoid SetPlaceMode( bool on )\r\n\t{\r\n\t\t_placeMode = on;\r\n\t\t_placeConvar = on;\r\n\t\tif ( on )\r\n\t\t{\r\n\t\t\tRebuildGhostModel();\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible;\r\n\t\t\tLog.Info( $\"[placement] mode ON, armed with {CurrentEntry?.DisplayName ?? \"(empty catalog)\"}\" );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tDestroyGhost();\r\n\t\t\tLog.Info( \"[placement] mode OFF\" );\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 aim \u2500\u2500\r\n\r\n\tbool AimHit( out Vector3 worldPos )\r\n\t{\r\n\t\tworldPos = default;\r\n\t\tvar cam = Scene.Camera;\r\n\t\tif ( !cam.IsValid() ) return false;\r\n\t\tvar ray = cam.ScreenPixelToRay( Mouse.Position );\r\n\t\tvar trace = Scene.Trace.Ray( ray, AimReach );\r\n\t\tif ( _ghost.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( _ghost ); // never hit the ghost itself\r\n\t\tvar tr = trace.Run();\r\n\t\tif ( !tr.Hit || tr.StartedSolid ) return false;\r\n\t\tworldPos = tr.HitPosition;\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid TryPlaceAtAim()\r\n\t{\r\n\t\tvar entry = CurrentEntry;\r\n\t\tif ( entry is null || !AimHit( out var hit ) ) return;\r\n\r\n\t\tvar pos = hit.WithZ( hit.z - SinkDepth );\r\n\t\tvar rot = Rotation.FromYaw( _ghostYaw );\r\n\r\n\t\tif ( !(Catalog?.IsValidSpot( pos, rot ) ?? true) )\r\n\t\t{\r\n\t\t\tLog.Info( $\"[placement] refused: invalid spot for {entry.DisplayName}\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar go = Spawn( entry, pos, rot );\r\n\t\tif ( go is null )\r\n\t\t{\r\n\t\t\tLog.Info( $\"[placement] could not spawn {entry.DisplayName} (no prefab or model)\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tLog.Info( $\"[placement] placed {entry.Id} at {pos} yaw={_ghostYaw:0} count={CountPlaced()}\" );\r\n\t}\r\n\r\n\tGameObject Spawn( PlaceableEntry entry, Vector3 pos, Rotation rot )\r\n\t{\r\n\t\tGameObject go = null;\r\n\t\tif ( entry.Prefab.IsValid() )\r\n\t\t{\r\n\t\t\tgo = entry.Prefab.Clone( pos, rot );\r\n\t\t}\r\n\t\telse if ( !string.IsNullOrEmpty( entry.ModelPath ) )\r\n\t\t{\r\n\t\t\tgo = Scene.CreateObject();\r\n\t\t\tgo.WorldPosition = pos;\r\n\t\t\tgo.WorldRotation = rot;\r\n\t\t\tvar r = go.Components.Create<ModelRenderer>();\r\n\t\t\tvar m = Model.Load( entry.ModelPath );\r\n\t\t\tif ( m is not null && !m.IsError ) r.Model = m;\r\n\t\t}\r\n\t\tif ( go is null ) return null;\r\n\r\n\t\tgo.Name = $\"placed_{entry.Id}\";\r\n\t\tgo.Components.GetOrCreate<PlacedInstance>().CatalogId = entry.Id;\r\n\t\treturn go;\r\n\t}\r\n\r\n\tvoid TryDeleteAtAim()\r\n\t{\r\n\t\tvar cam = Scene.Camera;\r\n\t\tif ( !cam.IsValid() ) return;\r\n\t\tvar ray = cam.ScreenPixelToRay( Mouse.Position );\r\n\t\tvar trace = Scene.Trace.Ray( ray, AimReach );\r\n\t\tif ( _ghost.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( _ghost );\r\n\t\tvar tr = trace.Run();\r\n\t\tif ( !tr.Hit ) return;\r\n\r\n\t\tvar placed = tr.GameObject?.Components.GetInAncestorsOrSelf<PlacedInstance>();\r\n\t\tif ( placed is null || !placed.IsValid() ) return;\r\n\t\tLog.Info( $\"[placement] deleted {placed.CatalogId}\" );\r\n\t\tplaced.GameObject.Destroy();\r\n\t}\r\n\r\n\t// \u2500\u2500 ghost preview (colliderless, tinted) \u2500\u2500\r\n\r\n\tvoid RebuildGhostModel() => _ghostModel = null;\r\n\r\n\tvoid UpdateGhost()\r\n\t{\r\n\t\tif ( !_placeMode ) { DestroyGhost(); return; }\r\n\r\n\t\tvar entry = CurrentEntry;\r\n\t\tif ( entry is null || !AimHit( out var hit ) )\r\n\t\t{\r\n\t\t\tif ( _ghost.IsValid() ) _ghost.Enabled = false;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureGhost( entry );\r\n\t\tif ( !_ghost.IsValid() ) return;\r\n\t\t_ghost.Enabled = true;\r\n\r\n\t\tvar pos = hit.WithZ( hit.z - SinkDepth );\r\n\t\tvar rot = Rotation.FromYaw( _ghostYaw );\r\n\t\t_ghost.WorldPosition = pos;\r\n\t\t_ghost.WorldRotation = rot;\r\n\r\n\t\tbool valid = Catalog?.IsValidSpot( pos, rot ) ?? true;\r\n\t\tvar r = _ghost.Components.Get<ModelRenderer>();\r\n\t\tif ( r is not null )\r\n\t\t\tr.Tint = valid ? new Color( 0.5f, 1f, 0.55f, 0.55f ) : new Color( 1f, 0.45f, 0.4f, 0.55f );\r\n\t}\r\n\r\n\tvoid EnsureGhost( PlaceableEntry entry )\r\n\t{\r\n\t\tstring modelPath = entry.ModelPath;\r\n\t\tif ( string.IsNullOrEmpty( modelPath ) ) { DestroyGhost(); return; } // prefab-only entries: no light ghost\r\n\r\n\t\tif ( !_ghost.IsValid() )\r\n\t\t{\r\n\t\t\t_ghost = Scene.CreateObject();\r\n\t\t\t_ghost.Name = \"placement_ghost\";\r\n\t\t\t_ghost.Components.Create<ModelRenderer>();\r\n\t\t\t_ghostModel = null;\r\n\t\t}\r\n\t\tif ( _ghostModel != modelPath )\r\n\t\t{\r\n\t\t\tvar r = _ghost.Components.Get<ModelRenderer>();\r\n\t\t\tvar m = Model.Load( modelPath );\r\n\t\t\tif ( m is not null && !m.IsError ) { r.Model = m; _ghostModel = modelPath; }\r\n\t\t}\r\n\t}\r\n\r\n\tvoid DestroyGhost()\r\n\t{\r\n\t\tif ( _ghost.IsValid() ) { _ghost.Destroy(); _ghost = null; }\r\n\t\t_ghostModel = null;\r\n\t}\r\n\r\n\tint CountPlaced() => Scene?.GetAllComponents<PlacedInstance>().Count() ?? 0;\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Tweak/TweakRanges.cs",
"FileName": "TweakRanges.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "namespace FieldGuide.Placement;\r\n\r\n/// <summary>\r\n/// Slider bounds and step sizes for one tweak target's rows, in ENGINE UNITS and degrees.\r\n///\r\n/// Why this is per-target and not a constant: fitting a hat to a head and laying out a building are the\r\n/// same three sliders over wildly different ranges. A position row spanning +/-1024 units at step 1 (the\r\n/// kit's original fixed range) cannot seat an accessory at all: one pixel of drag is several units, and\r\n/// the value you want lives in the first half-percent of the track. The accessory default below spans\r\n/// +/-48 units at step 0.25, so the xfine step (\u00f710) reaches 0.025 units and a full drag still covers a\r\n/// character-sized volume.\r\n/// </summary>\r\npublic sealed record TweakRanges(\r\n\tfloat PositionRange = 48f,\r\n\tfloat PositionStep = 0.25f,\r\n\tfloat RotationStep = 1f,\r\n\tfloat ScaleMin = 0.05f,\r\n\tfloat ScaleMax = 4f,\r\n\tfloat ScaleStep = 0.05f )\r\n{\r\n\t/// <summary>The default: character-accessory scale. +/-48 units at 0.25 (xfine reaches 0.025), rotation\r\n\t/// at 1 degree, scale 0.05 to 4. This is what an unconfigured target gets.</summary>\r\n\tpublic static readonly TweakRanges Accessory = new();\r\n\r\n\t/// <summary>Scene-authoring scale, for tweaking a placed prop rather than a worn accessory:\r\n\t/// +/-1024 units at step 1, scale up to 8.</summary>\r\n\tpublic static readonly TweakRanges Scene = new( PositionRange: 1024f, PositionStep: 1f, ScaleMax: 8f );\r\n\r\n\t/// <summary>Room-scale, between the two: +/-256 units at 0.5.</summary>\r\n\tpublic static readonly TweakRanges Prop = new( PositionRange: 256f, PositionStep: 0.5f );\r\n}\r\n"
},
{
"Ident": "fieldguide.placement",
"Path": "Tweak/TweakPanel.razor",
"FileName": "TweakPanel.razor",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 337697,
"Code": "@using Sandbox\r\n@using Sandbox.UI\r\n@using System\r\n@using System.Collections.Generic\r\n@namespace FieldGuide.Placement\r\n@inherits PanelComponent\r\n@attribute [StyleSheet]\r\n\r\n@*\r\n\tA live transform tuner for objects hanging off something else: an accessory on a character mount, a\r\n\tpart on a vehicle, a prop in a scene. One tab per ITweakTarget supplied by the scene's TweakSession;\r\n\tthe active tab edits that target GameObject's LocalPosition, LocalRotation (as pitch / yaw / roll) and\r\n\tuniform LocalScale, so what you drag is the OFFSET, which is the number your game code wants.\r\n\r\n\tEach row is a draggable slider (click-jump + drag-scrub, the engine SliderControl mechanic) with a\r\n\tsmall +/- stepper beside it for precision a drag can't hit; the three-state step toggle divides or\r\n\tmultiplies that stepper by ten. Row ranges come from the target's TweakRanges, so an accessory gets\r\n\tcharacter-scale sliders and a scene prop gets scene-scale ones.\r\n\r\n\tCopy puts the active target's paste-ready bake line on the clipboard (game-side Clipboard.SetText).\r\n\tExport writes every target and every placed object into FileSystem.Data as JSON plus a C# snippet.\r\n\tReset restores the transform captured when the target was registered, never zero.\r\n\r\n\tRows render in MAIN markup via @foreach (not a RenderFragment) per the fragment-undermeasure gotcha,\r\n\tand add shapes only (track/fill), keeping the text-run count low. Toggle with P (raw key, letter,\r\n\tnever an F key), the 42px x in the header, or the `placement_panel` console convar. Starts closed\r\n\tunless OpenOnStart is set; see the boot block in OnUpdate.\r\n*@\r\n\r\n<root>\r\n@if ( PanelOpen )\r\n{\r\n\t<div class=\"tp-card\">\r\n\t\t<div class=\"tp-hdr\">\r\n\t\t\t<span class=\"tp-title\">TRANSFORM TWEAK</span>\r\n\t\t\t<div class=\"tp-hr\">\r\n\t\t\t\t<span class=\"tp-key\">P</span>\r\n\t\t\t\t<div class=\"tp-x\" onclick=@ClosePanel>\u00d7</div>\r\n\t\t\t</div>\r\n\t\t</div>\r\n\r\n\t\t@if ( TargetList.Count == 0 )\r\n\t\t{\r\n\t\t\t<div class=\"tp-empty\">No targets. Add GameObjects to a TweakSession (Objects list) or call TweakSession.Add(...).</div>\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t@* ---- one tab per target ---- *@\r\n\t\t\t<div class=\"tp-tabs\">\r\n\t\t\t\t@for ( int i = 0; i < TargetList.Count; i++ )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar idx = i;\r\n\t\t\t\t\tstring tab = TargetList[idx].DisplayName;\r\n\t\t\t\t\t<div class=\"tp-tab @(idx == ActiveIndex ? \"on\" : \"\")\" onclick=@(() => SelectTab( idx ))>@tab</div>\r\n\t\t\t\t}\r\n\t\t\t</div>\r\n\r\n\t\t\t<div class=\"tp-sub\">\r\n\t\t\t\t<span class=\"tp-item\">@ActiveName</span>\r\n\t\t\t\t<span class=\"tp-note\">@FrameNote</span>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- step-size toggle ---- *@\r\n\t\t\t<div class=\"tp-steprow\">\r\n\t\t\t\t<span class=\"tp-sl\">Step</span>\r\n\t\t\t\t<div class=\"tp-seg @(_step == StepSize.XFine ? \"on\" : \"\")\" onclick=@(() => _step = StepSize.XFine)>xfine \u00f710</div>\r\n\t\t\t\t<div class=\"tp-seg @(_step == StepSize.Fine ? \"on\" : \"\")\" onclick=@(() => _step = StepSize.Fine)>fine</div>\r\n\t\t\t\t<div class=\"tp-seg @(_step == StepSize.Coarse ? \"on\" : \"\")\" onclick=@(() => _step = StepSize.Coarse)>coarse \u00d710</div>\r\n\t\t\t</div>\r\n\r\n\t\t\t@* ---- editable rows (draggable slider + stepper) ---- *@\r\n\t\t\t@foreach ( var r in Rows )\r\n\t\t\t{\r\n\t\t\t\tvar row = r;\r\n\t\t\t\tfloat cur = Get( ActiveTarget, row.field );\r\n\t\t\t\tstring val = cur.ToString( row.fmt );\r\n\t\t\t\tstring lab = row.label; // plain local before interpolating: an inline field read can render blank\r\n\t\t\t\tint fillPct = (int)( Frac( cur, row ) * 100f );\r\n\t\t\t\t<div class=\"tp-row @(row.header ? \"hdr\" : \"\")\">\r\n\t\t\t\t\t<div class=\"tp-rlab\">\r\n\t\t\t\t\t\t<span class=\"tp-rl\">@lab</span>\r\n\t\t\t\t\t\t<span class=\"tp-rv\">@val</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t\t<div class=\"tp-slider\">\r\n\t\t\t\t\t\t<span class=\"tp-stp\" onclick=@(() => Nudge( row.field, -row.step * StepMult ))>\u2212</span>\r\n\t\t\t\t\t\t<div class=\"tp-track\"\r\n\t\t\t\t\t\t\tonmousedown=@(e => TrackPointer( e, row, true ))\r\n\t\t\t\t\t\t\tonmousemove=@(e => TrackPointer( e, row, false ))>\r\n\t\t\t\t\t\t\t<div class=\"tp-fill\" style=\"width: @(fillPct)%;\"></div>\r\n\t\t\t\t\t\t</div>\r\n\t\t\t\t\t\t<span class=\"tp-stp\" onclick=@(() => Nudge( row.field, row.step * StepMult ))>+</span>\r\n\t\t\t\t\t</div>\r\n\t\t\t\t</div>\r\n\t\t\t}\r\n\r\n\t\t\t@* ---- actions ---- *@\r\n\t\t\t<div class=\"tp-btns\">\r\n\t\t\t\t<div class=\"tp-btn\" onclick=@ResetActive>Reset</div>\r\n\t\t\t\t<div class=\"tp-btn\" onclick=@CopyActive>@_copyLabel</div>\r\n\t\t\t</div>\r\n\t\t\t<div class=\"tp-btns\">\r\n\t\t\t\t<div class=\"tp-btn wide\" onclick=@ExportNow>Export all to Data</div>\r\n\t\t\t</div>\r\n\t\t}\r\n\t</div>\r\n}\r\n</root>\r\n\r\n@code\r\n{\r\n\t// ---- toggle state (P raw key + `placement_panel` convar fallback) ----\r\n\tstatic bool _open;\r\n\r\n\t/// <summary>Console fallback: `placement_panel 1` / `placement_panel 0` toggles the panel (P also toggles).</summary>\r\n\t[ConVar( \"placement_panel\", Help = \"Open or close the transform tweak panel (same as the P key)\" )]\r\n\tpublic static bool PanelOpen { get => _open; set => _open = value; }\r\n\r\n\t/// <summary>\r\n\t/// Whether this panel starts open. Off by default: a tuning panel that appears unbidden over a\r\n\t/// consumer's game is a bug, not a feature. Turn it on for a scene whose whole point is the panel, the\r\n\t/// way the kit's own demo does.\r\n\t///\r\n\t/// This is what decides the panel's boot state, and it is the ONLY thing that decides it. See the boot\r\n\t/// block in OnUpdate for why that matters.\r\n\t/// </summary>\r\n\t[Property] public bool OpenOnStart { get; set; }\r\n\r\n\tint _activeIndex;\r\n\tstring _copyLabel = \"Copy\";\r\n\tbool _wasOpen;\r\n\tbool _booted;\r\n\r\n\t/// <summary>Three-state stepper size (owner ruling, ported from the World Builder mount tuner): xfine for\r\n\t/// final seating, fine for normal work, coarse for getting into the neighbourhood.</summary>\r\n\tenum StepSize { XFine, Fine, Coarse }\r\n\tStepSize _step = StepSize.Fine;\r\n\r\n\tIReadOnlyList<ITweakTarget> TargetList => TweakSession.Instance?.Targets ?? System.Array.Empty<ITweakTarget>();\r\n\r\n\tint ActiveIndex\r\n\t{\r\n\t\tget => TargetList.Count == 0 ? 0 : Math.Clamp( _activeIndex, 0, TargetList.Count - 1 );\r\n\t\tset => _activeIndex = value;\r\n\t}\r\n\r\n\t// Named ActiveTarget (not Active): PanelComponent/Component already exposes an inherited\r\n\t// bool Active, and a razor-generated member named Active would hide it (CS0108).\r\n\tITweakTarget ActiveTarget => TargetList.Count == 0 ? null : TargetList[ActiveIndex];\r\n\r\n\t/// <summary>The active target's label, resolved to a single-identifier property so the markup never\r\n\t/// interpolates a chained member read (which renders blank in several razor cases).</summary>\r\n\tstring ActiveName => ActiveTarget?.DisplayName ?? \"\";\r\n\r\n\t/// <summary>The sub-line under the tabs: what the numbers below are measured against. This is the one\r\n\t/// piece of context that makes a baked offset readable later, so the panel shows it while you drag.</summary>\r\n\tstring FrameNote\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar frame = ActiveTarget?.FrameName;\r\n\t\t\treturn string.IsNullOrEmpty( frame ) ? \"local offset \u00b7 edited live\" : $\"{frame} \u00b7 edited live\";\r\n\t\t}\r\n\t}\r\n\r\n\tvoid SelectTab( int idx )\r\n\t{\r\n\t\tActiveIndex = idx;\r\n\t\t_copyLabel = \"Copy\"; // a stale \"Copied!\" on a different target reads as a lie\r\n\t}\r\n\r\n\tvoid ClosePanel()\r\n\t{\r\n\t\tPanelOpen = false;\r\n\t\t_copyLabel = \"Copy\";\r\n\t}\r\n\r\n\t// ---- field model ----\r\n\tpublic enum Field { PosX, PosY, PosZ, Pitch, Yaw, Roll, Scale }\r\n\r\n\tstruct Row { public Field field; public string label; public string fmt; public float step; public float min; public float max; public bool header; }\r\n\r\n\t/// <summary>The seven rows for the ACTIVE target, bounded by that target's TweakRanges. Built per read\r\n\t/// rather than held in a static array, because an accessory and a scene prop want very different\r\n\t/// ranges out of the same three sliders.</summary>\r\n\tList<Row> Rows\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar g = ActiveTarget?.Ranges ?? TweakRanges.Accessory;\r\n\t\t\tfloat p = MathF.Max( g.PositionRange, 0.001f );\r\n\t\t\treturn new List<Row>\r\n\t\t\t{\r\n\t\t\t\tnew Row { field = Field.PosX, label = \"Pos X\", fmt = \"0.###\", step = g.PositionStep, min = -p, max = p },\r\n\t\t\t\tnew Row { field = Field.PosY, label = \"Pos Y\", fmt = \"0.###\", step = g.PositionStep, min = -p, max = p },\r\n\t\t\t\tnew Row { field = Field.PosZ, label = \"Pos Z\", fmt = \"0.###\", step = g.PositionStep, min = -p, max = p },\r\n\t\t\t\tnew Row { field = Field.Pitch, label = \"Pitch \u00b0\", fmt = \"0.#\", step = g.RotationStep, min = -180f, max = 180f, header = true },\r\n\t\t\t\tnew Row { field = Field.Yaw, label = \"Yaw \u00b0\", fmt = \"0.#\", step = g.RotationStep, min = -180f, max = 180f },\r\n\t\t\t\tnew Row { field = Field.Roll, label = \"Roll \u00b0\", fmt = \"0.#\", step = g.RotationStep, min = -180f, max = 180f },\r\n\t\t\t\tnew Row { field = Field.Scale, label = \"Scale\", fmt = \"0.###\", step = g.ScaleStep, min = g.ScaleMin, max = g.ScaleMax, header = true },\r\n\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\tfloat StepMult => _step switch { StepSize.Coarse => 10f, StepSize.XFine => 0.1f, _ => 1f };\r\n\r\n\tstatic float Frac( float value, Row row )\r\n\t\t=> Math.Clamp( (value - row.min) / MathF.Max( row.max - row.min, 0.0001f ), 0f, 1f );\r\n\r\n\t// ---- read/write the active target's LOCAL transform ----\r\n\tstatic float Get( ITweakTarget t, Field f )\r\n\t{\r\n\t\tvar go = t?.Target;\r\n\t\tif ( go is null || !go.IsValid() ) return 0f;\r\n\t\tvar p = go.LocalPosition;\r\n\t\tvar a = go.LocalRotation.Angles();\r\n\t\treturn f switch\r\n\t\t{\r\n\t\t\tField.PosX => p.x,\r\n\t\t\tField.PosY => p.y,\r\n\t\t\tField.PosZ => p.z,\r\n\t\t\tField.Pitch => a.pitch,\r\n\t\t\tField.Yaw => a.yaw,\r\n\t\t\tField.Roll => a.roll,\r\n\t\t\tField.Scale => go.LocalScale.x,\r\n\t\t\t_ => 0f,\r\n\t\t};\r\n\t}\r\n\r\n\tvoid Nudge( Field f, float delta )\r\n\t{\r\n\t\tvar go = ActiveTarget?.Target;\r\n\t\tif ( go is null || !go.IsValid() ) return;\r\n\t\tvar p = go.LocalPosition;\r\n\t\tvar a = go.LocalRotation.Angles();\r\n\t\tswitch ( f )\r\n\t\t{\r\n\t\t\tcase Field.PosX: go.LocalPosition = p.WithX( p.x + delta ); break;\r\n\t\t\tcase Field.PosY: go.LocalPosition = p.WithY( p.y + delta ); break;\r\n\t\t\tcase Field.PosZ: go.LocalPosition = p.WithZ( p.z + delta ); break;\r\n\t\t\tcase Field.Pitch: go.LocalRotation = new Angles( a.pitch + delta, a.yaw, a.roll ).ToRotation(); break;\r\n\t\t\tcase Field.Yaw: go.LocalRotation = new Angles( a.pitch, a.yaw + delta, a.roll ).ToRotation(); break;\r\n\t\t\tcase Field.Roll: go.LocalRotation = new Angles( a.pitch, a.yaw, a.roll + delta ).ToRotation(); break;\r\n\t\t\tcase Field.Scale:\r\n\t\t\t\tfloat s = MathF.Max( 0.01f, go.LocalScale.x + delta );\r\n\t\t\t\tgo.LocalScale = new Vector3( s, s, s );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Draggable track (World Builder left-UI idiom): onmousedown JUMPS to the click, onmousemove\r\n\t/// SCRUBS while Active. Snaps to the row's fine step, applies as a DELTA through Nudge so the same\r\n\t/// write path runs whether you drag or step.</summary>\r\n\tvoid TrackPointer( PanelEvent ev, Row row, bool jump )\r\n\t{\r\n\t\tif ( ev is not MousePanelEvent e ) return;\r\n\t\tvar track = e.This;\r\n\t\tif ( track is null ) return;\r\n\t\tif ( !jump && !track.PseudoClass.HasFlag( PseudoClass.Active ) ) return;\r\n\r\n\t\tfloat w = track.Box.Rect.Width;\r\n\t\tif ( w <= 0f ) return;\r\n\t\tfloat frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );\r\n\t\tfloat target = row.min + frac * (row.max - row.min);\r\n\t\tif ( row.step > 0f ) target = MathF.Round( target / row.step ) * row.step;\r\n\t\ttarget = Math.Clamp( target, row.min, row.max );\r\n\t\tNudge( row.field, target - Get( ActiveTarget, row.field ) );\r\n\t}\r\n\r\n\t/// <summary>Restore the transform the target was REGISTERED with, not zero. Zeroing a hand-mounted\r\n\t/// accessory collapses it into the wrist, which is never the thing you wanted back.</summary>\r\n\tvoid ResetActive()\r\n\t{\r\n\t\tvar go = ActiveTarget?.Target;\r\n\t\tif ( go is null || !go.IsValid() ) return;\r\n\t\tif ( TweakSession.Instance?.ResetToSeed( go ) == true ) return;\r\n\r\n\t\t// Never registered here (a hand-built target list, say): identity is the only baseline we have.\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\t\tgo.LocalScale = Vector3.One;\r\n\t}\r\n\r\n\t/// <summary>Copy the active target's paste-ready bake line to the system clipboard. Game-side\r\n\t/// Sandbox.UI.Clipboard.SetText, so this works in play without an editor round trip.</summary>\r\n\tvoid CopyActive()\r\n\t{\r\n\t\tvar t = ActiveTarget;\r\n\t\tif ( t is null ) return;\r\n\t\tvar line = PlacementExport.BakeLine( t );\r\n\t\tif ( string.IsNullOrEmpty( line ) ) return;\r\n\t\tSandbox.UI.Clipboard.SetText( line );\r\n\t\t_copyLabel = \"Copied!\";\r\n\t}\r\n\r\n\tvoid ExportNow()\r\n\t{\r\n\t\tif ( Scene is not null )\r\n\t\t\tPlacementExport.WriteAll( Scene );\r\n\t}\r\n\r\n\t// ---- boot state, P toggle, cursor while open ----\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// BOOT. `placement_panel` is a convar and s&box PERSISTS convars across sessions, so a session can\r\n\t\t// otherwise come up with the panel logically open from whatever someone left set weeks ago. The rule\r\n\t\t// that prevents it: this component's own OpenOnStart decides the boot state, and the persisted value\r\n\t\t// never does. Default off means a consumer game still cannot be pre-opened by a stale convar; a scene\r\n\t\t// that wants the panel up says so explicitly.\r\n\t\t//\r\n\t\t// Deliberately in the FIRST UPDATE rather than OnStart. A panel built in code is configured by the\r\n\t\t// component that created it, and doing this in OnStart would race that assignment: whichever ran\r\n\t\t// first would win. The first update is after every OnStart in the frame, so the setting is always\r\n\t\t// read, never half-applied.\r\n\t\tif ( !_booted )\r\n\t\t{\r\n\t\t\t_booted = true;\r\n\t\t\tif ( PanelOpen && !OpenOnStart )\r\n\t\t\t\tLog.Info( \"[placement] tweak panel was OPEN at session start (persisted convar), forcing closed\" );\r\n\t\t\tPanelOpen = OpenOnStart;\r\n\t\t}\r\n\r\n\t\tif ( Input.Keyboard.Pressed( \"P\" ) )\r\n\t\t\tPanelOpen = !PanelOpen;\r\n\r\n\t\tif ( PanelOpen )\r\n\t\t{\r\n\t\t\tMouse.Visibility = MouseVisibility.Visible; // keep the cursor usable over the panel\r\n\t\t\t_wasOpen = true;\r\n\t\t}\r\n\t\telse if ( _wasOpen )\r\n\t\t{\r\n\t\t\t_wasOpen = false;\r\n\t\t\t_copyLabel = \"Copy\"; // closing clears the flash, so a reopen never claims a copy that was not made\r\n\t\t}\r\n\t}\r\n\r\n\t// Fold the toggle, active tab, step mode, the copy label, and every displayed value (rounded) so\r\n\t// readouts update the instant a value is nudged. Miss one and the number freezes on screen.\r\n\tprotected override int BuildHash()\r\n\t{\r\n\t\tint h = HashCode.Combine( PanelOpen, ActiveIndex, (int)_step, _copyLabel, TargetList.Count );\r\n\t\tvar a = ActiveTarget;\r\n\t\tif ( a is not null )\r\n\t\t{\r\n\t\t\th = HashCode.Combine( h, a.FrameName );\r\n\t\t\tforeach ( var r in Rows )\r\n\t\t\t\th = HashCode.Combine( h, (int)MathF.Round( Get( a, r.field ) * 1000f ) );\r\n\t\t}\r\n\t\treturn h;\r\n\t}\r\n}\r\n"
}
]
}