Editor utility that builds collision shapes for parts from either explicit per-body choices or from the part history. It inspects features (primitives, transforms, mirrors, sketches) to produce Box, Sphere, Cylinder or Hull CollisionShape entries and falls back to one convex hull or bounding box per body when history cannot represent the geometry.
using System;
using System.Collections.Generic;
namespace Effigy;
public enum CollisionKind
{
Box,
Sphere,
Cylinder,
Hull,
}
/// <summary>One convex piece of a part's physics representation.</summary>
public sealed class CollisionShape
{
public CollisionKind Kind;
/// <summary>Centre of the shape, in model space.</summary>
public Vec3 Position;
/// <summary>Box: half-extents. Sphere: radius in x. Cylinder: radius in x, half-height in z.</summary>
public Vec3 Size;
/// <summary>Hull only: the points on it.</summary>
public List<Vec3> Points;
/// <summary>Which body this came from, so a caller can group them.</summary>
public string BodyId;
/// <summary>
/// The bone this shape rides on, or null to sit on the model root.
///
/// WITHOUT THIS A RIGGED MODEL'S COLLISION IS FURNITURE. The shapes are welded to the root, the
/// mesh bends away from them, and the part is solid where it used to be rather than where it is.
/// Null is still the right answer for a static part, which has no bones to hang off.
///
/// Only a <see cref="CollisionKind.Hull"/> can carry one and mean it — see VmdlPhysics for the
/// measurement that says why.
/// </summary>
public string Bone;
public override string ToString() => Kind switch
{
CollisionKind.Box => $"box {Size.x * 2:0.##} x {Size.y * 2:0.##} x {Size.z * 2:0.##}",
CollisionKind.Sphere => $"sphere r{Size.x:0.##}",
CollisionKind.Cylinder => $"cylinder r{Size.x:0.##} h{Size.z * 2:0.##}",
_ => $"hull of {Points?.Count ?? 0} points",
};
}
/// <summary>What a collision build produced, and how it got there.</summary>
public sealed class CollisionReport
{
public readonly List<CollisionShape> Shapes;
public readonly bool FromHistory;
public readonly string Reason;
public CollisionReport( List<CollisionShape> shapes, bool fromHistory, string reason )
{
Shapes = shapes;
FromHistory = fromHistory;
Reason = reason;
}
public override string ToString() =>
$"{Shapes.Count} shape(s), {(FromHistory ? "from the feature history" : "as hulls")}"
+ (Reason is null ? "" : $" - {Reason}");
}
/// <summary>
/// A part's physics representation, taken from the feature history rather than from the mesh.
///
/// A MODEL KNOWN TO BE A UNION OF N CONVEX PRIMITIVES IS ALREADY ITS OWN COLLISION, so this is
/// bookkeeping rather than geometry. Walking the finished mesh instead would throw away the one
/// thing that makes the answer exact: that somebody typed "box, 2 by 2 by 2" and nothing since has
/// been allowed to disturb it.
///
/// WHAT SPOILS IT is anything that changes a body's shape in a way a primitive cannot describe — an
/// extrude, a revolve, a boolean, a shell, a fillet, a subdivide. The moment one of those is in the
/// tree the history stops being a description of the shape and the fallback takes over: one convex
/// hull per body. Bigger than the part wherever the part is concave, never smaller, always convex,
/// and honest about being an approximation.
///
/// Deliberately all-or-nothing rather than per body. Working out WHICH bodies a boolean touched
/// means tracking body identity through operations that create and destroy them, and a physics
/// representation that is exact for three props and quietly wrong for the fourth is worse than one
/// that is approximate for all four and says so.
/// </summary>
public static class CollisionBuilder
{
public static CollisionReport Build( PartStudio studio )
{
if ( studio is null )
throw new ArgumentNullException( nameof( studio ) );
// Collision chosen by hand wins over the history: the choice is what the modeller wants
// to ship, and the history cannot express "this part has none".
if ( studio.BodyCollision.Count > 0 )
return new CollisionReport( Hulls( studio ), false, "collision was set per part" );
if ( TryFromHistory( studio, out var shapes, out var spoiler ) )
return new CollisionReport( shapes, true, null );
return new CollisionReport( Hulls( studio ), false,
spoiler is null ? "there is nothing in the history to read" : $"{spoiler} is in the tree" );
}
static bool TryFromHistory( PartStudio studio, out List<CollisionShape> shapes, out string spoiler )
{
shapes = new List<CollisionShape>();
spoiler = null;
var count = Math.Min( studio.RollbackIndex, studio.Features.Count );
for ( var i = 0; i < count; i++ )
{
var feature = studio.Features[i];
if ( feature.Suppressed )
continue;
switch ( feature )
{
case PrimitiveFeature primitive:
if ( FromPrimitive( primitive ) is { } shape )
shapes.Add( shape );
else
spoiler = primitive.Shape.Value;
break;
// A sketch produces no solid, so it cannot spoil anything.
case SketchFeature:
continue;
// These copy what is already there, which is exactly what a shape list can do too.
case MirrorFeature mirror:
Mirror( shapes, mirror );
break;
case TransformFeature transform:
// A MOVE CAN BE FOLLOWED; A TURN OR A STRETCH CANNOT. A CollisionShape carries a
// position and a size, not an orientation, so a rotated box has nowhere to record
// that it is rotated - and a collision hull sitting square while the part it
// belongs to is at forty degrees is the kind of wrong that only shows up when
// something bounces off thin air. So this spoils the decomposition rather than
// quietly dropping the rotation, and the hulls that replace it are correct.
if ( MathF.Abs( transform.RotationAngle.Value ) > 1e-4f
|| (transform.Scale.Value - Vec3.One).LengthSquared > 1e-8f )
{
spoiler ??= "a rotated or scaled Transform";
break;
}
Move( shapes, transform );
break;
default:
spoiler ??= feature.TypeName;
break;
}
if ( spoiler is not null )
return false;
}
return shapes.Count > 0;
}
static CollisionShape FromPrimitive( PrimitiveFeature primitive )
{
var scale = primitive.Scale.Value;
var position = primitive.Position.Value;
switch ( primitive.Shape.Value )
{
case "Box":
case "Plane":
return new CollisionShape
{
Kind = CollisionKind.Box,
Position = position,
Size = new Vec3(
primitive.SizeX.Clamped * 0.5f * scale.x,
primitive.SizeY.Clamped * 0.5f * scale.y,
primitive.SizeZ.Clamped * 0.5f * scale.z ),
};
case "Sphere":
// A sphere scaled unevenly is an ellipsoid, and there is no ellipsoid collision
// shape - so it falls back rather than pretending the smallest axis is the radius.
if ( MathF.Abs( scale.x - scale.y ) > 1e-4f || MathF.Abs( scale.x - scale.z ) > 1e-4f )
return null;
return new CollisionShape
{
Kind = CollisionKind.Sphere,
Position = position,
Size = new Vec3( primitive.Radius.Clamped * scale.x, 0f, 0f ),
};
case "Cylinder":
if ( MathF.Abs( scale.x - scale.y ) > 1e-4f )
return null;
return new CollisionShape
{
Kind = CollisionKind.Cylinder,
Position = position,
Size = new Vec3( primitive.Radius.Clamped * scale.x, 0f,
primitive.SizeZ.Clamped * 0.5f * scale.z ),
};
// A wedge is a prism and a tube has a hole down the middle. Neither is one convex
// primitive, and a hull of a tube fills its bore in - so they go to the fallback, where
// at least the approximation is stated.
default:
return null;
}
}
static void Mirror( List<CollisionShape> shapes, MirrorFeature mirror )
{
var normal = mirror.PlaneNormal.Value;
if ( normal.LengthSquared < 1e-12f )
return;
normal = normal.Normal;
var origin = mirror.PlanePoint.Value;
var copies = new List<CollisionShape>( shapes.Count );
foreach ( var shape in shapes )
{
var offset = shape.Position - origin;
var reflected = origin + offset - normal * (2f * Vec3.Dot( offset, normal ));
copies.Add( new CollisionShape
{
Kind = shape.Kind,
Position = reflected,
Size = shape.Size,
Points = shape.Points,
BodyId = shape.BodyId,
Bone = shape.Bone,
} );
}
// Keep original off means the mirror REPLACES what it reflected, so the shapes have to go
// with it. Appending regardless would leave collision on a half of the part that is not there.
if ( !mirror.KeepOriginal.Value )
shapes.Clear();
shapes.AddRange( copies );
}
static void Move( List<CollisionShape> shapes, TransformFeature transform )
{
var offset = transform.Translate.Value;
foreach ( var shape in shapes )
shape.Position += offset;
}
/// <summary>
/// One hull per body, which is what an undecomposable model gets — and what a RIGGED model gets
/// whether or not its history could be decomposed.
///
/// WHY A RIG THROWS THE DECOMPOSITION AWAY. Bones are assigned to BODIES: the rig panel's map is
/// body id to bone name, and it is the only statement anywhere about which part of the model
/// moves with which bone. A history decomposition does not respect that boundary — a mirror
/// duplicates shapes, a move slides them, and the box that comes out the far end belongs to no
/// body in particular. One hull per body is coarser and it is ADDRESSABLE, which is worth more
/// than exactness the moment a shape has to name the bone it rides on.
/// </summary>
public static List<CollisionShape> HullsPerBody( PartStudio studio )
{
if ( studio is null )
throw new ArgumentNullException( nameof( studio ) );
return Hulls( studio );
}
static List<CollisionShape> Hulls( PartStudio studio )
{
var shapes = new List<CollisionShape>();
foreach ( var body in studio.Bodies )
{
if ( body.Mesh is not { VertexCount: >= 4 } mesh )
continue;
// A choice made per part: none, a box, or the hull below.
studio.BodyCollision.TryGetValue( body.Id, out var chosen );
if ( chosen == "none" )
continue;
if ( chosen == "box" )
{
shapes.Add( BoxAround( mesh, body.Id ) );
continue;
}
var hull = ConvexHull.Build( mesh.Positions );
if ( hull is not { } built )
{
// Flat, collinear, or a single point: no volume to enclose. A box round it is the
// only convex answer left and it is at least never smaller than the part.
shapes.Add( BoxAround( mesh, body.Id ) );
continue;
}
shapes.Add( new CollisionShape
{
Kind = CollisionKind.Hull,
Position = Vec3.Zero,
Points = built.Points,
BodyId = body.Id,
} );
}
return shapes;
}
static CollisionShape BoxAround( PolyMesh mesh, string bodyId )
{
var min = mesh.Positions[0];
var max = mesh.Positions[0];
foreach ( var p in mesh.Positions )
{
min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
}
return new CollisionShape
{
Kind = CollisionKind.Box,
Position = (min + max) * 0.5f,
Size = (max - min) * 0.5f,
BodyId = bodyId,
};
}
}