UI/PoliceShowroom.cs

The garage showroom preview for the police service menu. Builds a local, non-networked preview of the selected vehicle with plates, lightbar and studio lights, borrows the player camera, and reports the preview position and yaw for the real spawn.

namespace HorizonRP;

/// <summary>
/// Le salon du garage de service, façon « Argus Carshop » (FiveM), demandé par Julien le
/// 29/10/2026 : le véhicule choisi dans le menu (UI/PoliceGarageMenu.razor) est posé dans la ville,
/// à la place où il sortira (PoliceService.FreeGarageSpot), et la caméra du joueur le cadre comme
/// un studio photo.
///
/// - <b>Un aperçu, jamais un vrai véhicule</b> : des objets locaux (ni réseau, ni sauvegarde, sans
///   collision), sans composant <see cref="Vehicle"/> — <c>Vehicle.All</c> lit toute la scène, et
///   un faux véhicule y aurait bloqué la place, pris une plaque, montré « E · Monter »… La caisse,
///   les roues et la plaque viennent du gréement (<see cref="VehicleRig"/>, le même que le vrai
///   véhicule), la rampe est refaite ici à l'identique.
/// - <b>On tourne la voiture, pas la caméra</b> (glisser, <see cref="Turn"/>) : la caméra reste de
///   trois quarts avant par rapport à la place ; la molette zoome (<see cref="Zoom"/>). Le véhicule
///   sort ensuite exactement là et ainsi tourné (<see cref="Origin"/>, <see cref="Yaw"/>, revérifiés
///   par l'hôte).
/// - <b>La caméra est empruntée, puis rendue telle quelle</b> : la caméra du jeu (celle du
///   contrôleur, qui ne la pilote plus le temps du salon), son champ de vision, sa place et ses
///   étiquettes exclues sont notés à l'entrée et remis à la sortie. <see cref="Framing"/> dit à
///   WeaponHolder de ne plus imposer son champ de vision (comme CharacterCreator.Framing).
/// - <b>Éclairage de studio</b> : quatre lampes locales autour de la voiture, plus fortes la nuit,
///   baissées quand les gyrophares de démonstration tournent (sinon on ne verrait pas les éclats).
///
/// Rien n'est en réseau : la rampe clignote chez le joueur seul. <see cref="End"/> détruit tout et
/// rend la caméra ; le menu l'appelle en se fermant, quelle qu'en soit la raison (Échap, fin de
/// service, chute, éloignement, menu détruit).
/// </summary>
public sealed class PoliceShowroom
{
	/// <summary>Le salon tient la caméra : WeaponHolder ne lui impose plus son champ de vision.</summary>
	public static bool Framing { get; private set; }

	// Cadrage, réglable à chaud. Le champ est horizontal (CameraComponent.FieldOfView).
	public static float Fov { get; set; } = 52f;
	/// <summary>La caméra, de trois quarts avant : écart avec l'axe de la place (degrés) et plongée.</summary>
	public static float CameraAngle { get; set; } = 38f;
	public static float CameraPitch { get; set; } = 10f;
	/// <summary>
	/// La part de l'écran occupée par les panneaux du menu (en pixels d'une interface de 1080 de
	/// haut) : la voiture est cadrée au milieu de ce qui reste.
	/// </summary>
	public static float FreeLeft { get; set; } = 470f;
	public static float FreeRight { get; set; } = 390f;
	public static float FreeTop { get; set; } = 70f;
	public static float FreeBottom { get; set; } = 300f;
	/// <summary>Éclairage de studio : force la nuit et le jour.</summary>
	public static float StudioNight { get; set; } = 2.2f;
	public static float StudioDay { get; set; } = 0.7f;

	/// <summary>La rotation donnée par le joueur, en degrés, autour du milieu de la voiture.</summary>
	public float Turn { get; set; }

	/// <summary>Le zoom à la molette : -0,35 (plus large) à 1 (au plus près).</summary>
	public float Zoom
	{
		get => zoom;
		set => zoom = Math.Clamp( value, -0.35f, 1f );
	}

