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 <noreply@anthropic.com>
This commit is contained in:
parent
808692b002
commit
a454a5a77f
7 changed files with 629 additions and 7 deletions
251
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
Normal file
251
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Aetheria.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// </summary>
|
||||
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<Mesh>("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<Mesh>(meshPath);
|
||||
var mc = go.AddComponent<MeshCollider>();
|
||||
mc.sharedMesh = mesh;
|
||||
}
|
||||
go.AddComponent<ShieldEnvelope>();
|
||||
return go;
|
||||
}
|
||||
|
||||
static bool CheckEllipsoidSurface(GameObject envelopeObj, string label)
|
||||
{
|
||||
var envelope = envelopeObj.GetComponent<ShieldEnvelope>();
|
||||
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<ShieldEnvelope>();
|
||||
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<Vector3, double> 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<ShieldEnvelope>();
|
||||
// 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<ShieldEnvelope>();
|
||||
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<MeshCollider>();
|
||||
if (meshCollider != null && meshCollider.sharedMesh != null)
|
||||
return meshCollider.sharedMesh.bounds.extents;
|
||||
return Vector3.one;
|
||||
}
|
||||
|
||||
static double Sq(double x) => x * x;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta
Normal file
2
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fc6b6136927b78c45a78cfc0694ce93a
|
||||
|
|
@ -75,6 +75,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
private Material _field;
|
||||
private ComputeBuffer _hitBuffer;
|
||||
private List<FieldHit> _hits = new List<FieldHit>();
|
||||
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<MeshCollider>();
|
||||
_field = GetComponent<MeshRenderer>().material;
|
||||
_hitBuffer = new ComputeBuffer(MaxHits, 32);
|
||||
_envelope = GetComponent<ShieldEnvelope>();
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
Assets/Scripts/Gameplay/ShieldEnvelope.cs
Normal file
139
Assets/Scripts/Gameplay/ShieldEnvelope.cs
Normal file
|
|
@ -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<MeshCollider>();
|
||||
if (meshCollider != null) mesh = meshCollider.sharedMesh;
|
||||
if (mesh == null)
|
||||
{
|
||||
var meshFilter = GetComponent<MeshFilter>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>World-space centre of the ellipsoid.</summary>
|
||||
public Vector3 Center => transform.position;
|
||||
|
||||
/// <summary>World-space semi-axis lengths of the ellipsoid, accounting for the attached
|
||||
/// collider mesh's own local-space size (see the field comment above).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Vector3 ProjectToSurface(Vector3 worldPoint)
|
||||
{
|
||||
if (!_readExtents) ReadLocalExtents();
|
||||
var dir = LocalUnitDirection(worldPoint);
|
||||
var localSurface = Vector3.Scale(dir, _localExtents);
|
||||
return transform.TransformPoint(localSurface);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(worldPoint);
|
||||
}
|
||||
2
Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta
Normal file
2
Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e25c67ee83c01364fb121caeda952606
|
||||
|
|
@ -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<ShieldEnvelope>();
|
||||
}
|
||||
|
||||
private void OnCollisionEnter(Collision other)
|
||||
{
|
||||
var otherShield = other.collider.GetComponent<ShieldManager>();
|
||||
|
|
@ -73,7 +81,33 @@ public class ShieldManager : MonoBehaviour
|
|||
public void ShowHit(Vector3 point, float duration)
|
||||
{
|
||||
var shield = ShieldPrototype.Instantiate<ShieldAnimation>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
162
tests/mutation_tests_shield_panel_cut2.py
Normal file
162
tests/mutation_tests_shield_panel_cut2.py
Normal file
|
|
@ -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())
|
||||
Loading…
Reference in a new issue