Code/BoBiCo Soft-Physics/Components/BoBiCoSoftBoneCollider.cs

Editor component that defines a soft-bone collider attached to a target bone. Computes world-space collider shapes (sphere, capsule, box, flat), clamps shape parameters, and draws gizmos in the scene view.

File Access
//=========================================================================================================================
// BoBiCo Soft-Bone Collider Component
//=========================================================================================================================

using System;

[Title( "SoftBone Collider" )]
[Category( "BoBiCo" )]
[Icon( "radio_button_unchecked" )]
[HelpUrl( "https://bobico-lab.vercel.app/softbone-docs/bobico-softbones/core-components/softbone-collider" )]
public sealed class BoBiCoSoftBoneCollider : Component, Component.ExecuteInEditor
{
public enum ColliderShape
{
	/// <summary>
	/// A simple spherical collider.
	/// Best for rounded body parts and lightweight collision detection.
	/// </summary>
	Sphere,

	/// <summary>
	/// A capsule-shaped collider.
	/// Recommended for most bones and elongated body parts.
	/// </summary>
	Capsule,

	/// <summary>
	/// A box-shaped collider.
	/// Useful for broad collision areas and block-like shapes.
	/// </summary>
	Box,

	/// <summary>
	/// A one-sided flat collider.
	/// Useful for flat surface areas.
	/// </summary>
	Flat,
}

//=========================================================================================================================
// Collider Properties
//=========================================================================================================================

	/// <summary>
	/// Offsets the position of the collider.
	/// </summary>
	[Property, Order( 1 ), Title( "Position Offset" ), Group( "Collider Properties" )]
	public Vector3 PositionOffset { get; set; }

	/// <summary>
	/// Offsets the rotation of the collider.
	/// </summary>
	[Property, Order( 2 ), Title( "Rotation Offset" ), Group( "Collider Properties" )]
	public Angles RotationOffset { get; set; }

	/// <summary>
	/// Target bone to which the collider is attached. 
	/// </summary>
	[Property, Order( 3 ), Title( "Target Bone" ), Group( "Collider Properties" )]
	public GameObject TargetBone { get; set; }

//=========================================================================================================================
// Shape Settings
//=========================================================================================================================

    /// <summary>
    /// Shape used for this chain's collision volume.
    /// Different shapes provide different collision behavior and coverage.
    /// </summary>
	[Property, Order( 4 ), Title( "Collider Shape" ), Group( "Shape Settings" )]
	public ColliderShape Shape { get; set; } = ColliderShape.Sphere;

    /// <summary>
    /// Radius of the chain's collision volume.
    /// Larger values create thicker collision boundaries.
    /// </summary>
	[Property, Order( 5 ), Title( "Collider Radius" ), Group( "Shape Settings" ), HideIf( nameof( Shape ), ColliderShape.Box )]
	public float ColliderRadius { get; set; } = 2f;

    /// <summary>
    /// Height of Capsule collider.
    /// </summary>
	[Property, Order( 6 ), Title( "Collider Height" ), Group( "Shape Settings" ), ShowIf( nameof( Shape ), ColliderShape.Capsule )]
	public float ColliderHeight { get; set; } = 10f;

    /// <summary>
    /// Size of Box colliders.
    /// Only applies when the collider shape is set to Box.
    /// </summary>
	[Property, Order( 7 ), Title( "Box Dimension" ), Group( "Shape Settings" ), ShowIf( nameof( Shape ), ColliderShape.Box )]
	public Vector3 BoxSize { get; set; } = new Vector3( 1.25f, 1.25f, 1.25f );

//=========================================================================================================================
// Collider Gizmo Settings
//=========================================================================================================================

    /// <summary>
    /// Displays the chain's collider in the Scene view.
    /// Useful for visualizing collision shapes and sizes.
    /// </summary>
	[Property, Order( 8 ), Title( "Collider Gizmo" ), Group( "Gizmo Settings" )]
	public bool ShowColliderGizmo { get; set; } = true;