	/// <summary>Les gyrophares de démonstration (chez le joueur seul).</summary>
	public bool Beacons { get; set; }

	/// <summary>Le salon est ouvert (la caméra est tenue).</summary>
	public bool Active => begun;

	/// <summary>Le véhicule est construit et visible.</summary>
	public bool Loaded => begun && car.IsValid() && rig is not null && builtKey == shownKey;

	/// <summary>L'orientation du véhicule montré (degrés) : celle de la place, plus la rotation du joueur.</summary>
	public float Yaw => baseYaw + Turn;

	/// <summary>L'origine du véhicule montré : celle que prendra le vrai véhicule (à la hauteur près).</summary>
	public Vector3 Origin => car.IsValid() ? car.WorldPosition : ground;

	bool begun;
	float zoom;
	Vector3 anchor, ground;
	float baseYaw;
	GameObject car, studio;
	PointLight[] lamps = Array.Empty<PointLight>();
	VehicleRig rig;
	string shownKey, builtKey;
	float wheelBottom;
	float shownDistance;

	// La caméra empruntée et son état d'avant.
	CameraComponent camera;
	PlayerController controller;
	bool savedCameraControls, savedViewerExcluded, savedLegsExcluded;
	float savedFov;
	Vector3 savedPosition;
	Rotation savedRotation;

	// ------------------------------------------------------------------ entrée et sortie

	/// <summary>Ouvre le salon sur cette place (sa position au sol et son orientation).</summary>
	public void Begin( Vector3 place, float yaw )
	{
		if ( begun )
			End();
		var scene = Game.ActiveScene;
		camera = scene?.Camera;
		if ( camera is null )
			return;

		begun = true;
		Framing = true;
		anchor = place;
		ground = PoliceService.GroundAt( place );
		baseYaw = yaw;
		Turn = 0;
		zoom = 0;
		Beacons = false;
		shownDistance = 0;

		controller = RoleplayPlayer.Local?.GetComponent<PlayerController>();
		savedCameraControls = controller?.UseCameraControls ?? true;
		if ( controller.IsValid() )
			controller.UseCameraControls = false;
		savedFov = camera.FieldOfView;
		savedPosition = camera.WorldPosition;
		savedRotation = camera.WorldRotation;
		savedViewerExcluded = camera.RenderExcludeTags.Has( "viewer" );
		savedLegsExcluded = camera.RenderExcludeTags.Has( FirstPersonLegs.Tag );

		studio = Local( null, "Studio du garage" );
		studio.WorldPosition = ground;
		lamps = new[] { Lamp( "Principale" ), Lamp( "Appoint" ), Lamp( "Contre-jour" ), Lamp( "Plafond" ) };
	}

	/// <summary>
	/// Détruit l'aperçu et les lampes, et rend la caméra dans l'état d'avant. Sans effet si le
	/// salon n'est pas ouvert ; ne lève jamais (appelé en sortie de tout).
	/// </summary>
	public void End()
	{
		var wasBegun = begun;
		begun = false;
		Framing = false;
		try
		{
			HidePreview();
			if ( studio.IsValid() )
				studio.Destroy();
			studio = null;
			lamps = Array.Empty<PointLight>();
			if ( wasBegun && camera.IsValid() )
			{
				camera.FieldOfView = savedFov;
				camera.WorldPosition = savedPosition;
				camera.WorldRotation = savedRotation;
				SetTag( "viewer", savedViewerExcluded );
				SetTag( FirstPersonLegs.Tag, savedLegsExcluded );
			}
		}
		finally
		{
			// La caméra rendue au contrôleur quoi qu'il arrive : sans elle, la vue resterait figée sur le garage.
			if ( wasBegun && controller.IsValid() )
				controller.UseCameraControls = savedCameraControls;
			camera = null;
			controller = null;
		}
	}

