diff --git a/.agents/docs/2026-11-15-add-recastnavigation-mwrap-plan.md b/.agents/docs/2026-11-15-add-recastnavigation-mwrap-plan.md new file mode 100644 index 00000000..17fbc51e --- /dev/null +++ b/.agents/docs/2026-11-15-add-recastnavigation-mwrap-plan.md @@ -0,0 +1,37 @@ +# Add mcpplibs.mwrap.recastnavigation (recastnavigation-m 1.6.0) + +## Form + +- Third-party upstream [recastnavigation/recastnavigation](https://github.com/recastnavigation/recastnavigation), release + v1.6.0, zlib. +- Form A external mwrap module repository [mcpplibs/recastnavigation-m](https://github.com/mcpplibs/recastnavigation-m), + package `mcpplibs.mwrap.recastnavigation`, version 1.6.0 (the first indexed release of the identity), tag `mwrap-1.6.0`; + release asset `recastnavigation-m-1.6.0.tar.gz` (`13382f6c…daa05`), CN mirror `gitcode.com/mcpp-res/mwrap-recastnavigation` + with identical bytes. +- Implementation ownership: the library has no compat package in this index, so the wrapper vendors the four libraries + (Recast, Detour, DetourCrowd, DetourTileCache: `Include/` and `Source/`) from the release archive (checksums of every + file in `upstream/documents.json`) and builds them with `-ffp-contract=off` and no fast math, with `NDEBUG` in every + profile (upstream declares its assert hooks only without it). DebugUtils, the demo and upstream's tests are not + vendored. It deploys upstream's README and License.txt with its own LICENSE/NOTICE (`runtime.deploy`). + +## Module + +- `mcpplibs.mwrap.recastnavigation`: every name declared at global scope by the four libraries' public headers, by + `export using ::name;` (99 types, 75 enumerators of unscoped enumerations, 170 functions). +- Internal-linkage adaptation: 52 `static const` objects (`DT_VERTS_PER_POLYGON`, `DT_SUCCESS`, `DT_FAILURE`, + `RC_WALKABLE_AREA`, `RC_PI` …) are declared again under their names, with their types and values, in + `mcpplibs::mwrap::recastnavigation` (an integer from the header's constant, a float from its literal). +- No `.macros` module: upstream's macros are its asserts, branch hints and `RC_SIZE_MAX`, used by its own sources. +- The export list is derived by `tools/surface.py` from the headers as clang reads them (its JSON AST), recorded in + `tools/surface.json`, and audited in CI: the module is the record on every row, and on the Linux LLVM row the record + is derived again and compared. + +## Verification + +- Wrapper CI (Linux x86_64 and arm64 with GCC 16.1 and LLVM 23.1.3, macOS LLVM, Windows LLVM, each in the dev and + release profiles; wasm32-emscripten under node): a navigation mesh built from a floor and a wall, the same bytes + twice; a path around the wall, its straight path, a ray along the mesh stopped by the wall; a crowd of four walked + through the doorway; the mesh's bytes and the crowd's places pinned and the same on every row; the adapted + constants; the export audit; the default template from an installed copy with a strict build. +- This index: consumer `tests/mwrap/recastnavigation-path` (the same pinned hashes); the installed-template check + with `--deploy` (`STATIC_NOTICES`). diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 424c10a5..48df1233 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1738,6 +1738,11 @@ jobs: shell: bash run: python3 tests/mwrap/check_installed_template.py --mcpp "$MCPP" --package mcpplibs.mwrap.jolt@5.6.0 --deploy + - name: Installed recastnavigation wrapper archive, actual default path template and its relocated notices + if: contains(format(' {0} ', env.MEMBERS), ' recastnavigation-path ') && matrix.toolchain == 'default' + shell: bash + run: python3 tests/mwrap/check_installed_template.py --mcpp "$MCPP" --package mcpplibs.mwrap.recastnavigation@1.6.0 --deploy + - name: Installed libjpeg-turbo wrapper archive, actual default decoding template and its relocated notices if: contains(format(' {0} ', env.MEMBERS), ' libjpeg-turbo-decode ') && matrix.toolchain == 'default' shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index c4abe674..c739c9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,14 @@ ### Added +- **mcpplibs.mwrap.recastnavigation 1.6.0(新包)。** recastnavigation-m:Recast & Detour 1.6.0(由三角形构建导航网格、寻路、群体、 + 带障碍物的分块网格)的 C++23 模块,以 `export using` 在全局命名空间导出 Recast、Detour、DetourCrowd、DetourTileCache 头文件的 + 全部公开名字(99 个类型、75 个无作用域枚举的枚举项、170 个函数);52 个内部链接的 `static const` 常量(`DT_VERTS_PER_POLYGON`、 + `DT_SUCCESS`、`RC_WALKABLE_AREA`、`RC_PI` 等)以原名、原类型和原值位于 `mcpplibs::mwrap::recastnavigation`。导出清单由 clang 读头文件 + 推出并在 CI 中审计。封装仓库从上游 1.6.0 发布包取源码,以 `-ffp-contract=off`、不用快速数学构建,`NDEBUG` 使各配置相同:同一 + 组三角形与群体在 Linux x86-64 与 arm64、macOS、Windows、wasm32 上,dev 与 release,网格字节与群体位置的哈希相同(钉住)。不含 + DebugUtils 与演示程序。消费者测试 `tests/mwrap/recastnavigation-path`,安装后模板检查带 `--deploy`。GLOBAL 为 GitHub 发布资产, + CN 为 GitCode `mcpp-res/mwrap-recastnavigation` 镜像,sha256 相同。 - **mcpplibs.mwrap.jolt 5.6.0(新包)。** jolt-m:Jolt Physics 5.6.0(刚体与软体物理:形状、约束、查询、角色、载具)的 C++23 模块,以 `export using` 在上游的命名空间里导出 `` 下各头文件的全部公开名字(`JPH` 450 个类型、127 个函数与运算符、 6 个对象,及嵌套命名空间 `ClosestPoint`、`HalfFloatConversion`、`ScaleHelpers`、`StreamUtils`、`literals` 的名字);50 个内部链接的 diff --git a/mcpp.toml b/mcpp.toml index 8719a961..16d5118b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -39,6 +39,7 @@ members = [ "tests/mwrap/cgltf-inspect", "tests/mwrap/manifold-boolean", "tests/mwrap/jolt-world", + "tests/mwrap/recastnavigation-path", "tests/mwrap/basis-universal-transcode", "tests/mwrap/libjpeg-turbo-decode", "tests/mwrap/libarchive-bundle", diff --git a/pkgs/mwrap/recastnavigation.lua b/pkgs/mwrap/recastnavigation.lua new file mode 100644 index 00000000..9441e9ca --- /dev/null +++ b/pkgs/mwrap/recastnavigation.lua @@ -0,0 +1,50 @@ +-- Immutable upstream-aligned mwrap source release (recastnavigation-m mwrap-1.6.0, the first release of upstream +-- 1.6.0); GLOBAL and CN bytes are identical. +-- +-- The wrapper is Apache-2.0; the libraries it vendors and links statically, +-- Recast & Detour 1.6.0, are zlib: the package deploys their README and +-- License.txt beside programs (runtime.deploy). +package = { + spec = "1", + namespace = "mcpplibs.mwrap", + name = "recastnavigation", + description = "recastnavigation-m: Recast & Detour 1.6.0 (navigation meshes built from triangles, path finding, crowds, tiled meshes with obstacles) as a C++23 module", + licenses = { "Apache-2.0" }, + repo = "https://github.com/mcpplibs/recastnavigation-m", + type = "package", + extensions = { + autoupdate = { + tag = "mwrap-${version}", + url = "https://github.com/mcpplibs/recastnavigation-m/releases/download/${tag}/recastnavigation-m-${version}.tar.gz", + }, + }, + xpm = { + linux = { + ["1.6.0"] = { + url = { + GLOBAL = "https://github.com/mcpplibs/recastnavigation-m/releases/download/mwrap-1.6.0/recastnavigation-m-1.6.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/mwrap-recastnavigation/releases/download/1.6.0/recastnavigation-m-1.6.0.tar.gz", + }, + sha256 = "13382f6c751ee5d000987c8e45e3721a4106f928753e004856dc339fbf5daa05", + }, + }, + macosx = { + ["1.6.0"] = { + url = { + GLOBAL = "https://github.com/mcpplibs/recastnavigation-m/releases/download/mwrap-1.6.0/recastnavigation-m-1.6.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/mwrap-recastnavigation/releases/download/1.6.0/recastnavigation-m-1.6.0.tar.gz", + }, + sha256 = "13382f6c751ee5d000987c8e45e3721a4106f928753e004856dc339fbf5daa05", + }, + }, + windows = { + ["1.6.0"] = { + url = { + GLOBAL = "https://github.com/mcpplibs/recastnavigation-m/releases/download/mwrap-1.6.0/recastnavigation-m-1.6.0.tar.gz", + CN = "https://gitcode.com/mcpp-res/mwrap-recastnavigation/releases/download/1.6.0/recastnavigation-m-1.6.0.tar.gz", + }, + sha256 = "13382f6c751ee5d000987c8e45e3721a4106f928753e004856dc339fbf5daa05", + }, + }, + }, +} diff --git a/tests/mwrap/check_installed_template.py b/tests/mwrap/check_installed_template.py index 242749eb..66da69ef 100644 --- a/tests/mwrap/check_installed_template.py +++ b/tests/mwrap/check_installed_template.py @@ -23,6 +23,7 @@ "mcpplibs.mwrap.meshoptimizer": ("meshoptimizer.cppm", "upstream/meshoptimizer/LICENSE.md", b"MIT License", b"Arseny Kapoulkine"), "mcpplibs.mwrap.cgltf": ("cgltf.cppm", "upstream/cgltf/LICENSE", b"Permission is hereby granted", b"Kuhlmann"), "mcpplibs.mwrap.manifold": ("manifold.cppm", "upstream/manifold/LICENSE", b"Apache License", b"The Manifold Authors"), + "mcpplibs.mwrap.recastnavigation": ("recastnavigation.cppm", "upstream/recastnavigation/License.txt", b"This software is provided 'as-is'", b"Recast & Detour is Copyright (c) 2009 Mikko"), "mcpplibs.mwrap.jolt": ("jolt.cppm", "upstream/jolt/LICENSE", b"Permission is hereby granted", b"Jolt Physics is Copyright (c) 2021 Jorrit"), "mcpplibs.mwrap.libjpeg_turbo": ("libjpeg_turbo.cppm", "upstream/libjpeg-turbo/README.ijg", b"Independent JPEG Group", b"libjpeg-turbo Project"), "mcpplibs.mwrap.basis_universal": ("basis_universal.cppm", "upstream/basis_universal/NOTICE", b"Binomial LLC", b"Basis Universal"), diff --git a/tests/mwrap/recastnavigation-path/mcpp.toml b/tests/mwrap/recastnavigation-path/mcpp.toml new file mode 100644 index 00000000..bc70a5b8 --- /dev/null +++ b/tests/mwrap/recastnavigation-path/mcpp.toml @@ -0,0 +1,15 @@ +[package] +name = "recastnavigation-path" +version = "1.6.0" +description = "Consume the immutable Recast & Detour wrapper: a navigation mesh built from triangles, a path around a wall, a crowd walked through a doorway; the mesh's bytes and the crowd's places pinned" + +[indices] +# Use the complete candidate built-in index prepared by tests/run_members.sh. +# An explicit empty table overrides the legacy workspace redirect. + +[dependencies] +mcpplibs.mwrap.recastnavigation = "1.6.0" + +# Detour's inline math compiled here too, as the library is built. +[build] +cxxflags = ["-ffp-contract=off"] diff --git a/tests/mwrap/recastnavigation-path/tests/behavior.cpp b/tests/mwrap/recastnavigation-path/tests/behavior.cpp new file mode 100644 index 00000000..c1a9efd1 --- /dev/null +++ b/tests/mwrap/recastnavigation-path/tests/behavior.cpp @@ -0,0 +1,193 @@ +// The installed Recast & Detour wrapper: a navigation mesh built from a floor with a wall across it, the same +// bytes twice; a path around the wall and a ray along the mesh stopped by it; a crowd of four walked through the +// doorway to the far side. The mesh's bytes and the crowd's places are pinned, as the wrapper's CI pins them on +// every platform; the adapted constants. +import std; +import mcpplibs.mwrap.recastnavigation; + +namespace nav = mcpplibs::mwrap::recastnavigation; + +namespace { + +int failures = 0; +void check(bool held) { failures += held ? 0 : 1; } + +// A 20 m square floor at y 0, and a wall 0.4 m thick and 2 m high across x = 0 from z = -10 to z = 6 (a +// doorway from z = 6 to 10), as triangles. +struct Soup { + std::vector vertices; + std::vector triangles; + void quad(std::array a, std::array b, std::array c, std::array d) { + const int base = static_cast(vertices.size() / 3); + for (const auto& corner : {a, b, c, d}) vertices.insert(vertices.end(), corner.begin(), corner.end()); + triangles.insert(triangles.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); + } + // A box from `low` to `high`, its faces wound counter-clockwise seen from outside. + void box(std::array low, std::array high) { + const auto [x0, y0, z0] = low; + const auto [x1, y1, z1] = high; + quad({x0, y1, z0}, {x0, y1, z1}, {x1, y1, z1}, {x1, y1, z0}); // top + quad({x0, y0, z0}, {x1, y0, z0}, {x1, y0, z1}, {x0, y0, z1}); // bottom + quad({x0, y0, z0}, {x0, y1, z0}, {x1, y1, z0}, {x1, y0, z0}); // -z + quad({x0, y0, z1}, {x1, y0, z1}, {x1, y1, z1}, {x0, y1, z1}); // +z + quad({x0, y0, z0}, {x0, y0, z1}, {x0, y1, z1}, {x0, y1, z0}); // -x + quad({x1, y0, z0}, {x1, y1, z0}, {x1, y1, z1}, {x1, y0, z1}); // +x + } +}; +Soup scene() { + Soup soup; + soup.quad({-10, 0, -10}, {-10, 0, 10}, {10, 0, 10}, {10, 0, -10}); + soup.box({-0.2f, 0, -10}, {0.2f, 2, 6}); + return soup; +} + +// The fingerprint of bytes: FNV-1a, 64 bits. +std::uint64_t fnv(std::span bytes, std::uint64_t hash = 0xcbf29ce484222325ull) { + for (const unsigned char byte : bytes) hash = (hash ^ byte) * 0x100000001b3ull; + return hash; +} +std::uint64_t fnv(std::span values, std::uint64_t hash = 0xcbf29ce484222325ull) { + return fnv(std::as_bytes(values).size() ? std::span(reinterpret_cast(values.data()), values.size_bytes()) : std::span{}, hash); +} + +// Recast's pipeline for one tile, as upstream's solo-mesh sample runs it: the triangles rasterised into a +// heightfield, what an agent cannot stand on or pass under filtered out, regions, contours, polygons and the +// detail mesh; then Detour's data of it. +std::vector build(const Soup& soup) { + rcConfig config{}; + config.cs = 0.2f; + config.ch = 0.1f; + config.walkableSlopeAngle = 45; + config.walkableHeight = 20; // 2 m in cells of 0.1 + config.walkableClimb = 4; // 0.4 m + config.walkableRadius = 2; // 0.4 m in cells of 0.2 + config.maxEdgeLen = 60; + config.maxSimplificationError = 1.3f; + config.minRegionArea = 8 * 8; + config.mergeRegionArea = 20 * 20; + config.maxVertsPerPoly = 6; + config.detailSampleDist = 1.2f; + config.detailSampleMaxError = 0.1f; + const int vertex_count = static_cast(soup.vertices.size() / 3), triangle_count = static_cast(soup.triangles.size() / 3); + rcCalcBounds(soup.vertices.data(), vertex_count, config.bmin, config.bmax); + rcCalcGridSize(config.bmin, config.bmax, config.cs, &config.width, &config.height); + + rcContext context(false); + rcHeightfield* solid = rcAllocHeightfield(); + check(rcCreateHeightfield(&context, *solid, config.width, config.height, config.bmin, config.bmax, config.cs, config.ch)); + std::vector areas(static_cast(triangle_count), 0); + rcMarkWalkableTriangles(&context, config.walkableSlopeAngle, soup.vertices.data(), vertex_count, soup.triangles.data(), triangle_count, areas.data()); + check(rcRasterizeTriangles(&context, soup.vertices.data(), vertex_count, soup.triangles.data(), areas.data(), triangle_count, *solid, config.walkableClimb)); + rcFilterLowHangingWalkableObstacles(&context, config.walkableClimb, *solid); + rcFilterLedgeSpans(&context, config.walkableHeight, config.walkableClimb, *solid); + rcFilterWalkableLowHeightSpans(&context, config.walkableHeight, *solid); + rcCompactHeightfield* compact = rcAllocCompactHeightfield(); + check(rcBuildCompactHeightfield(&context, config.walkableHeight, config.walkableClimb, *solid, *compact)); + rcFreeHeightField(solid); + check(rcErodeWalkableArea(&context, config.walkableRadius, *compact)); + check(rcBuildDistanceField(&context, *compact)); + check(rcBuildRegions(&context, *compact, 0, config.minRegionArea, config.mergeRegionArea)); + rcContourSet* contours = rcAllocContourSet(); + check(rcBuildContours(&context, *compact, config.maxSimplificationError, config.maxEdgeLen, *contours)); + rcPolyMesh* mesh = rcAllocPolyMesh(); + check(rcBuildPolyMesh(&context, *contours, config.maxVertsPerPoly, *mesh)); + rcPolyMeshDetail* detail = rcAllocPolyMeshDetail(); + check(rcBuildPolyMeshDetail(&context, *mesh, *compact, config.detailSampleDist, config.detailSampleMaxError, *detail)); + rcFreeCompactHeightfield(compact); + rcFreeContourSet(contours); + for (int poly = 0; poly < mesh->npolys; ++poly) + if (mesh->areas[poly] == nav::RC_WALKABLE_AREA) mesh->flags[poly] = 1; + + dtNavMeshCreateParams params{}; + params.verts = mesh->verts; + params.vertCount = mesh->nverts; + params.polys = mesh->polys; + params.polyAreas = mesh->areas; + params.polyFlags = mesh->flags; + params.polyCount = mesh->npolys; + params.nvp = mesh->nvp; + params.detailMeshes = detail->meshes; + params.detailVerts = detail->verts; + params.detailVertsCount = detail->nverts; + params.detailTris = detail->tris; + params.detailTriCount = detail->ntris; + params.walkableHeight = 2.0f; + params.walkableRadius = 0.4f; + params.walkableClimb = 0.4f; + std::copy_n(mesh->bmin, 3, params.bmin); + std::copy_n(mesh->bmax, 3, params.bmax); + params.cs = config.cs; + params.ch = config.ch; + params.buildBvTree = true; + unsigned char* data = nullptr; + int size = 0; + check(dtCreateNavMeshData(¶ms, &data, &size)); + rcFreePolyMesh(mesh); + rcFreePolyMeshDetail(detail); + std::vector out(data, data + size); + dtFree(data); + return out; +} + +// The mesh loaded: Detour takes a copy it frees. +dtNavMesh* load(const std::vector& bytes) { + dtNavMesh* mesh = dtAllocNavMesh(); + auto* data = static_cast(dtAlloc(static_cast(bytes.size()), DT_ALLOC_PERM)); + std::ranges::copy(bytes, data); + check(dtStatusSucceed(mesh->init(data, static_cast(bytes.size()), DT_TILE_FREE_DATA))); + return mesh; +} + +} // namespace + +int main() { + const std::vector bytes = build(scene()); + check(!bytes.empty() && bytes == build(scene())); + dtNavMesh* mesh = load(bytes); + dtNavMeshQuery* query = dtAllocNavMeshQuery(); + check(dtStatusSucceed(query->init(mesh, 2048))); + dtQueryFilter filter; + const float extents[3] = {1, 2, 1}, start[3] = {-5, 0, -5}, end[3] = {5, 0, -5}; + dtPolyRef start_ref = 0, end_ref = 0; + float start_at[3], end_at[3]; + query->findNearestPoly(start, extents, &filter, &start_ref, start_at); + query->findNearestPoly(end, extents, &filter, &end_ref, end_at); + dtPolyRef polys[256]; + int poly_count = 0, corner_count = 0; + float corners[3 * 32]; + check(dtStatusSucceed(query->findPath(start_ref, end_ref, start_at, end_at, &filter, polys, &poly_count, 256))); + check(dtStatusSucceed(query->findStraightPath(start_at, end_at, polys, poly_count, corners, nullptr, nullptr, &corner_count, 32))); + float through = -100; + for (int corner = 0; corner < corner_count; ++corner) through = std::max(through, corners[3 * corner + 2]); + check(corner_count >= 3 && through > 6.0f); + float hit = 0, normal[3]; + dtPolyRef ray_polys[64]; + int ray_count = 0; + check(dtStatusSucceed(query->raycast(start_ref, start_at, end_at, &filter, &hit, normal, ray_polys, &ray_count, 64)) && hit > 0.4f && hit < 0.5f); + + dtCrowd* crowd = dtAllocCrowd(); + check(crowd->init(8, 0.4f, mesh)); + dtCrowdAgentParams agent{}; + agent.radius = 0.4f, agent.height = 2, agent.maxAcceleration = 8, agent.maxSpeed = 3.5f; + agent.collisionQueryRange = agent.radius * 12, agent.pathOptimizationRange = agent.radius * 30, agent.separationWeight = 2; + agent.updateFlags = DT_CROWD_ANTICIPATE_TURNS | DT_CROWD_OPTIMIZE_VIS | DT_CROWD_OPTIMIZE_TOPO | DT_CROWD_OBSTACLE_AVOIDANCE | DT_CROWD_SEPARATION; + agent.obstacleAvoidanceType = 3; + for (int index = 0; index < 4; ++index) { + const float at[3] = {-5, 0, -6.0f + static_cast(index)}; + check(crowd->addAgent(at, &agent) == index); + check(crowd->requestMoveTarget(index, end_ref, end_at)); + } + for (int step = 0; step < 600; ++step) crowd->update(1.0f / 60, nullptr); + std::vector places; + for (int index = 0; index < 4; ++index) { + const dtCrowdAgent* walker = crowd->getAgent(index); + places.insert(places.end(), walker->npos, walker->npos + 3); + check(dtVdist2D(walker->npos, end_at) < 2.5f); + } + check(fnv(bytes) == 0xd50409b3475ce9afull && fnv(places) == 0x040f1c330fce843bull); + check(nav::DT_VERTS_PER_POLYGON == 6 && nav::RC_WALKABLE_AREA == 63 && dtStatusFailed(nav::DT_FAILURE)); + std::println("Recast & Detour 1.6.0 through import mcpplibs.mwrap.recastnavigation: {} polygon refs to the far side, {} corners; the mesh {:#018x}, the crowd {:#018x}{}", + poly_count, corner_count, fnv(bytes), fnv(places), failures ? std::format(" — {} checks failed", failures) : ""); + dtFreeCrowd(crowd), dtFreeNavMeshQuery(query), dtFreeNavMesh(mesh); + return failures; +}