From a454a5a77f6d36ce5aeaad461c505f02ffb06f10 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 18 Sep 2026 06:33:38 +0200 Subject: [PATCH] Collapse the two implicit shield ellipsoids into ShieldEnvelope (Cut 2) FieldDriver derived the shield ellipsoid from its own transform.localScale; ShieldManager derived it independently from the Shield prefab instance's transform, doing the same normalise-and-project by hand against a different object. Add ShieldEnvelope as the one reader of a transform's centre, radii, surface projection, and true outward normal (derived, not authored, per R6 -- no second copy of numbers the physical MeshCollider already owns), and switch both consumers onto it, deleting their hand-rolled projections. Radii read the attached collider mesh's own bounds.extents per axis rather than assuming a flat 0.5 or 1.0: Shield.prefab's Icosphere collider measures (0.0095106, 0.01, 0.01), not a uniform round number, and the cubesphere's collider measures exactly (1,1,1) -- confirmed by a throwaway batchmode probe against the actual mesh assets, not guessed. SurfaceNormal is not normalize(p): a non-uniform ellipsoid's true normal is the gradient of the implicit surface, transformed by rotation and the INVERSE of lossyScale (a normal is a covector). The first implementation used TransformDirection (rotation and forward scale) and the batchmode probe's finite-difference gradient check caught it immediately -- exactly the plausible-looking-wrong-answer failure mode the cut map called out by name. Both original consumers keep normalize(p)-equivalent behaviour via the new SurfaceDirection method, since they only ever wanted a direction. FieldDriver falls back to its pre-cut formulas (logged once) when no ShieldEnvelope is present, since it has no guaranteed carrier yet. ShieldManager does not: every object that carries it gets an envelope in this cut, so a missing one logs an error and degrades to a fixed direction rather than recomputing a second projection. No prefab or scene file is touched here (Q5: the operator rigs prefabs and scenes by hand); ShieldPanelCut2Verify's batchmode probe instead builds synthetic transforms carrying Longinus's and Djinni's actual override values plus the real Icosphere mesh, and proves ProjectToSurface lands exactly on the ellipsoid and SurfaceNormal matches a finite-difference gradient to 1e-3. mutation_tests_shield_panel_cut2.py mutates SurfaceNormal three ways (back to normalize(p), back to TransformDirection, and a flattened 0.5 extents factor) and confirms the probe goes red for each, naming the reason. Co-Authored-By: Claude Opus 5 --- .../Scripts/Editor/ShieldPanelCut2Verify.cs | 251 ++++++++++++++++++ .../Editor/ShieldPanelCut2Verify.cs.meta | 2 + Assets/Scripts/Gameplay/FieldDriver.cs | 42 ++- Assets/Scripts/Gameplay/ShieldEnvelope.cs | 139 ++++++++++ .../Scripts/Gameplay/ShieldEnvelope.cs.meta | 2 + Assets/Scripts/Gameplay/ShieldManager.cs | 38 ++- tests/mutation_tests_shield_panel_cut2.py | 162 +++++++++++ 7 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 Assets/Scripts/Editor/ShieldPanelCut2Verify.cs create mode 100644 Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta create mode 100644 Assets/Scripts/Gameplay/ShieldEnvelope.cs create mode 100644 Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta create mode 100644 tests/mutation_tests_shield_panel_cut2.py diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs new file mode 100644 index 000000000..9557a21c2 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs @@ -0,0 +1,251 @@ +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"); + } + 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; + } + + 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; + } +} diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta new file mode 100644 index 000000000..6adb5a959 --- /dev/null +++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc6b6136927b78c45a78cfc0694ce93a \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/FieldDriver.cs b/Assets/Scripts/Gameplay/FieldDriver.cs index 3ca0927cb..7dee8de47 100644 --- a/Assets/Scripts/Gameplay/FieldDriver.cs +++ b/Assets/Scripts/Gameplay/FieldDriver.cs @@ -75,6 +75,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel private Material _field; private ComputeBuffer _hitBuffer; private List _hits = new List(); + private ShieldEnvelope _envelope; + private bool _loggedMissingEnvelope; private float _waveOffset; private float _meleeTime; private bool _meleeActive; @@ -115,14 +117,42 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel _collider = GetComponent(); _field = GetComponent().material; _hitBuffer = new ComputeBuffer(MaxHits, 32); + _envelope = GetComponent(); + } + + // Cut 2 of docs/shield-panel-cut.md: FieldDriver no longer owns the ellipsoid envelope -- + // ShieldEnvelope does, and this transform's localScale is display geometry for the field mesh + // only now. Absent an envelope (compatibility path only; every real carrier gets one in this + // cut), fall back to the pre-Cut-2 formulas and log once rather than silently keeping a second + // opinion about the envelope. + private void WarnMissingEnvelopeOnce() + { + if (_loggedMissingEnvelope) return; + _loggedMissingEnvelope = true; + Debug.LogWarning("FieldDriver has no ShieldEnvelope; falling back to its own transform for the envelope.", this); + } + + private Vector3 EnvelopeRadiiOrFallback() + { + if (_envelope != null) return _envelope.Radii; + WarnMissingEnvelopeOnce(); + return transform.localScale; } public void AddHit(float3 position, float3 direction, float magnitude) { if (_hits.Count >= MaxHits) return; + Vector3 surfacePosition; + if (_envelope != null) + surfacePosition = _envelope.ProjectToSurface(position.ToUnity()); + else + { + WarnMissingEnvelopeOnce(); + surfacePosition = (normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath()).ToUnity(); + } var hit = new FieldHit { - Position = normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath(), + Position = surfacePosition.ToCultMath(), Direction = normalize((transform.rotation * direction.ToUnity()).ToCultMath()), Magnitude = magnitude, Time = 0 @@ -198,7 +228,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel _field.SetFloat("_TwistFront", FrontTwist); _field.SetFloat("_TwistRear", RearTwist); - _field.SetVector("_InverseScale", new Vector4(1/transform.localScale.x,1/transform.localScale.y,1/transform.localScale.z)); + var envelopeRadii = EnvelopeRadiiOrFallback(); + _field.SetVector("_InverseScale", new Vector4(1/envelopeRadii.x,1/envelopeRadii.y,1/envelopeRadii.z)); _field.SetMatrix("_ReflRotate", refractionRotation); for (int i = 0; i < _hits.Count; i++) @@ -294,9 +325,10 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel default: throw new ArgumentOutOfRangeException(); } - _field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, transform.localScale)); - _field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), transform.localScale)); - _field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), transform.localScale)); + var tendrilRadii = EnvelopeRadiiOrFallback(); + _field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, tendrilRadii)); + _field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), tendrilRadii)); + _field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), tendrilRadii)); } } } diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs b/Assets/Scripts/Gameplay/ShieldEnvelope.cs new file mode 100644 index 000000000..1d88d1b1a --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs @@ -0,0 +1,139 @@ +using UnityEngine; + +// Cut 2 of docs/shield-panel-cut.md (R4/R6): the one owner of "where is the shield surface, and +// which way does it face". Before this component existed, that answer was computed twice and +// independently -- FieldDriver.cs derived it from its own transform.localScale, and +// ShieldManager.cs derived it from the Shield prefab instance's transform -- with no shared +// authority and no way for the two to notice if they disagreed. This collapses both into one +// reader of a single transform. It stores nothing; every value below is derived fresh. +public class ShieldEnvelope : MonoBehaviour +{ + // R6: the envelope is derived from the existing transform, not authored as separate centre + + // radii fields, because the transform already drives the physical collider (Shield.prefab's + // MeshCollider / FieldShieldTest's cubesphere MeshCollider) -- authoring a second copy next to + // it would be exactly the kind of duplicate authority this cut exists to remove. + // + // The one number a bare transform can't supply is the conversion from "lossyScale" to + // "ellipsoid semi-axis length in world units", because that depends on how big the attached + // collider mesh already is in local space. The map's draft guessed a flat 0.5 (unit-diameter + // sphere); measuring the two meshes actually in the tree (batchmode probe, both meshes' + // MeshCollider.sharedMesh.bounds) found neither is 0.5: + // - Shield.prefab's Icosphere collider: bounds.extents = (0.0095106, 0.01, 0.01) -- not a + // perfect cube, because a base icosahedron's vertices are not all equidistant from its own + // axis-aligned bounding box on every axis even though the shape is "spherical". Shield.prefab's + // base localScale of 100 is there to cancel the round number (100 * 0.01 = radius 1 on y/z), + // which is why the ship overrides (e.g. Longinus ~395,235,990) read directly as ~100x the + // documented "effective radii" in docs/shield-panel-cut.md's table without this factor, and + // why that factor must be read per-axis from the mesh rather than assumed uniform. + // - The FieldShieldTest cubesphere's collider: bounds.extents = (1, 1, 1) exactly, so its + // lossyScale (4, 3, 12) already *is* the radii with no correction -- which is why + // FieldDriver's pre-Cut-2 code could get away with using transform.localScale directly. + // Reading the mesh instead of hardcoding either number is what makes one component correct for + // both authorities without either one special-casing the other. + Vector3 _localExtents = Vector3.one; + bool _readExtents; + + void Awake() => ReadLocalExtents(); + + void ReadLocalExtents() + { + if (_readExtents) return; + _readExtents = true; + + Mesh mesh = null; + var meshCollider = GetComponent(); + if (meshCollider != null) mesh = meshCollider.sharedMesh; + if (mesh == null) + { + var meshFilter = GetComponent(); + if (meshFilter != null) mesh = meshFilter.sharedMesh; + } + + // No mesh at all: nothing to correct for, so the transform's own scale is already the + // radii (extents of 1 is a no-op multiplier). This is the compatibility case, not the + // expected one -- every carrier in the tree today (Shield.prefab, the cubesphere) has one. + _localExtents = mesh != null ? mesh.bounds.extents : Vector3.one; + } + + /// World-space centre of the ellipsoid. + public Vector3 Center => transform.position; + + /// World-space semi-axis lengths of the ellipsoid, accounting for the attached + /// collider mesh's own local-space size (see the field comment above). + public Vector3 Radii + { + get + { + if (!_readExtents) ReadLocalExtents(); // covers edit-mode / pre-Awake callers + var s = transform.lossyScale; + return new Vector3(s.x * _localExtents.x, s.y * _localExtents.y, s.z * _localExtents.z); + } + } + + // Local-space unit direction from the envelope's centre toward worldPoint, accounting for the + // transform's rotation and (non-uniform) scale. This is the one primitive both ProjectToSurface + // and SurfaceDirection are built from, and the only place InverseTransformPoint may appear for + // shield-surface purposes (see the negative check in docs/shield-panel-cut.md Cut 2). + Vector3 LocalUnitDirection(Vector3 worldPoint) + { + var local = transform.InverseTransformPoint(worldPoint); + return local.sqrMagnitude > 1e-12f ? local.normalized : Vector3.forward; + } + + /// + /// Projects a world-space point outward onto the ellipsoid surface, returning a world-space + /// point. This is the true surface projection: local direction, scale by the local-space + /// extents, transform back through the object's rotation and scale. + /// + public Vector3 ProjectToSurface(Vector3 worldPoint) + { + if (!_readExtents) ReadLocalExtents(); + var dir = LocalUnitDirection(worldPoint); + var localSurface = Vector3.Scale(dir, _localExtents); + return transform.TransformPoint(localSurface); + } + + /// + /// The true outward normal of the ellipsoid at a world-space point already on (or near) its + /// surface: normalize(p.x/a^2, p.y/b^2, p.z/c^2) in local space, then rotated back to world. + /// This is deliberately NOT normalize(p) -- normalize(p) is only the correct normal for a + /// sphere. On a non-uniform ellipsoid (Longinus is ~4:1) it is visibly wrong: it points along + /// the radius vector rather than perpendicular to the surface, which reads as the panel tilting + /// toward the ship's centre instead of lying flat against the hull (docs/shield-panel-cut.md + /// Cut 2 and the Cut 4 flank-click check). FieldDriver and ShieldManager both get away with + /// normalize(p) today only because they use it to pick a *direction*, not a *normal* -- see + /// SurfaceDirection below, which is what they keep using. + /// + public Vector3 SurfaceNormal(Vector3 worldSurfacePoint) + { + if (!_readExtents) ReadLocalExtents(); + var local = transform.InverseTransformPoint(worldSurfacePoint); + var a = Mathf.Max(_localExtents.x, 1e-6f); + var b = Mathf.Max(_localExtents.y, 1e-6f); + var c = Mathf.Max(_localExtents.z, 1e-6f); + var gradient = new Vector3(local.x / (a * a), local.y / (b * b), local.z / (c * c)); + // A normal is a covector: under a non-uniform scale it transforms by the INVERSE of the + // scale, then the rotation -- the opposite of how an ordinary vector (or TransformDirection, + // which multiplies by scale) transforms. Dividing by lossyScale here and rotating with the + // bare quaternion (no TransformDirection) is what makes this differ from normalize(p) on a + // non-uniform ellipsoid; using TransformDirection directly was tried and measurably wrong + // (see the batchmode probe in docs/shield-panel-cut.md Cut 2 -- it disagreed with a + // finite-difference gradient by 5-20%, not floating-point noise). + var s = transform.lossyScale; + var gradientOverScale = new Vector3( + s.x != 0 ? gradient.x / s.x : gradient.x, + s.y != 0 ? gradient.y / s.y : gradient.y, + s.z != 0 ? gradient.z / s.z : gradient.z); + var worldGradient = transform.rotation * gradientOverScale; + return worldGradient.sqrMagnitude > 1e-12f ? worldGradient.normalized : transform.forward; + } + + /// + /// The local-frame unit direction from the envelope's centre toward worldPoint -- exactly what + /// FieldDriver.AddHit and ShieldManager.ShowHit computed by hand before this cut, kept as its + /// own named method (rather than inlining InverseTransformPoint at each call site) so the + /// negative check ("no shield-surface projection outside ShieldEnvelope.cs") stays true. This + /// is a *direction*, not the true surface normal -- see the SurfaceNormal doc comment. + /// + public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(worldPoint); +} diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta new file mode 100644 index 000000000..d247e6050 --- /dev/null +++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e25c67ee83c01364fb121caeda952606 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 853e17c01..6d4623b86 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -11,9 +11,17 @@ public class ShieldManager : MonoBehaviour { public Prototype ShieldPrototype; public float CollisionHitDuration = 3; - + public Entity Entity { get; set; } + private ShieldEnvelope _envelope; + private bool _loggedMissingEnvelope; + + void Awake() + { + _envelope = GetComponent(); + } + private void OnCollisionEnter(Collision other) { var otherShield = other.collider.GetComponent(); @@ -73,7 +81,33 @@ public class ShieldManager : MonoBehaviour public void ShowHit(Vector3 point, float duration) { var shield = ShieldPrototype.Instantiate(); - shield.Direction = normalize(shield.transform.InverseTransformPoint(point).ToCultMath()).ToUnity(); + // Cut 2 of docs/shield-panel-cut.md: this used to compute + // normalize(shield.transform.InverseTransformPoint(point)) itself, duplicating + // FieldDriver's projection against a different transform (Authority B vs A in the map's + // §1). shield.transform sits at the same place as this ShieldManager's own transform (the + // Shield prefab instance's pooled visual is parented under it with an identity local + // offset), so this ShieldManager's ShieldEnvelope is the same envelope. Direction is a pick + // of direction, not the true surface normal -- ShieldAnimation.Direction keeps its existing + // meaning (R6's note that both existing consumers get away with normalize(p) because they + // want a direction, not a normal). + // Unlike FieldDriver (a compatibility path is explicit in the map for that consumer), + // ShieldManager has no legitimate reason to be missing its envelope -- Cut 2 adds + // ShieldEnvelope to every "Shield" object that carries a ShieldManager. Recomputing the + // projection here "just in case" would recreate the exact duplicate authority this cut + // removes, so a missing envelope logs loudly and degrades to a fixed direction instead. + if (_envelope == null) + { + if (!_loggedMissingEnvelope) + { + Debug.LogError("ShieldManager has no ShieldEnvelope; the shield hit direction cannot be computed.", this); + _loggedMissingEnvelope = true; + } + shield.Direction = Vector3.forward; + } + else + { + shield.Direction = _envelope.SurfaceDirection(point); + } shield.Duration = duration; } } diff --git a/tests/mutation_tests_shield_panel_cut2.py b/tests/mutation_tests_shield_panel_cut2.py new file mode 100644 index 000000000..4e2716521 --- /dev/null +++ b/tests/mutation_tests_shield_panel_cut2.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Mutation tests for docs/shield-panel-cut.md Cut 2 (the ShieldEnvelope landing). + +Assets/Scripts/Editor/ShieldPanelCut2Verify.cs is the batchmode probe Cut 2 added, for the same +reason Cut 1 has one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp, so ShieldEnvelope +(pure, testable math though it is) has no NUnit path. A probe nobody can break is a probe nobody +can trust, so this mutates the *real* source in specific ways the cut's own text calls out as the +plausible wrong answers, runs ShieldPanelCut2Verify.Run in Unity batchmode, asserts it goes red +for the reason the cut names, then restores the source exactly. A control case first proves the +unmutated tree passes. + +The mutation this file exists for is the one the map calls out by name: "the ellipsoid normal is +exactly the kind of arithmetic that has a plausible-looking wrong answer" — SurfaceNormal +collapsing to normalize(p), which is only correct for a sphere and silently wrong (but +plausible-looking) for Longinus's 4:1 ellipsoid. Two more mutations cover the other two +correctness-load-bearing lines this cut introduced: the per-axis mesh-extents factor collapsing +to a flat guess, and the covector (inverse-scale) transform collapsing to the more "obvious" but +wrong TransformDirection. + +This is a separate script rather than an addition to tests/mutation_tests.py (the item-provenance +cut's headless dotnet-test runner, with no path to driving a Unity batchmode check) or +tests/mutation_tests_shield_panel_cut1.py (that one owns Cut 1's rules; this one touches only +Cut 2's file, ShieldEnvelope.cs). + +Run from the repo root: python tests/mutation_tests_shield_panel_cut2.py +Exit code 0 only if every case behaved as Cut 2's verification section demands. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" + +SHIELD_ENVELOPE_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldEnvelope.cs" + + +def run_probe(log_path: Path) -> tuple[int, str]: + proc = subprocess.run( + [UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT), + "-executeMethod", "Aetheria.EditorTools.ShieldPanelCut2Verify.Run", "-logFile", str(log_path)], + timeout=600, + ) + text = log_path.read_text(encoding="utf-8", errors="replace") + return proc.returncode, text + + +def replace_unique(path: Path, anchor: str, mutated: str) -> str: + original = path.read_text(encoding="utf-8") + count = original.count(anchor) + if count != 1: + raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") + path.write_text(original.replace(anchor, mutated), encoding="utf-8") + return original + + +def main() -> int: + scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut2" + scratch.mkdir(parents=True, exist_ok=True) + failures: list[str] = [] + + original_envelope = SHIELD_ENVELOPE_CS.read_text(encoding="utf-8") + + try: + # --- control: the real, unmutated tree must pass --- + print("\n=== control: unmutated tree ===") + code, text = run_probe(scratch / "control.log") + if code != 0 or "[ShieldPanelCut2Verify] PASS" not in text: + failures.append("control: expected exit 0 and PASS on the unmutated tree") + else: + print("control OK: exit 0, PASS") + + # --- the mutation the map calls out by name: SurfaceNormal collapsing to normalize(p). + # This is the "plausible-looking wrong answer" — correct for a sphere, silently wrong for + # a non-uniform ellipsoid — that the map's Cut 2 verification section says must have a + # test that fails under its own mutation. --- + print("\n=== mutation: SurfaceNormal degenerates to normalize(p) ===") + anchor = ( + " var s = transform.lossyScale;\n" + " var gradientOverScale = new Vector3(\n" + " s.x != 0 ? gradient.x / s.x : gradient.x,\n" + " s.y != 0 ? gradient.y / s.y : gradient.y,\n" + " s.z != 0 ? gradient.z / s.z : gradient.z);\n" + " var worldGradient = transform.rotation * gradientOverScale;" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: reintroduce normalize(p) as the normal —\n" + " // correct for a sphere, plausible-looking, wrong for a non-uniform ellipsoid.\n" + " var worldGradient = transform.TransformDirection(local);" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "normalize_p.log") + caught = "SurfaceNormal equals normalize(p)" in text or "disagrees with the finite-difference gradient" in text + if code == 0 or not caught: + failures.append("normalize(p) regression: probe did not go red naming the sphere-vs-ellipsoid distinction") + else: + print("OK: caught — probe exited non-zero, sphere-vs-ellipsoid distinction check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the covector-transform bug this cut's own Hands run hit and fixed: dividing by scale + # is required (a normal is a covector), multiplying (TransformDirection) is the "obvious" + # but wrong choice for a non-uniform scale. --- + print("\n=== mutation: SurfaceNormal multiplies by scale instead of dividing (TransformDirection) ===") + anchor = ( + " var s = transform.lossyScale;\n" + " var gradientOverScale = new Vector3(\n" + " s.x != 0 ? gradient.x / s.x : gradient.x,\n" + " s.y != 0 ? gradient.y / s.y : gradient.y,\n" + " s.z != 0 ? gradient.z / s.z : gradient.z);\n" + " var worldGradient = transform.rotation * gradientOverScale;" + ) + mutated = ( + " // mutation_tests_shield_panel_cut2.py: the \"obvious\" wrong transform — a normal\n" + " // is a covector and must be scaled by the INVERSE of lossyScale, not lossyScale.\n" + " var worldGradient = transform.TransformDirection(gradient);" + ) + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "transformdirection.log") + caught = "disagrees with the finite-difference gradient" in text + if code == 0 or not caught: + failures.append("TransformDirection regression: probe did not go red naming the finite-difference disagreement") + else: + print("OK: caught — probe exited non-zero, finite-difference gradient check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + # --- the mesh-extents factor collapsing to a flat, wrong guess (the map's own draft + # guessed 0.5; this checks the fix stays measured-per-axis, not re-flattened). --- + print("\n=== mutation: Radii factor flattened to a guessed 0.5 instead of the measured mesh extents ===") + anchor = "_localExtents = mesh != null ? mesh.bounds.extents : Vector3.one;" + mutated = "_localExtents = mesh != null ? new Vector3(0.5f, 0.5f, 0.5f) : Vector3.one;" + try: + replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated) + code, text = run_probe(scratch / "flat_half.log") + caught = "does not match lossyScale*measuredExtents" in text + if code == 0 or not caught: + failures.append("flat-0.5 regression: probe did not go red naming the Radii mismatch") + else: + print("OK: caught — probe exited non-zero, Radii mismatch check fired") + finally: + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + finally: + # belt-and-suspenders: never leave a mutated file behind even if something above threw + SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8") + + print("\n=== summary ===") + if failures: + for f in failures: + print(f"FAIL: {f}") + return 1 + print(f"All cases behaved as Cut 2's verification section demands. Logs: {scratch}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())