	/// <summary>Retire la voiture d'aperçu (le vrai véhicule prend sa place), la caméra restant en place.</summary>
	public void HidePreview()
	{
		if ( car.IsValid() )
			car.Destroy();
		car = null;
		builtKey = null;
		shownKey = null;
		beacons.Clear();
		beaconLights.Clear();
	}

	void SetTag( string tag, bool excluded )
	{
		if ( excluded )
			camera.RenderExcludeTags.Add( tag );
		else
			camera.RenderExcludeTags.Remove( tag );
	}

	/// <summary>Un objet du salon : local, jamais en réseau ni sauvegardé.</summary>
	static GameObject Local( GameObject parent, string name )
	{
		var go = parent is null ? new GameObject( true, name ) : new GameObject( parent, true, name );
		go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;
		return go;
	}

	PointLight Lamp( string name )
	{
		var light = Local( studio, name ).Components.Create<PointLight>();
		light.Shadows = false;
		return light;
	}

	// ------------------------------------------------------------------ le véhicule

	/// <summary>Montre ce modèle de la flotte (sans effet s'il est déjà montré).</summary>
	public void Show( VehicleModel info, string colorHex, bool lightbar, bool ems, string plate )
	{
		if ( !begun || info is null )
			return;
		var key = $"{info.Id}|{info.Model}|{info.Scale}|{info.YawOffset}|{colorHex}|{lightbar}|{ems}|{plate}";
		if ( key == shownKey )
			return;
		shownKey = key;
		_ = Load( info, colorHex, lightbar, ems, plate, key );
	}

	async Task Load( VehicleModel info, string colorHex, bool lightbar, bool ems, string plate, string key )
	{
		var model = await ModelSources.Load( info.Model );
		if ( !begun || key != shownKey || model is null )
			return;
		var built = VehicleRig.Build( model, null, info.Scale, info.YawOffset );
		if ( built is null )
			return;

		if ( car.IsValid() )
			car.Destroy();
		beacons.Clear();
		beaconLights.Clear();
		rig = built;
		car = Local( null, "Aperçu du garage" );
		// Le gréement porte déjà la rotation de la fiche : comme la « Carrosserie » d'un vrai véhicule.
		var holder = Local( car, "Carrosserie" );

		var body = holder.Components.Create<ModelRenderer>();
		body.Model = rig.Body;
		body.Tint = Vehicle.PaintTint( colorHex );

		for ( var i = 0; i < rig.Wheels.Length; i++ )
		{
			if ( rig.WheelModels.ElementAtOrDefault( i ) is not { } wheelModel )
				continue;
			var wheel = Local( holder, $"Roue {i}" );
			wheel.LocalPosition = rig.Wheels[i].Center;
			wheel.Components.Create<ModelRenderer>().Model = wheelModel;
		}
		wheelBottom = rig.Wheels.Length > 0 ? rig.Wheels.Min( w => w.Center.z - w.Radius ) : rig.Bounds.Mins.z;

		AddPlates( holder, plate );
		var showBar = lightbar || Vehicle.LiveryServiceOf( info.Id ) is not null;
		BuildKit( holder, showBar, ems || Vehicle.LiveryServiceOf( info.Id ) == "ems" );

		builtKey = key;
		PlaceCar();
	}

	/// <summary>
	/// Le véhicule tourne autour de son milieu, posé sur ses roues : son origine bouge donc avec la
	/// rotation (c'est elle que l'on passe à l'hôte, <see cref="Origin"/>).
	/// </summary>
	void PlaceCar()
	{
		if ( !car.IsValid() || rig is null )
			return;
		var rotation = Rotation.FromYaw( Yaw );
		var center = rig.Bounds.Center.WithZ( 0 );
		car.WorldRotation = rotation;
		car.WorldPosition = ground.WithZ( ground.z - wheelBottom + 0.5f ) - rotation * center;
	}

