using System.Reflection;
using CultMath.UnityBridge;
using ShieldField;
using UnityEditor;
using UnityEngine;
namespace Aetheria.EditorTools
{
///
/// Batchmode probe for the shield-panel Cut 3 landing (docs/shield-panel-cut.md): the
/// interceptor presenter (ShieldInterceptor) and the panel pool. Tests.asmdef cannot see
/// Assembly-CSharp (the map's Q7), so this stands in for NUnit coverage exactly as Cut 1 and
/// Cut 2's probes do, and it runs the rules those probes cannot reach: same-frame injection
/// (D17/D20), reuse-strikes-the-existing-panel, pool exhaustion, and the D21 Penrose/Hex
/// density ratio.
///
/// Unlike Cut 1/2 this exercises actual MonoBehaviour simulation state (GraphicsBuffer
/// dispatches), which needs GPU access -- run WITHOUT -nographics, unlike the two probes before
/// it. ShieldPanel deliberately has no [ExecuteAlways] (Cut 1 removed it, D13), so Update()
/// never runs on its own in edit mode; this probe steps the simulation itself by invoking the
/// private Update() method directly; it is Application.isPlaying == false throughout, so
/// ShieldPanel's own dt fallback (1/60s, fixed) is exactly what's exercised.
///
/// Run via: Unity -batchmode -projectPath <repo> -executeMethod
/// Aetheria.EditorTools.ShieldPanelCut3Verify.Run -logFile <path>
/// (no -quit while iterating: EditorApplication.Exit both reports the code and quits.)
///
public static class ShieldPanelCut3Verify
{
static readonly MethodInfo PanelUpdate =
typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance);
static readonly MethodInfo PanelOnEnable =
typeof(ShieldPanel).GetMethod("OnEnable", BindingFlags.NonPublic | BindingFlags.Instance);
static readonly MethodInfo PanelOnDisable =
typeof(ShieldPanel).GetMethod("OnDisable", BindingFlags.NonPublic | BindingFlags.Instance);
static readonly FieldInfo PanelPending =
typeof(ShieldPanel).GetField("pending", BindingFlags.NonPublic | BindingFlags.Instance);
// pending's element type (PendingHit) is a private nested struct, so this reads it through
// the non-generic ICollection Queue also implements -- Count needs no knowledge of T.
static int PendingCount(ShieldPanel panel)
=> ((System.Collections.ICollection)PanelPending.GetValue(panel)).Count;
public static void Run()
{
// This probe dispatches the panel's compute kernels, so it needs a graphics device.
// Under -batchmode -nographics the kernels cannot be looked up and every check dies
// with "Kernel 'KInit' not found", which reads like a broken shader and is not one:
// run this probe without -nographics. Verified 2026-09-18, both ways.
// SystemInfo.supportsComputeShaders still reports true against the null device, so the
// device type is what actually answers this (checked 2026-09-18).
if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null)
{
Debug.LogError("[ShieldPanelCut3Verify] FAIL: no graphics device in this session. " +
"This probe needs a graphics device; rerun batchmode without -nographics.");
EditorApplication.Exit(1);
return;
}
bool ok = true;
ok &= CheckSpawnFrameInjectionAbsorbs();
ok &= CheckReuseStrikesExistingPanel();
ok &= CheckHitOutsideRadiusDoesNotReachPanel();
ok &= CheckPoolExhaustion();
ok &= CheckNullGuardsDoNotThrow();
ok &= CheckPoolReuseDoesNotRebuild();
ok &= CheckPenroseDensity();
Debug.Log(ok
? "[ShieldPanelCut3Verify] PASS"
: "[ShieldPanelCut3Verify] FAIL — see errors above");
EditorApplication.Exit(ok ? 0 : 1);
}
// ==============================================================
static void StepPanel(ShieldPanel panel) => PanelUpdate.Invoke(panel, null);
static (GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor)
BuildRig(int maxLive = 12, float panelRadius = 2f, float reuseFraction = 1f)
{
var sim = AssetDatabase.LoadAssetAtPath(
"Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
var shader = AssetDatabase.LoadAssetAtPath(
"Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
// Uniform-scale sphere at the origin: no MeshCollider/MeshFilter, so ShieldEnvelope
// takes its documented no-mesh fallback (_localExtents = Vector3.one) rather than
// needing a real hull mesh -- this probe is about the interceptor's plumbing, and
// Cut 2's own probe already pins the mesh-extents/normal arithmetic.
var envelopeGO = new GameObject("Cut3Probe_Envelope");
envelopeGO.transform.localScale = Vector3.one * 5f;
var envelope = envelopeGO.AddComponent();
var protoGO = new GameObject("Cut3Probe_PanelPrototype");
var panel = protoGO.AddComponent();
// Assigned after AddComponent so the OnEnable this triggers (sim/panelShader still
// null at that point) is a no-op -- Rebuild() below is the real, deliberate build,
// exactly as Cut 4's rig depends on the prototype's authored fields being set before
// its first real build, not on whatever OnEnable saw at GameObject-creation time.
panel.tiling = TilingKind.Hex;
panel.panelRadius = panelRadius;
panel.cellSize = 0.5f;
panel.sim = sim;
panel.panelShader = shader;
panel.Rebuild();
var proto = protoGO.AddComponent();
var interceptorGO = new GameObject("Cut3Probe_Interceptor");
var interceptor = interceptorGO.AddComponent();
interceptor.Envelope = envelope;
interceptor.PanelPrototype = proto;
interceptor.MaxLivePanels = maxLive;
interceptor.PanelRadius = panelRadius;
interceptor.ReuseFraction = reuseFraction;
return (envelopeGO, envelope, protoGO, interceptor);
}
static void DestroyRig((GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor) rig)
{
// The prototype's own pooled instances are children of protoGO's parent (null here, i.e.
// scene root) per Prototype.Instantiate's `SetParent(transform.parent, ...)` -- find and
// destroy them too so one check's panels can't be mistaken for the next check's.
foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None))
UnityEngine.Object.DestroyImmediate(go.gameObject);
UnityEngine.Object.DestroyImmediate(rig.envelopeGO);
UnityEngine.Object.DestroyImmediate(rig.interceptor.gameObject);
}
static AbsorbEvent Absorb(Vector3 worldPos, Vector3 worldDir, float energy = 5f,
DamageType type = DamageType.Kinetic)
=> new AbsorbEvent(worldPos.ToCultMath(), worldDir.ToCultMath(), energy, type);
// ==============================================================
// D17/D20: a panel struck in its own spawn frame must absorb that hit, not swallow it.
// ==============================================================
static bool CheckSpawnFrameInjectionAbsorbs()
{
var rig = BuildRig();
try
{
rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] spawn-frame: expected 1 live panel, got {rig.interceptor.LiveCount}.");
return false;
}
var panel = rig.interceptor.DebugPanelAt(0);
// The strike lands at the panel's own local origin (ShieldInterceptor poses the
// panel's transform AT the intercept point before calling Hit with that same
// point), so the nearest cell to local (0,0) is the one that must show injection.
int nearest = NearestCell(panel, Vector2.zero);
// Exactly one Update() call: the frame the hit was queued in.
StepPanel(panel);
var v = new float[panel.DebugCellCount];
panel.DebugVField.GetData(v);
if (Mathf.Abs(v[nearest]) < 1e-6f)
{
Debug.LogError($"[ShieldPanelCut3Verify] D17 not fixed: cell {nearest}'s wave velocity is " +
$"{v[nearest]:E3} after the spawn-frame hit -- the growth gate swallowed it.");
return false;
}
// D20: the panel's own arm clock must keep the gate open on the NEXT frame too, not
// just the spawn frame. KWaveStep zeroes both _UOut and _V outright for any cell
// whose growth is still < 0.6 (ShieldSim.compute), so under the pre-Cut-3 bug
// (_PanelSpawnTime hardcoded to 0f) growth gets reset to 0 by frame 1's own KUpdate
// and frame 2 is gated shut again -- a fresh injection landing on frame 2 can only
// push v MORE negative (compression) than whatever frame 1 left behind; the bug
// instead zeroes it outright. Decay alone (the fix, with no second hit) could only
// shrink |v| toward zero, never make it more negative, so this is a clean
// discriminator between "still gated" and "correctly still open".
float vBeforeSecondHit = v[nearest];
panel.Hit(panel.transform.position, new Vector3(0, 0, -1), 5f);
StepPanel(panel);
var v2 = new float[panel.DebugCellCount];
panel.DebugVField.GetData(v2);
if (!(v2[nearest] < vBeforeSecondHit - 1e-4f))
{
Debug.LogError($"[ShieldPanelCut3Verify] D20 not fixed: a second hit queued for frame 2 did not " +
$"inject (v {vBeforeSecondHit:E3} -> {v2[nearest]:E3}) -- growth likely regressed " +
"back below the gate between frame 1 and frame 2.");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] D17/D20 OK: frame 1 |v|={Mathf.Abs(v[nearest]):E3}, " +
$"frame 2 (after a second queued hit) v={v2[nearest]:E3} -- the gate stayed open.");
return true;
}
finally { DestroyRig(rig); }
}
static int NearestCell(ShieldPanel panel, Vector2 localPoint)
{
int best = 0;
float bestD2 = float.MaxValue;
for (int i = 0; i < panel.DebugCellCount; i++)
{
float d2 = (panel.DebugCellCenter(i) - localPoint).sqrMagnitude;
if (d2 < bestD2) { bestD2 = d2; best = i; }
}
return best;
}
// ==============================================================
// Reuse: a second hit inside a living panel strikes it rather than spawning a new one, and
// the strike actually reaches the material (temper falls), not merely "panel count stayed
// the same" -- a silently dropped second event would pass a panel-count-only check too.
// ==============================================================
static bool CheckReuseStrikesExistingPanel()
{
var rig = BuildRig(maxLive: 4);
try
{
var p1 = new Vector3(0, 0, 20);
var p2 = new Vector3(0.3f, 0, 20); // projects close to p1 -- inside PanelRadius(2)*ReuseFraction(1)
var pFar = new Vector3(20, 0, 0); // projects to (5,0,0) -- ~7 units from (0,0,5), well past the reuse radius
rig.interceptor.Absorb(Absorb(p1, new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 1: expected 1 live panel, got {rig.interceptor.LiveCount}.");
return false;
}
var panel = rig.interceptor.DebugPanelAt(0);
// Step until the struck region is past the growth threshold and the first hit's
// temper erosion has had time to register (growDuration default 0.35s @ 1/60 ~= 21
// frames; erosion is also gated to whichever frames catch the wave in tension --
// KFracture's `if (u <= 0) return` skips compression frames entirely -- so give it
// several oscillation cycles, not just enough frames to grow).
for (int i = 0; i < 40; i++) StepPanel(panel);
// The map's own wording: compare the panel's MINIMUM temper, not one hand-picked
// cell's -- a single cell can sit in a compression phase at the instant we sample
// even though the strike clearly reached the material (KFracture only erodes temper
// on a tension frame), so pinning one cell is a false-negative trap. The minimum
// across the whole panel is what the map's reuse test actually asks for.
float temperAfterFirst = MinTemper(panel);
rig.interceptor.Absorb(Absorb(p2, new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: a nearby second hit spawned a new panel " +
$"(live count {rig.interceptor.LiveCount}) instead of reusing the existing one.");
return false;
}
if (rig.interceptor.DebugPanelAt(0) != panel)
{
Debug.LogError("[ShieldPanelCut3Verify] reuse step 2: live count stayed 1 but the panel identity changed.");
return false;
}
for (int i = 0; i < 40; i++) StepPanel(panel);
float temperAfterSecond = MinTemper(panel);
if (!(temperAfterSecond < temperAfterFirst - 1e-5f))
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: min temper did not fall from the second hit " +
$"({temperAfterFirst:F4} -> {temperAfterSecond:F4}) -- the strike reached the panel " +
"but not the material.");
return false;
}
rig.interceptor.Absorb(Absorb(pFar, new Vector3(-1, 0, 0)));
if (rig.interceptor.LiveCount != 2)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 3: a far strike should spawn a second panel " +
$"(live count {rig.interceptor.LiveCount}, expected 2).");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] reuse OK: temper {temperAfterFirst:F4} -> {temperAfterSecond:F4} on reuse; " +
"a far strike opened a second panel.");
return true;
}
finally { DestroyRig(rig); }
}
// ==============================================================
// Operator's play report (docs/shield-panel-cut.md), playing FieldShieldTest: "clicking
// outside the hex still makes it tremble and shatter". The rule this pins: a hit outside a
// panel's own radius must not reach that panel at all -- no tremble, no temper loss, no
// break -- whether it arrives as a direct Hit() call or is routed there by the interceptor's
// reuse search; the existing panel is left exactly as it was, and a genuinely-outside click
// opens its own panel instead.
// ==============================================================
static bool CheckHitOutsideRadiusDoesNotReachPanel()
{
var rig = BuildRig(maxLive: 4, panelRadius: 2f);
try
{
rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), Vector3.back));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: expected 1 live panel after the first strike, got {rig.interceptor.LiveCount}.");
return false;
}
var panel = rig.interceptor.DebugPanelAt(0);
for (int i = 0; i < 40; i++) StepPanel(panel);
// §2.4/D10's own comment: breakBudgetFraction paces the fracture cascade over many
// frames on purpose, so min temper keeps drifting downward for a while even with zero
// NEW hits -- comparing temper before/after more stepping would be measuring that
// drift, not this rule. What must be verified deterministically instead: no PendingHit
// was ever enqueued for the rejected strike (Hit() returns before Enqueue), and the
// panel's own state buffer does not move at all across a call that does no stepping.
if (PendingCount(panel) != 0)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: pending queue not drained before the probe " +
$"({PendingCount(panel)} left) -- test setup is unreliable.");
return false;
}
float temperBefore = MinTemper(panel);
// Mutation target: ShieldPanel.ContainsWorldPoint / Hit's early-out. A point at local
// (5,0,0) is 5 units from panel-local origin against panelRadius 2 -- well outside.
Vector3 farAsWorld = panel.transform.TransformPoint(new Vector3(5f, 0f, 0f));
panel.Hit(farAsWorld, Vector3.back, 500f);
if (PendingCount(panel) != 0)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a direct Hit() 5 units outside panelRadius " +
$"(2) still enqueued a PendingHit -- Hit() is not gating on ContainsWorldPoint.");
return false;
}
float temperAfterDirectFarHit = MinTemper(panel);
if (temperAfterDirectFarHit != temperBefore)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a rejected Hit() still changed panel state " +
$"({temperBefore:F6} -> {temperAfterDirectFarHit:F6}) with no Update() in between.");
return false;
}
// Mutation target: ShieldInterceptor.FindReusable. A clearly-outside click routed
// through Absorb() must open its own panel, not disturb the existing one -- again
// checked with no intervening StepPanel, so any change can only be this call's doing.
rig.interceptor.Absorb(Absorb(new Vector3(20, 0, 0), Vector3.left));
if (rig.interceptor.LiveCount != 2)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a clearly-outside click should open its own " +
$"panel (live count {rig.interceptor.LiveCount}, expected 2).");
return false;
}
if (rig.interceptor.DebugPanelAt(0) != panel)
{
Debug.LogError("[ShieldPanelCut3Verify] out-of-radius: the original panel's identity changed from an out-of-radius strike.");
return false;
}
if (PendingCount(panel) != 0)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: a far Absorb() enqueued a PendingHit on the " +
"original panel instead of routing to a new one.");
return false;
}
float temperAfterFarAbsorb = MinTemper(panel);
if (temperAfterFarAbsorb != temperBefore)
{
Debug.LogError($"[ShieldPanelCut3Verify] out-of-radius: routing a far Absorb() changed the original " +
$"panel's state ({temperBefore:F6} -> {temperAfterFarAbsorb:F6}) instead of leaving it untouched.");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] out-of-radius OK: a direct Hit() and a routed Absorb() outside " +
$"panelRadius both enqueued nothing on the existing panel (temper held at {temperBefore:F6}), " +
"and the far click opened its own panel.");
return true;
}
finally { DestroyRig(rig); }
}
static float MinTemper(ShieldPanel panel)
{
var raw = new CellStateRaw[panel.DebugCellCount];
panel.DebugStateBuffer.GetData(raw);
float min = float.MaxValue;
foreach (var s in raw) min = Mathf.Min(min, s.temper);
return min;
}
// Mirrors ShieldField.CellState's layout for GraphicsBuffer.GetData -- kept local to the
// probe rather than reusing ShieldField.CellState directly so a future field reorder in the
// production struct (which IS this probe's business to catch) shows up as a stride/offset
// mismatch here rather than the probe silently reading through the same accidental typo.
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
struct CellStateRaw
{
public Vector3 vel; public float spin; public Vector3 pos; public float rot;
public float damage; public float breakTime; public float glow; public float growth;
public float temper; public float released; public float peakTension; public float pad2;
}
// ==============================================================
// Pool exhaustion: MaxLivePanels+3 widely-separated strikes must never exceed the cap, and
// must not drop a strike -- the oldest live panel is recycled in place.
// ==============================================================
static bool CheckPoolExhaustion()
{
const int cap = 3;
var rig = BuildRig(maxLive: cap, panelRadius: 1f);
try
{
// Spread strikes around the equator, far enough apart (PanelRadius*3+) that none reuse.
for (int i = 0; i < cap; i++)
{
float a = i * 137.5f * Mathf.Deg2Rad; // irrational-ish spacing, no two ever coincide
var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a));
rig.interceptor.Absorb(Absorb(dir * 20f, -dir));
}
if (rig.interceptor.LiveCount != cap)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: expected {cap} live panels before exhaustion, got {rig.interceptor.LiveCount}.");
return false;
}
long before = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = cap; i < cap + 3; i++)
{
float a = i * 137.5f * Mathf.Deg2Rad;
var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a));
rig.interceptor.Absorb(Absorb(dir * 20f, -dir));
}
long after = System.GC.GetAllocatedBytesForCurrentThread();
if (rig.interceptor.LiveCount != cap)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: live count is {rig.interceptor.LiveCount} " +
$"after exceeding MaxLivePanels ({cap}); the cap did not hold.");
return false;
}
// Soft budget, not a hard zero: JIT/editor background allocation noise is real, but
// three GraphicsBuffer.SetData-only re-arms plus list bookkeeping should be a small,
// fixed number of bytes, not the kilobytes a Rebuild (or a per-hit array/list
// allocation regression) would cost.
long perStrike = (after - before) / 3;
if (perStrike > 4096)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: ~{perStrike} B allocated per strike after " +
"the pool filled -- §3's 'no allocation at interception' looks violated.");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] exhaustion OK: live count capped at {cap}, ~{perStrike} B/strike once full.");
return true;
}
finally { DestroyRig(rig); }
}
// ==============================================================
static bool CheckNullGuardsDoNotThrow()
{
var go = new GameObject("Cut3Probe_NullGuard");
try
{
var interceptor = go.AddComponent();
try
{
interceptor.Absorb(Absorb(Vector3.forward * 10, Vector3.back));
}
catch (System.Exception ex)
{
Debug.LogError($"[ShieldPanelCut3Verify] null-guard: Absorb with no Envelope/PanelPrototype threw {ex}.");
return false;
}
Debug.Log("[ShieldPanelCut3Verify] null-guard OK: Absorb with no Envelope/PanelPrototype did not throw.");
return true;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
// ==============================================================
// The pooling fix this cut depends on: a panel returned to the pool and reused must NOT
// rebuild its tiling (§2.4's whole 123-2686x argument for prebuilding).
// ==============================================================
static bool CheckPoolReuseDoesNotRebuild()
{
var rig = BuildRig(maxLive: 4);
try
{
rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), Vector3.back));
var panel = rig.interceptor.DebugPanelAt(0);
int rebuildsAfterSpawn = panel.DebugRebuildCount;
// ReturnToPool() is called on the INSTANCE (it hands itself to its own
// _originalPrototype's pool); Instantiate() must be called on the ORIGINAL prototype
// -- Prototype.Instantiate's free list lives on whichever object it's called on,
// and only the original's is ever populated by ReturnToPool.
panel.GetComponent().ReturnToPool();
var reused = rig.interceptor.PanelPrototype.Instantiate();
if (!ReferenceEquals(reused, panel))
{
Debug.LogError("[ShieldPanelCut3Verify] pool-reuse: Instantiate() after ReturnToPool returned a " +
"different instance than the one just returned -- the free list isn't LIFO-of-one as expected.");
return false;
}
if (panel.DebugRebuildCount != rebuildsAfterSpawn)
{
Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: RebuildCount went {rebuildsAfterSpawn} -> " +
$"{panel.DebugRebuildCount} across a ReturnToPool/Instantiate cycle -- OnEnable is " +
"rebuilding on every reuse instead of only once.");
return false;
}
// The check above is necessary but not sufficient: SetActive(false)/(true) is not
// guaranteed to fire OnDisable/OnEnable synchronously within this same call stack in
// every context this probe can run in (observed directly -- the very first spawn's
// OnEnable does not run synchronously inside Object.Instantiate here, which is why
// ResetSim() is self-healing rather than relying on it). So drive OnEnable/OnDisable
// themselves, directly, to pin the rule those methods actually encode regardless of
// when/whether Unity's own SetActive plumbing calls them in a given context.
int rebuildsBeforeDirect = panel.DebugRebuildCount;
PanelOnDisable.Invoke(panel, null);
PanelOnEnable.Invoke(panel, null);
if (panel.DebugRebuildCount != rebuildsBeforeDirect || panel.DebugCellCount == 0)
{
Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: a direct OnDisable()/OnEnable() cycle " +
$"changed RebuildCount ({rebuildsBeforeDirect} -> {panel.DebugRebuildCount}) or " +
$"cleared the tiling (cellCount={panel.DebugCellCount}) -- OnEnable is rebuilding " +
"(or OnDisable is releasing) on every reuse instead of only once.");
return false;
}
Debug.Log("[ShieldPanelCut3Verify] pool-reuse OK: no Rebuild across a return/reuse cycle.");
return true;
}
finally { DestroyRig(rig); }
}
// ==============================================================
// D21: Penrose's achievable cell counts are phi-quantised, but must land within a small
// factor of Hex's at the same radius/cellSize, not an order of magnitude denser.
// ==============================================================
static bool CheckPenroseDensity()
{
const float cellSize = 0.35f;
// The ruling (R5/D21) fixes the MECHANISM -- nearest achievable edge length, not
// nearest achievable Hex-equivalent cell count -- and each Penrose substitution changes
// density by phi^2 (~2.618x), so landing within that step of Hex's count at an arbitrary
// radius/cellSize is already the honest ceiling for "nearest edge length", not a tuning
// knob. Measured after the fix (see the Cut 3 report): 2.12x-4.70x across r in
// {1.5,3,5,10} at cellSize 0.35, against the pre-fix 24x/8x order-of-magnitude bug this
// rule replaces. 5x is comfortably past that measured range and still an order of
// magnitude tighter than what D21 opened with.
const float smallFactor = 5f;
float[] radii = { 1.5f, 3f, 5f, 10f };
bool ok = true;
for (int i = 0; i < radii.Length; i++)
{
float r = radii[i];
var pSoup = Tilings.Generate(TilingKind.PenroseP3, r, cellSize, 1337);
var pMerged = TilingBuilder.MergeRhombs(pSoup);
var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337);
var hSoup = Tilings.Generate(TilingKind.Hex, r, cellSize, 1337);
var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337);
float ratio = (float)pData.CellCount / hData.CellCount;
Debug.Log($"[ShieldPanelCut3Verify] D21 radius={r} cellSize={cellSize} " +
$"PenroseP3(merged) cells={pData.CellCount} Hex cells={hData.CellCount} ratio={ratio:F2}");
if (ratio > smallFactor || ratio < 1f / smallFactor)
{
Debug.LogError($"[ShieldPanelCut3Verify] D21 not within a small factor at radius {r}: " +
$"Penrose/Hex ratio {ratio:F2} is outside [1/{smallFactor:F0}, {smallFactor:F0}].");
ok = false;
}
}
if (ok) Debug.Log("[ShieldPanelCut3Verify] D21 OK: Penrose stays within a small factor of Hex at every radius.");
return ok;
}
}
}