    /// <summary>
    /// Keeps the collider gizmo visible even when the chain is not selected.
    /// Useful for debugging collision setups.
    /// </summary>
	[Property, Order( 9 ), Title( "Always Show Gizmo" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
	public bool AlwaysShowGizmo { get; set; }

    /// <summary>
    /// Color used for rendering the collider gizmo.
    /// </summary>
	[Property, Order( 10 ), Title( "Collider Gizmo Color" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
	public Color ColliderGizmoColor { get; set; } = Gizmo.Colors.Green;

    /// <summary>
    /// Controls the transparency of the collider gizmo.
    /// Lower values make the visualization less intrusive.
    /// </summary>
	[Property, Order( 11 ), Range( 0, 1 ), Title( "Gizmo Opacity" ), Group( "Gizmo Settings" ), ShowIf( nameof( ShowColliderGizmo ), true )]
	public float ColliderGizmoOpacity { get; set; } = 1f;	

//=========================================================================================================================
// Collider Shape Calculation
//=========================================================================================================================
	public bool TryGetWorldShape( SkinnedModelRenderer renderer, out SoftBoneColliderShape shape )
	{
		shape = default;
		var boneName = TargetBone is not null && TargetBone.IsValid() ? TargetBone.Name?.Trim() : "";
		if ( string.IsNullOrWhiteSpace( boneName ) )
			return false;

		var hasBoneTransform = renderer is not null && renderer.IsValid() && renderer.Model?.Bones is not null && renderer.Model.Bones.HasBone( boneName );
		if ( hasBoneTransform )
		{
			var bone = renderer.Model.Bones.GetBone( boneName );
			if ( renderer.TryGetBoneTransformAnimation( bone, out var animatedTransform ) || renderer.TryGetBoneTransform( bone, out animatedTransform ) )
				return BuildWorldShape( animatedTransform, out shape );
		}

		return BuildWorldShape( TargetBone.WorldTransform, out shape );
	}

	private bool BuildWorldShape( Transform boneTransform, out SoftBoneColliderShape shape )
	{
		shape = default;

		// Swap pitch/yaw to match S&box bone forward = X axis
		var fixedRotOffset = new Angles( RotationOffset.roll, RotationOffset.pitch, RotationOffset.yaw );
		var rotation = boneTransform.Rotation * fixedRotOffset.ToRotation();
		var radius = Math.Max( ColliderRadius, 0.001f );
		var height = Math.Max( ColliderHeight, radius * 2f );
		// Collider offsets use the existing VRC-style axis order: X=up, Y=forward, Z=right.
		var remappedOffset = new Vector3( PositionOffset.y, PositionOffset.z, PositionOffset.x );
		var center = boneTransform.PointToWorld( remappedOffset );

		if ( Shape == ColliderShape.Sphere || (Shape == ColliderShape.Capsule && height <= radius * 2f) )
		{
			shape = SoftBoneColliderShape.Sphere( center, radius );
			return true;
		}

		if ( Shape == ColliderShape.Capsule )
		{
			var halfLine = Math.Max( 0f, height * 0.5f - radius );
			// VRC capsules are authored along their local Up axis. Using Forward here
			// made S&box Roll rotate around the capsule's own length, which is invisible.
			var axis = rotation * Vector3.Forward;
			shape = SoftBoneColliderShape.Capsule( center - axis * halfLine, center + axis * halfLine, radius );
		}
		else if ( Shape == ColliderShape.Box )
			shape = SoftBoneColliderShape.Box( center, rotation, new Vector3( Math.Max( BoxSize.z, 0.001f ), Math.Max( BoxSize.x, 0.001f ), Math.Max( BoxSize.y, 0.001f ) ) );
		else
			shape = SoftBoneColliderShape.Flat( center, rotation * Vector3.Up );
		return true;
	}

//=========================================================================================================================
// Collider Gizmo Drawing
//=========================================================================================================================

	protected override void DrawGizmos()
	{
		base.DrawGizmos();
		DrawColliderGizmo( AlwaysShowGizmo || Gizmo.IsSelected );
	}

	internal void DrawColliderGizmo( bool visible, bool force = false )
	{
		if ( !visible || ( !force && !ShowColliderGizmo ) || !TryGetWorldShape( FindRenderer(), out var shape ) )
			return;

		// Draw directly in world space so the sphere radius is never distorted by
		// the component's scale (which caused the squished ellipsoid).
		using ( Gizmo.Scope( "SoftBoneCollider" ) )
		{
			Gizmo.Transform = new Transform( Vector3.Zero, Rotation.Identity, 1f );
			Gizmo.Draw.IgnoreDepth = true;
			Gizmo.Draw.LineThickness = 1;
			Gizmo.Draw.Color = ColliderGizmoColor.WithAlpha( ColliderGizmoOpacity );

			if ( shape.IsSphere )
				Gizmo.Draw.LineSphere( new Sphere( shape.Start, shape.Radius ) );
			else if ( shape.Type == SoftBoneColliderShapeType.Capsule )
				Gizmo.Draw.LineCapsule( new Capsule( shape.Start, shape.End, shape.Radius ) );
			else if ( shape.Type == SoftBoneColliderShapeType.Box )
			{
				Gizmo.Transform = new Transform( shape.Center, shape.Rotation, 1f );
				Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( Vector3.Zero, shape.Size ) );
			}
			else
			{
				var tangent = Vector3.Cross( shape.Normal, Math.Abs( Vector3.Dot( shape.Normal, Vector3.Up ) ) < 0.99f ? Vector3.Up : Vector3.Right ).Normal;
				var bitangent = Vector3.Cross( shape.Normal, tangent ).Normal;
				var size = ColliderRadius;
				var a = shape.Center + (tangent + bitangent) * size;
				var b = shape.Center + (tangent - bitangent) * size;
				var c = shape.Center + (-tangent - bitangent) * size;
				var d = shape.Center + (-tangent + bitangent) * size;
				Gizmo.Draw.Line( a, b ); Gizmo.Draw.Line( b, c ); Gizmo.Draw.Line( c, d ); Gizmo.Draw.Line( d, a );
				Gizmo.Draw.Line( shape.Center, shape.Center + shape.Normal * 8f );
			}
		}
	}

//=========================================================================================================================
// Collider Bone Lookup
//=========================================================================================================================

	private SkinnedModelRenderer FindRenderer()
	{
		var targetRenderer = TargetBone?.GetComponentInParent<SkinnedModelRenderer>( true, true );
		if ( targetRenderer is not null && targetRenderer.IsValid() )
			return targetRenderer;
		return null;
	}

	internal SkinnedModelRenderer GetOwningRenderer() => FindRenderer();

	protected override void OnValidate()
	{
		ColliderRadius = Math.Max( ColliderRadius, 0.001f );
		ColliderHeight = Math.Max( ColliderHeight, ColliderRadius * 2f );
		BoxSize = new Vector3( Math.Max( BoxSize.x, 0.001f ), Math.Max( BoxSize.y, 0.001f ), Math.Max( BoxSize.z, 0.001f ) );
		ColliderGizmoOpacity = Math.Clamp( ColliderGizmoOpacity, 0f, 1f );
	}
}