Lands the working A8 indoor-rendering and streaming fixes accumulated this session. User has verified these visually to some degree (e.g. lifestone / translucent meshes confirmed fine under the FrontFace flip; bridge / wall / collision regressions confirmed fixed after travel); not every path has been exhaustively gated. The cellar-flap defect remains OPEN and will be solved the retail-faithful way via a dedicated brainstorm (see handoff docs). Rendering core (reviewed, high confidence): - EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of the 80B CPU InstanceData struct the shader never expected — fixes the transform/texture "explosion" for any draw with >1 instance (cells that dedupe to a shared cellGeomId). Real root cause. - WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into same-cull runs with absolute uDrawIDOffset per run). - EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) + WorldEntity.BuildingShellAnchorCellId so building shells scope to their dat-derived building cell instead of rendering everywhere. - RenderOutsideInAcdream (look into buildings from outside) + CollectVisiblePortalBuildings frustum cull of portal bounds. - Sky-when-inside-building + per-cell audit probe + GL-state probe. Streaming / perf (test-covered; not independently code-reviewed this session): - Near/far priority queues so near work wins over far; PromoteToNear carries full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids rebuilding the animated-lookup dict in the hot draw path. Fixes the bridge-not-appearing / missing-walls / broken-collision-after-travel regressions and improves post-transition FPS. Tooling + docs: - tools/A8CellAudit: offline dat cell/portal/building dumper (portals + buildings modes) — reproduces the cellar-flap investigation with no launch. - docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil double-duty finding + the WB-recursive design decision + brainstorm prompt), entity-taxonomy, replan, issue-78 visibility investigation. Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert provisional pos.w clamp, and the probe families are kept (env-var gated, zero cost when off) because the pending option-2 cellar-flap brainstorm needs them. Strip in the option-2 ship commit. Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8 visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
491 lines
19 KiB
C#
491 lines
19 KiB
C#
// CellVisibility.cs — portal-based interior cell visibility system.
|
||
//
|
||
// Ported from ACME EnvCellManager.cs (WorldBuilder-ACME-Edition).
|
||
// Key methods: FindCameraCell, PointInCell, GetVisibleCells.
|
||
// Constants: PointInCellEpsilon = 0.01f, CellSwitchGraceFrameCount = 3
|
||
// (ACME values; the original spec suggested 0.1f / 5 but ACME is ground truth).
|
||
//
|
||
// This file is intentionally free of GL / rendering types. It depends only on
|
||
// System.Numerics so it can be unit-tested without a GPU context.
|
||
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data structures
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// A loaded EnvCell with portal connectivity and spatial data, used by
|
||
/// <see cref="CellVisibility"/> for portal-traversal visibility decisions.
|
||
/// </summary>
|
||
public sealed class LoadedCell
|
||
{
|
||
/// <summary>Full 32-bit cell ID, e.g. 0xA9B40105.</summary>
|
||
public uint CellId;
|
||
|
||
/// <summary>Cell origin in world space (used for neighbour distance checks).</summary>
|
||
public Vector3 WorldPosition;
|
||
|
||
/// <summary>Cell-to-world transform (rotation + translation from EnvCell placement).</summary>
|
||
public Matrix4x4 WorldTransform;
|
||
|
||
/// <summary>
|
||
/// Cached inverse of <see cref="WorldTransform"/>. Pre-computed at load time so
|
||
/// PointInCell doesn't pay the inversion cost per frame.
|
||
/// </summary>
|
||
public Matrix4x4 InverseWorldTransform;
|
||
|
||
/// <summary>Local-space AABB minimum, computed from CellStruct vertices.</summary>
|
||
public Vector3 LocalBoundsMin;
|
||
|
||
/// <summary>Local-space AABB maximum, computed from CellStruct vertices.</summary>
|
||
public Vector3 LocalBoundsMax;
|
||
|
||
/// <summary>
|
||
/// Ordered portal connections. Index i in Portals corresponds to index i in
|
||
/// <see cref="ClipPlanes"/> (when ClipPlanes.Count > i).
|
||
/// </summary>
|
||
public List<CellPortalInfo> Portals = new();
|
||
|
||
/// <summary>
|
||
/// One clip plane per portal polygon, in cell-local space. Used by the
|
||
/// portal-side test to decide whether the camera can see through a portal.
|
||
/// Derived from portal polygon geometry during cell preparation.
|
||
/// </summary>
|
||
public List<PortalClipPlane> ClipPlanes = new();
|
||
|
||
/// <summary>
|
||
/// Portal polygon vertices in cell-local space, one Vector3[] per
|
||
/// <see cref="CellPortalInfo"/> entry in <see cref="Portals"/>. Index i
|
||
/// in this list corresponds to index i in <see cref="Portals"/> and
|
||
/// <see cref="ClipPlanes"/>. An empty array means the portal's polygon
|
||
/// could not be resolved at load time (degenerate cell or missing
|
||
/// polygon entry).
|
||
/// <para>
|
||
/// Used by the Phase A8 indoor-cell stencil pipeline to build a
|
||
/// per-frame triangle-fan mesh for portal silhouette masking.
|
||
/// </para>
|
||
/// </summary>
|
||
public List<Vector3[]> PortalPolygons = new();
|
||
|
||
/// <summary>
|
||
/// Phase A8 (2026-05-26): the building this cell belongs to, if any.
|
||
/// Set exactly once by <see cref="Wb.BuildingLoader"/> immediately after
|
||
/// LandblockLoader produces the cells. Null when the cell isn't part of
|
||
/// any building (outdoor surface cells; dungeon cells not enumerated in
|
||
/// LandBlockInfo.Buildings).
|
||
///
|
||
/// <para>Used by the render frame to derive the camera-buildings set
|
||
/// via <see cref="Wb.BuildingRegistry.GetBuildingsContainingCell"/>
|
||
/// and route IndoorPass cell scoping.</para>
|
||
/// </summary>
|
||
public uint? BuildingId { get; internal set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Portal connection to a neighbouring cell.
|
||
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
|
||
/// </summary>
|
||
public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
|
||
|
||
/// <summary>
|
||
/// Clip plane derived from a portal polygon, in cell-local space.
|
||
/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
|
||
/// </summary>
|
||
public struct PortalClipPlane
|
||
{
|
||
/// <summary>Plane normal (cell-local space, unit length).</summary>
|
||
public Vector3 Normal;
|
||
|
||
/// <summary>Plane offset so that Dot(Normal, point) + D = 0 on the plane.</summary>
|
||
public float D;
|
||
|
||
/// <summary>
|
||
/// Which half-space is "inside" this cell (the side from which you look outward
|
||
/// through the portal):
|
||
/// 0 → camera dot-product must be >= 0 (positive half-space is inside)
|
||
/// 1 → camera dot-product must be <= 0 (negative half-space is inside)
|
||
/// Determined from cell centroid position relative to the portal plane.
|
||
/// Ported from ACME EnvCellManager.cs ~line 404.
|
||
/// </summary>
|
||
public int InsideSide;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Result of a portal-based visibility BFS from the camera cell.
|
||
/// </summary>
|
||
public sealed class VisibilityResult
|
||
{
|
||
/// <summary>Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.</summary>
|
||
public HashSet<uint> VisibleCellIds { get; init; } = new();
|
||
|
||
/// <summary>
|
||
/// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during
|
||
/// traversal. The caller should render outdoor terrain when this is set.
|
||
/// </summary>
|
||
public bool HasExitPortalVisible { get; set; }
|
||
|
||
/// <summary>The cell the camera is currently inside.</summary>
|
||
public LoadedCell? CameraCell { get; set; }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CellVisibility
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Pure-logic portal visibility system for EnvCell interior rooms.
|
||
///
|
||
/// Maintains a per-landblock registry of <see cref="LoadedCell"/> objects and
|
||
/// performs a BFS through portal connections each frame to determine which cells
|
||
/// should be rendered given the current camera position.
|
||
///
|
||
/// Ported faithfully from ACME's EnvCellManager.cs portal-visibility region.
|
||
/// Constants and control flow match the ACME implementation.
|
||
/// </summary>
|
||
public sealed class CellVisibility
|
||
{
|
||
// ------------------------------------------------------------------
|
||
// Constants (ACME ground-truth values)
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Epsilon applied to AABB containment tests so that a camera sitting
|
||
/// exactly on a cell wall is still considered inside.
|
||
/// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f.
|
||
/// </summary>
|
||
private const float PointInCellEpsilon = 0.01f;
|
||
|
||
/// <summary>
|
||
/// Number of frames to keep the previous camera cell alive after the camera
|
||
/// leaves it (prevents one-frame pop-in when crossing cell boundaries).
|
||
/// Source: ACME EnvCellManager.cs CellSwitchGraceFrameCount = 3.
|
||
/// </summary>
|
||
private const int CellSwitchGraceFrameCount = 3;
|
||
|
||
// ------------------------------------------------------------------
|
||
// State
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID.</summary>
|
||
private readonly Dictionary<uint, List<LoadedCell>> _cellsByLandblock = new();
|
||
|
||
/// <summary>Full-ID lookup for O(1) neighbour resolution during BFS.</summary>
|
||
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
|
||
|
||
/// <summary>The cell the camera was in during the last <see cref="ComputeVisibility"/> call.</summary>
|
||
private LoadedCell? _lastCameraCell;
|
||
|
||
/// <summary>Frames remaining in the grace period after the camera left _lastCameraCell.</summary>
|
||
private int _cellSwitchGraceFrames;
|
||
|
||
/// <summary>The last visibility result produced by <see cref="ComputeVisibility"/>.</summary>
|
||
public VisibilityResult? LastVisibilityResult { get; private set; }
|
||
|
||
// ------------------------------------------------------------------
|
||
// Registration
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Registers a newly-loaded cell. Called from the streaming loader after
|
||
/// CPU preparation (transforms, clip planes, bounds) is complete.
|
||
/// Thread-safety: caller must not call this concurrently with rendering.
|
||
/// </summary>
|
||
public void AddCell(LoadedCell cell)
|
||
{
|
||
uint lbId = cell.CellId >> 16;
|
||
|
||
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
|
||
{
|
||
list = new List<LoadedCell>();
|
||
_cellsByLandblock[lbId] = list;
|
||
}
|
||
|
||
list.Add(cell);
|
||
_cellLookup[cell.CellId] = cell;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
||
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when
|
||
/// building the per-landblock <c>BuildingRegistry</c> — the per-frame
|
||
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
|
||
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
|
||
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
|
||
/// </summary>
|
||
/// <param name="lbId">Upper 16 bits of the landblock key (e.g. <c>0xA9B4</c>
|
||
/// for landblock <c>0xA9B40000</c>). NOT the full 32-bit landblock id.</param>
|
||
public IReadOnlyList<LoadedCell> GetCellsForLandblock(uint lbId)
|
||
{
|
||
return _cellsByLandblock.TryGetValue(lbId, out var list)
|
||
? list
|
||
: System.Array.Empty<LoadedCell>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Looks up a currently loaded cell by full 32-bit cell id.
|
||
/// </summary>
|
||
public bool TryGetCell(uint cellId, out LoadedCell? cell)
|
||
=> _cellLookup.TryGetValue(cellId, out cell);
|
||
|
||
/// <summary>
|
||
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
|
||
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
|
||
/// landblock unloads.
|
||
/// </summary>
|
||
public void RemoveLandblock(uint lbId)
|
||
{
|
||
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
|
||
return;
|
||
|
||
foreach (var cell in list)
|
||
{
|
||
_cellLookup.Remove(cell.CellId);
|
||
|
||
// If the evicted cell was cached, clear the cache so FindCameraCell
|
||
// does a fresh brute-force scan next frame.
|
||
if (_lastCameraCell?.CellId == cell.CellId)
|
||
{
|
||
_lastCameraCell = null;
|
||
_cellSwitchGraceFrames = 0;
|
||
}
|
||
}
|
||
|
||
_cellsByLandblock.Remove(lbId);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Per-frame entry point
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Computes portal-based visibility from <paramref name="cameraPos"/> and
|
||
/// caches the result in <see cref="LastVisibilityResult"/>.
|
||
///
|
||
/// Call once per frame, before the render pass. Returns null when the camera
|
||
/// is outside all loaded cells (outdoor — caller should fall back to frustum
|
||
/// culling of terrain).
|
||
/// </summary>
|
||
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
|
||
{
|
||
if (_cellLookup.Count == 0)
|
||
{
|
||
LastVisibilityResult = null;
|
||
return null;
|
||
}
|
||
|
||
LastVisibilityResult = GetVisibleCells(cameraPos);
|
||
return LastVisibilityResult;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// FindCameraCell
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Finds the <see cref="LoadedCell"/> the camera is currently inside, with
|
||
/// a short hysteresis window to prevent flicker at cell boundaries.
|
||
///
|
||
/// Search order:
|
||
/// 1. Cached cell fast path.
|
||
/// 2. Immediate portal neighbours of the cached cell.
|
||
/// 3. Brute-force scan of all loaded cells.
|
||
/// 4. Grace period — return the previous cell for a few frames.
|
||
/// 5. Return null (camera is outdoors).
|
||
///
|
||
/// Ported from ACME EnvCellManager.cs FindCameraCell().
|
||
/// </summary>
|
||
public LoadedCell? FindCameraCell(Vector3 cameraPos)
|
||
{
|
||
// 1. Fast path: cached cell.
|
||
if (_lastCameraCell != null && PointInCell(cameraPos, _lastCameraCell))
|
||
return _lastCameraCell;
|
||
|
||
// 2. One-hop neighbours of the cached cell.
|
||
if (_lastCameraCell != null)
|
||
{
|
||
uint lbMask = _lastCameraCell.CellId & 0xFFFF0000u;
|
||
foreach (var portal in _lastCameraCell.Portals)
|
||
{
|
||
if (portal.OtherCellId == 0xFFFF)
|
||
continue;
|
||
|
||
uint neighbourId = lbMask | portal.OtherCellId;
|
||
if (_cellLookup.TryGetValue(neighbourId, out var neighbour) &&
|
||
PointInCell(cameraPos, neighbour))
|
||
{
|
||
_lastCameraCell = neighbour;
|
||
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
|
||
return neighbour;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Brute-force scan.
|
||
foreach (var kvp in _cellsByLandblock)
|
||
{
|
||
foreach (var cell in kvp.Value)
|
||
{
|
||
if (PointInCell(cameraPos, cell))
|
||
{
|
||
_lastCameraCell = cell;
|
||
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
|
||
return cell;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Grace period: keep the previous cell alive for a few frames.
|
||
if (_lastCameraCell != null && _cellSwitchGraceFrames > 0)
|
||
{
|
||
_cellSwitchGraceFrames--;
|
||
return _lastCameraCell;
|
||
}
|
||
|
||
// 5. Camera is outside all cells.
|
||
_lastCameraCell = null;
|
||
return null;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// PointInCell
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Returns true when <paramref name="worldPoint"/> lies inside
|
||
/// <paramref name="cell"/>'s local-space AABB (within epsilon).
|
||
///
|
||
/// The point is transformed into cell-local space via the pre-computed
|
||
/// <see cref="LoadedCell.InverseWorldTransform"/> and then tested against
|
||
/// <see cref="LoadedCell.LocalBoundsMin"/> / <see cref="LoadedCell.LocalBoundsMax"/>.
|
||
///
|
||
/// Ported from ACME EnvCellManager.cs PointInCell().
|
||
/// </summary>
|
||
public static bool PointInCell(Vector3 worldPoint, LoadedCell cell)
|
||
{
|
||
// Degenerate cell (no geometry baked yet).
|
||
if (cell.LocalBoundsMin.X >= cell.LocalBoundsMax.X)
|
||
return false;
|
||
|
||
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||
|
||
return local.X >= cell.LocalBoundsMin.X - PointInCellEpsilon &&
|
||
local.X <= cell.LocalBoundsMax.X + PointInCellEpsilon &&
|
||
local.Y >= cell.LocalBoundsMin.Y - PointInCellEpsilon &&
|
||
local.Y <= cell.LocalBoundsMax.Y + PointInCellEpsilon &&
|
||
local.Z >= cell.LocalBoundsMin.Z - PointInCellEpsilon &&
|
||
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Brute-force scan of every loaded cell to test whether
|
||
/// <paramref name="worldPoint"/> is inside any of them. Does not touch
|
||
/// the camera cache (<see cref="_lastCameraCell"/>), so this is safe
|
||
/// to call alongside <see cref="ComputeVisibility"/> in the same frame
|
||
/// for a different position (e.g. player position when the camera is
|
||
/// in third-person chase mode).
|
||
/// </summary>
|
||
public bool IsInsideAnyCell(Vector3 worldPoint)
|
||
{
|
||
foreach (var cell in _cellLookup.Values)
|
||
if (PointInCell(worldPoint, cell)) return true;
|
||
return false;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// GetVisibleCells (BFS)
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Performs portal-based BFS visibility traversal starting from the camera
|
||
/// cell. Returns null when the camera is outside all loaded cells.
|
||
///
|
||
/// Algorithm:
|
||
/// • Start with the camera cell in the visited set and the work queue.
|
||
/// • For each dequeued cell, iterate its portals:
|
||
/// – OtherCellId == 0xFFFF → exit portal, set HasExitPortalVisible.
|
||
/// – Already visited → skip.
|
||
/// – Not loaded → skip.
|
||
/// – Portal-side test: transform camera to cell-local space, dot with
|
||
/// clip plane; skip if camera is on the wrong side.
|
||
/// – Enqueue neighbour and add to VisibleCellIds.
|
||
///
|
||
/// Note: ACME also applies a frustum test after the portal-side test. That
|
||
/// test is omitted here because <see cref="CellVisibility"/> is a pure-logic
|
||
/// class. Callers that have a frustum can post-filter VisibleCellIds.
|
||
///
|
||
/// The landblock mask for neighbour resolution is taken from the camera
|
||
/// cell's CellId (upper 16 bits). All portals in a dungeon are assumed to
|
||
/// connect cells within the same landblock.
|
||
///
|
||
/// Ported from ACME EnvCellManager.cs GetVisibleCells().
|
||
/// </summary>
|
||
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
|
||
{
|
||
var cameraCell = FindCameraCell(cameraPos);
|
||
if (cameraCell == null)
|
||
return null;
|
||
|
||
var result = new VisibilityResult { CameraCell = cameraCell };
|
||
var visited = new HashSet<uint>();
|
||
var queue = new Queue<LoadedCell>();
|
||
|
||
visited.Add(cameraCell.CellId);
|
||
result.VisibleCellIds.Add(cameraCell.CellId);
|
||
queue.Enqueue(cameraCell);
|
||
|
||
// All portals in a dungeon connect cells in the same landblock.
|
||
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
|
||
|
||
while (queue.Count > 0)
|
||
{
|
||
var cell = queue.Dequeue();
|
||
|
||
for (int i = 0; i < cell.Portals.Count; i++)
|
||
{
|
||
var portal = cell.Portals[i];
|
||
|
||
// Exit portal → outdoor terrain should be visible.
|
||
if (portal.OtherCellId == 0xFFFF)
|
||
{
|
||
result.HasExitPortalVisible = true;
|
||
continue;
|
||
}
|
||
|
||
uint neighbourId = lbMask | portal.OtherCellId;
|
||
|
||
if (visited.Contains(neighbourId))
|
||
continue;
|
||
|
||
if (!_cellLookup.TryGetValue(neighbourId, out var neighbour))
|
||
continue;
|
||
|
||
// Portal-side test: camera must be on the interior side of the
|
||
// portal clip plane to see through into the neighbouring cell.
|
||
if (i < cell.ClipPlanes.Count)
|
||
{
|
||
var plane = cell.ClipPlanes[i];
|
||
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||
|
||
// InsideSide == 0 → inside is positive half-space; reject if dot < -ε.
|
||
// InsideSide == 1 → inside is negative half-space; reject if dot > ε.
|
||
// Source: ACME EnvCellManager.cs lines 1458-1459.
|
||
if (plane.InsideSide == 0 && dot < -PointInCellEpsilon)
|
||
continue;
|
||
if (plane.InsideSide == 1 && dot > PointInCellEpsilon)
|
||
continue;
|
||
}
|
||
|
||
visited.Add(neighbourId);
|
||
result.VisibleCellIds.Add(neighbourId);
|
||
queue.Enqueue(neighbour);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
}
|