The vehicle rig builder for HorizonRP. Reads a Model's vertices, indices and per-material draw calls, then splits it into a body and per-wheel sub-models, finding wheels by tyre/rim material names or by low, outer geometry. Also extracts lamp layers, indicators, licence plates, glass/cabin position and police beacon pieces, applies an optional scale and yaw, and caches one rig per model and setting.
namespace HorizonRP;
/// <summary>
/// Le gréement d'un modèle de véhicule, quel qu'il soit : où sont ses roues, et des modèles
/// fabriqués à la volée pour les faire tourner — une carrosserie sans roues, et une roue par
/// roue, centrée sur son axe. C'est ce qui permet de prendre des véhicules de sources
/// diverses (le Workshop s&box, un FBX de Sketchfab, un pack acheté) sans les retoucher.
///
/// Les roues sont cherchées, dans l'ordre :
/// 1. par leur matériau (pneu, jante…) : les voitures reprises de GTA V ont toutes un
/// « vehicle_generic_tyrewall… » ;
/// 2. par la géométrie : les sommets bas, aux bords, groupés par essieu.
/// Les sommets trouvés sont groupés par essieu (selon X), puis par côté (selon Y) : une
/// voiture a deux essieux, un camion peut en avoir trois. Chaque roue est ensuite découpée :
/// toute pièce qui tient entière dans son cylindre (pneu, jante, disque) lui revient — une pièce
/// de caisse qui n'y passe qu'en partie (bord d'aile, doublure) reste à la caisse.
/// Une fiche de véhicule peut aussi donner les roues à la main (<see cref="Build"/>).
///
/// Le modèle doit avancer selon +X (sinon la fiche le tourne) et reposer sur z = 0.
/// Construit une fois par modèle et par machine, gardé en cache.
/// </summary>
public sealed class VehicleRig
{
public sealed record WheelInfo( Vector3 Center, float Radius, float Width, bool Front, bool Left, int Axle );
/// <summary>Un emplacement de plaque : centre, normale (vers l'extérieur), largeur et hauteur.</summary>
public sealed record PlateInfo( Vector3 Center, Vector3 Normal, float Width, float Height, bool Front );
/// <summary>Un morceau de carrosserie (un matériau) : de quoi en fabriquer une copie à cabosser.</summary>
public sealed record BodyGroup( Material Material, Vertex[] Vertices, int[] Indices );
public Model Source { get; private init; }
/// <summary>
/// La carrosserie par matériau (sommets et triangles) : chaque véhicule s'en fabrique sa
/// propre copie, qu'il déforme aux chocs (Vehicle.Damage). Vide pour un modèle non découpé.
/// </summary>
public BodyGroup[] BodyGroups { get; private init; } = Array.Empty<BodyGroup>();
/// <summary>
/// Les optiques du modèle (matériau « lights », « lamp »…), avant et arrière, en calques
/// posés un rien devant la caisse : allumées, elles reçoivent un matériau lumineux par
/// lui-même. Null si le modèle n'a pas de tel matériau.
/// </summary>
public Model FrontLamps { get; private init; }
public Model RearLamps { get; private init; }
/// <summary>Le centre des optiques de chaque côté (gauche, droite) ; null si inconnu.</summary>
public Vector3[] FrontLampCenters { get; private init; }
/// <summary>Les mêmes optiques, côté gauche (0) et droit (1) : les clignotants.</summary>
public Model[] FrontLampSides { get; private init; }
public Model[] RearLampSides { get; private init; }
/// <summary>
/// Le clignotant que le modèle désigne derrière est trop fin pour se voir (ThinIndicator) :
/// l'optique extérieure le remplace, et elle s'allume en aplat orange — les variantes
/// « _cligno » de ses textures n'éclairent que la tranche désignée.
/// </summary>
public bool RearIndicatorThin { get; private init; }
public Vector3[] RearLampCenters { get; private init; }
/// <summary>
/// Le milieu de la cabine en X, d'après les vitres du modèle (matériau « glass », « window »…) ;
/// null s'il n'en a pas. Le milieu de la longueur valait pour une voiture, pas pour un camion
/// dont la cabine est tout à l'avant : ses portières tombaient sur le caisson.
/// </summary>
public float? CabinX { get; private init; }
/// <summary>Le bas des vitres (leurs 10 % les plus bas) : l'appui des fenêtres de la cabine.</summary>
public float? GlassSillZ { get; private init; }
/// <summary>La carrosserie sans les roues détachées (le modèle d'origine si aucune ne l'a été).</summary>
public Model Body { get; private init; }
/// <summary>Une par roue, centrée sur l'axe de la roue ; null pour une roue non détachée.</summary>
public Model[] WheelModels { get; private init; }
public WheelInfo[] Wheels { get; private init; }
/// <summary>Les plaques du modèle (matériau « plate », « plaque »…), avant et arrière ; vide s'il n'en a pas.</summary>
public PlateInfo[] Plates { get; private init; } = Array.Empty<PlateInfo>();
/// <summary>
/// Un feu de police du modèle lui-même : son calque, son côté (gauche : rouge), sa place sur
/// la largeur (0 extérieur, 1 milieu, 2 intérieur, comme les modules de la rampe du jeu) et
/// son centre. <paramref name="Group"/> nomme la pièce d'origine (« gyro_rampe »,
/// « gyro_calandre »…) ; <paramref name="Top"/> dit qu'elle est sur le toit.
/// </summary>
public sealed record BeaconPiece( Model Model, bool Left, int Slot, Vector3 Center, string Group, bool Top );
/// <summary>
/// Les feux de police que le modèle porte déjà (matériaux « gyro_… » : rampe, feux de
/// calandre, barre derrière le pare-brise) ; vide pour un modèle civil. Le véhicule les allume
/// alors au lieu de poser sa propre rampe (Vehicle.Police).
/// </summary>
public BeaconPiece[] Beacons { get; private init; } = Array.Empty<BeaconPiece>();
/// <summary>Comment les roues ont été trouvées : « matériau », « géométrie », « fiche » ou « défaut ».</summary>
public string Method { get; private init; }
/// <summary>La carrosserie seule, roues exclues : la boîte de collision.</summary>
public BBox BodyBounds { get; private init; }
public BBox Bounds { get; private init; }
public int Axles => Wheels.Length == 0 ? 0 : Wheels.Max( x => x.Axle ) + 1;
static readonly string[] GlassWords = { "glass", "glasswindows", "window", "windows", "windscreen", "vitre", "vitres" };
public static bool IsGlass( Material mat )
{
var name = System.IO.Path.GetFileNameWithoutExtension( mat?.ResourcePath ?? "" ).ToLowerInvariant();
return name.Split( '_', '-', '.', ' ' ).Any( w => GlassWords.Any( g => w.StartsWith( g ) ) );
}
static readonly string[] LightWords = { "light", "lights", "lamp", "lamps", "headlight", "taillight", "phare", "feux" };
/// <summary>Un mot du nom du matériau est celui des optiques (« venem_lights », « bullet_lights »).</summary>
static bool IsLight( Material mat )
{
var name = System.IO.Path.GetFileNameWithoutExtension( mat?.ResourcePath ?? "" ).ToLowerInvariant();
return name.Split( '_', '-', '.', ' ' ).Any( w => LightWords.Contains( w ) );
}
static readonly string[] LensWords = { "verre", "lens", "lentille" };
/// <summary>
/// Ce qui s'allume d'une optique : pas son verre (« feux_verre_avant »). Allumé, le verre
/// entier faisait un aplat uni ; derrière lui, c'est l'ampoule (Vehicle.Lights) qui éclaire le
/// réflecteur, et seules les pièces intérieures (un feu stop texturé) s'allument.
/// </summary>
static List<Tri> Lit( List<Tri> tris ) => tris.Where( t =>
{
var name = System.IO.Path.GetFileNameWithoutExtension( t.Mat?.ResourcePath ?? "" ).ToLowerInvariant();
return !name.Split( '_', '-', '.', ' ' ).Any( w => LensWords.Contains( w ) ) && !IsIndicator( t );
} ).ToList();
/// <summary>
/// Un matériau nommé « clignotant » (« feux_clignotant ») : la pièce que le modèle prévoit
/// pour le clignotant (la bande noire des feux de la Clio). Elle seule s'allume en orange,
/// et jamais en rouge.
/// </summary>
static bool IsIndicator( Tri t ) =>
System.IO.Path.GetFileNameWithoutExtension( t.Mat?.ResourcePath ?? "" ).ToLowerInvariant()
.Split( '_', '-', '.', ' ' ).Contains( "clignotant" );
/// <summary>Un calque d'optiques : leurs triangles, poussés de <paramref name="lift"/> pouce le long de la normale.</summary>
static Model LampModel( Vertex[] verts, List<Tri> tris, string name, float lift = 0.08f )
{
if ( tris.Count == 0 )
return null;
var builder = Model.Builder.WithName( name );
foreach ( var group in Groups( verts, tris, Vector3.Zero ) )
{
var shifted = group.Vertices.Select( v => { v.Position += v.Normal * lift; return v; } ).ToArray();
builder.AddMesh( MeshOf( group, shifted ) );
}
return builder.Create();
}
/// <summary>
/// Les clignotants de chaque côté, gauche (+Y) puis droite ; null sans optiques. Seulement
/// l'optique la plus extérieure, comme le segment orange d'un vrai feu : sur toutes les
/// optiques, l'orange recouvrait le feu stop, qui ne se voyait plus au freinage. Un peu plus
/// relevé que les calques des feux (0,14 contre 0,08), pour passer devant eux.
///
/// L'optique se trouve par les morceaux du maillage (triangles qui se touchent) : couper à une
/// distance du centre laissait un croissant éteint au bord intérieur d'un feu rond. On garde
/// le morceau le plus à l'extérieur et ceux qui tombent dans sa largeur (cabochon, réflecteur).
/// Un seul morceau, ou une optique qui prendrait plus de 60 % du côté : le tiers extérieur.
/// </summary>
/// <summary>Le modèle désigne un clignotant (matériau « clignotant ») de moins de 4 pouces de haut.</summary>
static bool ThinIndicator( Vertex[] verts, List<Tri> tris )
{
var ids = tris.Where( IsIndicator ).SelectMany( t => new[] { t.A, t.B, t.C } ).ToList();
return ids.Count > 0 && ids.Max( i => verts[i].Position.z ) - ids.Min( i => verts[i].Position.z ) < 4f;
}
static Model[] Sides( Vertex[] verts, List<Tri> tris, float centerY, string name )
{
if ( tris.Count == 0 )
return null;
float Y( Tri t ) => (verts[t.A].Position.y + verts[t.B].Position.y + verts[t.C].Position.y) / 3;
// Le modèle désigne son clignotant : on le prend tel quel, de chaque côté — s'il se voit.
// Sur la Polo, le clignotant désigné à l'arrière n'est qu'une tranche de 2,7 pouces de haut,
// noyée dans le feu rouge : rien ne clignotait derrière (Julien, 30/10/2026). Sous 4 pouces
// de haut, on reprend l'optique extérieure, comme pour un modèle qui ne désigne rien.
var marked = tris.Where( IsIndicator ).ToList();
if ( marked.Count > 0 && !ThinIndicator( verts, tris ) )
return new[]
{
LampModel( verts, marked.Where( t => Y( t ) > centerY ).ToList(), name + "#gauche", 0.14f ),
LampModel( verts, marked.Where( t => Y( t ) <= centerY ).ToList(), name + "#droite", 0.14f ),
};
float Off( int i ) => MathF.Abs( verts[i].Position.y - centerY );
Model Outer( List<Tri> side, string suffix )
{
if ( side.Count == 0 )
return null;
var inner = side.Min( t => MathF.Min( Off( t.A ), MathF.Min( Off( t.B ), Off( t.C ) ) ) );
var outer = side.Max( t => MathF.Max( Off( t.A ), MathF.Max( Off( t.B ), Off( t.C ) ) ) );
var span = MathF.Max( 0.01f, outer - inner );
var kept = OutermostLamp( verts, side, Off, span );
if ( kept is null )
{
var from = outer - span * 0.36f;
kept = side.Where( t => MathF.Abs( Y( t ) - centerY ) >= from ).ToList();
}
return LampModel( verts, kept.Count > 0 ? kept : side, name + suffix, 0.14f );
}
return new[]
{
Outer( tris.Where( t => Y( t ) > centerY ).ToList(), "#gauche" ),
Outer( tris.Where( t => Y( t ) <= centerY ).ToList(), "#droite" ),
};
}
/// <summary>
/// Les triangles de l'optique la plus extérieure d'un côté (voir Sides), ou null quand le côté
/// ne se laisse pas découper ainsi. Morceaux : sommets confondus à 0,01 pouce près, car un
/// maillage dédouble souvent ses sommets aux coutures.
/// </summary>
static List<Tri> OutermostLamp( Vertex[] verts, List<Tri> side, Func<int, float> off, float span )
{
var ids = side.SelectMany( t => new[] { t.A, t.B, t.C } ).Distinct().ToList();
var parent = new Dictionary<int, int>();
int Find( int x )
{
while ( parent[x] != x )
x = parent[x] = parent[parent[x]];
return x;
}
foreach ( var v in ids )
parent[v] = v;
void Union( int a, int b ) { a = Find( a ); b = Find( b ); if ( a != b ) parent[a] = b; }
foreach ( var t in side )
{
Union( t.A, t.B );
Union( t.B, t.C );
}
var byPosition = new Dictionary<(int, int, int), int>();
foreach ( var v in ids )
{
var p = verts[v].Position;
var key = ((int)MathF.Round( p.x * 100 ), (int)MathF.Round( p.y * 100 ), (int)MathF.Round( p.z * 100 ));
if ( byPosition.TryGetValue( key, out var other ) )
Union( v, other );
else
byPosition[key] = v;
}
var pieces = side.GroupBy( t => Find( t.A ) ).Select( g =>
{
var list = g.ToList();
var offs = list.SelectMany( t => new[] { off( t.A ), off( t.B ), off( t.C ) } ).ToList();
return (Tris: list, Min: offs.Min(), Max: offs.Max(), Mid: offs.Average());
} ).ToList();
if ( pieces.Count < 2 )
return null;
var outerPiece = pieces.OrderByDescending( p => p.Max ).ThenByDescending( p => p.Tris.Count ).First();
var lamp = pieces.Where( p => p.Mid >= outerPiece.Min && p.Mid <= outerPiece.Max ).ToList();
var lampMin = lamp.Min( p => p.Min );
if ( (outerPiece.Max - lampMin) / span > 0.6f || lamp.Count == pieces.Count )
return null;
return lamp.SelectMany( p => p.Tris ).ToList();
}
/// <summary>Le centre des triangles d'optiques de chaque côté : gauche (+Y), puis droite.</summary>
static Vector3[] LampCenters( Vertex[] verts, List<Tri> tris, float centerY )
{
Vector3 C( Tri t ) => (verts[t.A].Position + verts[t.B].Position + verts[t.C].Position) / 3;
var left = tris.Select( C ).Where( c => c.y > centerY ).ToList();
var right = tris.Select( C ).Where( c => c.y <= centerY ).ToList();
if ( left.Count == 0 || right.Count == 0 )
return null;
Vector3 Mean( List<Vector3> l ) => l.Aggregate( Vector3.Zero, ( a, b ) => a + b ) / l.Count;
return new[] { Mean( left ), Mean( right ) };
}
static string MaterialName( Material mat ) => System.IO.Path.GetFileNameWithoutExtension( mat?.ResourcePath ?? "" ).ToLowerInvariant();
/// <summary>Un matériau « gyro_… » : un feu de police du modèle.</summary>
static bool IsBeacon( Material mat ) => MaterialName( mat ).Split( '_', '-', '.', ' ' ).Contains( "gyro" );
/// <summary>
/// Les feux de police du modèle, pièce par pièce (un matériau « gyro_… » chacune), coupés par
/// côté puis en trois sur la largeur de la pièce : les mêmes places que les modules de la
/// rampe du jeu, donc le même motif d'éclats (Vehicle.BeaconOn). Un calque à peine relevé
/// (0,05 pouce) : il recouvre la LED sans flotter devant, même derrière le capot de la rampe.
/// </summary>
static BeaconPiece[] FindBeacons( Vertex[] verts, List<Tri> tris, BBox bounds, string name )
{
Vector3 C( Tri t ) => (verts[t.A].Position + verts[t.B].Position + verts[t.C].Position) / 3;
var cy = bounds.Center.y;
var pieces = new List<BeaconPiece>();
foreach ( var group in tris.Where( t => IsBeacon( t.Mat ) ).GroupBy( t => MaterialName( t.Mat ) ) )
{
var list = group.ToList();
var reach = MathF.Max( 0.01f, list.Max( t => MathF.Abs( C( t ).y - cy ) ) );
var top = list.Average( t => C( t ).z ) > bounds.Mins.z + bounds.Size.z * 0.75f;
foreach ( var left in new[] { true, false } )
{
for ( var slot = 0; slot < 3; slot++ )
{
var part = list.Where( t =>
{
var off = C( t ).y - cy;
if ( off > 0 != left )
return false;
var third = MathF.Abs( off ) / reach;
return slot == 0 ? third > 2f / 3 : slot == 1 ? third > 1f / 3 && third <= 2f / 3 : third <= 1f / 3;
} ).ToList();
if ( part.Count == 0 )
continue;
var center = part.Select( C ).Aggregate( Vector3.Zero, ( a, b ) => a + b ) / part.Count;
var model = LampModel( verts, part, $"{name}#{group.Key}_{(left ? "g" : "d")}{slot}", 0.05f );
pieces.Add( new BeaconPiece( model, left, slot, center, group.Key, top ) );
}
}
}
return pieces.ToArray();
}
static readonly string[] PlateWords = { "licenseplate", "license", "plate", "plaque", "kennzeichen" };
static readonly string[] TyreWords = { "tyre", "tire", "wheel", "rim", "pneu", "roue", "jante", "lastik", "felge", "gummi", "rubber" };
// Gardé par modèle et par réglage de roues. Un cache statique survit au rechargement à
// chaud : un gréement fait avec l'ancien code y resterait, d'où la version.
const int Version = 23;
static readonly Dictionary<string, VehicleRig> Cache = new();
/// <summary>Le gréement d'un modèle, avec des roues données à la main (vide : on les cherche).</summary>
/// <param name="yaw">
/// La rotation de la fiche (YawOffset), appliquée aux sommets comme l'échelle : tout le
/// gréement est alors dans le repère du véhicule (avant = +X). Tourner seulement l'affichage
/// laissait l'essieu « avant » à l'arrière d'un modèle qui regarde vers -X (la Prestige et
/// l'Utilitaire roulaient en marche arrière, le braquage sur les roues de derrière).
/// </param>
public static VehicleRig Build( Model model, IReadOnlyList<WheelInfo> manual = null, float scale = 1f, float yaw = 0f )
{
if ( model is null || model.IsError )
return null;
scale = Math.Clamp( scale, 0.2f, 5f );
var key = $"{Version}|{model.ResourcePath}|{scale:0.###}|{yaw:0.#}|{(manual is null ? "" : string.Join( ";", manual ))}{Fingerprint( model )}";
if ( Cache.TryGetValue( key, out var cached ) && cached.Source == model )
return cached;
VehicleRig rig;
try
{
rig = Create( model, manual, scale, yaw );
}
catch ( Exception e )
{
Log.Warning( $"[Horizon] Gréement de {model.ResourcePath} impossible : {e.Message}" );
rig = Fallback( model );
}
Cache[key] = rig;
return rig;
}
/// <summary>
/// Dans l'éditeur seulement : une empreinte des sommets. Un modèle recompilé (repréparé par
/// outils/vehicules) reste le même objet Model : le gréement gardé en cache gardait ses anciens
/// sommets et ses anciennes UV (les optiques dessinées du 30/10/2026 ne se voyaient pas).
/// En jeu, les modèles ne changent pas : rien à calculer.
/// </summary>
static string Fingerprint( Model model )
{
if ( !Application.IsEditor )
return "";
var verts = model.GetVertices();
if ( verts is null || verts.Length == 0 )
return "|0";
var h = new HashCode();
h.Add( verts.Length );
for ( var i = 0; i < verts.Length; i += Math.Max( 1, verts.Length / 97 ) )
{
h.Add( verts[i].Position );
h.Add( verts[i].TexCoord0 );
}
return "|" + h.ToHashCode();
}
sealed record Tri( int A, int B, int C, Material Mat );
static VehicleRig Create( Model model, IReadOnlyList<WheelInfo> manual, float scale, float yaw )
{
// L'échelle est appliquée aux sommets : roues, plaques, boîtes, tout en découle.
var verts = model.GetVertices()?.ToArray();
if ( verts is not null && MathF.Abs( scale - 1 ) > 0.001f )
for ( var i = 0; i < verts.Length; i++ )
verts[i].Position *= scale;
if ( verts is not null && MathF.Abs( yaw ) > 0.01f )
{
var turn = Rotation.FromYaw( yaw );
for ( var i = 0; i < verts.Length; i++ )
{
verts[i].Position = turn * verts[i].Position;
verts[i].Normal = turn * verts[i].Normal;
var t = turn * new Vector3( verts[i].Tangent.x, verts[i].Tangent.y, verts[i].Tangent.z );
verts[i].Tangent = new Vector4( t.x, t.y, t.z, verts[i].Tangent.w );
}
}
var idx = model.GetIndices();
var meshes = model.MeshInfo?.Meshes?.Where( m => (m.LodMask & 1) != 0 ).ToList();
if ( verts is null || idx is null || meshes is null || meshes.Count == 0 || verts.Length == 0 )
return Fallback( model );
// Les triangles, matériau par matériau. Un modèle en plusieurs morceaux (la variante
// portes ouvertes du camion) compte indices et sommets par morceau : on les décale.
var tris = new List<Tri>();
int idxBase = 0, vtxBase = 0;
foreach ( var mesh in meshes )
{
var calls = mesh.DrawCalls ?? Array.Empty<Model.ModelMeshInfo.DrawCallData>();
foreach ( var c in calls )
{
var start = idxBase + c.StartIndex;
for ( var k = start; k + 2 < start + c.Indices && k + 2 < idx.Length; k += 3 )
{
int a = vtxBase + c.BaseVertex + (int)idx[k], b = vtxBase + c.BaseVertex + (int)idx[k + 1], d = vtxBase + c.BaseVertex + (int)idx[k + 2];
if ( a < verts.Length && b < verts.Length && d < verts.Length )
tris.Add( new Tri( a, b, d, c.Material ) );
}
}
idxBase += calls.Sum( c => c.Indices );
vtxBase += mesh.Vertices;
}
var bounds = BBox.FromPoints( verts.Select( x => x.Position ) );
var wheels = manual?.Count > 0 ? manual.ToArray() : null;
var method = "fiche";
if ( wheels is null )
{
// 1. Par le matériau des pneus.
var tyre = tris.Where( t => IsTyre( t.Mat ) ).SelectMany( t => new[] { t.A, t.B, t.C } ).Distinct().Select( i => verts[i].Position ).ToList();
wheels = FindWheels( tyre, bounds );
method = "matériau";
// 2. Par la géométrie : bas, et vers les flancs.
if ( wheels is null )
{
var half = bounds.Size.y / 2;
var low = verts.Select( x => x.Position )
.Where( p => p.z < bounds.Mins.z + bounds.Size.z * 0.42f && MathF.Abs( p.y - bounds.Center.y ) > half * 0.55f )
.ToList();
wheels = FindWheels( low, bounds );
method = "géométrie";
}
}
if ( wheels is null )
return Fallback( model );
// Chaque triangle va à la roue qui le contient tout entier, sinon à la carrosserie — et
// seulement si sa pièce y tient entière (WholePiecesOnly). Un triangle du matériau du pneu
// va toujours à la roue la plus proche : le cylindre, mesuré sur 96 % des sommets, coupait
// la bande de roulement, et seule la jante braquait.
var parts = new List<Tri>[wheels.Length + 1];
for ( var i = 0; i < parts.Length; i++ )
parts[i] = new List<Tri>();
var tyreOf = new Dictionary<string, bool>();
var isTyre = new bool[tris.Count];
var owners = new int[tris.Count];
for ( var n = 0; n < tris.Count; n++ )
{
var t = tris[n];
var key = t.Mat?.ResourcePath ?? "";
if ( !tyreOf.TryGetValue( key, out var tyreMat ) )
tyreOf[key] = tyreMat = IsTyre( t.Mat );
isTyre[n] = tyreMat;
var owner = -1;
if ( tyreMat )
{
var c = (verts[t.A].Position + verts[t.B].Position + verts[t.C].Position) / 3;
var best = float.MaxValue;
for ( var w = 0; w < wheels.Length; w++ )
{
var d = c.Distance( wheels[w].Center );
if ( d < best && d < wheels[w].Radius * 1.7f )
{
best = d;
owner = w;
}
}
}
for ( var w = 0; w < wheels.Length && owner < 0; w++ )
{
if ( Inside( verts[t.A].Position, wheels[w] ) && Inside( verts[t.B].Position, wheels[w] ) && Inside( verts[t.C].Position, wheels[w] ) )
owner = w;
}
owners[n] = owner;
}
WholePiecesOnly( verts, tris, isTyre, owners );
for ( var n = 0; n < tris.Count; n++ )
parts[owners[n] + 1].Add( tris[n] );
// Le rayon vrai, mesuré sur la roue découpée (98 % des sommets, à l'axe) : celui de la
// boîte rognée à 96 % était trop petit de deux pouces, la roue s'enfonçait dans le sol.
for ( var w = 0; w < wheels.Length; w++ )
{
var c = wheels[w].Center;
var radial = parts[w + 1].SelectMany( t => new[] { t.A, t.B, t.C } ).Distinct()
.Select( i => verts[i].Position - c ).Select( d => MathF.Sqrt( d.x * d.x + d.z * d.z ) )
.OrderBy( x => x ).ToList();
if ( radial.Count < 20 )
continue;
var measured = radial[(int)(radial.Count * 0.98f)];
if ( measured > wheels[w].Radius && measured < wheels[w].Radius * 1.35f )
wheels[w] = wheels[w] with { Radius = measured };
}
var bodyGroups = Groups( verts, parts[0], Vector3.Zero );
var body = BuildModel( verts, parts[0], Vector3.Zero, $"{model.ResourcePath}#caisse" );
var wheelModels = new Model[wheels.Length];
for ( var w = 0; w < wheels.Length; w++ )
wheelModels[w] = parts[w + 1].Count > 0 ? BuildModel( verts, parts[w + 1], wheels[w].Center, $"{model.ResourcePath}#roue{w}" ) : null;
var bodyPoints = parts[0].SelectMany( t => new[] { t.A, t.B, t.C } ).Select( i => verts[i].Position ).ToList();
// Les optiques : avant ce qui est devant le milieu de la voiture, arrière le reste.
var lights = parts[0].Where( t => IsLight( t.Mat ) ).ToList();
Vector3 Centroid( Tri t ) => (verts[t.A].Position + verts[t.B].Position + verts[t.C].Position) / 3;
var frontTris = lights.Where( t => Centroid( t ).x > bounds.Center.x ).ToList();
var rearTris = lights.Where( t => Centroid( t ).x <= bounds.Center.x ).ToList();
// La cabine : le milieu des vitres, pondéré par l'aire des triangles.
float? cabinX = null;
float? sillZ = null;
var glass = parts[0].Where( t => IsGlass( t.Mat ) ).ToList();
if ( glass.Count > 0 )
{
var zs = glass.SelectMany( t => new[] { t.A, t.B, t.C } ).Select( i => verts[i].Position.z ).OrderBy( z => z ).ToList();
sillZ = zs[(int)(zs.Count * 0.1f)];
}
if ( glass.Count > 0 )
{
float sum = 0, weight = 0;
foreach ( var t in glass )
{
var a = verts[t.A].Position; var b = verts[t.B].Position; var c = verts[t.C].Position;
var area = Vector3.Cross( b - a, c - a ).Length * 0.5f;
sum += (a.x + b.x + c.x) / 3 * area;
weight += area;
}
if ( weight > 0 )
cabinX = sum / weight;
}
return new VehicleRig
{
CabinX = cabinX,
GlassSillZ = sillZ,
FrontLampSides = Sides( verts, frontTris, bounds.Center.y, $"{model.ResourcePath}#optiques_avant" ),
RearLampSides = Sides( verts, rearTris, bounds.Center.y, $"{model.ResourcePath}#optiques_arriere" ),
RearIndicatorThin = ThinIndicator( verts, rearTris ),
FrontLamps = LampModel( verts, Lit( frontTris ), $"{model.ResourcePath}#optiques_avant" ),
RearLamps = LampModel( verts, Lit( rearTris ), $"{model.ResourcePath}#optiques_arriere" ),
FrontLampCenters = frontTris.Count > 0 ? LampCenters( verts, frontTris, bounds.Center.y ) : null,
RearLampCenters = rearTris.Count > 0 ? LampCenters( verts, rearTris, bounds.Center.y ) : null,
Plates = FindPlates( verts, tris, bounds ),
Beacons = FindBeacons( verts, parts[0], bounds, model.ResourcePath ),
Source = model,
Body = body ?? model,
BodyGroups = bodyGroups,
WheelModels = wheelModels,
Wheels = wheels,
Method = method,
Bounds = bounds,
BodyBounds = bodyPoints.Count > 0 ? BBox.FromPoints( bodyPoints ) : bounds,
};
}
/// <summary>
/// Un mot du nom du matériau commence par un des mots du pneu. Mot à mot : chercher « rim »
/// dans le nom entier trouvait « p-rim-ary », la peinture de toutes les voitures GTA.
/// </summary>
static bool IsTyre( Material mat )
{
var name = System.IO.Path.GetFileNameWithoutExtension( mat?.ResourcePath ?? "" ).ToLowerInvariant();
return name.Split( '_', '-', '.', ' ' ).Any( w => TyreWords.Any( w.StartsWith ) );
}
/// <summary>
/// Une roue ne prend que des pièces entières. Le cylindre de la roue (élargi pour le disque)
/// mord sur la caisse autour d'elle : le bord de l'aile de la Renault 9 et de la Classe S, la
/// doublure noire des passages de roue de la Classe S, de la 911, de la Polo, la moitié d'un bras
/// de suspension de la Model 3 — ces bouts de tôle tournaient et braquaient avec la roue. Or une
/// pièce de roue (jante, disque, étrier, cache, boulon) est un îlot de triangles qui tient tout
/// entier dans son cylindre, alors qu'une pièce de caisse le traverse : seuls quelques-uns de ses
/// triangles y tombent (34 sur 2 774 pour la peinture de la Renault 9). Un îlot à cheval revient
/// donc entier à la caisse. Îlots : triangles d'un même matériau qui se touchent, sommets
/// confondus à 0,01 pouce près (un maillage dédouble les siens aux coutures). Les pneus n'y
/// passent pas : ils vont toujours à la roue la plus proche.
/// </summary>
static void WholePiecesOnly( Vertex[] verts, List<Tri> tris, bool[] isTyre, int[] owners )
{
var parent = new int[tris.Count];
for ( var i = 0; i < parent.Length; i++ )
parent[i] = i;
int Find( int x )
{
while ( parent[x] != x )
x = parent[x] = parent[parent[x]];
return x;
}
var byPosition = new Dictionary<(string, int, int, int), int>();
for ( var i = 0; i < tris.Count; i++ )
{
if ( isTyre[i] )
continue;
var t = tris[i];
var mat = t.Mat?.ResourcePath ?? "";
foreach ( var v in new[] { t.A, t.B, t.C } )
{
var p = verts[v].Position;
var key = (mat, (int)MathF.Round( p.x * 100 ), (int)MathF.Round( p.y * 100 ), (int)MathF.Round( p.z * 100 ));
if ( byPosition.TryGetValue( key, out var other ) )
{
int a = Find( i ), b = Find( other );
if ( a != b )
parent[a] = b;
}
else
byPosition[key] = i;
}
}
// La roue de l'îlot : celle de tous ses triangles, sinon aucune (-1 : la caisse).
var verdict = new Dictionary<int, int>();
for ( var i = 0; i < tris.Count; i++ )
{
if ( isTyre[i] )
continue;
var root = Find( i );
if ( !verdict.TryGetValue( root, out var w ) )
verdict[root] = owners[i];
else if ( w != owners[i] )
verdict[root] = -1;
}
for ( var i = 0; i < tris.Count; i++ )
if ( !isTyre[i] )
owners[i] = verdict[Find( i )];
}
/// <summary>Dans le cylindre de la roue (axe selon Y), un peu élargi vers l'intérieur pour le disque.</summary>
static bool Inside( Vector3 p, WheelInfo w )
{
var dx = p.x - w.Center.x;
var dz = p.z - w.Center.z;
if ( dx * dx + dz * dz > w.Radius * w.Radius * 1.12f )
return false;
return MathF.Abs( p.y - w.Center.y ) <= w.Width * 0.5f + w.Width * 0.35f;
}
/// <summary>Les roues d'un nuage de sommets : groupés par essieu selon X, puis par côté.</summary>
static WheelInfo[] FindWheels( List<Vector3> points, BBox bounds )
{
if ( points.Count < 40 )
return null;
var sorted = points.OrderBy( p => p.x ).ToList();
var gap = MathF.Max( 4f, bounds.Size.x * 0.04f );
var axles = new List<List<Vector3>> { new() { sorted[0] } };
for ( var i = 1; i < sorted.Count; i++ )
{
if ( sorted[i].x - sorted[i - 1].x > gap )
axles.Add( new List<Vector3>() );
axles[^1].Add( sorted[i] );
}
axles = axles.Where( a => a.Count >= points.Count * 0.04f ).ToList();
if ( axles.Count < 2 || axles.Count > 5 )
return null;
// L'avant est +X : le dernier essieu dans l'ordre des X.
var wheels = new List<WheelInfo>();
var cy = bounds.Center.y;
for ( var a = 0; a < axles.Count; a++ )
{
var axleIndex = axles.Count - 1 - a; // 0 = l'essieu avant
foreach ( var left in new[] { true, false } )
{
var side = axles[a].Where( p => left ? p.y > cy : p.y <= cy ).ToList();
if ( side.Count < 10 )
return null;
var box = Trimmed( side );
var radius = MathF.Max( box.Size.x, box.Size.z ) / 2;
if ( radius < 5 || radius > 45 )
return null;
wheels.Add( new WheelInfo( box.Center, radius, MathF.Max( 2, box.Size.y ), axleIndex == 0, left, axleIndex ) );
}
}
return wheels.OrderBy( w => w.Axle ).ThenByDescending( w => w.Left ).ToArray();
}
/// <summary>Les plaques : les triangles d'un matériau de plaque, séparés en avant et arrière.</summary>
static PlateInfo[] FindPlates( Vertex[] verts, List<Tri> tris, BBox bounds )
{
var plate = tris.Where( t =>
{
var name = System.IO.Path.GetFileNameWithoutExtension( t.Mat?.ResourcePath ?? "" ).ToLowerInvariant();
return name.Split( '_', '-', '.', ' ' ).Any( w => PlateWords.Any( w.StartsWith ) );
} ).ToList();
var res = new List<PlateInfo>();
foreach ( var front in new[] { true, false } )
{
var side = plate.Where( t => (verts[t.A].Position.x > bounds.Center.x) == front ).ToList();
if ( side.Count == 0 )
continue;
var pts = side.SelectMany( t => new[] { t.A, t.B, t.C } ).Distinct().Select( i => verts[i] ).ToList();
var normal = pts.Aggregate( Vector3.Zero, ( a, v ) => a + v.Normal ).Normal;
if ( normal.Length < 0.5f )
normal = front ? Vector3.Forward : Vector3.Backward;
// Toujours vers l'extérieur : une plaque aux normales retournées (la 911 importée)
// pointait vers l'habitacle, et le numéro s'écrivait dans le pare-chocs.
if ( (normal.x > 0) != front )
normal = -normal;
var box = BBox.FromPoints( pts.Select( v => v.Position ) );
res.Add( new PlateInfo( box.Center, normal, MathF.Max( box.Size.y, 4 ), MathF.Max( box.Size.z, 2 ), front ) );
}
return res.ToArray();
}
/// <summary>La boîte des points, sans les 2 % les plus éloignés de chaque côté (une pièce égarée).</summary>
static BBox Trimmed( List<Vector3> pts )
{
float Q( IEnumerable<float> xs, float q )
{
var l = xs.OrderBy( x => x ).ToList();
return l[Math.Clamp( (int)(q * (l.Count - 1)), 0, l.Count - 1 )];
}
var mn = new Vector3( Q( pts.Select( p => p.x ), 0.02f ), Q( pts.Select( p => p.y ), 0.02f ), Q( pts.Select( p => p.z ), 0.02f ) );
var mx = new Vector3( Q( pts.Select( p => p.x ), 0.98f ), Q( pts.Select( p => p.y ), 0.98f ), Q( pts.Select( p => p.z ), 0.98f ) );
return new BBox( mn, mx );
}
/// <summary>Les triangles regroupés par matériau, sommets renumérotés et ramenés à <paramref name="origin"/>.</summary>
static BodyGroup[] Groups( Vertex[] verts, List<Tri> tris, Vector3 origin )
{
var groups = new List<BodyGroup>();
foreach ( var group in tris.GroupBy( t => t.Mat ) )
{
var remap = new Dictionary<int, int>();
var list = new List<Vertex>();
var indices = new List<int>();
foreach ( var t in group )
{
foreach ( var i in new[] { t.A, t.B, t.C } )
{
if ( !remap.TryGetValue( i, out var n ) )
{
var v = verts[i];
v.Position -= origin;
remap[i] = n = list.Count;
list.Add( v );
}
indices.Add( n );
}
}
groups.Add( new BodyGroup( group.Key, list.ToArray(), indices.ToArray() ) );
}
return groups.ToArray();
}
static Model BuildModel( Vertex[] verts, List<Tri> tris, Vector3 origin, string name )
{
if ( tris.Count == 0 )
return null;
var builder = Model.Builder.WithName( name );
foreach ( var group in Groups( verts, tris, origin ) )
builder.AddMesh( MeshOf( group, group.Vertices ) );
return builder.Create();
}
/// <summary>Un mesh d'un groupe, avec ses sommets (éventuellement déformés).</summary>
/// <summary>Le même morceau, avec seulement ces triangles (les vitres brisées retirées).</summary>
public static Mesh MeshOf( BodyGroup group, Vertex[] vertices, List<int> indices )
{
var mesh = new Mesh( group.Material, MeshPrimitiveType.Triangles );
mesh.CreateVertexBuffer( vertices.Length, Vertex.Layout, vertices.ToList() );
mesh.CreateIndexBuffer( indices.Count, indices );
mesh.Bounds = BBox.FromPoints( vertices.Select( x => x.Position ) );
return mesh;
}
public static Mesh MeshOf( BodyGroup group, Vertex[] vertices )
{
var mesh = new Mesh( group.Material, MeshPrimitiveType.Triangles );
mesh.CreateVertexBuffer( vertices.Length, Vertex.Layout, vertices.ToList() );
mesh.CreateIndexBuffer( group.Indices.Length, group.Indices.ToList() );
mesh.Bounds = BBox.FromPoints( vertices.Select( x => x.Position ) );
return mesh;
}
/// <summary>Rien de trouvé : le modèle entier, et quatre roues virtuelles aux coins (sans modèle).</summary>
static VehicleRig Fallback( Model model )
{
var b = model.Bounds;
if ( b.Size.Length < 10 )
b = new BBox( new Vector3( -90, -38, 0 ), new Vector3( 90, 38, 56 ) );
var r = MathF.Max( 10, b.Size.z * 0.22f );
WheelInfo W( float fx, bool left, bool front ) => new( new Vector3( b.Center.x + b.Size.x * fx, b.Center.y + b.Size.y * (left ? 0.4f : -0.4f), b.Mins.z + r ), r, 8, front, left, front ? 0 : 1 );
return new VehicleRig
{
Source = model,
Body = model,
WheelModels = new Model[4],
Wheels = new[] { W( 0.33f, true, true ), W( 0.33f, false, true ), W( -0.33f, true, false ), W( -0.33f, false, false ) },
Method = "défaut",
Bounds = b,
BodyBounds = new BBox( b.Mins + Vector3.Up * r, b.Maxs ),
};
}
}