World/HangingCable.cs
using HalfEdgeMesh;
using System;
namespace Causal;
/// <summary>
/// Drives one or more hanging wires: a rope joint per wire plus a runtime
/// tube render. The engine <see cref="CableComponent"/> mesh never recooks in
/// play, so only its static collision is used; the visible wire below is
/// rebuilt from the node positions. Two modes per link:
/// <list type="bullet">
/// <item>With a <c>HangBody</c>: hanger mode. The cable carries no collision
/// (a swinging prop must never hit the frozen authored tube) and the joint
/// anchors through a small auto-created static knob unless AnchorBody points
/// at something with a body already, hanging the authored prop.</item>
/// <item>Without a <c>HangBody</c>: dangling mode. The cable still hangs: a
/// tiny dynamic tip (Rigidbody + SphereCollider) is auto-created at the
/// HangNode end and the joint hangs that tip, so the wire sways on its own.</item>
/// </list>
/// Intended hierarchy (see the HangingLight setup in causal.scene):
/// <list type="bullet">
/// <item>A manager object (e.g. HangingLight) holds this component. It is pure
/// plumbing and may sit anywhere.</item>
/// <item>Each <see cref="CableLink"/> points at a Cable root placed at a ceiling
/// anchor, with Cable Node 1 at local origin (static top end) and Cable Node 2
/// as the driven bottom end.</item>
/// <item><c>HangBody</c> is the shared dynamic prop (e.g. LightFixture, needs a
/// <see cref="Rigidbody"/>). All links may hang the same body; leave it empty
/// and a tiny tip body is created per link so the wire still dangles.</item>
/// <item>Leave every object untagged ("world") so <see cref="TimeShiftManager"/>
/// never disables them across CAUSE/EFFECT shifts.</item>
/// </list>
/// </summary>
public sealed class HangingCable : Component
{
/// <summary>
/// One entry per wire. Fill in the editor: Cable and HangNode are required,
/// HangBody decides the mode (with = hanging prop, without = static hull).
/// </summary>
public class CableLink
{
/// <summary>
/// The Cable root at the ceiling anchor. Must have at least two
/// CableNodeComponent children (Node 1 top, Node 2 bottom).
/// </summary>
[Property] public CableComponent Cable { get; set; }
/// <summary>
/// The bottom node child of <see cref="Cable"/> (e.g. Cable Node 2).
/// Its authored placement IS the hang offset: on start it is converted
/// to prop-local space and used for both the joint frame and the
/// per-frame pin, then pinned back onto the prop every frame. Must be
/// a direct child of the Cable object or the engine cable ignores it.
/// </summary>
[Property] public GameObject HangNode { get; set; }
/// <summary>
/// The dynamic prop being hung (e.g. LightFixture). Must carry a
/// <see cref="Rigidbody"/>; its collider may be a <see cref="Collider"/>
/// component or a <see cref="MeshComponent"/> with Hull/Mesh collision.
/// This is also the physics side of the joint. Leave empty to hang
/// the cable itself: a tiny dynamic tip is auto-created at the
/// HangNode end and the joint sways that tip.
/// </summary>
[Property] public GameObject HangBody { get; set; }
/// <summary>
/// Static anchor side of the joint. Leave empty to use the Cable object,
/// whose generated mesh carries static collision. A node (e.g. Cable
/// Node 1) also works: like the engine, the body resolves through
/// parents, so it lands on the Cable's static mesh body.
/// </summary>
[Property] public GameObject AnchorBody { get; set; }
/// <summary>
/// Extra joint length beyond the anchor-to-prop distance. Small positive
/// values let the prop sag; total length never drops below 1.
/// </summary>
[Property] public float JointSlack { get; set; }
/// <summary>
/// Spring stiffness in Hz. Low values act like a rope (soft pull back
/// past rest length); high values act like a rigid rod.
/// </summary>
[Property] public float JointFrequency { get; set; } = 2f;
/// <summary>
/// Spring damping ratio, usually 0 to 1. Settles swing without
/// freezing it; 0 lets the prop oscillate freely.
/// </summary>
[Property] public float JointDamping { get; set; } = 1f;
/// <summary>
/// How the tip joint pushes and pulls when no HangBody is assigned.
/// Both lets a shove up compress the cable with slack before the
/// spring pushes back; Pull is rope-like (only resists stretch).
/// Prop links always use Pull.
/// </summary>
[Property] public SpringJoint.SpringForceMode TipForceMode { get; set; } = SpringJoint.SpringForceMode.Both;
/// <summary>
/// Allowed upward travel for a tip-only cable before the joint hits
/// its MinLength rod limit. Larger values mean more vertical slack
/// when the tip is pushed up.
/// </summary>
[Property] public float TipCompression { get; set; } = 12f;
/// <summary>
/// Stiffness for tip-only cables, separate from <see cref="JointFrequency"/>.
/// Lift is noticeably stronger than before.
/// </summary>
[Property] public float TipFrequency { get; set; } = 5f;
/// <summary>
/// Damping for tip-only cables, separate from <see cref="JointDamping"/>.
/// </summary>
[Property] public float TipDamping { get; set; } = 0.7f;
/// <summary>
/// Allowed downward stretch below rest for tip-only cables before
/// hitting MaxLength. Gives the spring displacement needed for a
/// strong upward rebound.
/// </summary>
[Property] public float TipDownTravel { get; set; } = 8f;
/// <summary>
/// Max prop spin speed in radians per second. Faster rotation is
/// clamped instantly, so the body can sway but never whir. Zero
/// locks rotation entirely.
/// </summary>
[Property] public float HangMaxSpin { get; set; } = 3f;
/// <summary>
/// Exponential spin damping rate per second. Heavy values kill
/// rotation fast while leaving position swing to the joint.
/// </summary>
[Property] public float HangSpinDamping { get; set; } = 6f;
/// <summary>
/// Exponential correction rate per second pulling the prop back to
/// its startup orientation. Counters tumble; gentle values let it
/// lean with the swing.
/// </summary>
[Property] public float HangRestCorrection { get; set; } = 1.5f;
/// <summary>
/// Number of simulated points along the rope, including the two
/// endpoints. Higher values give a smoother bend but cost more.
/// Set to 2 to disable interior simulation (straight wire).
/// </summary>
[Property, Range( 2, 32 )] public int RopeSegments { get; set; } = 12;
/// <summary>
/// Constraint iterations per physics step. Higher values make the
/// rope stiffer and less stretchy.
/// </summary>
[Property, Range( 1, 20 )] public int RopeIterations { get; set; } = 12;
/// <summary>
/// Downward acceleration for the rope interior. Produces a subtle
/// catenary so the wire is not perfectly straight at rest.
/// </summary>
[Property] public float RopeGravity { get; set; } = 350f;
/// <summary>
/// Velocity damping for interior rope points per step (0 = no
/// damping, 1 = frozen). Small values keep whip from ringing.
/// </summary>
[Property, Range( 0f, 0.5f )] public float RopeDamping { get; set; } = 0.03f;
/// <summary>HangNode converted to HangBody-local space on start.</summary>
[Hide] internal Vector3 HangOffset;
/// <summary>Resolved anchor side, rebuilt every <see cref="OnStart"/>.</summary>
[Hide] internal GameObject Anchor;
/// <summary>Runtime joint holder, rebuilt every <see cref="OnStart"/>.</summary>
[Hide] internal GameObject JointObject;
/// <summary>Auto-created static anchor knob, if the anchor side needed one.</summary>
[Hide] internal GameObject AnchorObject;
/// <summary>Auto-created dynamic tip when no HangBody was assigned.</summary>
[Hide] internal GameObject HangObject;
/// <summary>Runtime wire render object, rebuilt every <see cref="OnStart"/>.</summary>
[Hide] internal SceneObject WireObject;
/// <summary>Wire path ends at the last render rebuild, for change detection.</summary>
[Hide] internal Vector3 LastWireA;
/// <summary>Wire path ends at the last render rebuild, for change detection.</summary>
[Hide] internal Vector3 LastWireB;
/// <summary>Throttles wire render rebuilds; settled wires cost nothing.</summary>
[Hide] internal TimeSince TimeSinceWireBuild;
/// <summary>Whether the wire has been rendered at least once.</summary>
[Hide] internal bool WireBuilt;
/// <summary>Prop world orientation at startup; the correction target.</summary>
[Hide] internal Rotation HangRestRotation;
/// <summary>Set once the link survives <see cref="OnStart"/> validation.</summary>
[Hide] internal bool Started;
/// <summary>Verlet rope points in world space, including endpoints.</summary>
[Hide] internal List<Vector3> RopePoints;
/// <summary>Previous positions for verlet integration.</summary>
[Hide] internal List<Vector3> RopePrevPoints;
/// <summary>Rest length between anchor and tip at spawn.</summary>
[Hide] internal float RopeRestLength;
/// <summary>True after the rope has been seeded.</summary>
[Hide] internal bool RopeReady;
/// <summary>Set when the rope moved enough to require a wire rebuild.</summary>
[Hide] internal bool RopeDirty;
}
/// <summary>
/// Every wire this component drives. Add one entry per Cable root.
/// </summary>
[Property] public List<CableLink> Cables { get; set; } = new();
/// <summary>Minimum seconds between wire render rebuilds.</summary>
private const float WireRebuildInterval = 1f / 30f;
/// <summary>Bodies already stabilized this physics step (links may share one).</summary>
private readonly List<GameObject> _stabilizedBodies = new();
/// <summary>
/// Validates every link, then builds each joint, snaps its hang node to
/// the prop (or the auto tip) so the first frame has no visible pop,
/// hides the engine cable render (frozen in play) and draws the first
/// live wire frame.
/// </summary>
protected override void OnStart()
{
for ( int i = 0; i < Cables.Count; i++ )
{
var link = Cables[i];
if ( link is null || !Validate( link, i ) )
{
continue;
}
EnsureCablePhysics( link );
HideEngineCableMesh( link );
EnsureAnchorBody( link );
EnsureHangTip( link );
BuildJoint( link );
PinHangNode( link, true );
InitializeRope( link );
UpdateWire( link, true );
var hang = GetEffectiveHangBody( link );
if ( hang.IsValid() )
{
link.HangRestRotation = hang.WorldRotation;
}
link.Started = true;
}
}
/// <summary>
/// Re-pins every hang node to its prop and refreshes wires that moved.
/// Cheap when settled: the epsilon checks skip both the node write and
/// the mesh rebuild.
/// </summary>
protected override void OnUpdate()
{
foreach ( var link in Cables )
{
PinHangNode( link, false );
UpdateWire( link, false );
}
}
/// <summary>
/// Clamps and damps prop spin and eases orientation back to rest, once
/// per body per step. Runs in the physics step so velocity edits stick.
/// Also advances the interior rope simulation before the render pass.
/// </summary>
protected override void OnFixedUpdate()
{
_stabilizedBodies.Clear();
foreach ( var link in Cables )
{
StabilizeHangBody( link );
UpdateRope( link );
}
}
protected override void OnDisabled()
{
ClearJoints();
ClearWires();
}
protected override void OnDestroy()
{
ClearJoints();
ClearWires();
}
/// <summary>
/// Checks one link's references. Missing Cable/HangNode fails the link.
/// Missing HangBody is allowed: a tiny dynamic tip will be auto-created
/// and hung instead. A Rigidbody-less prop fails the link in hanger mode;
/// a bodiless anchor or misparented node only warns, since a small static
/// knob can be auto-created.
/// </summary>
private bool Validate( CableLink link, int index )
{
if ( !link.Cable.IsValid() )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} needs a CableComponent." );
return false;
}
if ( !link.HangNode.IsValid() )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} needs a HangNode." );
return false;
}
if ( link.HangNode.Parent != link.Cable.GameObject )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} needs HangNode as a direct child of the Cable object." );
}
link.Anchor = link.AnchorBody.IsValid() ? link.AnchorBody : link.Cable.GameObject;
if ( !link.HangBody.IsValid() )
{
if ( !HasJointBody( link.Anchor, false ) )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} anchor '{link.Anchor.Name}' has no physics body yet; chain: {DescribeBodyChain( link.Anchor )} - a small static knob will be created." );
}
return true;
}
if ( !HasJointBody( link.HangBody, true ) )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} needs a Rigidbody on or above HangBody '{link.HangBody.Name}'." );
return false;
}
if ( !HasCollisionShapeOnSelfOrAncestors( link.HangBody ) )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} HangBody '{link.HangBody.Name}' has no collision; give it a Collider or a MeshComponent with Hull/Mesh collision." );
}
if ( !HasJointBody( link.Anchor, false ) )
{
Log.Warning( $"HangingCable on '{GameObject.Name}' cable #{index} anchor '{link.Anchor.Name}' has no physics body yet; chain: {DescribeBodyChain( link.Anchor )} - a small static knob will be created." );
}
return true;
}
/// <summary>
/// Mirrors how the engine resolves joint bodies (<c>Joint.FindPhysicsBody</c>):
/// Rigidbody first, then collision, climbing parents. A node like Cable
/// Node 1 is therefore a valid anchor: it resolves through the Cable
/// object's static mesh collision.
/// </summary>
private static bool HasJointBody( GameObject go, bool requireRigidbody )
{
for ( var current = go; current.IsValid(); current = current.Parent )
{
if ( current.GetComponent<Rigidbody>().IsValid() )
{
return true;
}
if ( !requireRigidbody && HasCollisionShape( current ) )
{
return true;
}
}
return false;
}
private static bool HasCollisionShapeOnSelfOrAncestors( GameObject go )
{
for ( var current = go; current.IsValid(); current = current.Parent )
{
if ( HasCollisionShape( current ) )
{
return true;
}
}
return false;
}
/// <summary>
/// True when the object provides a physics shape: any enabled
/// <see cref="Collider"/>, including a <see cref="MeshComponent"/> whose
/// <see cref="MeshComponent.Collision"/> is Hull or Mesh.
/// </summary>
private static bool HasCollisionShape( GameObject go )
{
foreach ( var collider in go.GetComponents<Collider>() )
{
if ( !collider.IsValid() || !collider.Enabled )
{
continue;
}
if ( collider is MeshComponent mesh && mesh.Collision == MeshComponent.CollisionType.None )
{
continue;
}
return true;
}
return false;
}
/// <summary>
/// One-line dump of the anchor resolution chain for the warning above:
/// each self-or-ancestor object with its colliders, so a false positive
/// names exactly what the check saw.
/// </summary>
private static string DescribeBodyChain( GameObject go )
{
string chain = "";
for ( var current = go; current.IsValid(); current = current.Parent )
{
string shapes = "";
foreach ( var collider in current.GetComponents<Collider>() )
{
if ( !collider.IsValid() )
{
continue;
}
string detail = collider.GetType().Name + (collider.Enabled ? "" : ":disabled");
if ( collider is MeshComponent mesh )
{
detail += $"(collision={mesh.Collision})";
}
shapes += (shapes.Length > 0 ? "," : "") + detail;
}
string rigid = current.GetComponent<Rigidbody>().IsValid() ? "+rigidbody" : "";
chain += (chain.Length > 0 ? " < " : "") + $"'{current.Name}'[{shapes}{rigid}]";
}
return chain;
}
private static GameObject GetEffectiveHangBody( CableLink link )
{
if ( link is null )
{
return null;
}
if ( link.HangObject.IsValid() )
{
return link.HangObject;
}
return link.HangBody;
}
/// <summary>
/// Creates the rope-style <see cref="SpringJoint"/> for one link: a soft
/// spring sized from the anchor to the hang attach point plus
/// <c>JointSlack</c>. Prop links are pull-only (a wire never pushes);
/// tip-only cables use <see cref="CableLink.TipForceMode"/> (default
/// Both) with <see cref="CableLink.TipCompression"/> slack so a shove up
/// compresses and rebounds while the cable keeps its stiff length.
/// Frames are explicit (<see cref="Joint.AttachmentMode.LocalFrames"/>)
/// so the joint pulls the hang body at the hang node, not at its origin.
/// </summary>
private void BuildJoint( CableLink link )
{
var hang = GetEffectiveHangBody( link );
link.HangOffset = hang.WorldTransform.PointToLocal( link.HangNode.WorldPosition );
var anchorWorld = link.Cable.GameObject.WorldPosition;
var attach = hang.WorldTransform.PointToWorld( link.HangOffset );
float len = anchorWorld.Distance( attach ) + link.JointSlack;
if ( len < 1f )
{
len = 1f;
}
bool isTip = !link.HangBody.IsValid();
link.JointObject = new GameObject( "CableJoint" );
link.JointObject.Parent = link.Cable.GameObject;
link.JointObject.LocalPosition = Vector3.Zero;
var joint = link.JointObject.AddComponent<SpringJoint>();
joint.Attachment = Joint.AttachmentMode.LocalFrames;
joint.LocalFrame1 = new Transform( link.Anchor.WorldTransform.PointToLocal( anchorWorld ) );
joint.LocalFrame2 = new Transform( link.HangOffset );
joint.AnchorBody = link.Anchor;
joint.Body = hang;
joint.ForceMode = isTip ? link.TipForceMode : SpringJoint.SpringForceMode.Pull;
joint.MinLength = isTip ? Math.Max( 0f, len - Math.Max( 0f, link.TipCompression ) ) : 0f;
joint.MaxLength = isTip ? len + Math.Max( 0f, link.TipDownTravel ) : len;
joint.RestLength = len;
joint.Frequency = isTip ? link.TipFrequency : link.JointFrequency;
joint.Damping = isTip ? link.TipDamping : link.JointDamping;
joint.EnableCollision = true;
}
/// <summary>
/// Moves the link's hang node onto the hang body's attach point. Skips the
/// write when already there (<paramref name="force"/> bypasses the check
/// for the initial snap in <see cref="OnStart"/>).
/// </summary>
private static void PinHangNode( CableLink link, bool force )
{
if ( link is null || !link.HangNode.IsValid() )
{
return;
}
var hang = GetEffectiveHangBody( link );
if ( !hang.IsValid() )
{
return;
}
var target = hang.WorldTransform.PointToWorld( link.HangOffset );
if ( !force && link.HangNode.WorldPosition.AlmostEqual( target ) )
{
return;
}
link.HangNode.WorldPosition = target;
}
/// <summary>
/// Keeps one link's hang body from spinning out: hard-clamps spin speed,
/// then exponentially damps what remains, then eases orientation toward
/// the startup pose for prop links. Skips links that never started and
/// bodies already handled this step. Auto tips keep spin damping but
/// skip rotation correction so vertical push slack is not fought.
/// </summary>
private void StabilizeHangBody( CableLink link )
{
if ( link is null || !link.Started )
{
return;
}
var hang = GetEffectiveHangBody( link );
if ( !hang.IsValid() )
{
return;
}
if ( _stabilizedBodies.Contains( hang ) )
{
return;
}
_stabilizedBodies.Add( hang );
var rigid = hang.GetComponent<Rigidbody>();
if ( !rigid.IsValid() )
{
return;
}
float delta = Time.Delta;
Vector3 spin = rigid.AngularVelocity;
float speed = spin.Length;
if ( speed > link.HangMaxSpin && speed > 0.0001f )
{
spin *= link.HangMaxSpin / speed;
}
spin *= MathF.Exp( -link.HangSpinDamping * delta );
rigid.AngularVelocity = spin;
bool isTip = link.HangObject.IsValid();
if ( isTip )
{
return;
}
float correction = 1f - MathF.Exp( -link.HangRestCorrection * delta );
if ( correction > 0f )
{
hang.WorldRotation = Rotation.Slerp( hang.WorldRotation, link.HangRestRotation, correction );
}
}
/// <summary>
/// The cable never provides collision: in hanger mode the prop must never
/// hit the frozen authored tube, and in dangling mode the tiny tip is
/// the only physics shape. Disables the Cable's <see cref="MeshComponent"/>
/// collision every start.
/// </summary>
private static void EnsureCablePhysics( CableLink link )
{
var mesh = link.Cable.GameObject.GetComponent<MeshComponent>();
if ( !mesh.IsValid() )
{
return;
}
if ( mesh.Collision != MeshComponent.CollisionType.None )
{
mesh.Collision = MeshComponent.CollisionType.None;
}
}
/// <summary>
/// Guarantees a static body for the hanger joint. The Cable's own mesh
/// has no collision, so an explicit anchor object would otherwise be
/// bodiless. Creates a tiny static sphere knob at the ceiling anchor when
/// the resolved anchor has no body and points the joint at it.
/// </summary>
private void EnsureAnchorBody( CableLink link )
{
if ( HasJointBody( link.Anchor, false ) )
{
return;
}
if ( link.AnchorObject.IsValid() )
{
link.Anchor = link.AnchorObject;
return;
}
var anchorWorld = link.Cable.GameObject.WorldPosition;
link.AnchorObject = new GameObject( "CableAnchor" );
link.AnchorObject.Parent = link.Cable.GameObject;
link.AnchorObject.WorldPosition = anchorWorld;
var sphere = link.AnchorObject.AddComponent<SphereCollider>();
sphere.Radius = 2f;
link.Anchor = link.AnchorObject;
}
/// <summary>
/// When no <see cref="CableLink.HangBody"/> was assigned, the cable still
/// hangs: auto-creates a tiny dynamic tip at the HangNode end so the joint
/// has something to sway. The tip is a small Rigidbody + SphereCollider
/// with heavy damping; the visual wire is pinned to it every frame just
/// like the prop case.
/// </summary>
private void EnsureHangTip( CableLink link )
{
if ( link.HangBody.IsValid() || link.HangObject.IsValid() )
{
return;
}
var tipWorld = link.HangNode.WorldPosition;
link.HangObject = new GameObject( "CableTip" );
link.HangObject.Parent = GameObject;
link.HangObject.WorldPosition = tipWorld;
var body = link.HangObject.AddComponent<Rigidbody>();
body.MassOverride = 0.8f;
body.LinearDamping = 0.05f;
body.AngularDamping = 2f;
var tip = link.HangObject.AddComponent<SphereCollider>();
float size = link.Cable.IsValid() ? link.Cable.Size : 1f;
tip.Radius = Math.Max( 1.5f, size * 1.2f );
}
private static Vector3 GetRopeAnchor( CableLink link )
{
if ( !link.Cable.IsValid() )
{
return Vector3.Zero;
}
foreach ( var child in link.Cable.GameObject.Children )
{
if ( child.GetComponent<CableNodeComponent>() is not null )
{
return child.WorldPosition;
}
}
return link.Cable.GameObject.WorldPosition;
}
private static Vector3 GetRopeEnd( CableLink link )
{
var hang = GetEffectiveHangBody( link );
if ( hang.IsValid() )
{
return hang.WorldTransform.PointToWorld( link.HangOffset );
}
if ( link.HangNode.IsValid() )
{
return link.HangNode.WorldPosition;
}
return GetRopeAnchor( link );
}
private static void InitializeRope( CableLink link )
{
if ( link is null || !link.Cable.IsValid() )
{
return;
}
int segments = Math.Clamp( link.RopeSegments, 2, 32 );
if ( segments < 3 )
{
link.RopePoints = null;
link.RopePrevPoints = null;
link.RopeReady = false;
link.RopeDirty = false;
link.RopeRestLength = 0f;
return;
}
var anchor = GetRopeAnchor( link );
var end = GetRopeEnd( link );
float dist = anchor.Distance( end );
if ( dist < 1f )
{
dist = Math.Max( 1f, (anchor - end).Length + 1f );
}
link.RopeRestLength = dist;
link.RopePoints = new List<Vector3>( segments );
link.RopePrevPoints = new List<Vector3>( segments );
float sagBase = link.RopeGravity > 0f ? Math.Min( 6f, dist * 0.05f ) : 0f;
for ( int i = 0; i < segments; i++ )
{
float t = i / (float)(segments - 1);
var pos = Vector3.Lerp( anchor, end, t );
float sag = MathF.Sin( t * MathF.PI ) * sagBase;
pos += Vector3.Down * sag;
link.RopePoints.Add( pos );
link.RopePrevPoints.Add( pos );
}
link.RopeReady = true;
link.RopeDirty = true;
}
private void UpdateRope( CableLink link )
{
if ( link is null || !link.Started || !link.RopeReady || link.RopePoints is null || link.RopePrevPoints is null )
{
return;
}
int count = link.RopePoints.Count;
if ( count < 3 )
{
return;
}
var anchor = GetRopeAnchor( link );
var end = GetRopeEnd( link );
float delta = Time.Delta;
if ( delta <= 0f )
{
return;
}
if ( link.RopeSegments != count )
{
InitializeRope( link );
return;
}
float currentDist = anchor.Distance( end );
if ( link.RopeRestLength < 0.1f )
{
link.RopeRestLength = Math.Max( 1f, currentDist );
}
float targetTotal = Math.Max( link.RopeRestLength, currentDist );
float segLen = targetTotal / (count - 1);
Vector3 prevEndPos = link.RopePoints[count - 1];
link.RopePoints[0] = anchor;
link.RopePrevPoints[0] = anchor;
link.RopePoints[count - 1] = end;
link.RopePrevPoints[count - 1] = prevEndPos;
float damping = Math.Clamp( link.RopeDamping, 0f, 0.5f );
float gravity = link.RopeGravity;
Vector3 gravityStep = Vector3.Down * gravity * delta * delta;
bool moved = false;
for ( int i = 1; i < count - 1; i++ )
{
var pos = link.RopePoints[i];
var prev = link.RopePrevPoints[i];
var vel = (pos - prev) * (1f - damping);
var next = pos + vel + gravityStep;
if ( (next - pos).LengthSquared > 0.000001f )
{
moved = true;
}
link.RopePrevPoints[i] = pos;
link.RopePoints[i] = next;
}
int iters = Math.Clamp( link.RopeIterations, 1, 20 );
for ( int iter = 0; iter < iters; iter++ )
{
for ( int i = 0; i < count - 1; i++ )
{
var p1 = link.RopePoints[i];
var p2 = link.RopePoints[i + 1];
var d = p2 - p1;
float len = d.Length;
if ( len < 0.001f )
{
continue;
}
float diff = (len - segLen) / len;
bool pin1 = i == 0;
bool pin2 = i + 1 == count - 1;
if ( pin1 && pin2 )
{
var c = d * diff * 0.5f;
link.RopePoints[i] += c;
link.RopePoints[i + 1] -= c;
}
else if ( pin1 )
{
link.RopePoints[i + 1] -= d * diff;
}
else if ( pin2 )
{
link.RopePoints[i] += d * diff;
}
else
{
var c = d * diff * 0.5f;
link.RopePoints[i] += c;
link.RopePoints[i + 1] -= c;
}
}
link.RopePoints[0] = anchor;
link.RopePoints[count - 1] = end;
}
if ( moved )
{
link.RopeDirty = true;
}
else
{
float maxSq = 0f;
for ( int i = 1; i < count - 1; i++ )
{
var v = link.RopePoints[i] - link.RopePrevPoints[i];
float sq = v.LengthSquared;
if ( sq > maxSq )
{
maxSq = sq;
}
}
link.RopeDirty = maxSq > 0.0004f;
}
}
/// <summary>
/// Destroys every runtime-built joint, anchor knob and auto tip. The
/// scene-authored Cable, nodes and prop are left untouched.
/// </summary>
private void ClearJoints()
{
foreach ( var link in Cables )
{
if ( link is null )
{
continue;
}
if ( link.JointObject.IsValid() )
{
link.JointObject.Destroy();
link.JointObject = null;
}
if ( link.AnchorObject.IsValid() )
{
link.AnchorObject.Destroy();
link.AnchorObject = null;
}
if ( link.HangObject.IsValid() )
{
link.HangObject.Destroy();
link.HangObject = null;
}
link.RopePoints = null;
link.RopePrevPoints = null;
link.RopeReady = false;
link.RopeDirty = false;
link.RopeRestLength = 0f;
}
}
/// <summary>
/// Deletes every runtime-built wire render object.
/// </summary>
private void ClearWires()
{
foreach ( var link in Cables )
{
if ( link is null || !link.WireObject.IsValid() )
{
continue;
}
link.WireObject.Delete();
link.WireObject = null;
link.WireBuilt = false;
}
}
/// <summary>
/// Hides the engine cable's own render mesh. Its <see cref="MeshComponent"/>
/// never recooks in play (editor-gated), so it would sit frozen at the
/// authored pose while the live wire below tracks the prop. Render-only:
/// collision is managed separately by <see cref="EnsureCablePhysics"/>.
/// </summary>
private static void HideEngineCableMesh( CableLink link )
{
var mesh = link.Cable.GameObject.GetComponent<MeshComponent>();
if ( mesh.IsValid() && !mesh.HideInGame )
{
mesh.HideInGame = true;
}
}
/// <summary>
/// Rebuilds the link's wire render when its ends moved or the rope
/// interior swayed. Skips the rebuild while settled and throttles to
/// <see cref="WireRebuildInterval"/> mid-swing.
/// </summary>
private void UpdateWire( CableLink link, bool force )
{
if ( link is null || !link.Cable.IsValid() || !link.HangNode.IsValid() )
{
return;
}
Vector3 a = GetRopeAnchor( link );
Vector3 b = GetRopeEnd( link );
bool ropeActive = link.RopeReady && link.RopePoints is not null && link.RopePoints.Count >= 3;
bool endpointMoved = !link.WireBuilt || !link.LastWireA.AlmostEqual( a ) || !link.LastWireB.AlmostEqual( b );
if ( !force && link.WireBuilt && !endpointMoved && !(ropeActive && link.RopeDirty) )
{
return;
}
if ( !force && link.WireBuilt && link.TimeSinceWireBuild < WireRebuildInterval )
{
return;
}
var polygon = BuildWireMesh( link );
if ( polygon is null )
{
return;
}
var model = polygon.Rebuild();
if ( model.MeshCount == 0 )
{
return;
}
if ( !link.WireObject.IsValid() )
{
link.WireObject = new SceneObject( Scene.SceneWorld, model, new Transform( Vector3.Zero, Rotation.Identity, 1f ) );
}
else
{
link.WireObject.Model = model;
}
link.LastWireA = a;
link.LastWireB = b;
link.TimeSinceWireBuild = 0;
link.WireBuilt = true;
link.RopeDirty = false;
}
/// <summary>One tube path sample in world space.</summary>
private struct WireSample
{
public Vector3 Position;
public float RadiusScale;
public float Roll;
}
/// <summary>
/// Builds the wire tube in world space. When the verlet rope is active,
/// the rope points drive the tube directly (including gravity sag and
/// whip); otherwise it falls back to the engine-style node path with
/// Catmull-Rom and fake slack.
/// </summary>
private static PolygonMesh BuildWireMesh( CableLink link )
{
var cable = link.Cable;
List<WireSample> samples;
float slack = cable.Slack;
if ( link.RopeReady && link.RopePoints is not null && link.RopePoints.Count >= 2 )
{
samples = new List<WireSample>( link.RopePoints.Count );
foreach ( var p in link.RopePoints )
{
samples.Add( new WireSample { Position = p, RadiusScale = 1f, Roll = 0f } );
}
slack = 0f;
}
else
{
samples = new List<WireSample>();
foreach ( var child in cable.GameObject.Children )
{
var node = child.GetComponent<CableNodeComponent>();
if ( node is null )
{
continue;
}
samples.Add( new WireSample { Position = child.WorldPosition, RadiusScale = node.RadiusScale, Roll = node.Roll } );
}
Vector3 hangPos = link.HangNode.WorldPosition;
bool hangSampled = false;
foreach ( var sample in samples )
{
if ( sample.Position.AlmostEqual( hangPos ) )
{
hangSampled = true;
break;
}
}
if ( !hangSampled )
{
samples.Add( new WireSample { Position = hangPos, RadiusScale = 1f, Roll = 0f } );
}
if ( samples.Count < 2 )
{
return null;
}
}
if ( samples.Count < 2 )
{
return null;
}
var path = BuildWirePath( samples, cable.PathDetail, slack );
if ( path.Count < 2 )
{
return null;
}
int sides = Math.Max( 3, cable.Subdivisions );
float radius = Math.Max( 0.1f, cable.Size );
var material = cable.Material;
var mesh = new PolygonMesh();
var firstRing = new Vector3[sides];
var lastRing = new Vector3[sides];
VertexHandle[] prevHandles = null;
float prevU = 0f;
var tangent = (path[1].Position - path[0].Position).Normal;
var normal = BuildInitialNormal( tangent );
float length = 0f;
for ( int i = 0; i < path.Count; i++ )
{
var point = path[i].Position;
if ( i > 0 )
{
length += point.Distance( path[i - 1].Position );
}
tangent = BuildTangent( path, i );
normal = BuildNormalFromPrevious( tangent, normal );
normal = Rotation.FromAxis( tangent, path[i].Roll ) * normal;
var bitangent = tangent.Cross( normal ).Normal;
float nodeRadius = radius * Math.Max( 0.01f, path[i].RadiusScale );
float u = length * cable.TextureScale + cable.TextureOffsetAlongPath;
var ring = new Vector3[sides];
for ( int j = 0; j < sides; j++ )
{
float angle = (MathF.PI * 2f * j) / sides;
ring[j] = point + (normal * MathF.Cos( angle ) + bitangent * MathF.Sin( angle )) * nodeRadius;
}
var handles = mesh.AddVertices( ring );
if ( prevHandles is not null )
{
for ( int j = 0; j < sides; j++ )
{
int next = (j + 1) % sides;
var face = mesh.AddFace( prevHandles[j], prevHandles[next], handles[next], handles[j] );
mesh.SetFaceMaterial( face, material );
mesh.SetFaceTextureCoords( face, [BuildWireUv( prevU, j, sides, cable ), BuildWireUv( prevU, j + 1, sides, cable ), BuildWireUv( u, j + 1, sides, cable ), BuildWireUv( u, j, sides, cable )] );
}
}
prevHandles = handles;
prevU = u;
if ( i == 0 )
{
firstRing = ring;
}
lastRing = ring;
}
if ( cable.CapEnds )
{
var startCap = new Vector3[sides];
for ( int i = 0; i < sides; i++ )
{
startCap[i] = firstRing[sides - 1 - i];
}
var startFace = mesh.AddFace( mesh.AddVertices( startCap ) );
mesh.SetFaceMaterial( startFace, material );
var endFace = mesh.AddFace( mesh.AddVertices( lastRing ) );
mesh.SetFaceMaterial( endFace, material );
}
mesh.SetSmoothingAngle( 180f );
return mesh;
}
/// <summary>
/// Length-based cord UV. The side index is intentionally unwrapped so the
/// seam (side == sides) lands exactly one repeat past side zero.
/// </summary>
private static Vector2 BuildWireUv( float u, int side, int sides, CableComponent cable )
{
float v = side / (float)sides * cable.TextureRepeatsCircumference + cable.TextureOffsetCircumference;
return cable.TextureOrientation == CableComponent.CableTextureOrientation.Vertical ? new Vector2( v, u ) : new Vector2( u, v );
}
/// <summary>
/// Subdivides control samples into a smooth path, applying the engine's
/// fake-slack sag. Ports <c>CableComponent.BuildPathPoints</c>.
/// </summary>
private static List<WireSample> BuildWirePath( List<WireSample> controlPoints, int pathDetail, float slack )
{
var path = new List<WireSample>();
if ( controlPoints.Count <= 1 )
{
path.AddRange( controlPoints );
return path;
}
if ( pathDetail <= 0 && MathF.Abs( slack ) <= 0.0001f )
{
path.AddRange( controlPoints );
return path;
}
int minSteps = MathF.Abs( slack ) > 0.0001f ? 2 : 1;
int steps = Math.Max( minSteps, pathDetail + 1 );
for ( int i = 0; i < controlPoints.Count - 1; i++ )
{
var p0 = controlPoints[Math.Max( i - 1, 0 )];
var p1 = controlPoints[i];
var p2 = controlPoints[i + 1];
var p3 = controlPoints[Math.Min( i + 2, controlPoints.Count - 1 )];
for ( int s = 0; s < steps; s++ )
{
float t = s / (float)steps;
float sag = 4f * t * (1f - t);
path.Add( new WireSample
{
Position = CatmullRom( p0.Position, p1.Position, p2.Position, p3.Position, t ) + Vector3.Down * (sag * slack),
RadiusScale = CatmullRom( p0.RadiusScale, p1.RadiusScale, p2.RadiusScale, p3.RadiusScale, t ),
Roll = CatmullRom( p0.Roll, p1.Roll, p2.Roll, p3.Roll, t )
} );
}
}
path.Add( controlPoints[^1] );
return path;
}
private static Vector3 CatmullRom( Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t )
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5f * ((2f * p1) + (-p0 + p2) * t + (2f * p0 - 5f * p1 + 4f * p2 - p3) * t2 + (-p0 + 3f * p1 - 3f * p2 + p3) * t3);
}
private static float CatmullRom( float p0, float p1, float p2, float p3, float t )
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5f * ((2f * p1) + (-p0 + p2) * t + (2f * p0 - 5f * p1 + 4f * p2 - p3) * t2 + (-p0 + 3f * p1 - 3f * p2 + p3) * t3);
}
private static Vector3 BuildTangent( List<WireSample> points, int i )
{
if ( points.Count < 2 )
{
return Vector3.Forward;
}
if ( i == 0 )
{
return (points[1].Position - points[0].Position).Normal;
}
if ( i == points.Count - 1 )
{
return (points[^1].Position - points[^2].Position).Normal;
}
var tangent = ((points[i].Position - points[i - 1].Position).Normal + (points[i + 1].Position - points[i].Position).Normal).Normal;
return tangent.LengthSquared > 0.0001f ? tangent : (points[i + 1].Position - points[i].Position).Normal;
}
private static Vector3 BuildInitialNormal( Vector3 tangent )
{
var up = MathF.Abs( tangent.Dot( Vector3.Up ) ) > 0.98f ? Vector3.Right : Vector3.Up;
return tangent.Cross( up ).Normal;
}
private static Vector3 BuildNormalFromPrevious( Vector3 tangent, Vector3 previousNormal )
{
var projected = previousNormal - tangent * previousNormal.Dot( tangent );
if ( projected.LengthSquared > 0.0001f )
{
return projected.Normal;
}
return BuildInitialNormal( tangent );
}
}