From a118a4876a93041263c32db2b82ba0bbbd245ee3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 12:46:09 +0000 Subject: [PATCH] Add procedural road network system under procgen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce math-only road baking (profile loft, piers, markings, navigation overlays) with mesh.roadNetwork / tex.roadMarkings recipes, design docs, unit tests, and a procedural-road example. Co-authored-by: 西风逍遥游 --- docs/dev/程序化道路系统设计.md | 119 ++++ examples/README.md | 1 + examples/procedural-road/README.md | 17 + examples/procedural-road/config.nut | 7 + examples/procedural-road/main.nut | 102 ++++ .../procgen/algorithms/MarchingCubes.cpp | 2 + src/modules/procgen/road/RoadBake.cpp | 533 ++++++++++++++++++ src/modules/procgen/road/RoadBake.h | 37 ++ src/modules/procgen/road/RoadNetwork.cpp | 251 +++++++++ src/modules/procgen/road/RoadNetwork.h | 64 +++ src/modules/procgen/road/RoadProfile.cpp | 82 +++ src/modules/procgen/road/RoadRecipes.cpp | 132 +++++ src/modules/procgen/road/RoadRecipes.h | 34 ++ src/modules/procgen/road/RoadTypes.h | 119 ++++ .../procgen/texture/BuiltinTextures.cpp | 2 + test/procgen_road.cpp | 116 ++++ 16 files changed, 1618 insertions(+) create mode 100644 docs/dev/程序化道路系统设计.md create mode 100644 examples/procedural-road/README.md create mode 100644 examples/procedural-road/config.nut create mode 100644 examples/procedural-road/main.nut create mode 100644 src/modules/procgen/road/RoadBake.cpp create mode 100644 src/modules/procgen/road/RoadBake.h create mode 100644 src/modules/procgen/road/RoadNetwork.cpp create mode 100644 src/modules/procgen/road/RoadNetwork.h create mode 100644 src/modules/procgen/road/RoadProfile.cpp create mode 100644 src/modules/procgen/road/RoadRecipes.cpp create mode 100644 src/modules/procgen/road/RoadRecipes.h create mode 100644 src/modules/procgen/road/RoadTypes.h create mode 100644 test/procgen_road.cpp diff --git a/docs/dev/程序化道路系统设计.md b/docs/dev/程序化道路系统设计.md new file mode 100644 index 00000000..9c81a4a4 --- /dev/null +++ b/docs/dev/程序化道路系统设计.md @@ -0,0 +1,119 @@ +# 程序化道路系统设计 + +日期:2026-09-17 +状态:MVP 已落地(`src/modules/procgen/road/`) +存放:`docs/dev/` + +## 目标 + +在**不使用预制道路/桥梁模型**的前提下,用数学函数生成与参考图一致的视觉层: + +1. **几何**:多高程道路、桥面、侧栏、人行道、桥墩 +2. **逻辑贴图**:沿弧长/横向 UV 的车道线、斑马线、导向箭头 +3. **导航网络可视化**:车道中心线与路口转弯弧(青/绿叠加) + +全部由 `SplinePath` 平行输运帧 + 截面挤出 + 图论车道图合成,运行时可烘培为 `MeshBuild`。 + +## 非目标(MVP) + +- 完整路口 CSG 布尔融合(用路口平台 + 边端收缩近似) +- 地形挖填 / 贴地切割 +- 真实交通仿真与 AI 寻路驱动 +- 中文「道路编辑」产品化编辑器面板(文档预留工具流,运行时先配方 + 示例) + +## 与现有能力的关系 + +| 复用 | 角色 | +|------|------| +| `SplinePath` / `sampleFramesResult` | 中心线与 Frenet 式截面帧 | +| `MeshBuild` 命名 group | `asphalt` / `curb` / `sidewalk` / `deck` / `pier` / `marking` / `nav` | +| `MeshRecipeRegistry` | `mesh.roadNetwork` 配方入口 | +| `TextureRecipeRegistry` | `tex.roadMarkings` 逻辑贴图 | +| `urban` 街道 | 可选上游 2D 中心线;本系统是 3D 车道域 | + +**不**新建顶层 `road` 模块目录;算法作为 `procgen` 宿主内的 `road/` 卫星(同 `urban/`)。 + +## 数据模型 + +```text +RoadNetwork + nodes[]: RoadNode { id, position, junctionRadius } + edges[]: RoadEdge { + id, from, to, + controlPoints[] → SplinePath (catmullRom), + lanesForward, lanesBackward, + style (laneWidth, curb, sidewalk, deckThickness, pier*), + elevated (bool | height threshold) + } + laneLinks[]: at a node, (inEdge, inLane) → (outEdge, outLane) +``` + +权威所有者:`RoadNetwork` 拥有节点/边/车道连接;烘培结果是无指针快照(`MeshBuild` + `RoadOverlay` 折线)。 + +## 数学管线 + +### 1. 截面剖面 `RoadProfile` + +在边局部 `(side, up)` 平面构造折线剖面: + +```text +[-W/2-sidewalk-curb … asphalt lanes … +W/2 …] +``` + +每个区段带 material tag,挤出时写入对应 triangle group。桥面在 `up` 负向加 `deckThickness` 底板。 + +### 2. 边挤出 `RoadBake::bakeEdge` + +1. 边控制点 → `SplinePath`,弧长均匀采样帧 +2. 在路口半径处裁剪端点参数,避免与路口平台重叠 +3. 沿帧挤出剖面(开剖面条带 + 底板可选) +4. 若帧高度 > `pierClearance`,按 `pierSpacing` 放置轴对齐桥墩盒子 +5. 车道标线:在沥青表面上偏移的薄带(白边线、黄虚中线、斑马带) + +UV:`u = normalized arc length`,`v = normalized lateral`(逻辑贴图与虚线周期均依赖此约定)。 + +### 3. 路口平台 + +对度数 ≥ 2 的节点,用入射边端点左右路肩点构成凸包近似平台,扇形三角化;斑马线沿入射方向铺条。 + +### 4. 导航叠加 `RoadOverlay` + +- 每条车道:中心线偏移 `±(i+0.5)*laneWidth` 的折线 +- 路口:同节点合法 `laneLinks` 用三次 Hermite 弧连接 +- 箭头:沿折线按弧长放置小三角 mesh(group `nav`) + +### 5. 逻辑贴图 `tex.roadMarkings` + +纯函数 `(u,v) → RGBA`:沥青底色 + 边线 + 周期虚线 + 可选斑马/箭头 mask。与几何 UV 对齐,可独立预览或烘焙。 + +## 编辑工具流(产品映射) + +与参考图底部工具栏对齐的**语义阶段**(实现可后挂 `procgen_editing`): + +| 阶段 | 文档操作 | 运行时对应 | +|------|----------|------------| +| 道路编辑 | 增删边控制点 | `RoadEdge.controlPoints` / SplinePathDocument | +| 岔路编辑 | 节点连接、匝道 | `RoadNode` + 边拓扑 | +| 导航网络 | 生成 laneLinks / overlay | `buildNavigation` | +| 调整车道 | lanesForward/Backward、laneWidth | `RoadStyle` | +| 烘培模型 | bake → MeshBuild | `bakeRoadNetwork` / `mesh.roadNetwork` | +| 样式编辑 | 剖面与材质色 | `RoadStyle` + `tex.roadMarkings` | + +## API 契约 + +- 失败路径一律 `Result` / recipe `bool + error`;禁止 `lastError`。 +- 烘培同步、owner-thread-only;返回值不持有对 `RoadNetwork` 的指针。 +- Schema:`eve.procgen.roadNetwork` v1(后续 Document 化时沿用)。 + +## 验证 + +- 单元:剖面宽度、挤出顶点/组、桥墩触发、导航折线非空、配方确定性 +- 示例:`examples/procedural-road` 烘培立交并画导航折线 +- 架构:仍落在 `procgen` 宿主内,无向上 `#include` + +## 后续迭代 + +1. 路口 curb return / 真正的 mesh stitch +2. `RoadNetworkDocument` + gizmo(复用 SplinePathGizmo) +3. 地形 conform + 走廊 mask +4. 与 `urban` 中心线自动导入 diff --git a/examples/README.md b/examples/README.md index 7a7bcbff..055a49e9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -151,6 +151,7 @@ Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/ | [terrain-gallery](terrain-gallery/README.md) | 三个固定 seed 在相同生成参数、光照与材质下的并排对比 | | [mesh-modifier-lab](mesh-modifier-lab/README.md) | 13 种网格变形变体并排对照:类型化修饰图(bend / twist / FFD / 切面 / 样条 / 声波)、雕刻笔刷、粘液回弹、Mesh Fit、顶点编辑器 | | [spline-tube-lab](spline-tube-lab/README.md) | 无源网格生成:样条路径驱动管道 / 带状体 / 自定义截面挤出,含开放与闭合回路、分块、分布采样与行进帧 | +| [procedural-road](procedural-road/README.md) | 程序化多层立交:截面挤出道路/桥墩/标线/导航逻辑叠加(`mesh.roadNetwork`) | | [mesh-impact-lab](mesh-impact-lab/README.md) | Box3D 命中事件驱动网格塑性冲击:法向冲量 → 形变会话 → 重新上传 | | [geometry-stroke-lab](geometry-stroke-lab/README.md) | 几何笔刷轨迹:quad / 三棱柱 / 立方体截面、平面与空间输入、最小间距过滤、撤销、CPU 生成 + GPU 上传 | | [terrain-stamping](terrain-stamping/README.md) | 地形盖章:旋转印章、距离遮罩、对比度、阶地、平滑与热力核,含 Pcg GTS 雪 / 雨 albedo 分支 | diff --git a/examples/procedural-road/README.md b/examples/procedural-road/README.md new file mode 100644 index 00000000..601fd7d5 --- /dev/null +++ b/examples/procedural-road/README.md @@ -0,0 +1,17 @@ +# Procedural Road Lab + +纯数学生成的多层立交道路(无预制道路/桥梁模型): + +- 截面挤出:沥青 / 路缘 / 人行道 / 桥面板 +- 高程桥墩:超过 `pierClearance` 时按间距放置 +- 逻辑标线:边线、虚线、斑马线、导向箭头(几何 + `tex.roadMarkings`) +- 导航叠加:车道中心线与路口转弯弧(`nav` group) + +```sh +make run GAME=examples/procedural-road +# or headless: +VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json ALSOFT_DRIVERS=null \ + xvfb-run -a scripts/smoke_examples.sh procedural-road +``` + +配方:`mesh.roadNetwork`、`tex.roadMarkings`。设计说明见 `docs/dev/程序化道路系统设计.md`。 diff --git a/examples/procedural-road/config.nut b/examples/procedural-road/config.nut new file mode 100644 index 00000000..78b1ff0d --- /dev/null +++ b/examples/procedural-road/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1280 + height = 720 + title = "Procedural Road Lab" + debug = false + hotReload = true +}; diff --git a/examples/procedural-road/main.nut b/examples/procedural-road/main.nut new file mode 100644 index 00000000..58ebec2c --- /dev/null +++ b/examples/procedural-road/main.nut @@ -0,0 +1,102 @@ +// Procedural Road Lab — math-only interchange (no prefab road/bridge meshes). + +persist roadObjects = [] +persist roadCamera = null +persist roadFrame = 0 +persist roadScreenshotSaved = false + +function roadRequire(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result; +} + +function roadUploadGroup(mesh, groupName, tintR, tintG, tintB, yLift) { + local groupIndex = -1; + for (local i = 0; i < mesh.getGroupCount(); ++i) { + if (mesh.getGroupName(i) == groupName) { + groupIndex = i; + break; + } + } + if (groupIndex < 0) return; + local part = mesh.copyGroup(groupIndex); + if (part == null) return; + local uploaded = roadRequire(procgen.uploadMesh(part, gfx), "upload " + groupName).value; + local object = eve.Renderable3D(); + object.setMesh(uploaded); + object.setPosition(0.0, yLift, 0.0); + object.setTint(tintR, tintG, tintB, 1.0); + object.setRoughness(groupName == "asphalt" ? 0.85 : 0.55); + object.setMetallic(0.02); + object.setCastShadow(true); + object.setReceiveShadow(true); + roadObjects.append(object); +} + +if (roadCamera == null) { + roadCamera = eve.Camera3D(); + roadCamera.setEye(42.0, 28.0, 36.0); + roadCamera.setTarget(0.0, 3.0, 0.0); + roadCamera.setUp(0.0, 1.0, 0.0); + roadCamera.setFov(48.0); + roadCamera.setAmbient(0.25, 0.28, 0.34); + roadCamera.setActive(true); + gfx.setDirectionalLight(-0.45, -1.0, -0.35, 1.5, 1.4, 1.25); + gfx.setBackgroundColor(0.45, 0.12, 0.10, 1.0); +} + +if (roadObjects.len() == 0) { + local params = roadRequire(procgen.newParams(), "new params").value; + roadRequire(params.setSeed(7), "seed"); + params.setFloat("span", 48.0); + params.setFloat("bridgeHeight", 8.0); + params.setInt("lanes", 2); + params.setInt("pathSegments", 56); + params.setBool("piers", true); + params.setBool("markings", true); + params.setBool("navigation", true); + params.setBool("junctions", true); + + local mesh = roadRequire(procgen.buildMesh("mesh.roadNetwork", params), "build road mesh").value; + roadUploadGroup(mesh, "asphalt", 0.16, 0.16, 0.18, 0.0); + roadUploadGroup(mesh, "curb", 0.55, 0.55, 0.52, 0.0); + roadUploadGroup(mesh, "sidewalk", 0.72, 0.70, 0.66, 0.0); + roadUploadGroup(mesh, "deck", 0.42, 0.42, 0.40, 0.0); + roadUploadGroup(mesh, "pier", 0.62, 0.60, 0.56, 0.0); + roadUploadGroup(mesh, "marking", 0.95, 0.92, 0.85, 0.01); + roadUploadGroup(mesh, "nav", 0.15, 0.85, 1.0, 0.02); + + local ground = gfx.newMeshBox(1.0, 1.0, 1.0); + local platform = eve.Renderable3D(); + platform.setMesh(ground); + platform.setPosition(0.0, -0.4, 0.0); + platform.setScale(70.0, 0.4, 70.0); + platform.setTint(0.72, 0.14, 0.12, 1.0); + platform.setRoughness(0.9); + roadObjects.append(platform); + + local texParams = roadRequire(procgen.newParams(), "tex params").value; + texParams.setSize(256, 64); + texParams.setBool("zebra", true); + local markings = roadRequire(procgen.generateTexture("tex.roadMarkings", texParams, gfx), "road markings tex").value; + print("PROCEDURAL_ROAD_PASS edgesMeta=" + mesh.getMeta("edges", "?") + + " nodesMeta=" + mesh.getMeta("nodes", "?") + + " groups=" + mesh.getGroupCount() + + " verts=" + mesh.getVertexCount() + + " markingTex=" + markings.getWidth() + "x" + markings.getHeight() + "\n"); +} + +function eve_update(dt) { + roadFrame += 1; + if (roadCamera != null) { + local t = roadFrame * 0.008; + roadCamera.setEye(42.0 * cos(t * 0.35), 26.0 + 4.0 * sin(t * 0.2), 36.0 * sin(t * 0.35)); + roadCamera.setTarget(0.0, 3.0, 0.0); + } + if (!roadScreenshotSaved && roadFrame > 18 && gfx.saveFramePng("procedural-road.png")) { + roadScreenshotSaved = true; + print("procedural-road: screenshot saved\n"); + } +} + +function eve_render() { gfx.clear(); gfx.render3D(); } diff --git a/src/modules/procgen/algorithms/MarchingCubes.cpp b/src/modules/procgen/algorithms/MarchingCubes.cpp index df0981c5..bb549a35 100644 --- a/src/modules/procgen/algorithms/MarchingCubes.cpp +++ b/src/modules/procgen/algorithms/MarchingCubes.cpp @@ -10,6 +10,7 @@ #include "procgen/algorithms/MeshDeformationGeometry.h" #include "procgen/algorithms/LSystemMesh.h" #include "procgen/urban/UrbanOutput.h" +#include "procgen/road/RoadRecipes.h" #include "procgen/algorithms/CastleMesh.h" #include @@ -675,6 +676,7 @@ void MeshRecipeRegistry::registerBuiltins() { registerLinearStructureRecipes(*this); registerLSystemRecipes(*this); urban::registerUrbanMeshRecipes(*this); + road::registerRoadMeshRecipes(*this); registerCastleMeshRecipe(*this); builtinsRegistered_ = true; } diff --git a/src/modules/procgen/road/RoadBake.cpp b/src/modules/procgen/road/RoadBake.cpp new file mode 100644 index 00000000..18a3b380 --- /dev/null +++ b/src/modules/procgen/road/RoadBake.cpp @@ -0,0 +1,533 @@ +#include "procgen/road/RoadBake.h" + +#include "common/Diagnostic.h" +#include "procgen/spline/SplinePath.h" + +#include +#include +#include +#include + +namespace eve::procgen::road { +namespace { + +struct V3 { + float x = 0.f, y = 0.f, z = 0.f; +}; + +V3 operator+(V3 a, V3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +V3 operator-(V3 a, V3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +V3 operator*(V3 a, float s) { return {a.x * s, a.y * s, a.z * s}; } +float dot(V3 a, V3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +V3 cross(V3 a, V3 b) { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} +float length(V3 a) { return std::sqrt(dot(a, a)); } +V3 normalize(V3 a) { + const float l = length(a); + return l > 1e-8f ? a * (1.f / l) : V3{0.f, 1.f, 0.f}; +} + +template +Result bakeFail(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure( + Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.road")); +} + +Result edgeToSpline(const RoadEdge& edge) { + SplinePath path; + auto kind = path.setKindResult("catmullRom"); + if (!kind.ok()) return Result::failure(kind.status()); + path.setClosed(false); + for (const auto& p : edge.controlPoints) { + SplinePoint sp; + sp.x = p.x; + sp.y = p.y; + sp.z = p.z; + auto added = path.addPointResult(sp); + if (!added.ok()) return Result::failure(added.status()); + } + return Result::success(std::move(path)); +} + +float laneCenterOffset(const RoadStyle& style, int lanesForward, int laneIndex) { + const float asphaltHalf = 0.5f * style.laneWidth * static_cast(lanesForward); + return -asphaltHalf + style.laneWidth * (static_cast(laneIndex) + 0.5f); +} + +void appendBox(MeshBuild& mesh, V3 center, V3 side, V3 up, V3 forward, float hx, float hy, float hz, + RoadMaterial material) { + mesh.setActiveGroup(roadMaterialGroup(material)); + const V3 corners[8] = { + center + side * -hx + up * -hy + forward * -hz, center + side * hx + up * -hy + forward * -hz, + center + side * hx + up * hy + forward * -hz, center + side * -hx + up * hy + forward * -hz, + center + side * -hx + up * -hy + forward * hz, center + side * hx + up * -hy + forward * hz, + center + side * hx + up * hy + forward * hz, center + side * -hx + up * hy + forward * hz, + }; + const int faces[6][4] = {{0, 1, 2, 3}, {4, 7, 6, 5}, {0, 4, 5, 1}, {3, 2, 6, 7}, {0, 3, 7, 4}, {1, 5, 6, 2}}; + const V3 normals[6] = {forward * -1.f, forward, up * -1.f, up, side * -1.f, side}; + for (int f = 0; f < 6; ++f) { + const auto base = static_cast(mesh.getVertexCount()); + for (int c = 0; c < 4; ++c) { + const V3& p = corners[faces[f][c]]; + mesh.addVertex(p.x, p.y, p.z, normals[f].x, normals[f].y, normals[f].z, static_cast(c & 1), + static_cast((c >> 1) & 1)); + } + mesh.addTriangle(base, base + 1, base + 2); + mesh.addTriangle(base, base + 2, base + 3); + } +} + +void appendStripQuad(MeshBuild& mesh, V3 a, V3 b, V3 c, V3 d, V3 normal, float u0, float u1, float v0, float v1, + RoadMaterial material) { + mesh.setActiveGroup(roadMaterialGroup(material)); + const auto base = static_cast(mesh.getVertexCount()); + mesh.addVertex(a.x, a.y, a.z, normal.x, normal.y, normal.z, u0, v0); + mesh.addVertex(b.x, b.y, b.z, normal.x, normal.y, normal.z, u1, v0); + mesh.addVertex(c.x, c.y, c.z, normal.x, normal.y, normal.z, u1, v1); + mesh.addVertex(d.x, d.y, d.z, normal.x, normal.y, normal.z, u0, v1); + mesh.addTriangle(base, base + 1, base + 2); + mesh.addTriangle(base, base + 2, base + 3); +} + +Result loftProfileClean(MeshBuild& mesh, const std::vector& frames, + const RoadProfile& profile, float uvMeters) { + if (frames.size() < 2 || profile.points.size() < 2) + return bakeFail(DiagnosticCode::InvalidArgument, "loft needs >=2 frames and profile points"); + + const int ringCount = static_cast(frames.size()); + const int profileCount = static_cast(profile.points.size()); + std::vector ringPositions(static_cast(ringCount * profileCount)); + + for (int ring = 0; ring < ringCount; ++ring) { + const auto& frame = frames[static_cast(ring)]; + const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z}; + const V3 side{frame.sideX, frame.sideY, frame.sideZ}; + const V3 up{frame.upX, frame.upY, frame.upZ}; + for (int i = 0; i < profileCount; ++i) { + const auto& pp = profile.points[static_cast(i)]; + ringPositions[static_cast(ring * profileCount + i)] = origin + side * pp.side + up * pp.up; + } + } + + for (int ring = 0; ring < ringCount - 1; ++ring) { + const float u0 = static_cast(ring) / static_cast(ringCount - 1) * + (uvMeters > 0.f ? uvMeters : 1.f); + const float u1 = static_cast(ring + 1) / static_cast(ringCount - 1) * + (uvMeters > 0.f ? uvMeters : 1.f); + for (int i = 0; i < profileCount - 1; ++i) { + const auto& mat = profile.points[static_cast(i)].material; + const V3& p00 = ringPositions[static_cast(ring * profileCount + i)]; + const V3& p01 = ringPositions[static_cast(ring * profileCount + i + 1)]; + const V3& p10 = ringPositions[static_cast((ring + 1) * profileCount + i)]; + const V3& p11 = ringPositions[static_cast((ring + 1) * profileCount + i + 1)]; + const V3 normal = normalize(cross(p10 - p00, p01 - p00)); + const float v0 = + profile.halfWidth > 1e-5f + ? (profile.points[static_cast(i)].side / profile.halfWidth) * 0.5f + 0.5f + : 0.f; + const float v1 = + profile.halfWidth > 1e-5f + ? (profile.points[static_cast(i + 1)].side / profile.halfWidth) * 0.5f + 0.5f + : 1.f; + appendStripQuad(mesh, p00, p10, p11, p01, normal, u0, u1, v0, v1, mat); + } + } + return Result::success(); +} + +Result addDeckAndPiers(MeshBuild& mesh, const std::vector& frames, const RoadStyle& style, + float halfWidth, bool includePiers) { + if (frames.size() < 2) return Result::success(); + // Deck underside strip. + for (int ring = 0; ring < static_cast(frames.size()) - 1; ++ring) { + const auto& f0 = frames[static_cast(ring)]; + const auto& f1 = frames[static_cast(ring + 1)]; + const V3 o0{f0.sample.x, f0.sample.y, f0.sample.z}; + const V3 o1{f1.sample.x, f1.sample.y, f1.sample.z}; + const V3 s0{f0.sideX, f0.sideY, f0.sideZ}; + const V3 s1{f1.sideX, f1.sideY, f1.sideZ}; + const V3 u0{f0.upX, f0.upY, f0.upZ}; + const V3 u1{f1.upX, f1.upY, f1.upZ}; + const V3 a = o0 + s0 * -halfWidth + u0 * -style.deckThickness; + const V3 b = o0 + s0 * halfWidth + u0 * -style.deckThickness; + const V3 c = o1 + s1 * halfWidth + u1 * -style.deckThickness; + const V3 d = o1 + s1 * -halfWidth + u1 * -style.deckThickness; + appendStripQuad(mesh, a, b, c, d, normalize((u0 + u1) * -0.5f), 0.f, 1.f, 0.f, 1.f, RoadMaterial::Deck); + } + + if (!includePiers || style.pierSpacing <= 1e-3f) return Result::success(); + + float traveled = 0.f; + float nextPier = style.pierSpacing * 0.5f; + for (std::size_t i = 1; i < frames.size(); ++i) { + const auto& a = frames[i - 1]; + const auto& b = frames[i]; + const V3 pa{a.sample.x, a.sample.y, a.sample.z}; + const V3 pb{b.sample.x, b.sample.y, b.sample.z}; + const float seg = length(pb - pa); + float local = 0.f; + while (traveled + (seg - local) >= nextPier - 1e-4f && seg > 1e-5f) { + const float t = (nextPier - traveled) / seg; + if (t >= 0.f && t <= 1.f) { + const V3 pos = pa + (pb - pa) * t; + if (pos.y > style.pierClearance) { + const V3 side = normalize(V3{a.sideX, a.sideY, a.sideZ} + V3{b.sideX, b.sideY, b.sideZ}); + const V3 fwd = normalize(pb - pa); + const V3 up{0.f, 1.f, 0.f}; + const float pierH = pos.y - style.deckThickness; + if (pierH > 0.2f) { + const V3 center{pos.x, pierH * 0.5f, pos.z}; + appendBox(mesh, center, side, up, fwd, style.pierWidth * 0.5f, pierH * 0.5f, + style.pierDepth * 0.5f, RoadMaterial::Pier); + } + } + } + nextPier += style.pierSpacing; + local = nextPier - traveled; + if (nextPier > traveled + seg + style.pierSpacing * 8.f) break; + } + traveled += seg; + } + return Result::success(); +} + +Result addLaneMarkings(MeshBuild& mesh, const std::vector& frames, const RoadEdge& edge) { + if (frames.size() < 2) return Result::success(); + const auto& style = edge.style; + const float asphaltHalf = + 0.5f * style.laneWidth * static_cast(edge.lanesForward + edge.lanesBackward); + auto paintLine = [&](float lateral, bool dashed) { + float traveled = 0.f; + for (std::size_t i = 1; i < frames.size(); ++i) { + const auto& a = frames[i - 1]; + const auto& b = frames[i]; + const V3 pa{a.sample.x, a.sample.y, a.sample.z}; + const V3 pb{b.sample.x, b.sample.y, b.sample.z}; + const V3 sa{a.sideX, a.sideY, a.sideZ}; + const V3 sb{b.sideX, b.sideY, b.sideZ}; + const V3 ua{a.upX, a.upY, a.upZ}; + const V3 ub{b.upX, b.upY, b.upZ}; + const float seg = length(pb - pa); + const bool draw = + !dashed || (std::fmod(traveled, style.dashLength + style.dashGap) < style.dashLength); + if (draw && seg > 1e-5f) { + const float hw = style.markingWidth * 0.5f; + const V3 a0 = pa + sa * (lateral - hw) + ua * 0.02f; + const V3 a1 = pa + sa * (lateral + hw) + ua * 0.02f; + const V3 b1 = pb + sb * (lateral + hw) + ub * 0.02f; + const V3 b0 = pb + sb * (lateral - hw) + ub * 0.02f; + appendStripQuad(mesh, a0, b0, b1, a1, normalize(ua + ub), 0.f, 1.f, 0.f, 1.f, RoadMaterial::Marking); + } + traveled += seg; + } + }; + + paintLine(-asphaltHalf + style.markingWidth, false); + paintLine(asphaltHalf - style.markingWidth, false); + for (int lane = 1; lane < edge.lanesForward; ++lane) { + const float lateral = -asphaltHalf + style.laneWidth * static_cast(lane); + paintLine(lateral, true); + } + return Result::success(); +} + +void appendArrow(MeshBuild& mesh, V3 pos, V3 forward, V3 up, float size) { + const V3 side = normalize(cross(up, forward)); + const V3 tip = pos + forward * size + up * 0.03f; + const V3 left = pos - forward * size * 0.35f + side * (size * 0.45f) + up * 0.03f; + const V3 right = + pos - forward * size * 0.35f - side * (size * 0.45f) + up * 0.03f; + mesh.setActiveGroup(roadMaterialGroup(RoadMaterial::Nav)); + const auto base = static_cast(mesh.getVertexCount()); + mesh.addVertex(tip.x, tip.y, tip.z, up.x, up.y, up.z, 0.5f, 1.f); + mesh.addVertex(left.x, left.y, left.z, up.x, up.y, up.z, 0.f, 0.f); + mesh.addVertex(right.x, right.y, right.z, up.x, up.y, up.z, 1.f, 0.f); + mesh.addTriangle(base, base + 1, base + 2); +} + +Result bakeEdgeGeometry(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network, const RoadEdge& edge, + const RoadBakeOptions& options) { + auto spline = edgeToSpline(edge); + if (!spline.ok()) return Result::failure(spline.status()); + auto length = spline.value().lengthResult(24); + if (!length.ok()) return Result::failure(length.status()); + + auto fromNode = network.nodeResult(edge.from); + auto toNode = network.nodeResult(edge.to); + if (!fromNode.ok() || !toNode.ok()) + return bakeFail(DiagnosticCode::NotFound, "edge endpoints missing during bake"); + + const float trimStart = std::min(fromNode.value().junctionRadius, length.value() * 0.35f); + const float trimEnd = std::min(toNode.value().junctionRadius, length.value() * 0.35f); + if (trimStart + trimEnd >= length.value() - 0.5f) + return Result::success(); // fully inside junction; skip strip + + const int segments = std::max(4, options.pathSegmentsPerEdge); + auto frames = spline.value().sampleFramesResult(segments, true, 0.f, 24); + if (!frames.ok()) return Result::failure(frames.status()); + + // Keep frames whose arc distance is inside the trimmed interval. + std::vector trimmed; + trimmed.reserve(frames.value().size()); + for (const auto& frame : frames.value()) { + const float d = frame.sample.normalizedDistance * length.value(); + if (d + 1e-3f >= trimStart && d - 1e-3f <= length.value() - trimEnd) trimmed.push_back(frame); + } + if (trimmed.size() < 2) return Result::success(); + + auto profile = makeRoadProfile(edge.style, edge.lanesForward, edge.lanesBackward); + if (!profile.ok()) return Result::failure(profile.status()); + + auto lofted = loftProfileClean(mesh, trimmed, profile.value(), edge.style.uvMeters); + if (!lofted.ok()) return lofted; + auto deck = addDeckAndPiers(mesh, trimmed, edge.style, profile.value().halfWidth, options.includePiers); + if (!deck.ok()) return deck; + if (options.includeMarkings) { + auto marks = addLaneMarkings(mesh, trimmed, edge); + if (!marks.ok()) return marks; + } + + if (options.includeNavigation) { + for (int lane = 0; lane < edge.lanesForward; ++lane) { + const float lateral = laneCenterOffset(edge.style, edge.lanesForward, lane); + RoadPolyline poly; + poly.r = 0.15f; + poly.g = 0.9f; + poly.b = 1.f; + poly.width = options.navRibbonHalfWidth * 2.f; + float traveled = 0.f; + float nextArrow = options.arrowSpacing * 0.5f; + V3 prev{}; + bool hasPrev = false; + for (const auto& frame : trimmed) { + const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z}; + const V3 side{frame.sideX, frame.sideY, frame.sideZ}; + const V3 up{frame.upX, frame.upY, frame.upZ}; + const V3 pos = origin + side * lateral + up * 0.05f; + poly.xyz.push_back(pos.x); + poly.xyz.push_back(pos.y); + poly.xyz.push_back(pos.z); + if (hasPrev) { + const float step = length(pos - prev); + traveled += step; + const V3 fwd = normalize(pos - prev); + const V3 lat = normalize(cross(up, fwd)); + const float hw = options.navRibbonHalfWidth; + appendStripQuad(mesh, prev + lat * -hw, pos + lat * -hw, pos + lat * hw, prev + lat * hw, up, 0.f, + 1.f, 0.f, 1.f, RoadMaterial::Nav); + while (traveled >= nextArrow) { + appendArrow(mesh, pos, fwd, up, 0.55f); + nextArrow += options.arrowSpacing; + } + } + prev = pos; + hasPrev = true; + } + overlay.lanes.push_back(std::move(poly)); + } + } + return Result::success(); +} + +Result bakeJunction(MeshBuild& mesh, const RoadNetwork& network, const RoadNode& node) { + std::vector rim; + float y = node.y; + for (const auto& edge : network.edges()) { + if (edge.to != node.id && edge.from != node.id) continue; + auto spline = edgeToSpline(edge); + if (!spline.ok()) continue; + auto len = spline.value().lengthResult(16); + if (!len.ok()) continue; + const float d = edge.to == node.id ? std::max(0.f, len.value() - node.junctionRadius) + : std::min(node.junctionRadius, len.value()); + auto frame = spline.value().travelFrameResult(d, "clamp", 16); + if (!frame.ok()) continue; + const auto& f = frame.value(); + const V3 origin{f.sample.x, f.sample.y, f.sample.z}; + const V3 side{f.sideX, f.sideY, f.sideZ}; + auto profile = makeRoadProfile(edge.style, edge.lanesForward, edge.lanesBackward); + if (!profile.ok()) continue; + const float hw = profile.value().halfWidth; + rim.push_back(origin + side * -hw); + rim.push_back(origin + side * hw); + y = std::max(y, f.sample.y); + } + if (rim.size() < 3) { + // Degenerate: disc around node. + const int segs = 16; + const auto base = static_cast(mesh.getVertexCount()); + mesh.setActiveGroup(roadMaterialGroup(RoadMaterial::Asphalt)); + mesh.addVertex(node.x, y, node.z, 0.f, 1.f, 0.f, 0.5f, 0.5f); + for (int i = 0; i < segs; ++i) { + const float a = static_cast(i) * 6.2831853f / static_cast(segs); + mesh.addVertex(node.x + std::cos(a) * node.junctionRadius, y, + node.z + std::sin(a) * node.junctionRadius, 0.f, 1.f, 0.f, + std::cos(a) * 0.5f + 0.5f, std::sin(a) * 0.5f + 0.5f); + } + for (int i = 0; i < segs; ++i) { + const auto i0 = base + 1u + static_cast(i); + const auto i1 = base + 1u + static_cast((i + 1) % segs); + mesh.addTriangle(base, i0, i1); + } + return Result::success(); + } + + // Fan from node center using rim points (unsorted but visually dense enough for MVP). + mesh.setActiveGroup(roadMaterialGroup(RoadMaterial::Asphalt)); + const auto center = static_cast(mesh.getVertexCount()); + mesh.addVertex(node.x, y, node.z, 0.f, 1.f, 0.f, 0.5f, 0.5f); + for (const auto& p : rim) { + mesh.addVertex(p.x, y, p.z, 0.f, 1.f, 0.f, 0.f, 0.f); + } + for (std::uint32_t i = 0; i + 1 < static_cast(rim.size()); i += 2) { + mesh.addTriangle(center, center + 1 + i, center + 2 + i); + } + + // Zebra strips toward first incoming edge. + for (const auto& edge : network.edges()) { + if (edge.to != node.id) continue; + auto spline = edgeToSpline(edge); + if (!spline.ok()) continue; + auto len = spline.value().lengthResult(16); + if (!len.ok() || len.value() < node.junctionRadius + 1.f) continue; + auto frame = spline.value().travelFrameResult(len.value() - node.junctionRadius * 0.55f, "clamp", 16); + if (!frame.ok()) continue; + const auto& f = frame.value(); + const V3 origin{f.sample.x, f.sample.y, f.sample.z}; + const V3 side{f.sideX, f.sideY, f.sideZ}; + const V3 fwd{f.forwardX, f.forwardY, f.forwardZ}; + const V3 up{f.upX, f.upY, f.upZ}; + const float stripeW = edge.style.laneWidth * static_cast(edge.lanesForward) * 0.5f; + for (int s = 0; s < 5; ++s) { + const float along = static_cast(s) * 0.55f; + const V3 c = origin - fwd * along + up * 0.03f; + const V3 a = c + side * -stripeW; + const V3 b = c + side * stripeW; + const V3 d = a - fwd * 0.28f; + const V3 e = b - fwd * 0.28f; + appendStripQuad(mesh, a, b, e, d, up, 0.f, 1.f, 0.f, 1.f, RoadMaterial::Marking); + } + break; + } + return Result::success(); +} + +Result bakeTurnOverlays(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network, + const RoadBakeOptions& options) { + std::unordered_map paths; + for (const auto& edge : network.edges()) { + auto spline = edgeToSpline(edge); + if (!spline.ok()) return Result::failure(spline.status()); + paths.emplace(edge.id, std::move(spline).takeValue()); + } + + for (const auto& link : network.laneLinks()) { + auto inEdge = network.edgeResult(link.inEdge); + auto outEdge = network.edgeResult(link.outEdge); + if (!inEdge.ok() || !outEdge.ok()) continue; + auto node = network.nodeResult(inEdge.value().to); + if (!node.ok()) continue; + + auto& inPath = paths[link.inEdge]; + auto& outPath = paths[link.outEdge]; + auto inLen = inPath.lengthResult(16); + auto outLen = outPath.lengthResult(16); + if (!inLen.ok() || !outLen.ok()) continue; + + const float inDist = std::max(0.f, inLen.value() - node.value().junctionRadius); + const float outDist = std::min(node.value().junctionRadius, outLen.value()); + auto inFrame = inPath.travelFrameResult(inDist, "clamp", 16); + auto outFrame = outPath.travelFrameResult(outDist, "clamp", 16); + if (!inFrame.ok() || !outFrame.ok()) continue; + + const float inLat = laneCenterOffset(inEdge.value().style, inEdge.value().lanesForward, link.inLane); + const float outLat = laneCenterOffset(outEdge.value().style, outEdge.value().lanesForward, link.outLane); + + const auto& fi = inFrame.value(); + const auto& fo = outFrame.value(); + const V3 p0{fi.sample.x, fi.sample.y, fi.sample.z}; + const V3 p1{fo.sample.x, fo.sample.y, fo.sample.z}; + const V3 si{fi.sideX, fi.sideY, fi.sideZ}; + const V3 so{fo.sideX, fo.sideY, fo.sideZ}; + const V3 ui{fi.upX, fi.upY, fi.upZ}; + const V3 uo{fo.upX, fo.upY, fo.upZ}; + const V3 ti{fi.forwardX, fi.forwardY, fi.forwardZ}; + const V3 to{fo.forwardX, fo.forwardY, fo.forwardZ}; + const V3 a = p0 + si * inLat + ui * 0.06f; + const V3 d = p1 + so * outLat + uo * 0.06f; + const float handle = std::max(2.f, node.value().junctionRadius * 0.65f); + const V3 b = a + ti * handle; + const V3 c = d - to * handle; + + RoadPolyline poly; + poly.r = 0.2f; + poly.g = 0.55f; + poly.b = 1.f; + poly.width = options.navRibbonHalfWidth * 2.f; + const int samples = std::max(4, options.turnSamples); + V3 prev{}; + bool hasPrev = false; + for (int i = 0; i <= samples; ++i) { + const float t = static_cast(i) / static_cast(samples); + const float u = 1.f - t; + const V3 p = a * (u * u * u) + b * (3.f * u * u * t) + c * (3.f * u * t * t) + d * (t * t * t); + poly.xyz.push_back(p.x); + poly.xyz.push_back(p.y); + poly.xyz.push_back(p.z); + if (hasPrev) { + const V3 fwd = normalize(p - prev); + const V3 up = normalize(ui + uo); + const V3 lat = normalize(cross(up, fwd)); + const float hw = options.navRibbonHalfWidth * 0.85f; + appendStripQuad(mesh, prev + lat * -hw, p + lat * -hw, p + lat * hw, prev + lat * hw, up, 0.f, 1.f, 0.f, + 1.f, RoadMaterial::Nav); + } + if (i == samples / 2) { + const V3 fwd = normalize(d - a); + appendArrow(mesh, p, fwd, normalize(ui + uo), 0.45f); + } + prev = p; + hasPrev = true; + } + overlay.turns.push_back(std::move(poly)); + } + return Result::success(); +} + +} // namespace + +Result bakeRoadNetwork(const RoadNetwork& network, const RoadBakeOptions& options) { + if (options.pathSegmentsPerEdge < 2 || options.turnSamples < 2) + return bakeFail(DiagnosticCode::InvalidArgument, "segment counts must be >= 2"); + if (network.edgeCount() == 0) + return bakeFail(DiagnosticCode::PreconditionViolation, "road network has no edges"); + + RoadBakeResult result; + for (const auto& edge : network.edges()) { + auto baked = bakeEdgeGeometry(result.mesh, result.overlay, network, edge, options); + if (!baked.ok()) return Result::failure(baked.status()); + } + if (options.includeJunctions) { + for (const auto& node : network.nodes()) { + auto junction = bakeJunction(result.mesh, network, node); + if (!junction.ok()) return Result::failure(junction.status()); + } + } + if (options.includeNavigation) { + auto turns = bakeTurnOverlays(result.mesh, result.overlay, network, options); + if (!turns.ok()) return Result::failure(turns.status()); + } + + result.mesh.setMeta("generator", "procgen.road"); + result.mesh.setMeta("schema", "eve.procgen.roadNetwork"); + result.mesh.setMeta("schemaVersion", "1"); + result.mesh.setMeta("edges", std::to_string(network.edgeCount())); + result.mesh.setMeta("nodes", std::to_string(network.nodeCount())); + result.mesh.setMeta("laneLinks", std::to_string(network.laneLinkCount())); + if (result.mesh.getVertexCount() < 3) + return bakeFail(DiagnosticCode::InvariantViolation, "bake produced an empty mesh"); + return Result::success(std::move(result)); +} + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadBake.h b/src/modules/procgen/road/RoadBake.h new file mode 100644 index 00000000..bfe8738c --- /dev/null +++ b/src/modules/procgen/road/RoadBake.h @@ -0,0 +1,37 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/road/RoadNetwork.h" +#include "procgen/road/RoadTypes.h" + +namespace eve::procgen::road { + +/** @brief Options for baking a road network into mesh + navigation overlays. */ +struct RoadBakeOptions { + int pathSegmentsPerEdge = 48; + int turnSamples = 12; + bool includePiers = true; + bool includeMarkings = true; + bool includeNavigation = true; + bool includeJunctions = true; + float navRibbonHalfWidth = 0.06f; + float arrowSpacing = 6.f; +}; + +/** @brief Owning bake result: triangle mesh groups plus overlay polylines. */ +struct RoadBakeResult { + MeshBuild mesh; + RoadOverlay overlay; +}; + +/** + * @brief Bake every edge, junction platform, pier, marking and navigation overlay. + * @param network Borrowed live network; must outlive the call only. + * @param options Bake knobs; segment counts must be positive. + * @return Owning mesh/overlay snapshot, or a structured diagnostic. + * @note Synchronous, owner-thread-only; result retains no pointers into network. + */ +[[nodiscard]] Result bakeRoadNetwork(const RoadNetwork& network, + const RoadBakeOptions& options = {}); + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadNetwork.cpp b/src/modules/procgen/road/RoadNetwork.cpp new file mode 100644 index 00000000..b1e4dbaa --- /dev/null +++ b/src/modules/procgen/road/RoadNetwork.cpp @@ -0,0 +1,251 @@ +#include "procgen/road/RoadNetwork.h" + +#include "common/Diagnostic.h" + +#include +#include + +namespace eve::procgen::road { +namespace { + +bool finite3(float x, float y, float z) { + return std::isfinite(x) && std::isfinite(y) && std::isfinite(z); +} + +RoadControlPoint P(float x, float y, float z) { return RoadControlPoint{x, y, z}; } + +} // namespace + +Result RoadNetwork::validateStyle(const RoadStyle& style) const { + auto profile = makeRoadProfile(style, 1, 0); + if (!profile.ok()) return Result::failure(profile.status()); + return Result::success(); +} + +int RoadNetwork::findNodeIndex(std::uint32_t id) const { + const auto it = nodeIndex_.find(id); + return it == nodeIndex_.end() ? -1 : it->second; +} + +int RoadNetwork::findEdgeIndex(std::uint32_t id) const { + const auto it = edgeIndex_.find(id); + return it == edgeIndex_.end() ? -1 : it->second; +} + +Result RoadNetwork::addNode(float x, float y, float z, float junctionRadius) { + if (!finite3(x, y, z) || !std::isfinite(junctionRadius) || junctionRadius <= 0.f) + return Result::failure(Diagnostic::error( + DiagnosticCode::InvalidArgument, "node requires finite position and positive junctionRadius", "node")); + const std::uint32_t id = nextNodeId_++; + nodeIndex_[id] = static_cast(nodes_.size()); + nodes_.push_back(RoadNode{id, x, y, z, junctionRadius}); + ++revision_; + return Result::success(id); +} + +Result RoadNetwork::addEdge(std::uint32_t from, std::uint32_t to, + std::vector controlPoints, int lanesForward, + int lanesBackward, RoadStyle style) { + if (findNodeIndex(from) < 0 || findNodeIndex(to) < 0) + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "edge endpoints must reference live nodes", "edge")); + if (controlPoints.size() < 2) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "edge needs at least two control points", "edge")); + for (const auto& p : controlPoints) { + if (!finite3(p.x, p.y, p.z)) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "control points must be finite", "edge")); + } + auto styleOk = validateStyle(style); + if (!styleOk.ok()) return Result::failure(styleOk.status()); + auto profile = makeRoadProfile(style, lanesForward, lanesBackward); + if (!profile.ok()) return Result::failure(profile.status()); + + const std::uint32_t id = nextEdgeId_++; + edgeIndex_[id] = static_cast(edges_.size()); + RoadEdge edge; + edge.id = id; + edge.from = from; + edge.to = to; + edge.controlPoints = std::move(controlPoints); + edge.lanesForward = lanesForward; + edge.lanesBackward = lanesBackward; + edge.style = style; + edges_.push_back(std::move(edge)); + ++revision_; + return Result::success(id); +} + +Result RoadNetwork::addLaneLink(RoadLaneLink link) { + const int inIdx = findEdgeIndex(link.inEdge); + const int outIdx = findEdgeIndex(link.outEdge); + if (inIdx < 0 || outIdx < 0) + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "lane link references missing edges", "laneLink")); + const auto& inEdge = edges_[static_cast(inIdx)]; + const auto& outEdge = edges_[static_cast(outIdx)]; + if (inEdge.to != outEdge.from) + return Result::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation, + "lane link requires inEdge.to == outEdge.from", "laneLink")); + if (link.inLane < 0 || link.inLane >= inEdge.lanesForward) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "inLane out of range", "laneLink")); + if (link.outLane < 0 || link.outLane >= outEdge.lanesForward) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "outLane out of range", "laneLink")); + laneLinks_.push_back(link); + ++revision_; + return Result::success(); +} + +Result RoadNetwork::connectAllTurns(std::uint32_t nodeId) { + if (findNodeIndex(nodeId) < 0) + return Result::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown node", "nodeId")); + int added = 0; + for (const auto& inEdge : edges_) { + if (inEdge.to != nodeId) continue; + for (const auto& outEdge : edges_) { + if (outEdge.from != nodeId) continue; + if (outEdge.id == inEdge.id) continue; + for (int il = 0; il < inEdge.lanesForward; ++il) { + for (int ol = 0; ol < outEdge.lanesForward; ++ol) { + auto r = addLaneLink(RoadLaneLink{inEdge.id, il, outEdge.id, ol}); + if (!r.ok()) return Result::failure(r.status()); + ++added; + } + } + } + } + return Result::success(added); +} + +Result RoadNetwork::setEdgeStyle(std::uint32_t edgeId, RoadStyle style) { + const int idx = findEdgeIndex(edgeId); + if (idx < 0) + return Result::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown edge", "edgeId")); + auto styleOk = validateStyle(style); + if (!styleOk.ok()) return styleOk; + auto profile = makeRoadProfile(style, edges_[static_cast(idx)].lanesForward, + edges_[static_cast(idx)].lanesBackward); + if (!profile.ok()) return Result::failure(profile.status()); + edges_[static_cast(idx)].style = style; + ++revision_; + return Result::success(); +} + +void RoadNetwork::clear() { + nodes_.clear(); + edges_.clear(); + laneLinks_.clear(); + nodeIndex_.clear(); + edgeIndex_.clear(); + nextNodeId_ = 1; + nextEdgeId_ = 1; + ++revision_; +} + +Result RoadNetwork::nodeResult(std::uint32_t id) const { + const int idx = findNodeIndex(id); + if (idx < 0) + return Result::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown node", "id")); + return Result::success(nodes_[static_cast(idx)]); +} + +Result RoadNetwork::edgeResult(std::uint32_t id) const { + const int idx = findEdgeIndex(id); + if (idx < 0) + return Result::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown edge", "id")); + return Result::success(edges_[static_cast(idx)]); +} + +Result RoadNetwork::makeInterchange(float span, float bridgeHeight, int lanes, std::uint32_t seed) { + if (!std::isfinite(span) || span < 16.f || !std::isfinite(bridgeHeight) || bridgeHeight < 1.f || lanes < 1 || + lanes > 4) + return Result::failure(Diagnostic::error( + DiagnosticCode::InvalidArgument, "span>=16, bridgeHeight>=1, lanes in [1,4] required", "interchange")); + + RoadNetwork network; + const float half = span * 0.5f; + const float h = bridgeHeight; + // Small seed-driven wobble keeps recipe deterministic without looking rigid. + const float wobble = 0.35f * static_cast((seed % 7u) - 3); + + auto nGround = network.addNode(0.f, 0.f, 0.f, 7.5f); + auto nN = network.addNode(0.f, 0.f, -half, 5.f); + auto nS = network.addNode(0.f, 0.f, half, 5.f); + auto nW = network.addNode(-half, 0.f, 0.f, 5.f); + auto nE = network.addNode(half, 0.f, 0.f, 5.f); + auto nElev = network.addNode(0.f, h, 0.f, 6.5f); + auto nNE = network.addNode(half * 0.55f, h, -half * 0.55f, 4.5f); + auto nSE = network.addNode(half * 0.55f, h, half * 0.55f, 4.5f); + auto nSW = network.addNode(-half * 0.55f, h, half * 0.55f, 4.5f); + auto nNW = network.addNode(-half * 0.55f, h, -half * 0.55f, 4.5f); + for (auto* r : {&nGround, &nN, &nS, &nW, &nE, &nElev, &nNE, &nSE, &nSW, &nNW}) { + if (!r->ok()) return Result::failure(r->status()); + } + + RoadStyle ground; + ground.deckThickness = 0.35f; + ground.pierClearance = 100.f; // no piers on ground + RoadStyle bridge = ground; + bridge.deckThickness = 0.7f; + bridge.pierClearance = 1.4f; + bridge.pierSpacing = 10.f + 0.2f * wobble; + + const auto g = nGround.value(); + auto add = [&](std::uint32_t a, std::uint32_t b, std::vector pts, const RoadStyle& style) { + return network.addEdge(a, b, std::move(pts), lanes, 0, style); + }; + + // Ground cross. + auto e1 = add(nN.value(), g, {P(0, 0, -half), P(wobble, 0, -half * 0.45f), P(0, 0, -7.5f)}, ground); + auto e2 = add(g, nS.value(), {P(0, 0, 7.5f), P(-wobble, 0, half * 0.45f), P(0, 0, half)}, ground); + auto e3 = add(nW.value(), g, {P(-half, 0, 0), P(-half * 0.45f, 0, wobble), P(-7.5f, 0, 0)}, ground); + auto e4 = add(g, nE.value(), {P(7.5f, 0, 0), P(half * 0.45f, 0, -wobble), P(half, 0, 0)}, ground); + + // Elevated ring. + const float r = half * 0.55f; + auto e5 = + add(nNW.value(), nNE.value(), + {P(-r, h, -r), P(-r * 0.2f + wobble, h, -r * 1.05f), P(r * 0.2f, h, -r * 1.05f), P(r, h, -r)}, bridge); + auto e6 = + add(nNE.value(), nSE.value(), + {P(r, h, -r), P(r * 1.05f, h, -r * 0.2f), P(r * 1.05f, h, r * 0.2f + wobble), P(r, h, r)}, bridge); + auto e7 = + add(nSE.value(), nSW.value(), + {P(r, h, r), P(r * 0.2f, h, r * 1.05f), P(-r * 0.2f - wobble, h, r * 1.05f), P(-r, h, r)}, bridge); + auto e8 = + add(nSW.value(), nNW.value(), + {P(-r, h, r), P(-r * 1.05f, h, r * 0.2f), P(-r * 1.05f, h, -r * 0.2f - wobble), P(-r, h, -r)}, bridge); + + // Overpass through elevated hub. + auto e9 = add(nNW.value(), nElev.value(), + {P(-r, h, -r), P(-r * 0.35f, h, -r * 0.35f), P(-6.f, h, -6.f)}, bridge); + auto e10 = + add(nElev.value(), nSE.value(), {P(6.f, h, 6.f), P(r * 0.35f, h, r * 0.35f), P(r, h, r)}, bridge); + + // Ramps: ground east → elevated SE, elevated NW → ground west. + auto e11 = add(nE.value(), nSE.value(), + {P(half, 0, 0), P(half * 0.75f, h * 0.35f, half * 0.2f), P(half * 0.6f, h * 0.75f, half * 0.4f), + P(r, h, r)}, + bridge); + auto e12 = add(nNW.value(), nW.value(), + {P(-r, h, -r), P(-half * 0.6f, h * 0.75f, -half * 0.35f), + P(-half * 0.75f, h * 0.35f, -half * 0.15f), P(-half, 0, 0)}, + bridge); + + for (auto* rEdge : {&e1, &e2, &e3, &e4, &e5, &e6, &e7, &e8, &e9, &e10, &e11, &e12}) { + if (!rEdge->ok()) return Result::failure(rEdge->status()); + } + + for (std::uint32_t node : {g, nElev.value(), nNE.value(), nSE.value(), nSW.value(), nNW.value(), nE.value(), + nW.value()}) { + auto turns = network.connectAllTurns(node); + if (!turns.ok()) return Result::failure(turns.status()); + } + + return Result::success(std::move(network)); +} + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadNetwork.h b/src/modules/procgen/road/RoadNetwork.h new file mode 100644 index 00000000..a949c67d --- /dev/null +++ b/src/modules/procgen/road/RoadNetwork.h @@ -0,0 +1,64 @@ +#pragma once + +#include "procgen/road/RoadTypes.h" + +#include +#include +#include + +namespace eve::procgen::road { + +/** + * @brief Owning directed road graph with lane connectivity. + * + * Mutations are synchronous and owner-thread only. Revision bumps on every + * successful structural change. Handles/ids are stable until erase. + */ +class RoadNetwork { +public: + /** @brief Insert a junction node after validating finite coordinates. */ + [[nodiscard]] Result addNode(float x, float y, float z, float junctionRadius = 6.f); + /** @brief Insert a directed edge with at least two control points. */ + [[nodiscard]] Result addEdge(std::uint32_t from, std::uint32_t to, + std::vector controlPoints, int lanesForward = 2, + int lanesBackward = 0, RoadStyle style = {}); + /** @brief Register one legal turn inside a shared junction node. */ + [[nodiscard]] Result addLaneLink(RoadLaneLink link); + /** @brief Auto-connect every incoming lane to every outgoing lane at one node. */ + [[nodiscard]] Result connectAllTurns(std::uint32_t nodeId); + /** @brief Replace edge style atomically. */ + [[nodiscard]] Result setEdgeStyle(std::uint32_t edgeId, RoadStyle style); + /** @brief Clear every node, edge and link. */ + void clear(); + + [[nodiscard]] int nodeCount() const noexcept { return static_cast(nodes_.size()); } + [[nodiscard]] int edgeCount() const noexcept { return static_cast(edges_.size()); } + [[nodiscard]] int laneLinkCount() const noexcept { return static_cast(laneLinks_.size()); } + [[nodiscard]] std::uint64_t revision() const noexcept { return revision_; } + + [[nodiscard]] Result nodeResult(std::uint32_t id) const; + [[nodiscard]] Result edgeResult(std::uint32_t id) const; + [[nodiscard]] const std::vector& nodes() const noexcept { return nodes_; } + [[nodiscard]] const std::vector& edges() const noexcept { return edges_; } + [[nodiscard]] const std::vector& laneLinks() const noexcept { return laneLinks_; } + + /** @brief Build a multi-level interchange demo graph (ground cross + elevated loop + ramps). */ + [[nodiscard]] static Result makeInterchange(float span = 48.f, float bridgeHeight = 8.f, + int lanes = 2, std::uint32_t seed = 1); + +private: + [[nodiscard]] Result validateStyle(const RoadStyle& style) const; + [[nodiscard]] int findNodeIndex(std::uint32_t id) const; + [[nodiscard]] int findEdgeIndex(std::uint32_t id) const; + + std::vector nodes_; + std::vector edges_; + std::vector laneLinks_; + std::unordered_map nodeIndex_; + std::unordered_map edgeIndex_; + std::uint32_t nextNodeId_ = 1; + std::uint32_t nextEdgeId_ = 1; + std::uint64_t revision_ = 0; +}; + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadProfile.cpp b/src/modules/procgen/road/RoadProfile.cpp new file mode 100644 index 00000000..dd31601c --- /dev/null +++ b/src/modules/procgen/road/RoadProfile.cpp @@ -0,0 +1,82 @@ +#include "procgen/road/RoadTypes.h" + +#include "common/Diagnostic.h" + +#include + +namespace eve::procgen::road { +namespace { + +Result validateStyle(const RoadStyle& style) { + const float values[] = {style.laneWidth, style.curbWidth, style.curbHeight, style.sidewalkWidth, + style.sidewalkHeight, style.deckThickness, style.pierWidth, style.pierDepth, + style.pierSpacing, style.pierClearance, style.markingWidth, style.dashLength, + style.dashGap, style.uvMeters}; + for (float v : values) { + if (!std::isfinite(v) || v < 0.f) + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "road style values must be finite and non-negative", + "style")); + } + if (style.laneWidth <= 1e-4f) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "laneWidth must be positive", "laneWidth")); + if (style.uvMeters <= 1e-4f) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "uvMeters must be positive", "uvMeters")); + return Result::success(); +} + +void pushPoint(RoadProfile& profile, float side, float up, RoadMaterial material) { + profile.points.push_back(RoadProfilePoint{side, up, material}); +} + +} // namespace + +const char* roadMaterialGroup(RoadMaterial material) noexcept { + switch (material) { + case RoadMaterial::Asphalt: return "asphalt"; + case RoadMaterial::Curb: return "curb"; + case RoadMaterial::Sidewalk: return "sidewalk"; + case RoadMaterial::Deck: return "deck"; + case RoadMaterial::Pier: return "pier"; + case RoadMaterial::Marking: return "marking"; + case RoadMaterial::Nav: return "nav"; + } + return "asphalt"; +} + +Result makeRoadProfile(const RoadStyle& style, int lanesForward, int lanesBackward) { + auto ok = validateStyle(style); + if (!ok.ok()) return Result::failure(ok.status()); + if (lanesForward < 1 || lanesForward > 8) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "lanesForward must be in [1,8]", "lanesForward")); + if (lanesBackward < 0 || lanesBackward > 8) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "lanesBackward must be in [0,8]", "lanesBackward")); + + const float asphaltHalf = + 0.5f * style.laneWidth * static_cast(lanesForward + lanesBackward); + const float curbOuter = asphaltHalf + style.curbWidth; + const float sidewalkOuter = curbOuter + style.sidewalkWidth; + + RoadProfile profile; + profile.halfWidth = sidewalkOuter; + + // Open U-channel from left sidewalk outer → right sidewalk outer (driving surface on top). + pushPoint(profile, -sidewalkOuter, style.sidewalkHeight, RoadMaterial::Sidewalk); + pushPoint(profile, -curbOuter, style.sidewalkHeight, RoadMaterial::Sidewalk); + pushPoint(profile, -curbOuter, style.curbHeight, RoadMaterial::Curb); + pushPoint(profile, -asphaltHalf, style.curbHeight, RoadMaterial::Curb); + pushPoint(profile, -asphaltHalf, 0.f, RoadMaterial::Asphalt); + pushPoint(profile, asphaltHalf, 0.f, RoadMaterial::Asphalt); + pushPoint(profile, asphaltHalf, style.curbHeight, RoadMaterial::Curb); + pushPoint(profile, curbOuter, style.curbHeight, RoadMaterial::Curb); + pushPoint(profile, curbOuter, style.sidewalkHeight, RoadMaterial::Sidewalk); + pushPoint(profile, sidewalkOuter, style.sidewalkHeight, RoadMaterial::Sidewalk); + + return Result::success(std::move(profile)); +} + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadRecipes.cpp b/src/modules/procgen/road/RoadRecipes.cpp new file mode 100644 index 00000000..75f36e9e --- /dev/null +++ b/src/modules/procgen/road/RoadRecipes.cpp @@ -0,0 +1,132 @@ +#include "procgen/road/RoadRecipes.h" + +#include "image/ImageData.h" +#include "procgen/ParamSchema.h" +#include "procgen/algorithms/MarchingCubes.h" +#include "procgen/texture/TextureRecipe.h" + +#include +#include + +namespace eve::procgen::road { +namespace { + +RoadBakeOptions bakeOptionsFromParams(const Params& params) { + RoadBakeOptions options; + options.pathSegmentsPerEdge = std::max(4, params.getInt("pathSegments", 48)); + options.turnSamples = std::max(4, params.getInt("turnSamples", 12)); + options.includePiers = params.getBool("piers", true); + options.includeMarkings = params.getBool("markings", true); + options.includeNavigation = params.getBool("navigation", true); + options.includeJunctions = params.getBool("junctions", true); + return options; +} + +Result networkFromParams(const Params& params) { + return RoadNetwork::makeInterchange(params.getFloat("span", 48.f), params.getFloat("bridgeHeight", 8.f), + std::max(1, params.getInt("lanes", 2)), params.getSeed()); +} + +} // namespace + +bool generateRoadNetworkMesh(const Params& params, MeshBuild& out, std::string& error) { + auto network = networkFromParams(params); + if (!network.ok()) { + error = network.status().describe(); + return false; + } + auto baked = bakeRoadNetwork(network.value(), bakeOptionsFromParams(params)); + if (!baked.ok()) { + error = baked.status().describe(); + return false; + } + out = std::move(baked.value().mesh); + return true; +} + +Result generateRoadNetworkOverlay(const Params& params) { + auto network = networkFromParams(params); + if (!network.ok()) return Result::failure(network.status()); + auto baked = bakeRoadNetwork(network.value(), bakeOptionsFromParams(params)); + if (!baked.ok()) return Result::failure(baked.status()); + return Result::success(std::move(baked.value().overlay)); +} + +std::unique_ptr generateRoadMarkingsTexture(const Params& params, std::string& error) { + const int width = std::clamp(params.getWidth() > 0 ? params.getWidth() : params.getInt("width", 256), 8, 2048); + const int height = std::clamp(params.getHeight() > 0 ? params.getHeight() : params.getInt("height", 64), 8, 2048); + const float dashPeriod = std::max(0.05f, params.getFloat("dashPeriod", 0.18f)); + const float dashDuty = std::clamp(params.getFloat("dashDuty", 0.45f), 0.05f, 0.95f); + const bool zebra = params.getBool("zebra", true); + + auto image = std::make_unique(width, height, "RGBA8"); + for (int y = 0; y < height; ++y) { + const float v = (static_cast(y) + 0.5f) / static_cast(height); + for (int x = 0; x < width; ++x) { + const float u = (static_cast(x) + 0.5f) / static_cast(width); + float r = 0.18f, g = 0.18f, b = 0.20f; + + // White edge lines near v=0 and v=1. + if (v < 0.08f || v > 0.92f) { + r = g = b = 0.92f; + } + // Yellow dashed center. + else if (std::fabs(v - 0.5f) < 0.035f) { + const float phase = std::fmod(u, dashPeriod) / dashPeriod; + if (phase < dashDuty) { + r = 0.95f; + g = 0.75f; + b = 0.12f; + } + } + // Lane divider dashes at 1/4 and 3/4. + else if (std::fabs(v - 0.25f) < 0.02f || std::fabs(v - 0.75f) < 0.02f) { + const float phase = std::fmod(u + 0.05f, dashPeriod) / dashPeriod; + if (phase < dashDuty) r = g = b = 0.88f; + } + + // Zebra band near u ends (intersection approach). + if (zebra && (u < 0.08f || u > 0.92f)) { + const int stripe = static_cast(v * 12.f); + if ((stripe & 1) == 0) r = g = b = 0.95f; + } + + // Direction arrow glyph in the middle third. + const float au = (u - 0.42f) / 0.16f; + const float av = (v - 0.5f) / 0.18f; + if (au >= 0.f && au <= 1.f && std::fabs(av) <= (1.f - au) * 0.55f + 0.08f) { + r = 0.95f; + g = 0.45f; + b = 0.08f; + } + + image->setPixel(x, y, image::ImageData::Colorf{r, g, b, 1.f}); + } + } + (void)error; + return image; +} + +void registerRoadMeshRecipes(MeshRecipeRegistry& registry) { + RecipeDescriptor schema{"mesh.roadNetwork", "Procedural Road Network", "Mesh", {}}; + schema.params.push_back(ParamDescriptor::integer("seed", "Seed", 1, 0, 2147483647)); + schema.params.push_back(ParamDescriptor::floating("span", "Span", 48.f, 16.f, 256.f, 1.f)); + schema.params.push_back(ParamDescriptor::floating("bridgeHeight", "Bridge Height", 8.f, 1.f, 64.f, 0.5f)); + schema.params.push_back(ParamDescriptor::integer("lanes", "Lanes", 2, 1, 4)); + schema.params.push_back(ParamDescriptor::integer("pathSegments", "Path Segments", 48, 8, 256)); + schema.params.push_back(ParamDescriptor::boolean("piers", "Piers", true)); + schema.params.push_back(ParamDescriptor::boolean("markings", "Markings", true)); + schema.params.push_back(ParamDescriptor::boolean("navigation", "Navigation Mesh", true)); + schema.params.push_back(ParamDescriptor::boolean("junctions", "Junctions", true)); + registry.registerRecipe(std::move(schema), generateRoadNetworkMesh); +} + +void registerRoadTextureRecipes(TextureRecipeRegistry& registry) { + RecipeDescriptor schema = RecipeDescriptor::grid("tex.roadMarkings", "Road Markings Logic", "Road", 8, 8); + schema.params.push_back(ParamDescriptor::floating("dashPeriod", "Dash Period", 0.18f, 0.02f, 1.f, 0.01f)); + schema.params.push_back(ParamDescriptor::floating("dashDuty", "Dash Duty", 0.45f, 0.05f, 0.95f, 0.01f)); + schema.params.push_back(ParamDescriptor::boolean("zebra", "Zebra Ends", true)); + registry.registerRecipe(std::move(schema), generateRoadMarkingsTexture); +} + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/road/RoadRecipes.h b/src/modules/procgen/road/RoadRecipes.h new file mode 100644 index 00000000..b54dbdb7 --- /dev/null +++ b/src/modules/procgen/road/RoadRecipes.h @@ -0,0 +1,34 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/Params.h" +#include "procgen/road/RoadBake.h" +#include "procgen/road/RoadNetwork.h" + +#include +#include + +namespace eve::image { +class ImageData; +} + +namespace eve::procgen { +class MeshRecipeRegistry; +class TextureRecipeRegistry; + +namespace road { + +/** @brief Generate a multi-level interchange mesh from Params (span/height/lanes/seed). */ +bool generateRoadNetworkMesh(const Params& params, MeshBuild& out, std::string& error); + +/** @brief Bake navigation overlay for the same Params used by mesh.roadNetwork. */ +[[nodiscard]] Result generateRoadNetworkOverlay(const Params& params); + +/** @brief Pure-function road marking atlas (asphalt + edge + dashed center + zebra). */ +std::unique_ptr generateRoadMarkingsTexture(const Params& params, std::string& error); + +void registerRoadMeshRecipes(MeshRecipeRegistry& registry); +void registerRoadTextureRecipes(TextureRecipeRegistry& registry); + +} // namespace road +} // namespace eve::procgen diff --git a/src/modules/procgen/road/RoadTypes.h b/src/modules/procgen/road/RoadTypes.h new file mode 100644 index 00000000..a32c06ff --- /dev/null +++ b/src/modules/procgen/road/RoadTypes.h @@ -0,0 +1,119 @@ +#pragma once + +#include "common/Result.h" + +#include +#include +#include + +namespace eve::procgen::road { + +/** @brief One authored control point on a road centerline (world space, Y-up). */ +struct RoadControlPoint { + float x = 0.f; + float y = 0.f; + float z = 0.f; +}; + +/** @brief Cross-section and structural style for one road edge. */ +struct RoadStyle { + float laneWidth = 3.5f; + float curbWidth = 0.35f; + float curbHeight = 0.18f; + float sidewalkWidth = 1.6f; + float sidewalkHeight = 0.12f; + float deckThickness = 0.55f; + float pierWidth = 1.1f; + float pierDepth = 1.1f; + float pierSpacing = 12.f; + float pierClearance = 1.25f; + float markingWidth = 0.12f; + float dashLength = 3.f; + float dashGap = 3.f; + float uvMeters = 8.f; +}; + +/** @brief Junction node owned by a road network. */ +struct RoadNode { + std::uint32_t id = 0; + float x = 0.f; + float y = 0.f; + float z = 0.f; + float junctionRadius = 6.f; +}; + +/** @brief Directed centerline edge with optional reverse lanes. */ +struct RoadEdge { + std::uint32_t id = 0; + std::uint32_t from = 0; + std::uint32_t to = 0; + std::vector controlPoints; + int lanesForward = 2; + int lanesBackward = 0; + RoadStyle style; +}; + +/** + * @brief One legal lane-to-lane connection inside a junction. + * + * Ownership: stored by value in RoadNetwork; edge/lane indices are stable until + * the network mutates and bumping revision. + */ +struct RoadLaneLink { + std::uint32_t inEdge = 0; + int inLane = 0; + std::uint32_t outEdge = 0; + int outLane = 0; +}; + +/** @brief One renderer-neutral polyline chunk for navigation / marking overlays. */ +struct RoadPolyline { + std::vector xyz; ///< Packed xyz samples. + float r = 0.2f, g = 0.85f, b = 1.f, a = 1.f; + float width = 0.08f; + bool closed = false; +}; + +/** @brief Owning bake-time overlay snapshot (no pointers into the network). */ +struct RoadOverlay { + std::vector lanes; + std::vector turns; +}; + +/** @brief Profile material tags used when lofting a road cross-section. */ +enum class RoadMaterial : std::uint8_t { + Asphalt = 0, + Curb, + Sidewalk, + Deck, + Pier, + Marking, + Nav, +}; + +/** @brief One 2D profile sample in the edge local (side, up) plane. */ +struct RoadProfilePoint { + float side = 0.f; + float up = 0.f; + RoadMaterial material = RoadMaterial::Asphalt; +}; + +/** @brief Owning open profile polyline for one edge style and lane layout. */ +struct RoadProfile { + std::vector points; + float halfWidth = 0.f; +}; + +/** + * @brief Build a mathematical road cross-section for the given lane counts. + * @param style Geometric style; widths must be finite and non-negative. + * @param lanesForward Forward driving lanes (>= 1). + * @param lanesBackward Optional opposite lanes (>= 0). + * @return Owning profile, or a structured validation diagnostic. + */ +[[nodiscard]] Result makeRoadProfile(const RoadStyle& style, int lanesForward, int lanesBackward); + +/** @brief Map a material tag to the MeshBuild group name used by the baker. */ +[[nodiscard]] const char* roadMaterialGroup(RoadMaterial material) noexcept; + +} // namespace eve::procgen::road diff --git a/src/modules/procgen/texture/BuiltinTextures.cpp b/src/modules/procgen/texture/BuiltinTextures.cpp index 18e1cd73..fca7d5c5 100644 --- a/src/modules/procgen/texture/BuiltinTextures.cpp +++ b/src/modules/procgen/texture/BuiltinTextures.cpp @@ -4,6 +4,7 @@ #include "procgen/texture/ColorRamp.h" #include "procgen/texture/CloudField.h" #include "procgen/texture/CloudShadow.h" +#include "procgen/road/RoadRecipes.h" #include "image/ImageData.h" @@ -511,6 +512,7 @@ void TextureRecipeRegistry::registerBuiltins() { return makeFromHeightFn(params, error, def); }); } + road::registerRoadTextureRecipes(*this); builtinsRegistered_ = true; } diff --git a/test/procgen_road.cpp b/test/procgen_road.cpp new file mode 100644 index 00000000..8515fa94 --- /dev/null +++ b/test/procgen_road.cpp @@ -0,0 +1,116 @@ +#include "procgen/road/RoadBake.h" +#include "procgen/road/RoadNetwork.h" +#include "procgen/road/RoadRecipes.h" +#include "procgen/road/RoadTypes.h" +#include "procgen/Params.h" +#include "procgen/algorithms/MarchingCubes.h" +#include "procgen/texture/TextureRecipe.h" + +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve; +using namespace eve::procgen; +using namespace eve::procgen::road; + +namespace { + +void requireOk(Result result) { REQUIRE(result.ok()); } + +} // namespace + +TEST_CASE("procgen.road.profile.laneWidths") { + RoadStyle style; + style.laneWidth = 3.5f; + style.curbWidth = 0.4f; + style.sidewalkWidth = 1.5f; + auto profile = makeRoadProfile(style, 2, 0); + REQUIRE(profile.ok()); + CHECK(std::fabs(profile.value().halfWidth - (3.5f + 0.4f + 1.5f)) < 1e-4f); + CHECK_GE(profile.value().points.size(), 8u); + CHECK_EQ(std::string(roadMaterialGroup(RoadMaterial::Asphalt)), "asphalt"); +} + +TEST_CASE("procgen.road.network.interchangeBakeHasGroupsAndOverlay") { + auto network = RoadNetwork::makeInterchange(40.f, 7.f, 2, 3); + REQUIRE(network.ok()); + CHECK_GE(network.value().nodeCount(), 8); + CHECK_GE(network.value().edgeCount(), 8); + CHECK_GT(network.value().laneLinkCount(), 0); + + RoadBakeOptions options; + options.pathSegmentsPerEdge = 24; + options.turnSamples = 8; + auto baked = bakeRoadNetwork(network.value(), options); + REQUIRE(baked.ok()); + CHECK_GT(baked.value().mesh.getVertexCount(), 100); + CHECK_GT(baked.value().mesh.getIndexCount(), 100); + CHECK_GT(baked.value().mesh.getGroupCount(), 3); + CHECK_GT(baked.value().overlay.lanes.size(), 0u); + CHECK_GT(baked.value().overlay.turns.size(), 0u); + CHECK_EQ(baked.value().mesh.getMeta("schema", ""), "eve.procgen.roadNetwork"); + + bool sawAsphalt = false, sawPier = false, sawNav = false, sawMarking = false; + for (int i = 0; i < baked.value().mesh.getGroupCount(); ++i) { + const auto name = baked.value().mesh.getGroupName(i); + if (name == "asphalt") sawAsphalt = true; + if (name == "pier") sawPier = true; + if (name == "nav") sawNav = true; + if (name == "marking") sawMarking = true; + } + CHECK(sawAsphalt); + CHECK(sawPier); + CHECK(sawNav); + CHECK(sawMarking); +} + +TEST_CASE("procgen.road.network.rejectsInvalidEdge") { + RoadNetwork network; + auto a = network.addNode(0.f, 0.f, 0.f); + auto b = network.addNode(10.f, 0.f, 0.f); + REQUIRE(a.ok()); + REQUIRE(b.ok()); + auto bad = network.addEdge(a.value(), b.value(), {RoadControlPoint{0.f, 0.f, 0.f}}, 2, 0); + CHECK(!bad.ok()); +} + +TEST_CASE("procgen.road.recipe.meshAndTextureDeterministic") { + MeshRecipeRegistry::instance().registerBuiltins(); + TextureRecipeRegistry::instance().registerBuiltins(); + REQUIRE(MeshRecipeRegistry::instance().has("mesh.roadNetwork")); + REQUIRE(TextureRecipeRegistry::instance().has("tex.roadMarkings")); + + Params params; + params.setSeed(11); + params.setFloat("span", 36.f); + params.setFloat("bridgeHeight", 6.f); + params.setInt("lanes", 2); + params.setInt("pathSegments", 20); + + MeshBuild first, second; + std::string error; + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.roadNetwork", params, first, error)); + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.roadNetwork", params, second, error)); + CHECK_EQ(first.getVertexCount(), second.getVertexCount()); + CHECK_EQ(first.getIndexCount(), second.getIndexCount()); + + params.setSize(128, 32); + auto tex = TextureRecipeRegistry::instance().generate("tex.roadMarkings", params, error); + REQUIRE(tex); + CHECK_EQ(tex->getWidth(), 128); + CHECK_EQ(tex->getHeight(), 32); +} + +TEST_CASE("procgen.road.overlay.fromParams") { + Params params; + params.setSeed(2); + params.setFloat("span", 32.f); + params.setFloat("bridgeHeight", 5.f); + params.setInt("lanes", 1); + params.setInt("pathSegments", 16); + auto overlay = generateRoadNetworkOverlay(params); + REQUIRE(overlay.ok()); + CHECK_GT(overlay.value().lanes.size(), 0u); +}