	/// <summary>La plaque, comme sur le vrai véhicule (Vehicle.ApplyModel) : notre fond, le numéro par-dessus.</summary>
	void AddPlates( GameObject holder, string plate )
	{
		foreach ( var p in rig.Plates )
		{
			var width = Vector3.Cross( Vector3.Up, p.Normal ).Normal;
			if ( width.Length < 0.5f )
				width = Vector3.Left;
			var back = Local( holder, p.Front ? "Fond plaque avant" : "Fond plaque arrière" );
			back.LocalPosition = p.Center + p.Normal * 0.6f;
			back.LocalRotation = Rotation.LookAt( width, p.Normal );
			back.LocalScale = new Vector3( p.Width, p.Height, 1 );
			back.Components.Create<ModelRenderer>().Model = FlatQuad.Get( "materials/horizon/plaque.vmat" );

			var go = Local( holder, p.Front ? "Plaque avant" : "Plaque arrière" );
			go.LocalPosition = p.Center + p.Normal * 0.85f - Vector3.Up * p.Height * 0.08f;
			go.LocalRotation = Rotation.LookAt( -p.Normal, Vector3.Up );
			var text = go.Components.Create<TextRenderer>();
			text.Text = plate ?? "";
			text.FontFamily = "Roboto Condensed";
			text.FontWeight = 700;
			text.Color = new Color( 0.5f, 0.52f, 0.55f );
			text.FontSize = 64;
			text.Scale = p.Width * 0.5f / MathF.Max( 1, (plate?.Length ?? 7) * 28f );
		}
	}

	// ------------------------------------------------------------------ chaque image

	/// <summary>À chaque image, salon ouvert : la voiture, ses éclats, les lampes et la caméra.</summary>
	public void Tick()
	{
		if ( !begun )
			return;
		if ( !camera.IsValid() )
		{
			End();
			return;
		}
		PlaceCar();
		UpdateBeacons();
		PlaceCamera();
	}

	void PlaceCamera()
	{
		var size = rig?.Bounds.Size ?? new Vector3( 180, 80, 60 );
		var height = rig is null ? 30f : rig.Bounds.Center.z - wheelBottom;
		var target = ground + Vector3.Up * height;

		// Recul : la voiture, vue sous n'importe quel angle (sa diagonale au sol), tient dans la partie
		// libre de l'écran, en largeur comme en hauteur.
		var aspect = Screen.Width / MathF.Max( 1f, Screen.Height );
		var uiWidth = 1080f * aspect;
		var freeWidth = MathF.Max( 0.2f, (uiWidth - FreeLeft - FreeRight) / uiWidth );
		var freeHeight = MathF.Max( 0.2f, (1080f - FreeTop - FreeBottom) / 1080f );
		var halfH = (Fov / 2f).DegreeToRadian();
		var halfV = MathF.Atan( MathF.Tan( halfH ) / aspect );
		var radius = size.WithZ( 0 ).Length / 2f * 1.04f;
		var wanted = MathF.Max( radius / (MathF.Tan( halfH ) * freeWidth), size.z * 0.75f / (MathF.Tan( halfV ) * freeHeight) );
		wanted *= 1f - zoom * 0.5f;
		shownDistance = shownDistance <= 0 ? wanted : MathX.Lerp( shownDistance, wanted, MathF.Min( 1f, RealTime.Delta * 10f ) );
		var distance = shownDistance;

		var pitch = CameraPitch.DegreeToRadian();
		var direction = Rotation.FromYaw( baseYaw + CameraAngle ).Forward * MathF.Cos( pitch ) + Vector3.Up * MathF.Sin( pitch );
		var position = target + direction * distance;
		var rotation = Rotation.LookAt( -direction, Vector3.Up );

		// La voiture au milieu de la partie libre : la caméra glisse de côté (sans tourner), comme
		// à la création du personnage. En demi-largeurs d'écran, le décalage vaut la différence des
		// panneaux de gauche et de droite.
		var shiftX = (FreeLeft - FreeRight) / 2f / (uiWidth / 2f) * distance * MathF.Tan( halfH );
		var shiftY = (FreeBottom - FreeTop) / 2f / 540f * distance * MathF.Tan( halfV );
		position += -rotation.Right * shiftX - rotation.Up * shiftY;

		// Un mur, un pilier du garage entre la voiture et la caméra : elle s'avance devant.
		var tr = Game.ActiveScene.Trace.Ray( target, position ).WithoutTags( "player", "trigger" ).Run();
		if ( tr.Hit )
			position = tr.EndPosition + (target - position).Normal * 6f;

		camera.FieldOfView = Fov;
		camera.WorldPosition = position;
		camera.WorldRotation = rotation;
		// Le corps du joueur (étiquette « viewer » à la 1re personne) se voit dans le salon, ses
		// jambes de 1re personne (une copie reculée sous la caméra) non.
		camera.RenderExcludeTags.Remove( "viewer" );
		camera.RenderExcludeTags.Add( FirstPersonLegs.Tag );

		PlaceStudio( target, size, direction );
	}

