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.
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 );
}
}