Effects/StripLights.cs

Component that finds map light-strip faces, groups them into fixtures, makes per-fixture material copies, and dims/flickers those copies based on power and tremor state. It also handles an Easter-egg panel flicker and provides a console command for inspecting and forcing strip states and a joinsim diagnostic that serializes meshes to test material naming behavior.

File AccessExternal Download
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE MAP'S LIGHT STRIPS — dark until the power comes on, then lit in a wave from the switch; and whenever the ground shakes,
/// flickering, each on its own: *"we could make the light strips be off untill power is turned on — and when the map shakes the
/// strips flicker asyncronously — when i say strips i mean any light from the hexagons"* (2026-09-28). `Gameplay.StripsNeedPower`
/// and `Gameplay.LightFlicker` ask for it; basalt asks for both.
///
/// ⛔ THE STRIPS ARE THE MAP'S OWN FACES, ALL IN ONE MATERIAL. Basalt draws every strip in `lights/white001` — 405 faces over 46
/// of its mesh chunks — so dimming that material dims them all at once, and dims the Easter egg's panels with it (`HexPlatforms`
/// draws in it too). So each FIXTURE — a strip, or strips touching, within one 256u cell — gets A COPY OF ITS OWN, set on its faces
/// (`PolygonMesh.SetFaceMaterial`), and is lit, dimmed or put out through its copy's tint alone.
///
/// ⚠️ THE CHUNK IS SWITCHED OFF AND ON AGAIN to take its new materials: a MeshComponent builds its model as it is switched on,
/// and `RebuildMesh` does nothing outside the editor (it returns unless `Scene.IsEditor`). Once, as the map's config is shown; the
/// geometry, the collision and the navmesh are the same after as before.
///
/// ⚠️ A NEW MANAGER TAKES UP THE COPIES AN OLD ONE LEFT (a hotload), by their names, and never switches a chunk again for them.
///
/// ⚠️ AND THE EASTER EGG'S LIGHT PANELS FLICKER TOO, while the ground shakes — each renderer's tint dipped on its own clock and put
/// back after (<see cref="PanelFlicker"/>). Only its lights: panels drawn in white001 or one of its `nz_hex_` copies, never its
/// stone or the risen lava.
///
/// ⚠️ EACH MACHINE, ITS OWN, AND NOTHING SENT: the power's state and every tremor reach every machine already.
/// ⚠️ THE TUNABLES ARE NULLABLE-BACKED (INSTRUCTIONS §1).
/// </summary>
public sealed class StripLights : Component
{
	public static StripLights Instance { get; private set; }

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	public static StripLights Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Strip Lights";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<StripLights>();
	}

	/// <summary>The strips' material, when the map names none (`Gameplay.StripMaterial`): the map's own white light.</summary>
	public const string DefaultStripMaterial = "lights/white001";

	/// <summary>How a fixture's own copy is named — what a new manager knows them by.</summary>
	const string CopyPrefix = "nz_strip_";

	/// <summary>
	/// This machine's mark in its copies' names — `nz_strip_{salt}_{n}.vmat` — so that only ITS OWN are taken up (<see cref="Build"/>).
	///
	/// ⛔ A COPY EXISTS ONLY WHERE IT WAS MADE (2026-09-29). The strips' copies sit on the MAP'S OWN chunks, which a joiner's
	/// snapshot carries, and a PolygonMesh saves a face's material as its NAME, which the joiner hands to `Material.Load`:
	/// - a name with no extension is refused outright ("Illegal path, missing extension"), and the face comes back with NO
	///   material, which nothing can tell was a strip — so the copies end in `.vmat`;
	/// - with it, the joiner looks for the file, does not find it, and gets the engine's error material under that very name, one
	///   per copy (`nz_strips joinsim` shows both, 2026-09-29).
	/// Somebody else's copy is then a strip face like any other, and gets a copy made here. The salt keeps this machine's names off
	/// the host's, which the mesh would otherwise match by name to the dead one.
	///
	/// ⚠️ A STATIC, SO IT OUTLIVES A HOTLOAD (INSTRUCTIONS §1): a new manager still knows this machine's copies and never switches a
	/// chunk again for them. Random per run, so it is nobody else's.
	/// </summary>
	static string Salt => _salt ??= Guid.NewGuid().ToString( "N" )[..6];

	static string _salt;

	/// <summary>The start of every copy made on this machine.</summary>
	static string MinePrefix => $"{CopyPrefix}{Salt}_";

	/// <summary>The Easter egg's panels, as `HexPlatforms` tags them; its light copies are named `nz_hex_…`.</summary>
	const string PanelTag = "nz_hexpanel", PanelCopyPrefix = "nz_hex_";

	/// <summary>A fixture is strips within one cell this big, touching (within <see cref="JoinGap"/>) — so no fixture spans a room.</summary>
	const float CellSize = 256f, JoinGap = 6f;

	/// <summary>A strip lit, as white001 draws it: its tint, [2 2 2].</summary>
	public static float Glow
	{
		get => _glow ?? 2f;
		set => _glow = value;
	}

	static float? _glow;

	/// <summary>A strip out: its tint's share of lit — dark, not black, so it still reads as a lamp, only off.</summary>
	public static float OffLevel
	{
		get => _offLevel ?? 0.03f;
		set => _offLevel = value;
	}

	static float? _offLevel;

	sealed class Fixture
	{
		public Material Copy;
		public Vector3 Centre;
		public int Seed, Faces;
		public readonly TremorFlicker Flicker = new();
		public float Shown = -1f;
	}

	readonly List<Fixture> _fixtures = new();
	readonly List<MeshComponent> _chunks = new();

	/// <summary>How many fixtures there are, and how many faces and chunks between them.</summary>
	public int Fixtures => _fixtures.Count;
	public int Faces => _fixtures.Sum( f => f.Faces );
	public int Chunks => _chunks.Count;

	/// <summary>`nz_strips on|off`: every strip lit, or dark, whatever the power — -1 when the power decides.</summary>
	float _forced = -1f;

	RealTimeSince _sinceTried;

	/// <summary>The map last looked over and found with chunks but no strips — not looked over again until another map.</summary>
	string _noStripsOn;

	static GameplaySettings Cfg => ActiveConfig.Current?.Gameplay;

	/// <summary>Does this map ask for it: strips that wait for the power, or lights that flicker when the ground shakes.</summary>
	static bool Wanted => Cfg is { } g && (g.StripsNeedPower || g.LightFlicker > 0f);

	/// <summary>
	/// Find the strips and give each fixture its copy — once for a map (`NZGame.ShowConfig`, which runs at every mode change: a
	/// second call with the map's chunks still standing does nothing).
	/// </summary>
	public void Rebuild()
	{
		if ( !Wanted ) return;
		if ( _fixtures.Count > 0 && _chunks.All( c => c.IsValid() ) ) return;
		if ( _fixtures.Count == 0 && _noStripsOn == NZMap.Current ) return;

		Build();
	}

	void Build()
	{
		_fixtures.Clear();
		_chunks.Clear();
		_sinceTried = 0f;
		if ( !Scene.IsValid() ) return;

		var strip = (Cfg?.StripMaterial ?? "").Trim();
		if ( strip.Length == 0 ) strip = DefaultStripMaterial;

		Material source = null;
		var fresh = new List<(MeshComponent Chunk, HalfEdgeMesh.FaceHandle Face, BBox Box)>();
		var adopted = new Dictionary<Material, Fixture>();
		var meshes = 0;

		foreach ( var mc in Scene.GetAllComponents<MeshComponent>() )
		{
			var mesh = mc.Mesh;
			if ( !mc.Active || mesh is null ) continue;
			meshes++;

			var found = false;
			foreach ( var f in mesh.FaceHandles )
			{
				var mat = mesh.GetFaceMaterial( f );
				if ( mat is null ) continue;

				var name = mat.Name ?? "";

				// ⚠️ AN OLD MANAGER'S COPY: taken up as it is, the chunk left alone — ⛔ ONE MADE ON THIS MACHINE ONLY (<see cref="Salt"/>)
				if ( name.StartsWith( MinePrefix, StringComparison.Ordinal ) )
				{
					if ( !adopted.TryGetValue( mat, out var fx ) )
						adopted[mat] = fx = new Fixture { Copy = mat, Seed = adopted.Count * 131 + 17 };
					fx.Centre += FaceBox( mc, mesh, f ).Center;
					fx.Faces++;
					found = true;
					continue;
				}

				// ⛔ SOMEBODY ELSE'S COPY — the host's, come in a joiner's snapshot as a name that loads as the error material: a strip
				// face all the same, made again below
				if ( name.StartsWith( CopyPrefix, StringComparison.Ordinal ) )
				{
					fresh.Add( (mc, f, FaceBox( mc, mesh, f )) );
					found = true;
					continue;
				}

				var path = mat.ResourcePath ?? mat.Name ?? "";
				if ( !path.Contains( strip, StringComparison.OrdinalIgnoreCase ) ) continue;

				source ??= mat;
				fresh.Add( (mc, f, FaceBox( mc, mesh, f )) );
				found = true;
			}

			if ( found ) _chunks.Add( mc );
		}

		foreach ( var fx in adopted.Values )
		{
			fx.Centre /= Math.Max( 1, fx.Faces );
			_fixtures.Add( fx );
		}

		// ⚠️ A JOINER'S STRIPS CAN ALL BE THE HOST'S COPIES, with no face left in the strip material to take it from: loaded by its path
		if ( fresh.Count > 0 && source is null ) source = LoadStrip( strip );

		if ( fresh.Count > 0 && source is not null ) MakeFixtures( fresh, source );

		// ⚠️ A MAP STANDING WITH NO STRIPS IS NOT LOOKED OVER AGAIN — thirteen thousand faces every few seconds for nothing. A map not
		// standing yet (no mesh at all) is.
		_noStripsOn = _fixtures.Count == 0 && meshes > 0 ? NZMap.Current : null;
		if ( _fixtures.Count == 0 ) return;

		Log.Info( $"[nz-light] the map's strips: {Faces} face(s) of '{strip}' in {Chunks} chunk(s), as {Fixtures} fixture(s)"
			+ (adopted.Count > 0 ? $" ({adopted.Count} taken up from before)" : "")
			+ $" · {(Cfg?.StripsNeedPower == true ? "dark until the power" : "always lit")}"
			+ $" · flicker x{Cfg?.LightFlicker ?? 0f:0.##}" );
	}

	/// <summary>The strip material by its path, as the config names it: "lights/white001" is `materials/lights/white001.vmat`.</summary>
	static Material LoadStrip( string strip )
	{
		var path = strip.Replace( '\\', '/' ).Trim().TrimStart( '/' );
		if ( !path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) ) path = "materials/" + path;
		if ( !path.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) ) path += ".vmat";
		return Material.Load( path );
	}

	/// <summary>The faces grouped into fixtures, a copy each, set on their faces; and each chunk they are in switched off and on.</summary>
	void MakeFixtures( List<(MeshComponent Chunk, HalfEdgeMesh.FaceHandle Face, BBox Box)> faces, Material source )
	{
		// ⚠️ ONE FIXTURE = TOUCHING FACES IN ONE CELL, found by union-find: a strip split into faces stays one light, and a ring of
		// strips round a room does not become a single one
		var parent = Enumerable.Range( 0, faces.Count ).ToArray();
		int Find( int i )
		{
			while ( parent[i] != i ) i = parent[i] = parent[parent[i]];
			return i;
		}

		foreach ( var cell in Enumerable.Range( 0, faces.Count ).GroupBy( i => CellOf( faces[i].Box.Center ) ) )
		{
			var members = cell.ToList();
			for ( var a = 0; a < members.Count; a++ )
				for ( var b = a + 1; b < members.Count; b++ )
					if ( Touch( faces[members[a]].Box, faces[members[b]].Box ) )
						parent[Find( members[a] )] = Find( members[b] );
		}

		var touched = new HashSet<MeshComponent>();
		foreach ( var group in Enumerable.Range( 0, faces.Count ).GroupBy( Find ) )
		{
			var k = _fixtures.Count;
			// ⛔ `.vmat` ON THE END, a name the engine will look for — see <see cref="Salt"/>
			var fx = new Fixture { Copy = source.CreateCopy( $"{MinePrefix}{k}.vmat" ), Seed = k * 131 + 17 };
			fx.Copy.Set( "g_vColorTint", new Vector3( Glow, Glow, Glow ) );

			foreach ( var i in group )
			{
				var (chunk, face, box) = faces[i];
				chunk.Mesh.SetFaceMaterial( face, fx.Copy );
				fx.Centre += box.Center;
				fx.Faces++;
				touched.Add( chunk );
			}

			fx.Centre /= fx.Faces;
			_fixtures.Add( fx );
		}

		// ⛔ THE REBUILD: off and on, in one breath — no physics step between them, so nothing standing on a chunk can fall
		foreach ( var chunk in touched )
		{
			chunk.Enabled = false;
			chunk.Enabled = true;
		}
	}

	static BBox FaceBox( MeshComponent mc, PolygonMesh mesh, HalfEdgeMesh.FaceHandle f )
	{
		var min = new Vector3( float.MaxValue );
		var max = new Vector3( float.MinValue );
		foreach ( var v in mesh.GetFaceVertices( f ) )
		{
			var p = mc.WorldTransform.PointToWorld( mesh.GetVertexPosition( v ) );
			min = Vector3.Min( min, p );
			max = Vector3.Max( max, p );
		}

		return new BBox( min, max );
	}

	static (int, int, int) CellOf( Vector3 p )
		=> ((int)MathF.Floor( p.x / CellSize ), (int)MathF.Floor( p.y / CellSize ), (int)MathF.Floor( p.z / CellSize ));

	static bool Touch( BBox a, BBox b )
		=> a.Mins.x - JoinGap <= b.Maxs.x && a.Maxs.x + JoinGap >= b.Mins.x
		&& a.Mins.y - JoinGap <= b.Maxs.y && a.Maxs.y + JoinGap >= b.Mins.y
		&& a.Mins.z - JoinGap <= b.Maxs.z && a.Maxs.z + JoinGap >= b.Mins.z;

	protected override void OnUpdate()
	{
		if ( _fixtures.Count == 0 )
		{
			// ⚠️ THE MAP MAY NOT HAVE STOOD WHEN ITS CONFIG WAS SHOWN: looked for again, now and then, while the map asks for it
			if ( Wanted && _sinceTried > 3f && _noStripsOn != NZMap.Current ) Build();
			return;
		}

		PowerWave.Sync();

		var g = Cfg;
		var waits = g?.StripsNeedPower == true;
		var dark = waits && PowerWave.Dark;
		var level = MapTremor.Level * (g?.LightFlicker ?? 0f);

		foreach ( var fx in _fixtures )
		{
			if ( fx.Copy is null ) continue;

			float lit;
			if ( _forced >= 0f ) lit = _forced;
			else if ( dark ) lit = 0f;
			else lit = (waits ? PowerWave.Lit( fx.Centre, fx.Seed ) : 1f) * fx.Flicker.Lit( level );

			var b = MathF.Max( OffLevel, lit );
			if ( MathF.Abs( b - fx.Shown ) < 0.004f ) continue;

			fx.Shown = b;
			var v = Glow * b;
			fx.Copy.Set( "g_vColorTint", new Vector3( v, v, v ) );
		}

		PanelFlicker( _forced >= 0f ? 0f : level );
	}

	// ══ the Easter egg's light panels ══════════════════════════════════════════════════════

	readonly List<(ModelRenderer Renderer, Color Tint, TremorFlicker Flicker)> _panels = new();
	bool _panelsShaking;

	/// <summary>
	/// While the ground shakes, each of the Easter egg's light panels dips on its own clock, through its renderer's tint; when it is
	/// still again every tint is put back as it was. ⚠️ GATHERED AS THE SHAKING BEGINS, not every frame: a panel made during it
	/// simply does not flicker.
	/// </summary>
	void PanelFlicker( float level )
	{
		if ( level < 0.005f )
		{
			if ( !_panelsShaking ) return;

			foreach ( var p in _panels )
				if ( p.Renderer.IsValid() ) p.Renderer.Tint = p.Tint;
			_panels.Clear();
			_panelsShaking = false;
			return;
		}

		if ( !_panelsShaking )
		{
			_panelsShaking = true;
			_panels.Clear();
			var seen = new HashSet<ModelRenderer>();
			foreach ( var go in Scene.GetAllObjects( true ).Where( x => x.Tags.Has( PanelTag ) ) )
				foreach ( var r in go.Components.GetAll<ModelRenderer>( FindMode.EnabledInSelfAndDescendants ) )
					if ( seen.Add( r ) && IsLightPanel( r ) ) _panels.Add( (r, r.Tint, new TremorFlicker()) );
		}

		foreach ( var (r, tint, flicker) in _panels )
		{
			if ( !r.IsValid() ) continue;
			var lit = MathF.Max( OffLevel, flicker.Lit( level ) );
			r.Tint = new Color( tint.r * lit, tint.g * lit, tint.b * lit, tint.a );
		}
	}

	/// <summary>Is this one of the Easter egg's lights: drawn in white001, or in one of its `nz_hex_` copies?</summary>
	static bool IsLightPanel( ModelRenderer r )
	{
		if ( IsLight( r.MaterialOverride ) ) return true;
		return r.Model?.Materials.Any( IsLight ) ?? false;
	}

	static bool IsLight( Material m )
		=> m is not null && ((m.Name ?? "").StartsWith( PanelCopyPrefix, StringComparison.Ordinal )
			|| (m.ResourcePath ?? m.Name ?? "").Contains( "white001", StringComparison.OrdinalIgnoreCase ));

	/// <summary>
	/// `nz_strips [wave | on | off | auto | rebuild | joinsim]` — the map's light strips. Bare: what was found, and whether they wait
	/// for the power. `wave` runs the power's wave again, to see it. `on` and `off` light or darken every strip whatever the power,
	/// and `auto` hands them back to it (this session). `rebuild` looks for them again. `joinsim` checks, without touching the
	/// scene, what a joiner's snapshot makes of a strip chunk (<see cref="JoinSim"/>).
	/// </summary>
	[ConCmd( "nz_strips" )]
	public static void Cmd( string what = "" )
	{
		// ⚠️ BEFORE `Ensure`, WHICH WOULD MAKE A MANAGER: this one reads the scene and changes nothing in it, the editor's included
		if ( what.Trim().Equals( "joinsim", StringComparison.OrdinalIgnoreCase ) ) { JoinSim(); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-light] no scene" ); return; }

		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "wave":
				m._forced = -1f;
				PowerWave.Replay();
				break;

			case "on":
				m._forced = 1f;
				break;

			case "off":
				m._forced = 0f;
				break;

			case "auto":
				m._forced = -1f;
				break;

			case "rebuild":
				m.Build();
				break;

			default:
				Log.Warning( "[nz-light] nz_strips [wave | on | off | auto | rebuild | joinsim]" );
				return;
		}

		var g = Cfg;
		Log.Info( $"[nz-light] strips: {m.Fixtures} fixture(s), {m.Faces} face(s) in {m.Chunks} chunk(s)"
			+ $" · {(g?.StripsNeedPower == true ? "they wait for the power" : "always lit")} — the power is {(Power.IsOn ? "ON" : "off")}"
			+ $" · flicker x{g?.LightFlicker ?? 0f:0.##}, the ground at {MapTremor.Level:0.###}"
			+ (m._forced >= 0f ? $" · FORCED {(m._forced > 0f ? "on" : "off")} (nz_strips auto)" : "") );
	}

	/// <summary>
	/// `nz_strips joinsim` — what a joiner's snapshot does to a strip chunk, with the scene left as it is. One chunk's mesh is
	/// copied through JSON, as the snapshot carries it; its strip faces get three copies on the copy, as <see cref="MakeFixtures"/>
	/// gives them; then it goes through JSON again with the copies' names changed to ones nothing on this machine made, which is
	/// what a joiner reads. Every face's material is compared before and after: did each keep its own name (so <see cref="Build"/>
	/// knows a stranger's copy on a joiner), or did any come back pointing at another face's material?
	/// </summary>
	static void JoinSim()
	{
		var strip = (Cfg?.StripMaterial ?? "").Trim();
		if ( strip.Length == 0 ) strip = DefaultStripMaterial;

		var mc = Game.ActiveScene?.GetAllComponents<MeshComponent>()
			.FirstOrDefault( x => x.Mesh is not null && x.Mesh.FaceHandles.Any( f => IsStripFace( x.Mesh, f, strip ) ) );

		try
		{
			// ⚠️ A CHUNK OF THE MAP WHEN ONE STANDS; otherwise a mesh of its own — the engine's reading is the question, not the map
			string where, baseJson;
			if ( mc is not null )
			{
				where = mc.GameObject.Name;
				baseJson = Json.Serialize( mc.Mesh );
			}
			else
			{
				where = "six squares of its own";
				baseJson = Json.Serialize( SyntheticMesh( strip ).Mesh );
				Log.Info( $"[nz-light] joinsim: no mesh here has a face of '{strip}' — trying six squares of its own" );
			}

			// ⚠️ BOTH NAMINGS: a name with no extension is refused outright ("Illegal path, missing extension"), and comes back as
			// no material at all; one with an extension is looked for, not found, and may come back as the error material under it
			JoinSimRun( baseJson, strip, where, "" );
			JoinSimRun( baseJson, strip, where, ".vmat" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-light] joinsim failed: {e.Message}" );
		}
	}

	/// <summary>One of `joinsim`'s tries: the copies named with <paramref name="suffix"/> after their number ("" or ".vmat").</summary>
	static void JoinSimRun( string baseJson, string strip, string where, string suffix )
	{
		var sim = Json.Deserialize<PolygonMesh>( baseJson );
		var strips = sim.FaceHandles.Where( f => IsStripFace( sim, f, strip ) ).ToList();
		if ( strips.Count == 0 ) { Log.Warning( "[nz-light] joinsim: the copy has no strip face" ); return; }

		var source = sim.GetFaceMaterial( strips[0] );
		for ( var i = 0; i < strips.Count; i++ )
			sim.SetFaceMaterial( strips[i], source.CreateCopy( $"{CopyPrefix}sim_{i % 3}{suffix}" ) );

		string Ghost( string n ) => n.Replace( $"{CopyPrefix}sim_", $"{CopyPrefix}ghost_" );
		var before = sim.FaceHandles.Select( f => Ghost( sim.GetFaceMaterial( f )?.Name ?? "" ) ).ToList();

		// ⚠️ THE NAMES A JOINER READS: ones nothing here made, so they load here as they load there
		var back = Json.Deserialize<PolygonMesh>( Ghost( Json.Serialize( sim ) ) );
		var mats = back.FaceHandles.Select( f => back.GetFaceMaterial( f ) ).ToList();
		var after = mats.Select( m => m?.Name ?? "" ).ToList();

		int empty = 0, moved = 0;
		for ( var i = 0; i < Math.Min( before.Count, after.Count ); i++ )
		{
			if ( mats[i] is null ) empty++;
			else if ( before[i] != after[i] ) moved++;
		}

		var ghosts = mats.Where( m => (m?.Name ?? "").StartsWith( CopyPrefix, StringComparison.Ordinal ) ).Distinct().Count();
		var named = string.Join( ", ", after.Where( a => a.StartsWith( CopyPrefix, StringComparison.Ordinal ) ).Distinct().Take( 3 ) );

		Log.Info( $"[nz-light] joinsim, copies named 'nz_strip_N{suffix}', on {where}: {before.Count} face(s), {strips.Count} strip(s) —"
			+ $" as a joiner reads it, {empty} with no material, {moved} with another face's, the copies as {ghosts} distinct"
			+ $" material(s){(named.Length > 0 ? $" ({named})" : "")}" );
		Log.Info( empty == 0 && moved == 0 && ghosts == Math.Min( 3, strips.Count )
			? $"[nz-light] joinsim '{suffix}': GOOD — each copy comes back under its own name, so Build knows it as somebody else's and makes its own"
			: $"[nz-light] joinsim '{suffix}': BAD — a joiner cannot tell these faces were strips" );
	}

	/// <summary>
	/// A mesh of its own for `joinsim`, when the scene holds no strip (the editor, where the map is not loaded): six separate
	/// squares — 0 and 5 in the engine's grey, 3 in the strip material, 1, 2 and 4 the strips, for the copies. Three missing names
	/// in the middle of the list are enough to show whether the ones after them come back where they were.
	/// </summary>
	static (PolygonMesh Mesh, List<HalfEdgeMesh.FaceHandle> Strips) SyntheticMesh( string strip )
	{
		var mesh = new PolygonMesh();
		var grey = Material.Load( "materials/dev/gray_50.vmat" );
		var light = LoadStrip( strip );
		var strips = new List<HalfEdgeMesh.FaceHandle>();

		for ( var i = 0; i < 6; i++ )
		{
			var o = new Vector3( i * 20f, 0f, 0f );
			var f = mesh.AddFace( mesh.AddVertices( new[] { o, o + new Vector3( 10f, 0f, 0f ), o + new Vector3( 10f, 10f, 0f ), o + new Vector3( 0f, 10f, 0f ) } ) );
			var isStrip = i is 1 or 2 or 4;
			mesh.SetFaceMaterial( f, isStrip || i == 3 ? light : grey );
			if ( isStrip ) strips.Add( f );
		}

		return (mesh, strips);
	}

	/// <summary>Is this face one of the strips: drawn in the strip material itself, not in one of its copies?</summary>
	static bool IsStripFace( PolygonMesh mesh, HalfEdgeMesh.FaceHandle f, string strip )
	{
		var mat = mesh.GetFaceMaterial( f );
		var path = mat?.ResourcePath ?? mat?.Name ?? "";
		return path.Contains( strip, StringComparison.OrdinalIgnoreCase );
	}
}