🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (38 total matches found)
fieldguide.placement / Code/Attach/AttachedTweakTarget.cs
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;
	}
}
fieldguide.placement / Code/Demo/DemoBootstrap.cs
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>();
	}
}
fieldguide.placement / Code/Placement/PlacedInstance.cs
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; }
}
fieldguide.placement / Demo/DemoBootstrap.cs
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>();
	}
}
fieldguide.placement / Tweak/ITweakTarget.cs
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;
	}
}
fieldguide.placement / Code/Attach/CharacterAttachPoint.cs
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 ) + "]";
}
fieldguide.placement / Code/Tweak/TweakRanges.cs
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 );
}
fieldguide.placement / Export/BakeFormat.cs
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();
	}
}
fieldguide.placement / Code/Demo/DemoHintCard.razor.scss
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;
	}
}
fieldguide.placement / .obj/__compiler_extra.cs
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")]
fieldguide.placement / Attach/CharacterAttachPoint.cs
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 ) + "]";
}
fieldguide.placement / Code/Placement/PlacementCatalog.cs
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;
	}
}
fieldguide.placement / Code/Tweak/TweakSession.cs
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;
	}
}
fieldguide.placement / Placement/PlaceableEntry.cs
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;
	}
}
fieldguide.placement / Placement/PlacedInstance.cs
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; }
}
fieldguide.placement / Code/Tweak/TweakPanel.razor.scss
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; }
	}
}
fieldguide.placement / Code/Export/PlacementExport.cs
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;
	}
}
fieldguide.placement / Placement/GhostPlacer.cs
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;
}
fieldguide.placement / Tweak/TweakRanges.cs
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 );
}
fieldguide.placement / Tweak/TweakPanel.razor
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"
        }
    ]
}