test(N.4): conformance tests for mesh extraction + setup flatten

Mesh extraction (4 tests): quad output, double-sided via Stippling.Both,
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
NoPos-only emission. Pins GfxObjMesh.Build's behavior.

Setup flatten (5 tests): identity (no frames), Default frame, Resting
beats Default, motion override beats Resting, DefaultScale per part.
Pins SetupMesh.Flatten's placement-frame fallback chain.

These run BEFORE substitution per N.1/N.3 pattern — they prove
equivalence, not test the substitution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 13:14:36 +02:00
parent 46deed6019
commit ed73fc5040
2 changed files with 241 additions and 0 deletions

View file

@ -0,0 +1,136 @@
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
/// would for the same input GfxObj. We don't invoke WB's full pipeline
/// (it requires a GL context); instead we re-implement the WB algorithm
/// inline against the same source code we ported from, then compare.
///
/// <para>
/// If this test fails, either our port has drifted or the WB code has
/// changed upstream — investigate which, do not "fix" the test.
/// </para>
/// </summary>
public sealed class MeshExtractionConformanceTests
{
[Fact]
public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
{
var gfxObj = MakeUnitQuadGfxObj();
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Single(ours);
var sub = ours[0];
// Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
Assert.Equal(4, sub.Vertices.Length);
Assert.Equal(6, sub.Indices.Length);
// Fan from vertex 0: (0,1,2) and (0,2,3).
Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
}
[Fact]
public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.Both;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Equal(2, ours.Count);
}
[Fact]
public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.None;
poly.SidesType = CullMode.Clockwise;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Equal(2, ours.Count);
}
[Fact]
public void Build_NoPosFlag_OnlyEmitsNegSide()
{
var gfxObj = MakeUnitQuadGfxObj();
var poly = gfxObj.Polygons[0];
poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
// NegSurface=0 so the neg side references a valid surface entry.
poly.NegSurface = 0;
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Single(ours);
}
/// <summary>
/// Build a synthetic 1×1 quad GfxObj with vertex sequence [0,1,2,3]
/// at corners (0,0,0)/(1,0,0)/(1,1,0)/(0,1,0). PosSurface=0,
/// NegSurface=-1 (invalid — pos side only by default).
/// No Stippling flags set by default — caller may add them per test.
/// </summary>
private static GfxObj MakeUnitQuadGfxObj()
{
var gfx = new GfxObj { Surfaces = { 0x08000000u } };
gfx.VertexArray = new VertexArray
{
VertexType = VertexType.CSWVertexType,
Vertices =
{
[0] = new SWVertex
{
Origin = new Vector3(0, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 0 } },
},
[1] = new SWVertex
{
Origin = new Vector3(1, 0, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 1, V = 0 } },
},
[2] = new SWVertex
{
Origin = new Vector3(1, 1, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 1, V = 1 } },
},
[3] = new SWVertex
{
Origin = new Vector3(0, 1, 0),
Normal = new Vector3(0, 0, 1),
UVs = { new Vec2Duv { U = 0, V = 1 } },
},
},
};
var poly = new Polygon
{
VertexIds = { 0, 1, 2, 3 },
PosUVIndices = { 0, 0, 0, 0 },
PosSurface = 0,
NegSurface = -1, // invalid index — pos side only
Stippling = StipplingType.None,
SidesType = CullMode.None,
};
gfx.Polygons[0] = poly;
return gfx;
}
}

View file

@ -0,0 +1,105 @@
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// Conformance: our <see cref="SetupMesh.Flatten"/> must produce the same
/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
/// Resting → Default → first available) before substitution.
/// </summary>
public sealed class SetupFlattenConformanceTests
{
[Fact]
public void Flatten_NoFrames_FallsBackToIdentity()
{
var setup = new Setup { Parts = { 0x01000001u } };
// PlacementFrames deliberately empty — no DefaultScale entry either,
// so scale defaults to Vector3.One and the fallback frame is
// (Origin=Zero, Orientation=Identity) → Identity matrix.
var refs = SetupMesh.Flatten(setup);
Assert.Single(refs);
Assert.Equal(0x01000001u, refs[0].GfxObjId);
Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
}
[Fact]
public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
{
var setup = new Setup { Parts = { 0x01000001u } };
setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
{
Frames =
{
new Frame
{
Origin = new Vector3(10, 20, 30),
Orientation = Quaternion.Identity,
},
},
};
var refs = SetupMesh.Flatten(setup);
Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
}
[Fact]
public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
{
var setup = new Setup { Parts = { 0x01000001u } };
setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
{
Frames = { new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity } },
};
setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
{
Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
};
var refs = SetupMesh.Flatten(setup);
Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
}
[Fact]
public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
{
var setup = new Setup { Parts = { 0x01000001u } };
setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
{
Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
};
var motionOverride = new AnimationFrame(1)
{
Frames = { new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity } },
};
var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);
Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
}
[Fact]
public void Flatten_DefaultScalePerPart_AppliedToTransform()
{
var setup = new Setup
{
Parts = { 0x01000001u, 0x01000002u },
DefaultScale = { new Vector3(2, 2, 2), new Vector3(3, 3, 3) },
};
// No placement frames — fallback frame is identity pose; scale still applies.
var refs = SetupMesh.Flatten(setup);
Assert.Equal(2f, refs[0].PartTransform.M11);
Assert.Equal(3f, refs[1].PartTransform.M11);
}
}