	void PlaceStudio( Vector3 target, Vector3 size, Vector3 toCamera )
	{
		if ( lamps.Length < 4 || lamps.Any( x => !x.IsValid() ) )
			return;
		var daylight = GameClock.Daylight;
		var strength = MathX.Lerp( StudioNight, StudioDay, daylight );
		// Gyrophares allumés, de nuit : le studio se baisse, ce sont les éclats qui éclairent.
		if ( Beacons )
			strength *= MathX.Lerp( 0.22f, 0.8f, daylight );
		var warm = new Color( 1f, 0.95f, 0.88f );
		var reach = MathF.Max( 260f, size.Length * 2.2f );
		var facing = Rotation.LookAt( toCamera.WithZ( 0 ), Vector3.Up );
		var side = size.Length * 0.55f;

		Set( lamps[0], target + facing.Forward * side + facing.Left * side * 0.8f + Vector3.Up * size.z * 1.4f, warm * strength );
		Set( lamps[1], target + facing.Forward * side + facing.Right * side + Vector3.Up * size.z * 0.6f, new Color( 0.85f, 0.9f, 1f ) * strength * 0.55f );
		Set( lamps[2], target - facing.Forward * side * 1.1f + facing.Right * side * 0.3f + Vector3.Up * size.z * 1.2f, warm * strength * 0.7f );
		Set( lamps[3], target + Vector3.Up * size.z * 2.4f, warm * strength * 0.6f );

		void Set( PointLight light, Vector3 at, Color color )
		{
			light.WorldPosition = at;
			light.LightColor = color;
			light.Radius = reach;
		}
	}

	// ------------------------------------------------------------------ la rampe (recopiée de Vehicle.Police.cs)

	// La rampe d'un vrai véhicule se construit dans Vehicle.Police.cs (BuildPoliceKit), en privé :
	// elle est refaite ici à l'identique — mêmes mesures du toit, mêmes matériaux, même motif. Une
	// retouche de la rampe là-bas se reporte ici.

	sealed class Beacon
	{
		public ModelRenderer Lens;
		public bool Left;
		public int Slot;
		public bool Lit;
	}

	readonly List<Beacon> beacons = new();
	readonly List<(PointLight Light, bool Left, bool Main)> beaconLights = new();
	bool emsColors;

	const string LensRed = "materials/horizon/gyro_rouge.vmat";
	const string LensBlue = "materials/horizon/gyro_bleu.vmat";
	const string LensRedOff = "materials/horizon/gyro_rouge_eteint.vmat";
	const string LensBlueOff = "materials/horizon/gyro_bleu_eteint.vmat";
	const string LensWhite = "materials/horizon/gyro_blanc.vmat";
	const string LensWhiteOff = "materials/horizon/gyro_blanc_eteint.vmat";
	const string HousingMaterial = "materials/horizon/gyro_boitier.vmat";

	static readonly Color BeaconRed = new( 1f, 0.05f, 0.03f );
	static readonly Color BeaconBlue = new( 0.08f, 0.25f, 1f );
	static readonly Color BeaconWhite = new( 1f, 0.93f, 0.85f );

