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:
16 files changed
+1618
No files matched your search
@@ -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` 中心线自动导入
|
||||
@@ -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 分支 |
|
||||
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,7 @@
|
||||
config <- {
|
||||
width = 1280
|
||||
height = 720
|
||||
title = "Procedural Road Lab"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user