using System; using UnityEditor; using UnityEngine; namespace Aetheria.EditorTools { /// /// Batchmode probe for the shield-panel Cut 2 landing (docs/shield-panel-cut.md): /// ShieldEnvelope collapsing the two implicit ellipsoids (FieldDriver's transform, the Shield /// prefab instance's transform) into one reader. Same reason Cut 1 has one (the map's Q7): /// Tests.asmdef cannot see Assembly-CSharp, so this batchmode probe stands in for NUnit /// coverage. /// /// Cut 2 does not edit any prefab or scene (Q5: the operator rigs prefabs/scenes by hand), so /// this cannot literally load Longinus.prefab/Djinni.prefab and find a ShieldEnvelope on them /// yet. Instead it builds synthetic transforms carrying the exact override values the map's /// §1 table recorded for Longinus and Djinni (scale, position, rotation) plus the real /// Icosphere collider mesh those ships' Shield objects actually use, and runs the map's own /// verification rule against those: ProjectToSurface lands exactly on the ellipsoid, and /// SurfaceNormal matches a finite-difference gradient of the implicit surface to within 1e-3, /// checked on Longinus's 4:1-aspect envelope specifically (a sphere could not distinguish a /// correct normal from a plain normalize(p)). /// /// Run via: Unity -batchmode -projectPath <repo> -executeMethod /// Aetheria.EditorTools.ShieldPanelCut2Verify.Run -quit -logFile <path> /// public static class ShieldPanelCut2Verify { const string Tag = "[ShieldPanelCut2Verify]"; public static void Run() { bool ok = true; GameObject longinus = null, djinni = null, cubesphere = null; try { ok &= CheckIcosphereExtents(); longinus = BuildEnvelope( "LonginusShieldProbe", new Vector3(395.3487f, 235.47029f, 990.37805f), new Vector3(0f, 0.73f, 1.32f), Quaternion.identity, "Assets/Models/Icosphere.fbx"); djinni = BuildEnvelope( "DjinniShieldProbe", new Vector3(857.9317f, 424.00012f, 1036.2562f), new Vector3(0f, 1.75f, -1.06f), new Quaternion(0.08715578f, 0f, 0f, 0.9961947f), "Assets/Models/Icosphere.fbx"); cubesphere = BuildEnvelope( "FieldShieldTestProbe", new Vector3(4f, 3f, 12f), Vector3.zero, Quaternion.identity, null); // no mesh -- matches the cubesphere's collider extents of (1,1,1) exactly ok &= CheckEllipsoidSurface(longinus, "Longinus"); ok &= CheckEllipsoidSurface(djinni, "Djinni"); ok &= CheckEllipsoidSurface(cubesphere, "FieldShieldTest cubesphere"); ok &= CheckNormalMatchesGradient(longinus, "Longinus"); ok &= CheckNormalMatchesGradient(djinni, "Djinni"); // The sphere-vs-ellipsoid distinction the map's whole Cut 2 rests on: on // Longinus's 4:1 envelope, the true normal and normalize(p) must visibly differ. ok &= CheckNormalDiffersFromNormalizeP(longinus, "Longinus"); // Radii must reflect the measured mesh factor (per-axis; the Icosphere is not a // perfect cube of extents -- see IcosphereExtents above), not a guessed flat 0.5. ok &= CheckRadiiUsesMeasuredExtents(longinus, new Vector3(395.3487f, 235.47029f, 990.37805f), IcosphereExtents, "Longinus"); ok &= CheckRadiiUsesMeasuredExtents(cubesphere, new Vector3(4f, 3f, 12f), Vector3.one, "FieldShieldTest cubesphere"); // Pin the fallback: a carrier with NO ShieldEnvelope component must produce exactly // the pre-Cut-2 hit direction/position, for both FieldDriver's and ShieldManager's // formulas. This is what a Longinus/Djinni prefab looks like before the operator // adds the component (Q5) -- the fallback is not a courtesy, it is load-bearing for // every ship in the game until then. ok &= CheckLegacyFallbackFormulas(); } catch (Exception e) { Debug.LogError($"{Tag} exception: {e}"); ok = false; } finally { if (longinus != null) UnityEngine.Object.DestroyImmediate(longinus); if (djinni != null) UnityEngine.Object.DestroyImmediate(djinni); if (cubesphere != null) UnityEngine.Object.DestroyImmediate(cubesphere); } Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above"); EditorApplication.Exit(ok ? 0 : 1); } // Measured once via a throwaway batchmode probe (deleted): the Icosphere collider mesh's // bounds.extents at import scale. Only y and z happen to land on exactly 0.01 -- x is // slightly smaller (a base icosahedron's vertices are not all equidistant from its own AABB // faces on every axis, so its bounding box is not a perfect cube even though the shape is // "spherical"). This is exactly why ShieldEnvelope reads the mesh's actual bounds rather // than assuming a round number on every axis: the real collider is not perfectly isotropic. static readonly Vector3 IcosphereExtents = new Vector3(0.0095106f, 0.01f, 0.01f); static bool CheckIcosphereExtents() { var mesh = AssetDatabase.LoadAssetAtPath("Assets/Models/Icosphere.fbx"); if (mesh == null) { Debug.LogError($"{Tag} Icosphere.fbx mesh not found."); return false; } var e = mesh.bounds.extents; bool ok = Vector3.Distance(e, IcosphereExtents) < 1e-4f; if (!ok) Debug.LogError($"{Tag} Icosphere.fbx bounds.extents is {e:F7}, expected {IcosphereExtents:F7} " + "(measured once via batchmode; if the asset legitimately changed, update this constant " + "and re-derive ShieldEnvelope's documented factor)."); else Debug.Log($"{Tag} Icosphere.fbx bounds.extents={e:F7} (as measured)."); return ok; } static GameObject BuildEnvelope(string name, Vector3 scale, Vector3 position, Quaternion rotation, string meshPath) { var go = new GameObject(name); go.transform.position = position; go.transform.rotation = rotation; go.transform.localScale = scale; if (meshPath != null) { var mesh = AssetDatabase.LoadAssetAtPath(meshPath); var mc = go.AddComponent(); mc.sharedMesh = mesh; } go.AddComponent(); return go; } static bool CheckEllipsoidSurface(GameObject envelopeObj, string label) { var envelope = envelopeObj.GetComponent(); var center = envelope.Center; var radii = envelope.Radii; bool ok = true; var rng = new System.Random(1337); for (int i = 0; i < 20; i++) { var probe = center + new Vector3( (float)(rng.NextDouble() * 4 - 2) * radii.x, (float)(rng.NextDouble() * 4 - 2) * radii.y, (float)(rng.NextDouble() * 4 - 2) * radii.z); if (probe == center) probe += Vector3.right * 0.001f; var surface = envelope.ProjectToSurface(probe); var local = envelopeObj.transform.InverseTransformPoint(surface); var extents = LocalExtentsOf(envelopeObj); double sum = Sq(local.x / extents.x) + Sq(local.y / extents.y) + Sq(local.z / extents.z); if (Math.Abs(sum - 1.0) > 1e-3) { Debug.LogError($"{Tag} {label}: ProjectToSurface point {i} is off the ellipsoid, sum(p_i/r_i)^2={sum:F6} (want 1)."); ok = false; } } if (ok) Debug.Log($"{Tag} {label}: 20/20 ProjectToSurface probes land on the ellipsoid."); return ok; } static bool CheckNormalMatchesGradient(GameObject envelopeObj, string label) { var envelope = envelopeObj.GetComponent(); bool ok = true; var rng = new System.Random(2026); const float h = 1e-3f; for (int i = 0; i < 10; i++) { var dir = new Vector3((float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f).normalized; var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(dir, envelope.Radii * 3f); var surface = envelope.ProjectToSurface(probe); var analytic = envelope.SurfaceNormal(surface); // Finite-difference gradient of F(x) = sum((local_i)/extents_i)^2 - 1 in world space. var extents = LocalExtentsOf(envelopeObj); Func f = worldP => { var l = envelopeObj.transform.InverseTransformPoint(worldP); return Sq(l.x / extents.x) + Sq(l.y / extents.y) + Sq(l.z / extents.z); }; var grad = new Vector3( (float)((f(surface + Vector3.right * h) - f(surface - Vector3.right * h)) / (2 * h)), (float)((f(surface + Vector3.up * h) - f(surface - Vector3.up * h)) / (2 * h)), (float)((f(surface + Vector3.forward * h) - f(surface - Vector3.forward * h)) / (2 * h))); var fd = grad.normalized; var dot = Vector3.Dot(analytic, fd); if (dot < 1 - 1e-3f) { Debug.LogError($"{Tag} {label}: SurfaceNormal probe {i} disagrees with the finite-difference " + $"gradient, dot={dot:F6} (want ~1). analytic={analytic} fd={fd}"); ok = false; } } if (ok) Debug.Log($"{Tag} {label}: 10/10 SurfaceNormal probes match the finite-difference gradient within 1e-3."); return ok; } static bool CheckNormalDiffersFromNormalizeP(GameObject envelopeObj, string label) { var envelope = envelopeObj.GetComponent(); // normalize(p) and the true gradient normal coincide at every point lying exactly on a // principal axis (a pole) -- both directions collapse to that axis there, for any radii. // They diverge everywhere else, more so the more the axes differ. Longinus's radii are // ~3.95/2.35/9.90 (x/y/z), so a direction mixing the short x axis and the long z axis, // off every pole, is where the two disagree most. var localDir = new Vector3(0.8f, 0f, 0.6f).normalized; var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(localDir, envelope.Radii * 3f); var surface = envelope.ProjectToSurface(probe); var trueNormal = envelope.SurfaceNormal(surface); var wrongNormal = (surface - envelope.Center).normalized; var dot = Vector3.Dot(trueNormal, wrongNormal); // On this envelope the two are safely coincident only at the poles; off-axis they must // differ by more than a token amount, or SurfaceNormal has silently degenerated into // normalize(p) again. bool ok = dot < 0.999f; if (!ok) Debug.LogError($"{Tag} {label}: SurfaceNormal equals normalize(p) (dot={dot:F6}) on a 4:1 " + "ellipsoid's flank -- this is the exact wrong-answer mutation the map warns about."); else Debug.Log($"{Tag} {label}: SurfaceNormal correctly diverges from normalize(p) on the flank (dot={dot:F6})."); return ok; } static bool CheckRadiiUsesMeasuredExtents(GameObject envelopeObj, Vector3 lossyScale, Vector3 extentFactor, string label) { var envelope = envelopeObj.GetComponent(); var expected = Vector3.Scale(lossyScale, extentFactor); var actual = envelope.Radii; bool ok = Vector3.Distance(expected, actual) < 1e-2f; if (!ok) Debug.LogError($"{Tag} {label}: Radii={actual:F6} does not match lossyScale*measuredExtents={expected:F6}. " + $"actualLossyScale={envelopeObj.transform.lossyScale:F6} localScale={envelopeObj.transform.localScale:F6}"); else Debug.Log($"{Tag} {label}: Radii={actual:F6} matches lossyScale*measuredExtents."); return ok; } /// /// Pins ShieldEnvelope's two static fallback entry points (SurfaceDirection, /// LegacyLocalSurfacePoint) against an INDEPENDENT re-derivation of the exact pre-Cut-2 /// formulas -- not a call into the production code under test -- on a carrier with no /// ShieldEnvelope component and a non-trivial transform (nonzero position, non-identity /// rotation), so the check is not vacuous the way it would be at the origin with identity /// rotation. This is the regression the coordinator flagged: ShieldManager's fallback used /// to silently change behaviour (hard-fail to Vector3.forward) instead of reproducing the /// deleted formula, which would have shown every ship's shield hits in the wrong place /// until the operator hand-edits Longinus.prefab/Djinni.prefab. /// static bool CheckLegacyFallbackFormulas() { var go = new GameObject("LegacyFallbackProbe"); bool ok = true; try { go.transform.position = new Vector3(5f, -2f, 13f); go.transform.rotation = Quaternion.Euler(15f, 40f, -8f); go.transform.localScale = new Vector3(4f, 3f, 12f); // Deliberately no MeshCollider/MeshFilter/ShieldEnvelope on this object -- this is // exactly what Longinus.prefab's "Shield" object looks like before the operator // rigs it (Q5), and what FieldShieldTest's cubesphere looked like before Cut 2. var probePoints = new[] { go.transform.position + new Vector3(10f, 2f, -6f), go.transform.position + new Vector3(-3f, 8f, 20f), go.transform.position + new Vector3(0.4f, -0.1f, 0.2f), }; foreach (var worldPoint in probePoints) { // Independent re-derivation of ShieldManager's deleted formula // (normalize(shield.transform.InverseTransformPoint(point))). var expectedDirection = go.transform.InverseTransformPoint(worldPoint).normalized; var actualDirection = ShieldEnvelope.SurfaceDirection(go.transform, worldPoint); if (Vector3.Distance(expectedDirection, actualDirection) > 1e-5f) { Debug.LogError($"{Tag} SurfaceDirection(transform, p) fallback diverged from " + $"ShieldManager's deleted formula: expected={expectedDirection:F6} actual={actualDirection:F6}"); ok = false; } // Independent re-derivation of FieldDriver.AddHit's deleted formula // (normalize(InverseTransformPoint(p)) * transform.localScale). var expectedLocalSurface = Vector3.Scale( go.transform.InverseTransformPoint(worldPoint).normalized, go.transform.localScale); var actualLocalSurface = ShieldEnvelope.LegacyLocalSurfacePoint(go.transform, worldPoint); if (Vector3.Distance(expectedLocalSurface, actualLocalSurface) > 1e-3f) { Debug.LogError($"{Tag} LegacyLocalSurfacePoint(transform, p) fallback diverged from " + $"FieldDriver's deleted formula: expected={expectedLocalSurface:F6} actual={actualLocalSurface:F6}"); ok = false; } } } finally { UnityEngine.Object.DestroyImmediate(go); } if (ok) Debug.Log($"{Tag} Legacy fallback statics (SurfaceDirection, LegacyLocalSurfacePoint) match " + "the pre-Cut-2 formulas exactly, for a carrier with no ShieldEnvelope component and a " + "non-trivial transform."); return ok; } static Vector3 LocalExtentsOf(GameObject envelopeObj) { var meshCollider = envelopeObj.GetComponent(); if (meshCollider != null && meshCollider.sharedMesh != null) return meshCollider.sharedMesh.bounds.extents; return Vector3.one; } static double Sq(double x) => x * x; } }