	string RightLens( bool lit ) => emsColors ? (lit ? LensWhite : LensWhiteOff) : (lit ? LensBlue : LensBlueOff);
	Color RightColor => emsColors ? BeaconWhite : BeaconBlue;

	void UpdateBeacons()
	{
		var daylight = GameClock.Daylight;
		foreach ( var beacon in beacons )
		{
			var lit = Beacons && BeaconOn( beacon.Left, beacon.Slot );
			if ( lit == beacon.Lit || !beacon.Lens.IsValid() )
				continue;
			beacon.Lit = lit;
			beacon.Lens.MaterialOverride = Material.Load( beacon.Left ? (lit ? LensRed : LensRedOff) : RightLens( lit ) );
		}
		foreach ( var (light, left, main) in beaconLights )
		{
			if ( !light.IsValid() )
				continue;
			var lit = Beacons && BeaconOn( left, -1 );
			light.Enabled = lit;
			if ( !lit )
				continue;
			var strength = MathX.Lerp( main ? 5.5f : 2.2f, main ? 1.6f : 0.8f, daylight );
			light.LightColor = (left ? BeaconRed : RightColor) * strength;
			light.Radius = MathX.Lerp( main ? 480f : 160f, main ? 260f : 90f, daylight );
		}
	}

	/// <summary>Le motif Whelen / Code 3 de Vehicle.BeaconOn : doubles éclats alternés, puis en croix.</summary>
	static bool BeaconOn( bool left, int slot )
	{
		const float Phase = 0.3f;
		var t = RealTime.Now % (Phase * 8);
		var index = (int)(t / Phase);
		var u = t - index * Phase;
		var flash = u < 0.07f || u >= 0.12f && u < 0.19f;
		if ( !flash )
			return false;
		var leftPhase = index % 2 == 0;
		if ( index < 4 || slot < 0 )
			return left == leftPhase;
		return slot == 2 ? left != leftPhase : left == leftPhase;
	}

	sealed record RoofInfo( float FrontX, float BackX, float BarX, float Z, float Width );

	/// <summary>Le dessus du toit, mesuré comme Vehicle.RoofOf (l'antenne de la Polo écartée).</summary>
	static RoofInfo RoofOf( VehicleRig rig )
	{
		var bb = rig.BodyBounds;
		var cy = bb.Center.y;
		var half = bb.Size.y / 2;
		var points = rig.BodyGroups.Where( g => !VehicleRig.IsGlass( g.Material ) ).SelectMany( g => g.Vertices ).Select( v => v.Position ).ToList();
		if ( points.Count < 50 )
		{
			var front = (rig.CabinX ?? bb.Center.x) + bb.Size.x * 0.08f;
			return new RoofInfo( front, front - bb.Size.x * 0.3f, front - bb.Size.x * 0.1f, bb.Maxs.z, bb.Size.y * 0.7f );
		}
		const int Bins = 48;
		var top = new float[Bins];
		Array.Fill( top, float.MinValue );
		int BinOf( float x ) => Math.Clamp( (int)((x - bb.Mins.x) / bb.Size.x * Bins), 0, Bins - 1 );
		foreach ( var p in points )
		{
			if ( MathF.Abs( p.y - cy ) > half * 0.3f )
				continue;
			var b = BinOf( p.x );
			top[b] = MathF.Max( top[b], p.z );
		}
		var peak = top.Where( t => t > float.MinValue ).OrderByDescending( t => t ).Skip( 3 ).FirstOrDefault( top.Max() );
		var tolerance = MathF.Max( 2.5f, bb.Size.z * 0.04f );
		var roofBins = Enumerable.Range( 0, Bins ).Where( b => top[b] >= peak - tolerance ).ToList();
		var binLength = bb.Size.x / Bins;
		var frontX = bb.Mins.x + (roofBins.Max() + 1) * binLength;
		var backX = bb.Mins.x + roofBins.Min() * binLength;
		var barX = MathF.Max( frontX - (frontX - backX) * 0.32f, backX + 7f );
		var nearBar = points.Where( p => MathF.Abs( p.x - barX ) < 6f && MathF.Abs( p.y - cy ) < half * 0.5f ).ToList();
		var z = nearBar.Count > 0 ? nearBar.Max( p => p.z ) : peak;
		var across = points.Where( p => MathF.Abs( p.x - barX ) < 4f && p.z > z - 4f ).ToList();
		var width = across.Count > 4 ? across.Max( p => p.y ) - across.Min( p => p.y ) : bb.Size.y * 0.7f;
		return new RoofInfo( frontX, backX, barX, z, width );
	}

