feat(core): UCG Stage 1 — LandCell synthesized from TerrainSurface

Outdoor terrain cell (retail CLandCell) synthesized on demand from a
landblock's TerrainSurface. Factory Synthesize() samples four quad
corners to establish Z bounds; PointInCell() tests the 24 m XY quad
in world-local space. BuildingCellId stub is null (Stage 2).
2/2 tests RED→GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 09:17:57 +02:00
parent 03f08f00c1
commit b4c4318c8b
2 changed files with 91 additions and 0 deletions

View file

@ -0,0 +1,55 @@
using System;
using System.Numerics;
using AcDream.Core.Physics; // TerrainSurface
namespace AcDream.Core.World.Cells;
/// <summary>
/// Outdoor terrain cell — synthesized on demand from a landblock's
/// <see cref="TerrainSurface"/> (retail CLandCell is positionally resolved, not stored).
/// Retail anchor: CLandCell (acclient.h:31886) / CSortCell (acclient.h:31880).
/// </summary>
public sealed class LandCell : ObjCell
{
public const float CellSize = 24f; // TerrainSurface 24 m cell
public TerrainSurface Terrain { get; }
public int Cx { get; }
public int Cy { get; }
/// <summary>CSortCell building bridge ref (population logic is Stage 2). Always null in Stage 1.</summary>
public uint? BuildingCellId { get; }
private readonly Vector3 _worldOrigin;
private LandCell(uint id, TerrainSurface terrain, Vector3 worldOrigin, int cx, int cy,
Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
Vector3 localBoundsMin, Vector3 localBoundsMax)
: base(id, worldTransform, inverseWorldTransform, localBoundsMin, localBoundsMax,
Array.Empty<CellPortal>(), Array.Empty<uint>(), seenOutside: false)
{
Terrain = terrain; Cx = cx; Cy = cy; _worldOrigin = worldOrigin; BuildingCellId = null;
}
public static LandCell Synthesize(uint id, TerrainSurface terrain, Vector3 worldOrigin, int cx, int cy)
{
float ox = cx * CellSize, oy = cy * CellSize;
float z0 = terrain.SampleZ(ox, oy), z1 = terrain.SampleZ(ox + CellSize, oy);
float z2 = terrain.SampleZ(ox, oy + CellSize), z3 = terrain.SampleZ(ox + CellSize, oy + CellSize);
float zMin = MathF.Min(MathF.Min(z0, z1), MathF.Min(z2, z3));
float zMax = MathF.Max(MathF.Max(z0, z1), MathF.Max(z2, z3));
var min = new Vector3(ox, oy, zMin);
var max = new Vector3(ox + CellSize, oy + CellSize, zMax);
var transform = Matrix4x4.CreateTranslation(worldOrigin);
Matrix4x4.Invert(transform, out var inverse);
return new LandCell(id, terrain, worldOrigin, cx, cy, transform, inverse, min, max);
}
public override bool PointInCell(Vector3 worldPoint)
{
float lx = worldPoint.X - _worldOrigin.X;
float ly = worldPoint.Y - _worldOrigin.Y;
return lx >= Cx * CellSize && lx < (Cx + 1) * CellSize
&& ly >= Cy * CellSize && ly < (Cy + 1) * CellSize;
}
}

View file

@ -0,0 +1,36 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
using Xunit;
namespace AcDream.Core.Tests.World.Cells;
public class LandCellTests
{
private static TerrainSurface FlatTerrain()
=> new TerrainSurface(new byte[81], new float[256], landblockX: 0, landblockY: 0);
[Fact]
public void Synthesize_SetsCellIndicesAndQuadBounds()
{
var origin = new Vector3(1000f, 2000f, 0f);
var cell = LandCell.Synthesize(0xA9B40014u, FlatTerrain(), origin, cx: 2, cy: 3);
Assert.Equal(2, cell.Cx);
Assert.Equal(3, cell.Cy);
Assert.Equal(2 * 24f, cell.LocalBoundsMin.X);
Assert.Equal(3 * 24f, cell.LocalBoundsMin.Y);
Assert.Equal(3 * 24f, cell.LocalBoundsMax.X);
Assert.Equal(4 * 24f, cell.LocalBoundsMax.Y);
Assert.False(cell.IsEnv);
}
[Fact]
public void PointInCell_TestsWorldXyAgainstThe24mQuad()
{
var origin = new Vector3(1000f, 2000f, 0f);
var cell = LandCell.Synthesize(0xA9B40014u, FlatTerrain(), origin, cx: 2, cy: 3);
Assert.True(cell.PointInCell(new Vector3(1060f, 2080f, 12.3f)));
Assert.False(cell.PointInCell(new Vector3(1000f, 2080f, 0f)));
Assert.False(cell.PointInCell(new Vector3(1060f, 2100f, 0f)));
}
}