Aetheria/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs

327 lines
17 KiB
C#
Raw Permalink Normal View History

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>
2026-09-18 06:33:38 +02:00
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 &lt;repo&gt; -executeMethod
/// Aetheria.EditorTools.ShieldPanelCut2Verify.Run -quit -logFile &lt;path&gt;
/// </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");
Fix ShieldManager's missing-envelope fallback regression ShieldManager's fallback (no ShieldEnvelope yet, e.g. every ship prefab before the operator hand-rigs it per Q5) hard-failed to a fixed Vector3.forward direction with a per-hit Debug.LogError. That silently changed behaviour for every ship in the game until the operator edits Longinus.prefab/Djinni.prefab on their own schedule -- worse than either the deleted code or a loud one-time failure. Extract ShieldEnvelope's local-direction primitive into two static, no-component-required entry points -- SurfaceDirection(Transform, Vector3) and LegacyLocalSurfacePoint(Transform, Vector3) -- so both FieldDriver's and ShieldManager's fallbacks go through one owner instead of each re-deriving the pre-Cut-2 formula by hand. ShieldManager's fallback now reproduces the deleted normalize(shield.transform.InverseTransformPoint(point)) formula exactly via the shared static, logging once (a warning, not a per-hit error). FieldDriver's fallback is rewired onto the same static rather than its own inline copy of the maths. Documented, not silently assumed: FieldDriver's "envelope present" branch (envelope.ProjectToSurface, world-space) and its fallback (the legacy local- space formula) are only numerically identical when the carrier's transform has zero position and identity rotation -- true of the one FieldDriver carrier in the tree today, not true in general. This is a characteristic of Cut 2 as scoped by the map (FieldDriver.cs:125 reads envelope.ProjectToSurface directly, per the map's own text), not something this fix introduces or resolves; flagged so nobody assumes the two branches are interchangeable for a future non-origin, non-identity FieldDriver carrier. ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins both static entry points against an independent re-derivation of the exact pre-Cut-2 formulas, on a carrier with a non-trivial transform (nonzero position, non-identity rotation) so the check isn't vacuous. mutation_tests_shield_panel_cut2.py gained two cases: SurfaceDirection collapsing back to a fixed Vector3.forward (the exact regression fixed here), and LegacyLocalSurfacePoint collapsing to the raw, untransformed world point -- both correctly fail the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:45:07 +02:00
// 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();
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>
2026-09-18 06:33:38 +02:00
}
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;
}
Fix ShieldManager's missing-envelope fallback regression ShieldManager's fallback (no ShieldEnvelope yet, e.g. every ship prefab before the operator hand-rigs it per Q5) hard-failed to a fixed Vector3.forward direction with a per-hit Debug.LogError. That silently changed behaviour for every ship in the game until the operator edits Longinus.prefab/Djinni.prefab on their own schedule -- worse than either the deleted code or a loud one-time failure. Extract ShieldEnvelope's local-direction primitive into two static, no-component-required entry points -- SurfaceDirection(Transform, Vector3) and LegacyLocalSurfacePoint(Transform, Vector3) -- so both FieldDriver's and ShieldManager's fallbacks go through one owner instead of each re-deriving the pre-Cut-2 formula by hand. ShieldManager's fallback now reproduces the deleted normalize(shield.transform.InverseTransformPoint(point)) formula exactly via the shared static, logging once (a warning, not a per-hit error). FieldDriver's fallback is rewired onto the same static rather than its own inline copy of the maths. Documented, not silently assumed: FieldDriver's "envelope present" branch (envelope.ProjectToSurface, world-space) and its fallback (the legacy local- space formula) are only numerically identical when the carrier's transform has zero position and identity rotation -- true of the one FieldDriver carrier in the tree today, not true in general. This is a characteristic of Cut 2 as scoped by the map (FieldDriver.cs:125 reads envelope.ProjectToSurface directly, per the map's own text), not something this fix introduces or resolves; flagged so nobody assumes the two branches are interchangeable for a future non-origin, non-identity FieldDriver carrier. ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins both static entry points against an independent re-derivation of the exact pre-Cut-2 formulas, on a carrier with a non-trivial transform (nonzero position, non-identity rotation) so the check isn't vacuous. mutation_tests_shield_panel_cut2.py gained two cases: SurfaceDirection collapsing back to a fixed Vector3.forward (the exact regression fixed here), and LegacyLocalSurfacePoint collapsing to the raw, untransformed world point -- both correctly fail the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:45:07 +02:00
/// <summary>
/// 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.
/// </summary>
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;
}
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>
2026-09-18 06:33:38 +02:00
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;
}
}