	static float FrontFaceX( VehicleRig rig, float z )
	{
		var bb = rig.BodyBounds;
		var near = rig.BodyGroups.SelectMany( g => g.Vertices ).Select( v => v.Position )
			.Where( p => MathF.Abs( p.z - z ) < 3f && MathF.Abs( p.y - bb.Center.y ) < bb.Size.y * 0.25f ).ToList();
		return near.Count > 0 ? near.Max( p => p.x ) : bb.Maxs.x - 2f;
	}

	static Model cube;

	/// <summary>Un cube d'un pouce, centré, faces éclairées : boîtier et modules (comme Vehicle.BeaconCube).</summary>
	static Model Cube()
	{
		if ( cube is not null && !cube.IsError )
			return cube;
		var verts = new List<Vertex>();
		var indices = new List<int>();
		foreach ( var n in new[] { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down } )
		{
			var u = MathF.Abs( n.z ) > 0.5f ? Vector3.Forward : Vector3.Up;
			var v = Vector3.Cross( n, u );
			var start = verts.Count;
			foreach ( var (a, b) in new[] { (-1f, -1f), (1f, -1f), (1f, 1f), (-1f, 1f) } )
				verts.Add( new Vertex( (n + u * a + v * b) * 0.5f, n, u, new Vector4( (a + 1) / 2, (b + 1) / 2, 0, 0 ) ) );
			indices.AddRange( new[] { start, start + 2, start + 1, start, start + 3, start + 2, start, start + 1, start + 2, start, start + 2, start + 3 } );
		}
		var mesh = new Mesh( Material.Load( HousingMaterial ), MeshPrimitiveType.Triangles );
		mesh.CreateVertexBuffer( verts.Count, Vertex.Layout, verts );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = new BBox( -Vector3.One * 0.5f, Vector3.One * 0.5f );
		cube = Model.Builder.WithName( "horizon/police/cube_apercu" ).AddMesh( mesh ).Create();
		return cube;
	}

	static ModelRenderer Block( GameObject parent, string name, Vector3 center, Vector3 size, string material )
	{
		var go = Local( parent, name );
		go.LocalPosition = center;
		go.LocalScale = size;
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = Cube();
		renderer.MaterialOverride = Material.Load( material );
		return renderer;
	}

	void AddBeacon( GameObject parent, string name, Vector3 center, Vector3 size, bool left, int slot )
	{
		var lens = Block( parent, name, center, size, left ? LensRedOff : RightLens( false ) );
		lens.RenderType = ModelRenderer.ShadowRenderType.Off;
		beacons.Add( new Beacon { Lens = lens, Left = left, Slot = slot } );
	}

	void AddBeaconLight( GameObject parent, string name, Vector3 at, bool left, bool main )
	{
		var go = Local( parent, name );
		go.LocalPosition = at;
		var light = go.Components.Create<PointLight>();
		light.LightColor = left ? BeaconRed : RightColor;
		light.Radius = main ? 400f : 140f;
		light.Shadows = false;
		light.Enabled = false;
		beaconLights.Add( (light, left, main) );
	}

