Add procedural road network system under procgen

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: 西风逍遥游 <sunxfancy@gmail.com>
This commit is contained in:
Cursor Agentand西风逍遥游 committed 2026-09-18 13:50:36 +00:00
1 parent bfa8996a0d
commit a118a4876a
16 files changed
+1618

No files matched your search

+119
View File
@@ -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<T>` / 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` 中心线自动导入
+1
View File
@@ -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 分支 |
+17
View File
@@ -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`。
+7
View File
@@ -0,0 +1,7 @@
config <- {
width = 1280
height = 720
title = "Procedural Road Lab"
debug = false
hotReload = true
};
+102
View File
@@ -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(); }
@@ -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 <algorithm>
@@ -675,6 +676,7 @@ void MeshRecipeRegistry::registerBuiltins() {
registerLinearStructureRecipes(*this);
registerLSystemRecipes(*this);
urban::registerUrbanMeshRecipes(*this);
road::registerRoadMeshRecipes(*this);
registerCastleMeshRecipe(*this);
builtinsRegistered_ = true;
}
+533
View File
@@ -0,0 +1,533 @@
#include "procgen/road/RoadBake.h"
#include "common/Diagnostic.h"
#include "procgen/spline/SplinePath.h"
#include <algorithm>
#include <cmath>
#include <unordered_map>
#include <utility>
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 <class T>
Result<T> bakeFail(DiagnosticCode code, std::string message, std::string path = {}) {
return Result<T>::failure(
Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.road"));
}
Result<SplinePath> edgeToSpline(const RoadEdge& edge) {
SplinePath path;
auto kind = path.setKindResult("catmullRom");
if (!kind.ok()) return Result<SplinePath>::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<SplinePath>::failure(added.status());
}
return Result<SplinePath>::success(std::move(path));
}
float laneCenterOffset(const RoadStyle& style, int lanesForward, int laneIndex) {
const float asphaltHalf = 0.5f * style.laneWidth * static_cast<float>(lanesForward);
return -asphaltHalf + style.laneWidth * (static_cast<float>(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<std::uint32_t>(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<float>(c & 1),
static_cast<float>((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<std::uint32_t>(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<void> loftProfileClean(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames,
const RoadProfile& profile, float uvMeters) {
if (frames.size() < 2 || profile.points.size() < 2)
return bakeFail<void>(DiagnosticCode::InvalidArgument, "loft needs >=2 frames and profile points");
const int ringCount = static_cast<int>(frames.size());
const int profileCount = static_cast<int>(profile.points.size());
std::vector<V3> ringPositions(static_cast<std::size_t>(ringCount * profileCount));
for (int ring = 0; ring < ringCount; ++ring) {
const auto& frame = frames[static_cast<std::size_t>(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<std::size_t>(i)];
ringPositions[static_cast<std::size_t>(ring * profileCount + i)] = origin + side * pp.side + up * pp.up;
}
}
for (int ring = 0; ring < ringCount - 1; ++ring) {
const float u0 = static_cast<float>(ring) / static_cast<float>(ringCount - 1) *
(uvMeters > 0.f ? uvMeters : 1.f);
const float u1 = static_cast<float>(ring + 1) / static_cast<float>(ringCount - 1) *
(uvMeters > 0.f ? uvMeters : 1.f);
for (int i = 0; i < profileCount - 1; ++i) {
const auto& mat = profile.points[static_cast<std::size_t>(i)].material;
const V3& p00 = ringPositions[static_cast<std::size_t>(ring * profileCount + i)];
const V3& p01 = ringPositions[static_cast<std::size_t>(ring * profileCount + i + 1)];
const V3& p10 = ringPositions[static_cast<std::size_t>((ring + 1) * profileCount + i)];
const V3& p11 = ringPositions[static_cast<std::size_t>((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<std::size_t>(i)].side / profile.halfWidth) * 0.5f + 0.5f
: 0.f;
const float v1 =
profile.halfWidth > 1e-5f
? (profile.points[static_cast<std::size_t>(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<void>::success();
}
Result<void> addDeckAndPiers(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames, const RoadStyle& style,
float halfWidth, bool includePiers) {
if (frames.size() < 2) return Result<void>::success();
// Deck underside strip.
for (int ring = 0; ring < static_cast<int>(frames.size()) - 1; ++ring) {
const auto& f0 = frames[static_cast<std::size_t>(ring)];
const auto& f1 = frames[static_cast<std::size_t>(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<void>::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<void>::success();
}
Result<void> addLaneMarkings(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames, const RoadEdge& edge) {
if (frames.size() < 2) return Result<void>::success();
const auto& style = edge.style;
const float asphaltHalf =
0.5f * style.laneWidth * static_cast<float>(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<float>(lane);
paintLine(lateral, true);
}
return Result<void>::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<std::uint32_t>(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<void> bakeEdgeGeometry(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network, const RoadEdge& edge,
const RoadBakeOptions& options) {
auto spline = edgeToSpline(edge);
if (!spline.ok()) return Result<void>::failure(spline.status());
auto length = spline.value().lengthResult(24);
if (!length.ok()) return Result<void>::failure(length.status());
auto fromNode = network.nodeResult(edge.from);
auto toNode = network.nodeResult(edge.to);
if (!fromNode.ok() || !toNode.ok())
return bakeFail<void>(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<void>::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<void>::failure(frames.status());
// Keep frames whose arc distance is inside the trimmed interval.
std::vector<SplineFrameSample> 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<void>::success();
auto profile = makeRoadProfile(edge.style, edge.lanesForward, edge.lanesBackward);
if (!profile.ok()) return Result<void>::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<void>::success();
}
Result<void> bakeJunction(MeshBuild& mesh, const RoadNetwork& network, const RoadNode& node) {
std::vector<V3> 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<std::uint32_t>(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<float>(i) * 6.2831853f / static_cast<float>(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<std::uint32_t>(i);
const auto i1 = base + 1u + static_cast<std::uint32_t>((i + 1) % segs);
mesh.addTriangle(base, i0, i1);
}
return Result<void>::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<std::uint32_t>(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<std::uint32_t>(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<float>(edge.lanesForward) * 0.5f;
for (int s = 0; s < 5; ++s) {
const float along = static_cast<float>(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<void>::success();
}
Result<void> bakeTurnOverlays(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network,
const RoadBakeOptions& options) {
std::unordered_map<std::uint32_t, SplinePath> paths;
for (const auto& edge : network.edges()) {
auto spline = edgeToSpline(edge);
if (!spline.ok()) return Result<void>::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<float>(i) / static_cast<float>(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<void>::success();
}
} // namespace
Result<RoadBakeResult> bakeRoadNetwork(const RoadNetwork& network, const RoadBakeOptions& options) {
if (options.pathSegmentsPerEdge < 2 || options.turnSamples < 2)
return bakeFail<RoadBakeResult>(DiagnosticCode::InvalidArgument, "segment counts must be >= 2");
if (network.edgeCount() == 0)
return bakeFail<RoadBakeResult>(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<RoadBakeResult>::failure(baked.status());
}
if (options.includeJunctions) {
for (const auto& node : network.nodes()) {
auto junction = bakeJunction(result.mesh, network, node);
if (!junction.ok()) return Result<RoadBakeResult>::failure(junction.status());
}
}
if (options.includeNavigation) {
auto turns = bakeTurnOverlays(result.mesh, result.overlay, network, options);
if (!turns.ok()) return Result<RoadBakeResult>::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<RoadBakeResult>(DiagnosticCode::InvariantViolation, "bake produced an empty mesh");
return Result<RoadBakeResult>::success(std::move(result));
}
} // namespace eve::procgen::road
+37
View File
@@ -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<RoadBakeResult> bakeRoadNetwork(const RoadNetwork& network,
const RoadBakeOptions& options = {});
} // namespace eve::procgen::road
+251
View File
@@ -0,0 +1,251 @@
#include "procgen/road/RoadNetwork.h"
#include "common/Diagnostic.h"
#include <cmath>
#include <utility>
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<void> RoadNetwork::validateStyle(const RoadStyle& style) const {
auto profile = makeRoadProfile(style, 1, 0);
if (!profile.ok()) return Result<void>::failure(profile.status());
return Result<void>::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<std::uint32_t> RoadNetwork::addNode(float x, float y, float z, float junctionRadius) {
if (!finite3(x, y, z) || !std::isfinite(junctionRadius) || junctionRadius <= 0.f)
return Result<std::uint32_t>::failure(Diagnostic::error(
DiagnosticCode::InvalidArgument, "node requires finite position and positive junctionRadius", "node"));
const std::uint32_t id = nextNodeId_++;
nodeIndex_[id] = static_cast<int>(nodes_.size());
nodes_.push_back(RoadNode{id, x, y, z, junctionRadius});
++revision_;
return Result<std::uint32_t>::success(id);
}
Result<std::uint32_t> RoadNetwork::addEdge(std::uint32_t from, std::uint32_t to,
std::vector<RoadControlPoint> controlPoints, int lanesForward,
int lanesBackward, RoadStyle style) {
if (findNodeIndex(from) < 0 || findNodeIndex(to) < 0)
return Result<std::uint32_t>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "edge endpoints must reference live nodes", "edge"));
if (controlPoints.size() < 2)
return Result<std::uint32_t>::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<std::uint32_t>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "control points must be finite", "edge"));
}
auto styleOk = validateStyle(style);
if (!styleOk.ok()) return Result<std::uint32_t>::failure(styleOk.status());
auto profile = makeRoadProfile(style, lanesForward, lanesBackward);
if (!profile.ok()) return Result<std::uint32_t>::failure(profile.status());
const std::uint32_t id = nextEdgeId_++;
edgeIndex_[id] = static_cast<int>(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<std::uint32_t>::success(id);
}
Result<void> RoadNetwork::addLaneLink(RoadLaneLink link) {
const int inIdx = findEdgeIndex(link.inEdge);
const int outIdx = findEdgeIndex(link.outEdge);
if (inIdx < 0 || outIdx < 0)
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "lane link references missing edges", "laneLink"));
const auto& inEdge = edges_[static_cast<std::size_t>(inIdx)];
const auto& outEdge = edges_[static_cast<std::size_t>(outIdx)];
if (inEdge.to != outEdge.from)
return Result<void>::failure(Diagnostic::error(DiagnosticCode::PreconditionViolation,
"lane link requires inEdge.to == outEdge.from", "laneLink"));
if (link.inLane < 0 || link.inLane >= inEdge.lanesForward)
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "inLane out of range", "laneLink"));
if (link.outLane < 0 || link.outLane >= outEdge.lanesForward)
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "outLane out of range", "laneLink"));
laneLinks_.push_back(link);
++revision_;
return Result<void>::success();
}
Result<int> RoadNetwork::connectAllTurns(std::uint32_t nodeId) {
if (findNodeIndex(nodeId) < 0)
return Result<int>::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<int>::failure(r.status());
++added;
}
}
}
}
return Result<int>::success(added);
}
Result<void> RoadNetwork::setEdgeStyle(std::uint32_t edgeId, RoadStyle style) {
const int idx = findEdgeIndex(edgeId);
if (idx < 0)
return Result<void>::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown edge", "edgeId"));
auto styleOk = validateStyle(style);
if (!styleOk.ok()) return styleOk;
auto profile = makeRoadProfile(style, edges_[static_cast<std::size_t>(idx)].lanesForward,
edges_[static_cast<std::size_t>(idx)].lanesBackward);
if (!profile.ok()) return Result<void>::failure(profile.status());
edges_[static_cast<std::size_t>(idx)].style = style;
++revision_;
return Result<void>::success();
}
void RoadNetwork::clear() {
nodes_.clear();
edges_.clear();
laneLinks_.clear();
nodeIndex_.clear();
edgeIndex_.clear();
nextNodeId_ = 1;
nextEdgeId_ = 1;
++revision_;
}
Result<RoadNode> RoadNetwork::nodeResult(std::uint32_t id) const {
const int idx = findNodeIndex(id);
if (idx < 0)
return Result<RoadNode>::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown node", "id"));
return Result<RoadNode>::success(nodes_[static_cast<std::size_t>(idx)]);
}
Result<RoadEdge> RoadNetwork::edgeResult(std::uint32_t id) const {
const int idx = findEdgeIndex(id);
if (idx < 0)
return Result<RoadEdge>::failure(Diagnostic::error(DiagnosticCode::NotFound, "unknown edge", "id"));
return Result<RoadEdge>::success(edges_[static_cast<std::size_t>(idx)]);
}
Result<RoadNetwork> 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<RoadNetwork>::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<float>((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<RoadNetwork>::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<RoadControlPoint> 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<RoadNetwork>::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<RoadNetwork>::failure(turns.status());
}
return Result<RoadNetwork>::success(std::move(network));
}
} // namespace eve::procgen::road
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "procgen/road/RoadTypes.h"
#include <cstdint>
#include <unordered_map>
#include <vector>
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<std::uint32_t> addNode(float x, float y, float z, float junctionRadius = 6.f);
/** @brief Insert a directed edge with at least two control points. */
[[nodiscard]] Result<std::uint32_t> addEdge(std::uint32_t from, std::uint32_t to,
std::vector<RoadControlPoint> controlPoints, int lanesForward = 2,
int lanesBackward = 0, RoadStyle style = {});
/** @brief Register one legal turn inside a shared junction node. */
[[nodiscard]] Result<void> addLaneLink(RoadLaneLink link);
/** @brief Auto-connect every incoming lane to every outgoing lane at one node. */
[[nodiscard]] Result<int> connectAllTurns(std::uint32_t nodeId);
/** @brief Replace edge style atomically. */
[[nodiscard]] Result<void> setEdgeStyle(std::uint32_t edgeId, RoadStyle style);
/** @brief Clear every node, edge and link. */
void clear();
[[nodiscard]] int nodeCount() const noexcept { return static_cast<int>(nodes_.size()); }
[[nodiscard]] int edgeCount() const noexcept { return static_cast<int>(edges_.size()); }
[[nodiscard]] int laneLinkCount() const noexcept { return static_cast<int>(laneLinks_.size()); }
[[nodiscard]] std::uint64_t revision() const noexcept { return revision_; }
[[nodiscard]] Result<RoadNode> nodeResult(std::uint32_t id) const;
[[nodiscard]] Result<RoadEdge> edgeResult(std::uint32_t id) const;
[[nodiscard]] const std::vector<RoadNode>& nodes() const noexcept { return nodes_; }
[[nodiscard]] const std::vector<RoadEdge>& edges() const noexcept { return edges_; }
[[nodiscard]] const std::vector<RoadLaneLink>& laneLinks() const noexcept { return laneLinks_; }
/** @brief Build a multi-level interchange demo graph (ground cross + elevated loop + ramps). */
[[nodiscard]] static Result<RoadNetwork> makeInterchange(float span = 48.f, float bridgeHeight = 8.f,
int lanes = 2, std::uint32_t seed = 1);
private:
[[nodiscard]] Result<void> validateStyle(const RoadStyle& style) const;
[[nodiscard]] int findNodeIndex(std::uint32_t id) const;
[[nodiscard]] int findEdgeIndex(std::uint32_t id) const;
std::vector<RoadNode> nodes_;
std::vector<RoadEdge> edges_;
std::vector<RoadLaneLink> laneLinks_;
std::unordered_map<std::uint32_t, int> nodeIndex_;
std::unordered_map<std::uint32_t, int> edgeIndex_;
std::uint32_t nextNodeId_ = 1;
std::uint32_t nextEdgeId_ = 1;
std::uint64_t revision_ = 0;
};
} // namespace eve::procgen::road
+82
View File
@@ -0,0 +1,82 @@
#include "procgen/road/RoadTypes.h"
#include "common/Diagnostic.h"
#include <cmath>
namespace eve::procgen::road {
namespace {
Result<void> 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<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,
"road style values must be finite and non-negative",
"style"));
}
if (style.laneWidth <= 1e-4f)
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "laneWidth must be positive", "laneWidth"));
if (style.uvMeters <= 1e-4f)
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "uvMeters must be positive", "uvMeters"));
return Result<void>::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<RoadProfile> makeRoadProfile(const RoadStyle& style, int lanesForward, int lanesBackward) {
auto ok = validateStyle(style);
if (!ok.ok()) return Result<RoadProfile>::failure(ok.status());
if (lanesForward < 1 || lanesForward > 8)
return Result<RoadProfile>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "lanesForward must be in [1,8]", "lanesForward"));
if (lanesBackward < 0 || lanesBackward > 8)
return Result<RoadProfile>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "lanesBackward must be in [0,8]", "lanesBackward"));
const float asphaltHalf =
0.5f * style.laneWidth * static_cast<float>(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<RoadProfile>::success(std::move(profile));
}
} // namespace eve::procgen::road
+132
View File
@@ -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 <algorithm>
#include <cmath>
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<RoadNetwork> 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<RoadOverlay> generateRoadNetworkOverlay(const Params& params) {
auto network = networkFromParams(params);
if (!network.ok()) return Result<RoadOverlay>::failure(network.status());
auto baked = bakeRoadNetwork(network.value(), bakeOptionsFromParams(params));
if (!baked.ok()) return Result<RoadOverlay>::failure(baked.status());
return Result<RoadOverlay>::success(std::move(baked.value().overlay));
}
std::unique_ptr<image::ImageData> 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<image::ImageData>(width, height, "RGBA8");
for (int y = 0; y < height; ++y) {
const float v = (static_cast<float>(y) + 0.5f) / static_cast<float>(height);
for (int x = 0; x < width; ++x) {
const float u = (static_cast<float>(x) + 0.5f) / static_cast<float>(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<int>(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
+34
View File
@@ -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 <memory>
#include <string>
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<RoadOverlay> generateRoadNetworkOverlay(const Params& params);
/** @brief Pure-function road marking atlas (asphalt + edge + dashed center + zebra). */
std::unique_ptr<image::ImageData> generateRoadMarkingsTexture(const Params& params, std::string& error);
void registerRoadMeshRecipes(MeshRecipeRegistry& registry);
void registerRoadTextureRecipes(TextureRecipeRegistry& registry);
} // namespace road
} // namespace eve::procgen
+119
View File
@@ -0,0 +1,119 @@
#pragma once
#include "common/Result.h"
#include <cstdint>
#include <string>
#include <vector>
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<RoadControlPoint> 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<float> 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<RoadPolyline> lanes;
std::vector<RoadPolyline> 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<RoadProfilePoint> 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<RoadProfile> 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
@@ -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;
}
+116
View File
@@ -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 <cmath>
#include <string>
using namespace eve;
using namespace eve::procgen;
using namespace eve::procgen::road;
namespace {
void requireOk(Result<void> 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);
}