fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope

Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-07 21:15:11 +02:00
parent 17b4ffde12
commit 833d167ebc
4 changed files with 203 additions and 49 deletions

View file

@ -139,6 +139,52 @@ public class TerrainSurfaceTests
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
}
[Fact]
public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne()
{
var heights = FlatHeightmap(50);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f);
Assert.Equal(1f, nz, precision: 5);
}
[Fact]
public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x * 20);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f);
Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}");
Assert.True(nz > 0f, $"nz should be positive, got {nz}");
}
[Fact]
public void SampleNormalZFromHeightmap_AgreesWithSampleSurface()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
var hTable = LinearHeightTable();
const uint lbX = 0xA9, lbY = 0xB3;
var instance = new TerrainSurface(heights, hTable, lbX, lbY);
for (float lx = 0.5f; lx < 192f; lx += 8f)
for (float ly = 0.5f; ly < 192f; ly += 8f)
{
var (_, normal) = instance.SampleSurface(lx, ly);
float staticNz = TerrainSurface.SampleNormalZFromHeightmap(
heights, hTable, lbX, lbY, lx, ly);
Assert.True(
Math.Abs(normal.Z - staticNz) < 0.0001f,
$"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}");
}
}
[Fact]
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
{