	/// <summary>La rampe sur le toit, ou les feux banalisés (calandre, haut du pare-brise et de la lunette).</summary>
	void BuildKit( GameObject holder, bool bar, bool ems )
	{
		emsColors = ems;
		var kit = Local( holder, bar ? "Rampe de gyrophares" : "Feux de police banalisés" );
		var bb = rig.BodyBounds;
		var cy = bb.Center.y;
		var roof = RoofOf( rig );

		if ( bar )
		{
			var width = Math.Clamp( roof.Width * 0.94f, 30f, MathF.Min( 58f, bb.Size.y * 0.78f ) );
			const float Depth = 11f, Feet = 1.6f, Base = 1.4f, Lens = 2.8f;
			var x = roof.BarX;
			var z0 = roof.Z;
			foreach ( var side in new[] { 1f, -1f } )
				Block( kit, "Pied de la rampe", new Vector3( x, cy + side * (width / 2 - 5f), z0 + Feet / 2 - 0.4f ), new Vector3( 5f, 3.5f, Feet + 0.8f ), HousingMaterial );
			Block( kit, "Boîtier de la rampe", new Vector3( x, cy, z0 + Feet + Base / 2 ), new Vector3( Depth, width, Base ), HousingMaterial );
			var moduleWidth = (width - 1f) / 6f;
			for ( var i = 0; i < 6; i++ )
			{
				var left = i < 3;
				var slot = left ? i : 5 - i;
				var y = cy + width / 2 - 0.5f - moduleWidth * (i + 0.5f);
				AddBeacon( kit, left ? $"Module rouge {slot}" : $"Module droit {slot}", new Vector3( x, y, z0 + Feet + Base + Lens / 2 ), new Vector3( Depth - 0.6f, moduleWidth - 0.35f, Lens ), left, slot );
			}
			Block( kit, "Couvercle de la rampe", new Vector3( x, cy, z0 + Feet + Base + Lens + 0.25f ), new Vector3( Depth - 2.5f, width - 4f, 0.5f ), HousingMaterial );

			var lightZ = z0 + Feet + Base + Lens + 6f;
			AddBeaconLight( kit, "Lumière rouge", new Vector3( x, cy + width * 0.35f, lightZ ), true, true );
			AddBeaconLight( kit, "Lumière droite", new Vector3( x, cy - width * 0.35f, lightZ ), false, true );
			return;
		}

		var frontZ = rig.FrontLampCenters is { Length: > 0 } frontLamps ? frontLamps.Average( l => l.z ) - 3f : bb.Mins.z + bb.Size.z * 0.36f;
		var faceX = FrontFaceX( rig, frontZ );
		foreach ( var left in new[] { true, false } )
		{
			var y = cy + (left ? 1 : -1) * bb.Size.y * 0.16f;
			AddBeacon( kit, left ? "Feu de calandre rouge" : "Feu de calandre droit", new Vector3( faceX + 0.3f, y, frontZ ), new Vector3( 1.2f, 6f, 2f ), left, 0 );
			AddBeaconLight( kit, left ? "Lueur de calandre rouge" : "Lueur de calandre droite", new Vector3( faceX + 8f, y, frontZ ), left, false );
		}
		var visorZ = roof.Z - 4.5f;
		foreach ( var (atX, label) in new[] { (roof.FrontX - 3f, "pare-brise"), (roof.BackX + 3f, "lunette") } )
		{
			foreach ( var left in new[] { true, false } )
			{
				var y = cy + (left ? 6.5f : -6.5f);
				AddBeacon( kit, left ? $"Feu de {label} rouge" : $"Feu de {label} droit", new Vector3( atX, y, visorZ ), new Vector3( 2.4f, 11.5f, 2f ), left, 0 );
			}
		}
		AddBeaconLight( kit, "Lueur de pare-brise rouge", new Vector3( roof.FrontX + 10f, cy + 10f, visorZ ), true, true );
		AddBeaconLight( kit, "Lueur de pare-brise droite", new Vector3( roof.FrontX + 10f, cy - 10f, visorZ ), false, true );
	}
}