载入中...
搜索中...
未找到
RoadBake.cpp
浏览该文件的文档.
3
4#include "common/Diagnostic.h"
6
7#include <algorithm>
8#include <array>
9#include <cmath>
10#include <functional>
11#include <limits>
12#include <unordered_map>
13#include <unordered_set>
14#include <utility>
15#include <vector>
16
18namespace {
19
20struct V3 {
21 float x = 0.f, y = 0.f, z = 0.f;
22};
23
24V3 operator+(V3 a, V3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
25V3 operator-(V3 a, V3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
26V3 operator*(V3 a, float s) { return {a.x * s, a.y * s, a.z * s}; }
27float dot(V3 a, V3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
28V3 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}; }
29float length(V3 a) { return std::sqrt(dot(a, a)); }
30V3 normalize(V3 a) {
31 const float l = length(a);
32 return l > 1e-8f ? a * (1.f / l) : V3{0.f, 1.f, 0.f};
33}
34
35template <class T>
36Result<T> bakeFail(DiagnosticCode code, std::string message, std::string path = {}) {
37 return Result<T>::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.road"));
38}
39
40bool exceedsMeshBudget(const MeshBuild& mesh, const RoadBakeOptions& options) {
41 return static_cast<std::size_t>(mesh.getVertexCount()) + static_cast<std::size_t>(mesh.getIndexCount()) >
42 options.maximumMeshElements;
43}
44
45float junctionTrimDistance(float pathLength, float requestedRadius) {
46 const float maximum = std::max(0.f, pathLength * 0.5f - 0.5f);
47 return std::min(std::max(0.f, requestedRadius), maximum);
48}
49
50int circularArcSegments(float radius, float angle, float maximumError) {
51 if (radius <= maximumError) return 4;
52 const float halfStep = std::acos(std::clamp(1.f - maximumError / radius, -1.f, 1.f));
53 if (!std::isfinite(halfStep) || halfStep <= 1e-5f) return 64;
54 return std::clamp(static_cast<int>(std::ceil(angle / (2.f * halfStep))), 4, 64);
55}
56
57int quadraticSegments(V3 start, V3 control, V3 end, float angle, float maximumError) {
58 const float secondDifference = length(start - control * 2.f + end);
59 const int errorSegments =
60 std::max(1, static_cast<int>(std::ceil(std::sqrt(secondDifference / (8.f * maximumError)))));
61 const int angleSegments = 3 + static_cast<int>(angle * 3.f);
62 return std::clamp(std::max(errorSegments, angleSegments), 4, 64);
63}
64
65Result<SplinePath> edgeToSpline(const RoadEdge& edge) {
66 SplinePath path;
67 auto kind = path.setKindResult("catmullRom");
68 if (!kind.ok()) return Result<SplinePath>::failure(kind.status());
69 path.setClosed(false);
70 for (const auto& p : edge.controlPoints) {
71 SplinePoint sp;
72 sp.x = p.x;
73 sp.y = p.y;
74 sp.z = p.z;
75 auto added = path.addPointResult(sp);
76 if (!added.ok()) return Result<SplinePath>::failure(added.status());
77 }
78 return Result<SplinePath>::success(std::move(path));
79}
80
81V3 authoredOutwardDirection(const RoadNode& node, const RoadEdge& edge) {
82 if (edge.controlPoints.size() < 2) return {1.f, 0.f, 0.f};
83 const auto& adjacent =
85 return normalize(V3{adjacent.x - node.x, 0.f, adjacent.z - node.z});
86}
87
88float asphaltHalfWidth(const RoadEdge& edge) {
89 return 0.5f * edge.style.laneWidth * static_cast<float>(edge.lanesForward + edge.lanesBackward);
90}
91
93float junctionSocketDistance(const RoadNetwork& network, const RoadNode& node, const RoadEdge& edge, float pathLength) {
95 const V3 direction = authoredOutwardDirection(node, edge);
96 const float halfWidth = asphaltHalfWidth(edge);
97
98 // Only the immediate clockwise/counter-clockwise arms bound this mouth.
99 // Letting every incident arm participate makes dense junctions grow with
100 // unrelated opposite/diagonal roads and creates overlapping aprons.
101 const RoadEdge* clockwise = nullptr;
102 const RoadEdge* counterClockwise = nullptr;
103 float clockwiseAngle = std::numeric_limits<float>::max();
104 float counterClockwiseAngle = std::numeric_limits<float>::max();
105 for (const auto& other : network.edges()) {
106 if (other.id == edge.id || (other.from != node.id && other.to != node.id)) continue;
107 const V3 otherDirection = authoredOutwardDirection(node, other);
108 const float cosine = std::clamp(dot(direction, otherDirection), -1.f, 1.f);
109 const float signedAngle =
110 std::atan2(direction.x * otherDirection.z - direction.z * otherDirection.x, cosine);
111 const float angle = std::fabs(signedAngle);
112 if (signedAngle < 0.f && angle < clockwiseAngle) {
113 clockwise = &other;
114 clockwiseAngle = angle;
115 } else if (signedAngle >= 0.f && angle < counterClockwiseAngle) {
116 counterClockwise = &other;
117 counterClockwiseAngle = angle;
118 }
119 }
120
121 auto constrainTo = [&](const RoadEdge* other) {
122 if (other == nullptr) return;
123 const V3 otherDirection = authoredOutwardDirection(node, *other);
124 const float cosine = std::clamp(dot(direction, otherDirection), -1.f, 1.f);
125 const float sine = std::sqrt(std::max(0.f, 1.f - cosine * cosine));
126 // Collinear continuations constrain no corner; near-parallel duplicate
127 // arms are rejected by the network validator before reaching bake.
128 if (sine < 0.08f) return;
129 const float tangentDistance = (asphaltHalfWidth(*other) + halfWidth * std::fabs(cosine)) / sine;
130 required = std::max(required, tangentDistance + 0.35f);
131 };
132 constrainTo(clockwise);
133 if (counterClockwise != clockwise) constrainTo(counterClockwise);
134 return junctionTrimDistance(pathLength, required);
135}
136
137struct JunctionPlane {
139 float gradeX = 0.f;
140 float gradeZ = 0.f;
141 V3 up{0.f, 1.f, 0.f};
142};
143
144float planeHeight(const JunctionPlane& plane, float x, float z) {
145 return plane.origin.y + plane.gradeX * (x - plane.origin.x) + plane.gradeZ * (z - plane.origin.z);
146}
147
149JunctionPlane fitJunctionPlane(const RoadNetwork& network, const RoadNode& node) {
150 JunctionPlane plane{{node.x, node.y, node.z}};
151 float xx = 0.f, xz = 0.f, zz = 0.f, xy = 0.f, zy = 0.f;
152 for (const auto& edge : network.edges()) {
153 if (edge.from != node.id && edge.to != node.id) continue;
154 auto spline = edgeToSpline(edge);
155 if (!spline.ok()) continue;
156 auto pathLength = spline.value().lengthResult(16);
157 if (!pathLength.ok()) continue;
158 const float trim = junctionSocketDistance(network, node, edge, pathLength.value());
159 const float d = edge.from == node.id ? trim : pathLength.value() - trim;
160 auto frame = spline.value().travelFrameResult(d, "clamp", 16);
161 if (!frame.ok()) continue;
162 const float dx = frame.value().sample.x - node.x;
163 const float dz = frame.value().sample.z - node.z;
164 const float dy = frame.value().sample.y - node.y;
165 const float weight =
166 std::max(1.f, edge.style.laneWidth * static_cast<float>(edge.lanesForward + edge.lanesBackward));
167 xx += weight * dx * dx;
168 xz += weight * dx * dz;
169 zz += weight * dz * dz;
170 xy += weight * dx * dy;
171 zy += weight * dz * dy;
172 }
173 const float determinant = xx * zz - xz * xz;
174 if (std::fabs(determinant) > 1e-5f) {
175 plane.gradeX = (xy * zz - zy * xz) / determinant;
176 plane.gradeZ = (zy * xx - xy * xz) / determinant;
177 }
178 // A road junction is a local construction plane, not a terrain patch. Limit
179 // the fitted grade so a single malformed arm cannot turn it into a wall.
180 const float grade = std::sqrt(plane.gradeX * plane.gradeX + plane.gradeZ * plane.gradeZ);
181 if (grade > 0.25f) {
182 plane.gradeX *= 0.25f / grade;
183 plane.gradeZ *= 0.25f / grade;
184 }
185 plane.up = normalize(V3{-plane.gradeX, 1.f, -plane.gradeZ});
186 return plane;
187}
188
189Result<void> validateJunctionGeometry(const RoadNetwork& network, const RoadNode& node) {
190 std::vector<const RoadEdge*> incident;
191 std::vector<V3> directions;
192 for (const auto& edge : network.edges()) {
193 if (edge.from != node.id && edge.to != node.id) continue;
194 incident.push_back(&edge);
195 directions.push_back(authoredOutwardDirection(node, edge));
196 }
197 for (std::size_t first = 0; directions.size() >= 3u && first < directions.size(); ++first)
198 for (std::size_t second = first + 1; second < directions.size(); ++second)
199 if (dot(directions[first], directions[second]) > 0.9999905f)
200 return bakeFail<void>(DiagnosticCode::PreconditionViolation,
201 "junction contains two arms with indistinguishable outgoing directions",
202 "junction");
203
204 const JunctionPlane plane = fitJunctionPlane(network, node);
205 if (incident.size() < 4u) return Result<void>::success();
206 for (const RoadEdge* edge : incident) {
207 auto spline = edgeToSpline(*edge);
208 if (!spline.ok()) return Result<void>::failure(spline.status());
209 auto pathLength = spline.value().lengthResult(16);
210 if (!pathLength.ok()) return Result<void>::failure(pathLength.status());
211 const float trim = junctionSocketDistance(network, node, *edge, pathLength.value());
212 const float distance = edge->from == node.id ? trim : pathLength.value() - trim;
213 auto frame = spline.value().travelFrameResult(distance, "clamp", 16);
214 if (!frame.ok()) return Result<void>::failure(frame.status());
215 const float expected = planeHeight(plane, frame.value().sample.x, frame.value().sample.z);
216 const float tolerance = std::max(0.35f, asphaltHalfWidth(*edge) * 0.15f);
217 if (std::fabs(frame.value().sample.y - expected) > tolerance)
218 return bakeFail<void>(DiagnosticCode::PreconditionViolation,
219 "junction arm sockets cannot share one bounded construction plane", "junction");
220 }
221 return Result<void>::success();
222}
223
224void blendFrameToJunctionPlane(SplineFrameSample& frame, const JunctionPlane& plane, float weight) {
225 weight = std::clamp(weight, 0.f, 1.f);
226 const float targetY = planeHeight(plane, frame.sample.x, frame.sample.z);
227 frame.sample.y += (targetY - frame.sample.y) * weight;
228
229 const V3 oldForward = normalize(V3{frame.forwardX, frame.forwardY, frame.forwardZ});
230 const V3 oldSide = normalize(V3{frame.sideX, frame.sideY, frame.sideZ});
231 V3 targetForward =
232 normalize(V3{oldForward.x, plane.gradeX * oldForward.x + plane.gradeZ * oldForward.z, oldForward.z});
233 V3 targetSide = normalize(cross(plane.up, targetForward));
234 if (dot(targetSide, oldSide) < 0.f) targetSide = targetSide * -1.f;
235 V3 targetUp = normalize(cross(targetForward, targetSide));
236 if (targetUp.y < 0.f) targetUp = targetUp * -1.f;
237
238 const V3 forward = normalize(oldForward * (1.f - weight) + targetForward * weight);
239 const V3 side = normalize(oldSide * (1.f - weight) + targetSide * weight);
240 V3 up = normalize(cross(forward, side));
241 if (dot(up, targetUp) < 0.f) up = up * -1.f;
242 frame.forwardX = forward.x;
243 frame.forwardY = forward.y;
244 frame.forwardZ = forward.z;
245 frame.sideX = side.x;
246 frame.sideY = side.y;
247 frame.sideZ = side.z;
248 frame.upX = up.x;
249 frame.upY = up.y;
250 frame.upZ = up.z;
251}
252
253float signedAreaXZ(const std::vector<V3>& polygon) {
254 float area = 0.f;
255 for (std::size_t i = 0; i < polygon.size(); ++i) {
256 const V3& a = polygon[i];
257 const V3& b = polygon[(i + 1) % polygon.size()];
258 area += a.x * b.z - b.x * a.z;
259 }
260 return area * 0.5f;
261}
262
263float crossXZ(V3 a, V3 b, V3 c) { return (b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x); }
264
265bool pointInTriangleXZ(V3 p, V3 a, V3 b, V3 c, float orientation) {
266 constexpr float epsilon = 1e-5f;
267 // Boundary points on a sampled curve must not veto the ear; only a point
268 // strictly inside the candidate triangle makes that diagonal invalid.
269 return crossXZ(a, b, p) * orientation > epsilon && crossXZ(b, c, p) * orientation > epsilon &&
270 crossXZ(c, a, p) * orientation > epsilon;
271}
272
274bool triangulateBoundary(const std::vector<V3>& polygon, std::vector<std::array<std::size_t, 3>>& triangles) {
275 if (polygon.size() < 3) return false;
276 const float area = signedAreaXZ(polygon);
277 if (std::fabs(area) < 1e-5f) return false;
278 const float orientation = area > 0.f ? 1.f : -1.f;
279 std::vector<std::size_t> remaining(polygon.size());
280 for (std::size_t i = 0; i < remaining.size(); ++i) remaining[i] = i;
281 triangles.clear();
282 triangles.reserve(polygon.size() - 2);
283 std::size_t guard = polygon.size() * polygon.size();
284 while (remaining.size() > 3 && guard-- > 0) {
285 bool clipped = false;
286 for (std::size_t i = 0; i < remaining.size(); ++i) {
287 const std::size_t previous = remaining[(i + remaining.size() - 1) % remaining.size()];
288 const std::size_t current = remaining[i];
289 const std::size_t next = remaining[(i + 1) % remaining.size()];
290 if (crossXZ(polygon[previous], polygon[current], polygon[next]) * orientation <= 1e-5f) continue;
291 bool contains = false;
292 for (const std::size_t candidate : remaining) {
293 if (candidate == previous || candidate == current || candidate == next) continue;
294 if (pointInTriangleXZ(polygon[candidate], polygon[previous], polygon[current], polygon[next],
295 orientation)) {
296 contains = true;
297 break;
298 }
299 }
300 if (contains) continue;
301 triangles.push_back({previous, current, next});
302 remaining.erase(remaining.begin() + static_cast<std::ptrdiff_t>(i));
303 clipped = true;
304 break;
305 }
306 if (!clipped) return false;
307 }
308 if (remaining.size() == 3) triangles.push_back({remaining[0], remaining[1], remaining[2]});
309 return triangles.size() + 2 == polygon.size();
310}
311
312float laneCenterOffset(const RoadStyle& style, int lanesForward, int lanesBackward, int laneIndex,
314 const float asphaltHalf = 0.5f * style.laneWidth * static_cast<float>(lanesForward + lanesBackward);
315 if (lanesBackward <= 0 || direction == RoadLaneDirection::Backward)
316 return -asphaltHalf + style.laneWidth * (static_cast<float>(laneIndex) + 0.5f);
317 const float opposingBoundary = -asphaltHalf + style.laneWidth * static_cast<float>(lanesBackward);
318 return opposingBoundary + style.laneWidth * (static_cast<float>(laneIndex) + 0.5f);
319}
320
321void appendBox(MeshBuild& mesh, V3 center, V3 side, V3 up, V3 forward, float hx, float hy, float hz,
323 mesh.setActiveGroup(roadMaterialGroup(material));
324 const V3 corners[8] = {
325 center + side * -hx + up * -hy + forward * -hz, center + side * hx + up * -hy + forward * -hz,
326 center + side * hx + up * hy + forward * -hz, center + side * -hx + up * hy + forward * -hz,
327 center + side * -hx + up * -hy + forward * hz, center + side * hx + up * -hy + forward * hz,
328 center + side * hx + up * hy + forward * hz, center + side * -hx + up * hy + forward * hz,
329 };
330 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}};
331 const V3 normals[6] = {forward * -1.f, forward, up * -1.f, up, side * -1.f, side};
332 for (int f = 0; f < 6; ++f) {
333 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
334 for (int c = 0; c < 4; ++c) {
335 const V3& p = corners[faces[f][c]];
336 mesh.addVertex(p.x, p.y, p.z, normals[f].x, normals[f].y, normals[f].z, static_cast<float>(c & 1),
337 static_cast<float>((c >> 1) & 1));
338 }
339 mesh.addTriangle(base, base + 1, base + 2);
340 mesh.addTriangle(base, base + 2, base + 3);
341 }
342}
343
344void appendStripQuad(MeshBuild& mesh, V3 a, V3 b, V3 c, V3 d, V3 normal, float u0, float u1, float v0, float v1,
346 mesh.setActiveGroup(roadMaterialGroup(material));
347 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
348 mesh.addVertex(a.x, a.y, a.z, normal.x, normal.y, normal.z, u0, v0);
349 mesh.addVertex(b.x, b.y, b.z, normal.x, normal.y, normal.z, u1, v0);
350 mesh.addVertex(c.x, c.y, c.z, normal.x, normal.y, normal.z, u1, v1);
351 mesh.addVertex(d.x, d.y, d.z, normal.x, normal.y, normal.z, u0, v1);
352 const V3 firstAreaNormal = cross(b - a, c - a);
353 const V3 secondAreaNormal = cross(c - a, d - a);
354 if (dot(firstAreaNormal, firstAreaNormal) > 1e-12f) mesh.addTriangle(base, base + 1, base + 2);
355 if (dot(secondAreaNormal, secondAreaNormal) > 1e-12f) mesh.addTriangle(base, base + 2, base + 3);
356}
357
359void appendOrientedQuad(MeshBuild& mesh, V3 a, V3 b, V3 c, V3 d, V3 normal, float u0, float u1, float v0, float v1,
361 const V3 n = normalize(normal);
362 // Use both constituent triangles so a deliberately collapsed edge still
363 // gets the winding of its non-degenerate half.
364 const V3 geometricNormal = cross(b - a, c - a) + cross(c - a, d - a);
365 if (dot(geometricNormal, geometricNormal) <= 1e-12f) return;
366 if (dot(geometricNormal, n) < 0.f) {
367 appendStripQuad(mesh, a, d, c, b, n, u0, u1, v1, v0, material);
368 } else {
369 appendStripQuad(mesh, a, b, c, d, n, u0, u1, v0, v1, material);
370 }
371}
372
373void appendOrientedTri(MeshBuild& mesh, V3 a, V3 b, V3 c, V3 normal, float uvMeters, RoadMaterial material) {
374 const V3 n = normalize(normal);
375 const V3 areaNormal = cross(b - a, c - a);
376 if (dot(areaNormal, areaNormal) <= 1e-12f) return;
377 const float uvScale = 1.f / std::max(uvMeters, 0.1f);
378 mesh.setActiveGroup(roadMaterialGroup(material));
379 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
380 mesh.addVertex(a.x, a.y, a.z, n.x, n.y, n.z, a.x * uvScale, a.z * uvScale);
381 mesh.addVertex(b.x, b.y, b.z, n.x, n.y, n.z, b.x * uvScale, b.z * uvScale);
382 mesh.addVertex(c.x, c.y, c.z, n.x, n.y, n.z, c.x * uvScale, c.z * uvScale);
383 if (dot(areaNormal, n) < 0.f)
384 mesh.addTriangle(base, base + 2, base + 1);
385 else
386 mesh.addTriangle(base, base + 1, base + 2);
387}
388
389Result<void> loftProfileClean(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames, const RoadProfile& profile,
390 float pathLength, float uvMeters) {
391 if (frames.size() < 2 || profile.points.size() < 2)
392 return bakeFail<void>(DiagnosticCode::InvalidArgument, "loft needs >=2 frames and profile points");
393
394 const int ringCount = static_cast<int>(frames.size());
395 const int profileCount = static_cast<int>(profile.points.size());
396 std::vector<V3> ringPositions(static_cast<std::size_t>(ringCount * profileCount));
397
398 for (int ring = 0; ring < ringCount; ++ring) {
399 const auto& frame = frames[static_cast<std::size_t>(ring)];
400 const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z};
401 const V3 side{frame.sideX, frame.sideY, frame.sideZ};
402 const V3 up{frame.upX, frame.upY, frame.upZ};
403 for (int i = 0; i < profileCount; ++i) {
404 const auto& pp = profile.points[static_cast<std::size_t>(i)];
405 ringPositions[static_cast<std::size_t>(ring * profileCount + i)] = origin + side * pp.side + up * pp.up;
406 }
407 }
408
409 for (int ring = 0; ring < ringCount - 1; ++ring) {
410 // Spline samples retain their distance along the untrimmed edge. Using
411 // that distance keeps texture phase stable when junction radii or bake
412 // tessellation change; uvMeters is metres per repeat, not repeat count.
413 const float u0 = frames[static_cast<std::size_t>(ring)].sample.normalizedDistance * pathLength / uvMeters;
414 const float u1 = frames[static_cast<std::size_t>(ring + 1)].sample.normalizedDistance * pathLength / uvMeters;
415 for (int i = 0; i < profileCount - 1; ++i) {
416 const auto& a = profile.points[static_cast<std::size_t>(i)];
417 const auto& b = profile.points[static_cast<std::size_t>(i + 1)];
418 // Prefer asphalt for near-horizontal driving strips (endpoint material alone
419 // would tag the asphalt span as curb because the left corner is a curb drop).
420 RoadMaterial mat = a.material;
421 if (std::fabs(a.up - b.up) <= 1e-3f &&
422 (a.material == RoadMaterial::Asphalt || b.material == RoadMaterial::Asphalt)) {
424 }
425 const V3& p00 = ringPositions[static_cast<std::size_t>(ring * profileCount + i)];
426 const V3& p01 = ringPositions[static_cast<std::size_t>(ring * profileCount + i + 1)];
427 const V3& p10 = ringPositions[static_cast<std::size_t>((ring + 1) * profileCount + i)];
428 const V3& p11 = ringPositions[static_cast<std::size_t>((ring + 1) * profileCount + i + 1)];
429 const V3 normal = normalize(cross(p10 - p00, p01 - p00));
430 const float v0 = a.side / uvMeters;
431 const float v1 = b.side / uvMeters;
432 appendStripQuad(mesh, p00, p10, p11, p01, normal, u0, u1, v0, v1, mat);
433 }
434 }
435 return Result<void>::success();
436}
437
439void capProfileRing(MeshBuild& mesh, const SplineFrameSample& frame, const RoadProfile& profile, bool outward) {
440 if (profile.points.size() < 3) return;
441 const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z};
442 const V3 side{frame.sideX, frame.sideY, frame.sideZ};
443 const V3 up{frame.upX, frame.upY, frame.upZ};
444 V3 fwd{frame.forwardX, frame.forwardY, frame.forwardZ};
445 if (!outward) fwd = fwd * -1.f;
446 const V3 nrm = normalize(fwd);
447 std::vector<V3> ring;
448 ring.reserve(profile.points.size());
449 for (const auto& pp : profile.points) ring.push_back(origin + side * pp.side + up * pp.up);
450 // Fan-fill the end polygon (closes the jersey-barrier U looking into the hub).
452 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
453 for (const V3& p : ring) mesh.addVertex(p.x, p.y, p.z, nrm.x, nrm.y, nrm.z, 0.f, 0.f);
454 for (std::size_t i = 1; i + 1 < ring.size(); ++i) {
455 const V3 areaNormal = cross(ring[i] - ring[0], ring[i + 1] - ring[0]);
456 if (dot(areaNormal, areaNormal) <= 1e-12f) continue;
457 if (outward)
458 mesh.addTriangle(base, base + static_cast<std::uint32_t>(i), base + static_cast<std::uint32_t>(i + 1));
459 else
460 mesh.addTriangle(base, base + static_cast<std::uint32_t>(i + 1), base + static_cast<std::uint32_t>(i));
461 }
462}
463
471void capProfileShoulders(MeshBuild& mesh, const SplineFrameSample& frame, const RoadProfile& profile, bool outward) {
472 if (profile.points.size() < 6) return;
473 const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z};
474 const V3 side{frame.sideX, frame.sideY, frame.sideZ};
475 const V3 up{frame.upX, frame.upY, frame.upZ};
476 V3 fwd{frame.forwardX, frame.forwardY, frame.forwardZ};
477 if (!outward) fwd = fwd * -1.f;
478 const V3 nrm = normalize(fwd);
479
480 // First Asphalt point marks the deck span start; shoulders are everything else.
481 std::size_t asphaltBegin = profile.points.size();
482 for (std::size_t i = 0; i < profile.points.size(); ++i) {
483 if (profile.points[i].material == RoadMaterial::Asphalt) {
484 asphaltBegin = i;
485 break;
486 }
487 }
488 if (asphaltBegin == 0 || asphaltBegin >= profile.points.size()) return;
489
490 auto emitFan = [&](std::size_t begin, std::size_t end, RoadMaterial mat) {
491 if (end < begin + 3) return;
492 mesh.setActiveGroup(roadMaterialGroup(mat));
493 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
494 for (std::size_t i = begin; i < end; ++i) {
495 const auto& pp = profile.points[i];
496 const V3 p = origin + side * pp.side + up * pp.up;
497 mesh.addVertex(p.x, p.y, p.z, nrm.x, nrm.y, nrm.z, 0.f, 0.f);
498 }
499 const auto count = static_cast<std::uint32_t>(end - begin);
500 for (std::uint32_t i = 1; i + 1 < count; ++i) {
501 const auto& originPoint = profile.points[begin];
502 const auto& pointA = profile.points[begin + i];
503 const auto& pointB = profile.points[begin + i + 1];
504 const V3 edgeA = side * (pointA.side - originPoint.side) + up * (pointA.up - originPoint.up);
505 const V3 edgeB = side * (pointB.side - originPoint.side) + up * (pointB.up - originPoint.up);
506 const V3 areaNormal = cross(edgeA, edgeB);
507 if (dot(areaNormal, areaNormal) <= 1e-12f) continue;
508 if (outward)
509 mesh.addTriangle(base, base + i, base + i + 1);
510 else
511 mesh.addTriangle(base, base + i + 1, base + i);
512 }
513 };
514
515 // Left shoulder: outer sidewalk → curb down to asphalt lip (excludes asphalt point).
516 emitFan(0, asphaltBegin, RoadMaterial::Sidewalk);
517 // Right shoulder: asphalt lip → curb → outer sidewalk.
518 emitFan(asphaltBegin, profile.points.size(), RoadMaterial::Sidewalk);
519}
520
521Result<void> addDeckAndPiers(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames, const RoadStyle& style,
522 float halfWidth, bool includePiers) {
523 if (frames.size() < 2) return Result<void>::success();
524 // Deck underside strip.
525 for (int ring = 0; ring < static_cast<int>(frames.size()) - 1; ++ring) {
526 const auto& f0 = frames[static_cast<std::size_t>(ring)];
527 const auto& f1 = frames[static_cast<std::size_t>(ring + 1)];
528 const V3 o0{f0.sample.x, f0.sample.y, f0.sample.z};
529 const V3 o1{f1.sample.x, f1.sample.y, f1.sample.z};
530 const V3 s0{f0.sideX, f0.sideY, f0.sideZ};
531 const V3 s1{f1.sideX, f1.sideY, f1.sideZ};
532 const V3 u0{f0.upX, f0.upY, f0.upZ};
533 const V3 u1{f1.upX, f1.upY, f1.upZ};
534 const V3 a = o0 + s0 * -halfWidth + u0 * -style.deckThickness;
535 const V3 b = o0 + s0 * halfWidth + u0 * -style.deckThickness;
536 const V3 c = o1 + s1 * halfWidth + u1 * -style.deckThickness;
537 const V3 d = o1 + s1 * -halfWidth + u1 * -style.deckThickness;
538 appendStripQuad(mesh, a, b, c, d, normalize((u0 + u1) * -0.5f), 0.f, 1.f, 0.f, 1.f, RoadMaterial::Deck);
539 }
540
541 if (!includePiers || style.pierSpacing <= 1e-3f) return Result<void>::success();
542
543 float traveled = 0.f;
544 float nextPier = style.pierSpacing * 0.5f;
545 for (std::size_t i = 1; i < frames.size(); ++i) {
546 const auto& a = frames[i - 1];
547 const auto& b = frames[i];
548 const V3 pa{a.sample.x, a.sample.y, a.sample.z};
549 const V3 pb{b.sample.x, b.sample.y, b.sample.z};
550 const float seg = length(pb - pa);
551 if (seg <= 1e-5f) continue;
552 while (nextPier <= traveled + seg + 1e-4f) {
553 const float t = std::clamp((nextPier - traveled) / seg, 0.f, 1.f);
554 const V3 pos = pa + (pb - pa) * t;
555 if (pos.y > style.pierClearance) {
556 // World-up pier: never reuse Frenet side (can flip / tilt and skew the box).
557 V3 fwdFlat{pb.x - pa.x, 0.f, pb.z - pa.z};
558 const float fwdLen = length(fwdFlat);
559 fwdFlat = fwdLen > 1e-5f ? fwdFlat * (1.f / fwdLen) : V3{1.f, 0.f, 0.f};
560 const V3 up{0.f, 1.f, 0.f};
561 const V3 side = normalize(cross(up, fwdFlat));
562 const float pierH = pos.y - style.deckThickness;
563 if (pierH > 0.2f) {
564 const V3 center{pos.x, pierH * 0.5f, pos.z};
565 appendBox(mesh, center, side, up, fwdFlat, style.pierWidth * 0.5f, pierH * 0.5f,
566 style.pierDepth * 0.5f, RoadMaterial::Pier);
567 }
568 }
569 nextPier += style.pierSpacing;
570 if (nextPier > 1.0e7f) break;
571 }
572 traveled += seg;
573 }
574 return Result<void>::success();
575}
576
577Result<void> addLaneMarkings(MeshBuild& mesh, const std::vector<SplineFrameSample>& frames, const RoadEdge& edge,
578 float pathLength, const RoadBakeOptions& options) {
579 if (frames.size() < 2) return Result<void>::success();
580 const auto& style = edge.style;
581 const float asphaltHalf = 0.5f * style.laneWidth * static_cast<float>(edge.lanesForward + edge.lanesBackward);
582 auto paintLine = [&](float lateral, bool dashed, RoadMaterial material) -> Result<void> {
583 if (dashed && style.dashLength <= 1e-5f) return Result<void>::success();
584 const bool continuous = !dashed || style.dashGap <= 1e-5f;
585 for (std::size_t i = 1; i < frames.size(); ++i) {
586 const auto& a = frames[i - 1];
587 const auto& b = frames[i];
588 const V3 pa{a.sample.x, a.sample.y, a.sample.z};
589 const V3 pb{b.sample.x, b.sample.y, b.sample.z};
590 const V3 sa{a.sideX, a.sideY, a.sideZ};
591 const V3 sb{b.sideX, b.sideY, b.sideZ};
592 const V3 ua{a.upX, a.upY, a.upZ};
593 const V3 ub{b.upX, b.upY, b.upZ};
594 const float seg = length(pb - pa);
595 if (seg <= 1e-5f) continue;
596 auto emitSpan = [&](float t0, float t1) -> Result<void> {
597 constexpr std::size_t quadElements = 10u;
598 const std::size_t currentElements = static_cast<std::size_t>(mesh.getVertexCount()) +
599 static_cast<std::size_t>(mesh.getIndexCount());
600 if (currentElements > options.maximumMeshElements ||
601 options.maximumMeshElements - currentElements < quadElements)
602 return bakeFail<void>(DiagnosticCode::PreconditionViolation,
603 "road markings exceed the mesh element budget", "mesh");
604 const float hw = style.markingWidth * 0.5f;
605 const V3 p0 = pa + (pb - pa) * t0;
606 const V3 p1 = pa + (pb - pa) * t1;
607 if (length(p1 - p0) <= 1e-5f) return Result<void>::success();
608 const V3 s0 = normalize(sa + (sb - sa) * t0);
609 const V3 s1 = normalize(sa + (sb - sa) * t1);
610 const V3 u0 = normalize(ua + (ub - ua) * t0);
611 const V3 u1 = normalize(ua + (ub - ua) * t1);
612 const V3 a0 = p0 + s0 * (lateral - hw) + u0 * 0.035f;
613 const V3 a1 = p0 + s0 * (lateral + hw) + u0 * 0.035f;
614 const V3 b1 = p1 + s1 * (lateral + hw) + u1 * 0.035f;
615 const V3 b0 = p1 + s1 * (lateral - hw) + u1 * 0.035f;
616 appendStripQuad(mesh, a0, b0, b1, a1, normalize(u0 + u1), 0.f, 1.f, 0.f, 1.f, material);
617 return Result<void>::success();
618 };
619 if (continuous) {
620 auto emitted = emitSpan(0.f, 1.f);
621 if (!emitted.ok()) return emitted;
622 continue;
623 }
624 const double cycle = static_cast<double>(style.dashLength) + static_cast<double>(style.dashGap);
625 const double startDistance = static_cast<double>(a.sample.normalizedDistance) * pathLength;
626 const double endDistance = static_cast<double>(b.sample.normalizedDistance) * pathLength;
627 const double distanceSpan = std::max(0.0, endDistance - startDistance);
628 double local = 0.0;
629 while (local < distanceSpan - 1e-7) {
630 const double phase = std::fmod(startDistance + local, cycle);
631 const bool draw = phase < static_cast<double>(style.dashLength);
632 const double boundary = draw ? static_cast<double>(style.dashLength) - phase : cycle - phase;
633 const double next = std::min(distanceSpan, local + boundary);
634 if (!(next > local))
635 return bakeFail<void>(DiagnosticCode::PreconditionViolation,
636 "road marking period is too small to advance", "style.dashLength");
637 if (draw) {
638 auto emitted = emitSpan(static_cast<float>(local / distanceSpan),
639 static_cast<float>(next / distanceSpan));
640 if (!emitted.ok()) return emitted;
641 }
642 local = next;
643 }
644 }
645 return Result<void>::success();
646 };
647
648 auto leftEdge = paintLine(-asphaltHalf + style.markingWidth, false, RoadMaterial::Marking);
649 if (!leftEdge.ok()) return leftEdge;
650 auto rightEdge = paintLine(asphaltHalf - style.markingWidth, false, RoadMaterial::Marking);
651 if (!rightEdge.ok()) return rightEdge;
652 const float divider = -asphaltHalf + style.laneWidth * static_cast<float>(edge.lanesBackward);
653 for (int lane = 1; lane < edge.lanesForward; ++lane) {
654 const float lateral = (edge.lanesBackward > 0 ? divider : -asphaltHalf) +
655 style.laneWidth * static_cast<float>(lane);
656 auto line = paintLine(lateral, true, RoadMaterial::Marking);
657 if (!line.ok()) return line;
658 }
659 for (int lane = 1; lane < edge.lanesBackward; ++lane) {
660 const float lateral = -asphaltHalf + style.laneWidth * static_cast<float>(lane);
661 auto line = paintLine(lateral, true, RoadMaterial::Marking);
662 if (!line.ok()) return line;
663 }
664 if (edge.lanesForward > 0 && edge.lanesBackward > 0) {
665 const float offset = std::max(0.08f, style.markingWidth);
666 auto first = paintLine(divider - offset, false, RoadMaterial::MarkingYellow);
667 if (!first.ok()) return first;
668 auto second = paintLine(divider + offset, false, RoadMaterial::MarkingYellow);
669 if (!second.ok()) return second;
670 }
671 return Result<void>::success();
672}
673
674void appendArrow(MeshBuild& mesh, V3 pos, V3 forward, V3 up, float size) {
675 const V3 side = normalize(cross(up, forward));
676 const V3 tip = pos + forward * size + up * 0.05f;
677 const V3 left = pos - forward * size * 0.35f + side * (size * 0.5f) + up * 0.05f;
678 const V3 right = pos - forward * size * 0.35f - side * (size * 0.5f) + up * 0.05f;
680 const auto base = static_cast<std::uint32_t>(mesh.getVertexCount());
681 mesh.addVertex(tip.x, tip.y, tip.z, up.x, up.y, up.z, 0.5f, 1.f);
682 mesh.addVertex(left.x, left.y, left.z, up.x, up.y, up.z, 0.f, 0.f);
683 mesh.addVertex(right.x, right.y, right.z, up.x, up.y, up.z, 1.f, 0.f);
684 mesh.addTriangle(base, base + 1, base + 2);
685 // Back face so arrows stay visible under top-down interchange cameras.
686 const auto back = static_cast<std::uint32_t>(mesh.getVertexCount());
687 const V3 nd = up * -1.f;
688 mesh.addVertex(tip.x, tip.y, tip.z, nd.x, nd.y, nd.z, 0.5f, 1.f);
689 mesh.addVertex(right.x, right.y, right.z, nd.x, nd.y, nd.z, 1.f, 0.f);
690 mesh.addVertex(left.x, left.y, left.z, nd.x, nd.y, nd.z, 0.f, 0.f);
691 mesh.addTriangle(back, back + 1, back + 2);
692}
693
694Result<void> bakeEdgeGeometry(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network, const RoadEdge& edge,
695 const RoadBakeOptions& options,
696 const std::unordered_set<std::uint32_t>& activeJunctionNodes,
697 const std::unordered_set<std::uint32_t>& terminalNodes) {
698 auto spline = edgeToSpline(edge);
699 if (!spline.ok()) return Result<void>::failure(spline.status());
700 auto pathLength = spline.value().lengthResult(24);
701 if (!pathLength.ok()) return Result<void>::failure(pathLength.status());
702
703 auto fromNode = network.nodeResult(edge.from);
704 auto toNode = network.nodeResult(edge.to);
705 if (!fromNode.ok() || !toNode.ok())
706 return bakeFail<void>(DiagnosticCode::NotFound, "edge endpoints missing during bake");
707
708 // A junction may not consume more than half of a short edge. Keep this
709 // clamp shared with junction mouths and navigation turn anchors.
710 const float trimStart = activeJunctionNodes.contains(fromNode.value().id)
711 ? junctionSocketDistance(network, fromNode.value(), edge, pathLength.value())
712 : 0.f;
713 const float trimEnd = activeJunctionNodes.contains(toNode.value().id)
714 ? junctionSocketDistance(network, toNode.value(), edge, pathLength.value())
715 : 0.f;
716 if (trimStart + trimEnd >= pathLength.value() - 0.5f)
717 return Result<void>::success(); // fully inside junction; skip strip
718
719 const int segments = std::max(4, options.pathSegmentsPerEdge);
720 auto frames = spline.value().sampleFramesResult(segments, true, 0.f, 24);
721 if (!frames.ok()) return Result<void>::failure(frames.status());
722
723 // Keep mid frames, then force exact trim endpoints so the loft meets the
724 // junction apron (uniform samples alone leave ~segment-sized salmon gaps).
725 std::vector<SplineFrameSample> trimmed;
726 trimmed.reserve(frames.value().size() + 2);
727 if (trimStart > 1e-3f) {
728 auto tip = spline.value().travelFrameResult(trimStart, "clamp", 24);
729 if (!tip.ok()) return Result<void>::failure(tip.status());
730 trimmed.push_back(std::move(tip).takeValue());
731 }
732 const float endDist = pathLength.value() - trimEnd;
733 for (const auto& frame : frames.value()) {
734 const float d = frame.sample.normalizedDistance * pathLength.value();
735 if (d > trimStart + 1e-3f && d < endDist - 1e-3f) trimmed.push_back(frame);
736 }
737 if (trimEnd > 1e-3f) {
738 auto tip = spline.value().travelFrameResult(endDist, "clamp", 24);
739 if (!tip.ok()) return Result<void>::failure(tip.status());
740 trimmed.push_back(std::move(tip).takeValue());
741 }
742 if (trimmed.size() < 2) return Result<void>::success();
743
744 // The edge owns the transition into the shared junction plane. The last
745 // cross-section is therefore bit-for-bit compatible with the socket used
746 // by bakeJunction, while the preceding rings absorb grade and crossfall.
747 if (trimStart > 1e-3f) {
748 const JunctionPlane plane = fitJunctionPlane(network, fromNode.value());
749 const float transition = std::max(3.f, fromNode.value().junctionRadius);
750 for (auto& frame : trimmed) {
751 const float distance = frame.sample.normalizedDistance * pathLength.value() - trimStart;
752 if (distance > transition) continue;
753 const float t = std::clamp(1.f - std::max(0.f, distance) / transition, 0.f, 1.f);
754 blendFrameToJunctionPlane(frame, plane, t * t * (3.f - 2.f * t));
755 }
756 }
757 if (trimEnd > 1e-3f) {
758 const JunctionPlane plane = fitJunctionPlane(network, toNode.value());
759 const float transition = std::max(3.f, toNode.value().junctionRadius);
760 for (auto& frame : trimmed) {
761 const float distance = endDist - frame.sample.normalizedDistance * pathLength.value();
762 if (distance > transition) continue;
763 const float t = std::clamp(1.f - std::max(0.f, distance) / transition, 0.f, 1.f);
764 blendFrameToJunctionPlane(frame, plane, t * t * (3.f - 2.f * t));
765 }
766 }
767
769 if (!profile.ok()) return Result<void>::failure(profile.status());
770
771 auto lofted = loftProfileClean(mesh, trimmed, profile.value(), pathLength.value(), edge.style.uvMeters);
772 if (!lofted.ok()) return lofted;
773 // Cap stub ends fully. On large junction trims, only close curb/sidewalk
774 // shoulders — a full U-cap draws a dark asphalt tip bar that reads as a gap.
775 if (trimStart <= 0.05f && terminalNodes.contains(fromNode.value().id)) {
776 capProfileRing(mesh, trimmed.front(), profile.value(), false);
777 } else if (trimStart > 0.05f) {
778 if (trimStart < 2.5f)
779 capProfileRing(mesh, trimmed.front(), profile.value(), false);
780 else
781 capProfileShoulders(mesh, trimmed.front(), profile.value(), false);
782 }
783 if (trimEnd <= 0.05f && terminalNodes.contains(toNode.value().id)) {
784 capProfileRing(mesh, trimmed.back(), profile.value(), true);
785 } else if (trimEnd > 0.05f) {
786 if (trimEnd < 2.5f)
787 capProfileRing(mesh, trimmed.back(), profile.value(), true);
788 else
789 capProfileShoulders(mesh, trimmed.back(), profile.value(), true);
790 }
791 auto deck = addDeckAndPiers(mesh, trimmed, edge.style, profile.value().halfWidth, options.includePiers);
792 if (!deck.ok()) return deck;
793 if (options.includeMarkings) {
794 auto marks = addLaneMarkings(mesh, trimmed, edge, pathLength.value(), options);
795 if (!marks.ok()) return marks;
796 }
797
798 if (options.includeNavigation) {
799 for (int lane = 0; lane < edge.lanesForward; ++lane) {
800 const float lateral =
802 RoadPolyline poly;
803 poly.r = 0.15f;
804 poly.g = 0.9f;
805 poly.b = 1.f;
806 poly.width = options.navRibbonHalfWidth * 2.f;
807 poly.inEdge = edge.id;
808 poly.inLane = lane;
809 poly.inDirection = RoadLaneDirection::Forward;
810 poly.outDirection = RoadLaneDirection::Forward;
811 poly.speedLimitMps = edge.style.speedLimitMps;
812 poly.trafficPriority = edge.style.trafficPriority;
813 float traveled = 0.f;
814 float nextArrow = options.arrowSpacing * 0.5f;
815 V3 prev{};
816 bool hasPrev = false;
817 for (const auto& frame : trimmed) {
818 const V3 origin{frame.sample.x, frame.sample.y, frame.sample.z};
819 const V3 side{frame.sideX, frame.sideY, frame.sideZ};
820 const V3 up{frame.upX, frame.upY, frame.upZ};
821 if (!std::isfinite(origin.x) || !std::isfinite(origin.y) || !std::isfinite(origin.z)) continue;
822 const V3 pos = origin + side * lateral + up * 0.08f;
823 poly.xyz.push_back(pos.x);
824 poly.xyz.push_back(pos.y);
825 poly.xyz.push_back(pos.z);
826 if (hasPrev) {
827 const float step = length(pos - prev);
828 if (std::isfinite(step) && step > 1e-5f && step < 1.0e4f) {
829 traveled += step;
830 const V3 fwd = normalize(pos - prev);
831 const V3 lat = normalize(cross(up, fwd));
832 const float hw = options.navRibbonHalfWidth;
833 appendStripQuad(mesh, prev + lat * -hw, pos + lat * -hw, pos + lat * hw, prev + lat * hw, up,
834 0.f, 1.f, 0.f, 1.f, RoadMaterial::Nav);
835 int arrowGuard = 0;
836 while (traveled >= nextArrow && arrowGuard++ < 64) {
837 appendArrow(mesh, pos, fwd, up, 0.85f);
838 nextArrow += std::max(options.arrowSpacing, 0.5f);
839 }
840 if (arrowGuard >= 64) nextArrow = traveled + std::max(options.arrowSpacing, 0.5f);
841 }
842 }
843 prev = pos;
844 hasPrev = true;
845 }
846 overlay.lanes.push_back(std::move(poly));
847 }
848 for (int lane = 0; lane < edge.lanesBackward; ++lane) {
849 const float lateral =
851 RoadPolyline poly;
852 poly.r = 0.2f;
853 poly.g = 0.75f;
854 poly.b = 1.f;
855 poly.width = options.navRibbonHalfWidth * 2.f;
856 poly.inEdge = edge.id;
857 poly.inLane = lane;
858 poly.inDirection = RoadLaneDirection::Backward;
859 poly.outDirection = RoadLaneDirection::Backward;
860 poly.speedLimitMps = edge.style.speedLimitMps;
861 poly.trafficPriority = edge.style.trafficPriority;
862 for (auto it = trimmed.rbegin(); it != trimmed.rend(); ++it) {
863 const V3 origin{it->sample.x, it->sample.y, it->sample.z};
864 const V3 side{it->sideX, it->sideY, it->sideZ};
865 const V3 up{it->upX, it->upY, it->upZ};
866 const V3 pos = origin + side * lateral + up * 0.08f;
867 poly.xyz.insert(poly.xyz.end(), {pos.x, pos.y, pos.z});
868 }
869 overlay.lanes.push_back(std::move(poly));
870 }
871 }
872 return Result<void>::success();
873}
874
875Result<void> bakeJunction(MeshBuild& mesh, const RoadNetwork& network, const RoadNode& node,
876 const RoadBakeOptions& options) {
877 struct ArmTip {
878 V3 origin;
879 V3 outward;
880 V3 authoredOutward;
881 V3 side;
882 V3 up;
883 float asphaltHalf = 0.f;
884 float halfWidth = 0.f;
885 float curbWidth = 0.f;
886 float curbHeight = 0.f;
887 float sidewalkW = 0.f;
888 float sidewalkH = 0.f;
889 float uvMeters = 8.f;
890 };
891 std::vector<ArmTip> arms;
892 float maxHalfWidth = 0.f;
893 float maxAsphalt = 0.f;
894 float maxCurbWidth = 0.f;
895 float maxCurbHeight = 0.f;
896 float maxSidewalkWidth = 0.f;
897 float maxSidewalkHeight = 0.f;
898 const JunctionPlane junctionPlane = fitJunctionPlane(network, node);
899 for (const auto& edge : network.edges()) {
900 if (edge.to != node.id && edge.from != node.id) continue;
901 auto spline = edgeToSpline(edge);
902 if (!spline.ok()) continue;
903 auto len = spline.value().lengthResult(16);
904 if (!len.ok()) continue;
905 const float trim = junctionSocketDistance(network, node, edge, len.value());
906 const float d = edge.to == node.id ? len.value() - trim : trim;
907 auto frame = spline.value().travelFrameResult(d, "clamp", 16);
908 if (!frame.ok()) continue;
909 blendFrameToJunctionPlane(frame.value(), junctionPlane, 1.f);
911 if (!profile.ok()) continue;
912 const float asphaltHalf =
913 0.5f * edge.style.laneWidth * static_cast<float>(edge.lanesForward + edge.lanesBackward);
914 const auto& f = frame.value();
915 ArmTip tip;
916 tip.origin = V3{f.sample.x, f.sample.y, f.sample.z};
917 const V3 outward = normalize(V3{tip.origin.x - node.x, 0.f, tip.origin.z - node.z});
918 V3 tangentOut{f.forwardX, f.forwardY, f.forwardZ};
919 if (edge.to == node.id) tangentOut = tangentOut * -1.f;
920 tip.outward = normalize(
921 V3{tangentOut.x, junctionPlane.gradeX * tangentOut.x + junctionPlane.gradeZ * tangentOut.z, tangentOut.z});
922 tip.authoredOutward = authoredOutwardDirection(node, edge);
923 const V3 canonicalSide{-outward.z, 0.f, outward.x};
924 // Preserve the exact spline frame at the seam. Only normalize its sign
925 // so every mouth is ordered clockwise -> counter-clockwise around the
926 // hub; deriving a new radial side breaks curved approaches.
927 tip.side = normalize(V3{f.sideX, f.sideY, f.sideZ});
928 if (dot(tip.side, canonicalSide) < 0.f) tip.side = tip.side * -1.f;
929 tip.up = normalize(V3{f.upX, f.upY, f.upZ});
930 if (tip.up.y < 0.f) tip.up = tip.up * -1.f;
931 tip.asphaltHalf = asphaltHalf;
932 tip.halfWidth = profile.value().halfWidth;
933 tip.curbWidth = edge.style.curbWidth;
934 tip.curbHeight = edge.style.curbHeight;
935 tip.sidewalkW = edge.style.sidewalkWidth;
936 tip.sidewalkH = edge.style.sidewalkHeight;
937 tip.uvMeters = edge.style.uvMeters;
938 arms.push_back(tip);
939 maxHalfWidth = std::max(maxHalfWidth, tip.halfWidth);
940 maxAsphalt = std::max(maxAsphalt, tip.asphaltHalf);
941 maxCurbWidth = std::max(maxCurbWidth, edge.style.curbWidth);
942 maxCurbHeight = std::max(maxCurbHeight, edge.style.curbHeight);
943 maxSidewalkWidth = std::max(maxSidewalkWidth, edge.style.sidewalkWidth);
944 maxSidewalkHeight = std::max(maxSidewalkHeight, edge.style.sidewalkHeight);
945 }
946 if (static_cast<int>(arms.size()) < 2) return Result<void>::success();
947
948 float jr = node.junctionRadius;
949 for (const auto& tip : arms) jr = std::min(jr, length(V3{tip.origin.x - node.x, 0.f, tip.origin.z - node.z}));
950
951 // Detect an axis-aligned 4-way cross (the simple debug scene).
952 bool axisCross = arms.size() == 4;
953 if (axisCross) {
954 int axisHits = 0;
955 for (const auto& tip : arms) {
956 const float dx = std::fabs(tip.origin.x - node.x);
957 const float dz = std::fabs(tip.origin.z - node.z);
958 if ((dx < 0.35f && dz > jr * 0.4f) || (dz < 0.35f && dx > jr * 0.4f)) ++axisHits;
959 }
960 axisCross = axisHits == 4;
961 }
962
963 // Asphalt fill. Fan from a hub vertex — a single large strip-quad can vanish
964 // under Lavapipe even when the CPU triangles cover the origin.
965 if (axisCross) {
966 // Curb-return fillet: circle centers sit in the outer corner (jr,jr), so the
967 // asphalt edge is concave and sidewalks wrap toward the property corner.
968 const float ah = maxAsphalt;
969 const float cornerR = std::max(0.75f, jr - ah);
970 const int segs = circularArcSegments(cornerR, 1.5707963f, options.junctionChordError);
971 const V3 upN = junctionPlane.up;
972 auto surfacePoint = [&](float x, float z, float offset = 0.f) {
973 return V3{x, planeHeight(junctionPlane, x, z), z} + upN * offset;
974 };
975
976 // Hub fan along the filleted outline (arm tips + concave corner arcs).
977 {
978 std::vector<std::pair<float, float>> rim;
979 rim.reserve(static_cast<std::size_t>(4 * (segs + 3)));
980 auto pushPt = [&](float x, float z) {
981 if (!rim.empty() && std::fabs(rim.back().first - x) < 1e-4f && std::fabs(rim.back().second - z) < 1e-4f)
982 return;
983 rim.emplace_back(x, z);
984 };
985 auto pushCornerArc = [&](int sx, int sz, bool reverse) {
986 const float sxf = static_cast<float>(sx);
987 const float szf = static_cast<float>(sz);
988 const float cx = node.x + sxf * jr;
989 const float cz = node.z + szf * jr;
990 for (int i = 0; i <= segs; ++i) {
991 const int ii = reverse ? (segs - i) : i;
992 const float t = static_cast<float>(ii) / static_cast<float>(segs);
993 const float th = t * 1.5707963f;
994 pushPt(cx - sxf * cornerR * std::sin(th), cz - szf * cornerR * std::cos(th));
995 }
996 };
997 pushPt(node.x + jr, node.z - ah);
998 pushPt(node.x + jr, node.z + ah);
999 pushCornerArc(+1, +1, false);
1000 pushPt(node.x - ah, node.z + jr);
1001 pushCornerArc(-1, +1, true);
1002 pushPt(node.x - jr, node.z - ah);
1003 pushCornerArc(-1, -1, false);
1004 pushPt(node.x + ah, node.z - jr);
1005 pushCornerArc(+1, -1, true);
1006
1007 const V3 hub = surfacePoint(node.x, node.z, 0.01f);
1008 for (std::size_t i = 0; i < rim.size(); ++i) {
1009 const auto& p0 = rim[i];
1010 const auto& p1 = rim[(i + 1) % rim.size()];
1011 appendOrientedTri(mesh, hub, surfacePoint(p0.first, p0.second, 0.01f),
1012 surfacePoint(p1.first, p1.second, 0.01f), upN, arms.front().uvMeters,
1014 }
1015
1016 // Thin seal strips under each arm tip — hides residual sub-cm seams.
1017 const float seal = 0.12f;
1018 auto addSeal = [&](float x0, float z0, float x1, float z1, float x2, float z2, float x3, float z3) {
1019 appendOrientedQuad(mesh, surfacePoint(x0, z0, 0.012f), surfacePoint(x1, z1, 0.012f),
1020 surfacePoint(x2, z2, 0.012f), surfacePoint(x3, z3, 0.012f), upN, 0.f, 1.f, 0.f,
1022 };
1023 addSeal(node.x - ah, node.z + jr - seal, node.x + ah, node.z + jr - seal, node.x + ah, node.z + jr + seal,
1024 node.x - ah, node.z + jr + seal);
1025 addSeal(node.x - ah, node.z - jr - seal, node.x + ah, node.z - jr - seal, node.x + ah, node.z - jr + seal,
1026 node.x - ah, node.z - jr + seal);
1027 addSeal(node.x + jr - seal, node.z - ah, node.x + jr + seal, node.z - ah, node.x + jr + seal, node.z + ah,
1028 node.x + jr - seal, node.z + ah);
1029 addSeal(node.x - jr - seal, node.z - ah, node.x - jr + seal, node.z - ah, node.x - jr + seal, node.z + ah,
1030 node.x - jr - seal, node.z + ah);
1031 }
1032
1033 const float curbW = 0.35f;
1034 const float walkW = std::max(0.4f, maxHalfWidth - ah - curbW);
1035 float curbH = 0.45f;
1036 float walkH = 0.14f;
1037 if (!arms.empty()) {
1038 curbH = arms.front().curbHeight;
1039 walkH = arms.front().sidewalkH;
1040 }
1041 const float r0 = cornerR;
1042 const float r1 = std::max(0.2f, cornerR - curbW);
1043 const float r2 = std::max(0.15f, cornerR - curbW - walkW);
1044 for (int sx : {-1, 1}) {
1045 for (int sz : {-1, 1}) {
1046 const float sxf = static_cast<float>(sx);
1047 const float szf = static_cast<float>(sz);
1048 const float cx = node.x + sxf * jr;
1049 const float cz = node.z + szf * jr;
1050 auto arcPt = [&](float radius, float t) {
1051 const float th = t * 1.5707963f;
1052 return V3{cx - sxf * radius * std::sin(th), 0.f, cz - szf * radius * std::cos(th)};
1053 };
1054 auto wall = [&](V3 p0, V3 p1, float offset0, float offset1, V3 n, RoadMaterial mat) {
1055 appendOrientedQuad(mesh, surfacePoint(p0.x, p0.z, offset0), surfacePoint(p1.x, p1.z, offset0),
1056 surfacePoint(p1.x, p1.z, offset1), surfacePoint(p0.x, p0.z, offset1), n, 0.f,
1057 1.f, 0.f, 1.f, mat);
1058 appendOrientedQuad(mesh, surfacePoint(p0.x, p0.z, offset0), surfacePoint(p1.x, p1.z, offset0),
1059 surfacePoint(p1.x, p1.z, offset1), surfacePoint(p0.x, p0.z, offset1), n * -1.f,
1060 0.f, 1.f, 0.f, 1.f, mat);
1061 };
1062 for (int i = 0; i < segs; ++i) {
1063 const float t0 = static_cast<float>(i) / static_cast<float>(segs);
1064 const float t1 = static_cast<float>(i + 1) / static_cast<float>(segs);
1065 const V3 a0 = arcPt(r0, t0);
1066 const V3 a1 = arcPt(r0, t1);
1067 const V3 b0 = arcPt(r1, t0);
1068 const V3 b1 = arcPt(r1, t1);
1069 const V3 s0 = arcPt(r2, t0);
1070 const V3 s1 = arcPt(r2, t1);
1071 // Sidewalk top slightly past r2 toward the property to cover the skirt seam.
1072 const V3 c0 = arcPt(std::max(0.05f, r2 - 0.06f), t0);
1073 const V3 c1 = arcPt(std::max(0.05f, r2 - 0.06f), t1);
1074 const V3 b0i = arcPt(r1 - 0.02f, t0);
1075 const V3 b1i = arcPt(r1 - 0.02f, t1);
1076 const V3 b0o = arcPt(r1 + 0.02f, t0);
1077 const V3 b1o = arcPt(r1 + 0.02f, t1);
1078 const V3 inToCorner = normalize(V3{cx - a0.x, 0.f, cz - a0.z});
1079 const V3 outToRoad = inToCorner * -1.f;
1080
1081 appendOrientedQuad(mesh, surfacePoint(a0.x, a0.z, curbH), surfacePoint(b0o.x, b0o.z, curbH),
1082 surfacePoint(b1o.x, b1o.z, curbH), surfacePoint(a1.x, a1.z, curbH), upN, 0.f,
1083 1.f, 0.f, 1.f, RoadMaterial::Curb);
1084 appendOrientedQuad(mesh, surfacePoint(b0i.x, b0i.z, walkH), surfacePoint(c0.x, c0.z, walkH),
1085 surfacePoint(c1.x, c1.z, walkH), surfacePoint(b1i.x, b1i.z, walkH), upN, 0.f,
1086 1.f, 0.f, 1.f, RoadMaterial::Sidewalk);
1087
1088 wall(a0, a1, 0.f, curbH, outToRoad, RoadMaterial::Curb);
1089 wall(b0, b1, walkH, curbH, outToRoad, RoadMaterial::Curb);
1090 wall(s0, s1, 0.f, walkH, outToRoad, RoadMaterial::Sidewalk);
1091 }
1092 }
1093 }
1094 } else {
1095 // General junction: retain every trimmed road mouth as an explicit open
1096 // edge. A convex hull is invalid here: unequal widths can hide a mouth
1097 // corner inside the hull and the curb pass then closes across traffic.
1098 // Ordering mouths around the hub produces a star-shaped apron whose
1099 // alternating edges are exactly [mouth, exposed connector].
1100 std::sort(arms.begin(), arms.end(), [&](const ArmTip& a, const ArmTip& b) {
1101 return std::atan2(a.origin.z - node.z, a.origin.x - node.x) <
1102 std::atan2(b.origin.z - node.z, b.origin.x - node.x);
1103 });
1104 struct RimStyle {
1105 float curbWidth;
1106 float curbHeight;
1107 float sidewalkWidth;
1108 float sidewalkHeight;
1109 };
1110 std::vector<V3> rim;
1111 std::vector<V3> rimUps;
1112 std::vector<RimStyle> rimStyles;
1113 rim.reserve(arms.size() * 8);
1114 rimUps.reserve(arms.size() * 8);
1115 rimStyles.reserve(arms.size() * 8);
1116 auto pushRim = [&](V3 point, RimStyle style) {
1117 point.y = planeHeight(junctionPlane, point.x, point.z);
1118 if (!rim.empty() && length(point - rim.back()) < 1e-4f) return;
1119 rim.push_back(point);
1120 rimUps.push_back(junctionPlane.up);
1121 rimStyles.push_back(style);
1122 };
1123 auto armStyle = [](const ArmTip& tip) {
1124 return RimStyle{tip.curbWidth, tip.curbHeight, tip.sidewalkW, tip.sidewalkH};
1125 };
1126 auto mixStyle = [](RimStyle a, RimStyle b, float t) {
1127 const float s = t * t * (3.f - 2.f * t);
1128 return RimStyle{a.curbWidth + (b.curbWidth - a.curbWidth) * s,
1129 a.curbHeight + (b.curbHeight - a.curbHeight) * s,
1130 a.sidewalkWidth + (b.sidewalkWidth - a.sidewalkWidth) * s,
1131 a.sidewalkHeight + (b.sidewalkHeight - a.sidewalkHeight) * s};
1132 };
1133 for (std::size_t armIndex = 0; armIndex < arms.size(); ++armIndex) {
1134 const auto& tip = arms[armIndex];
1135 const auto& next = arms[(armIndex + 1) % arms.size()];
1136 V3 clockwise = tip.origin - tip.side * tip.asphaltHalf;
1137 V3 counterClockwise = tip.origin + tip.side * tip.asphaltHalf;
1138 const RimStyle fromStyle = armStyle(tip);
1139 const RimStyle toStyle = armStyle(next);
1140 pushRim(clockwise, fromStyle);
1141 pushRim(counterClockwise, fromStyle);
1142
1143 // The curb return is defined by the intersection of both outward
1144 // boundary rays. This is the same finite-corner constraint used by
1145 // the reference implementation: parallel, rear-facing or remote
1146 // intersections stay straight instead of creating a wild miter.
1147 const V3 end = next.origin - next.side * next.asphaltHalf;
1148 const float denominator = tip.outward.x * next.outward.z - tip.outward.z * next.outward.x;
1149 if (arms.size() >= 3 && std::fabs(denominator) > 1e-4f) {
1150 const V3 delta = end - counterClockwise;
1151 const float alongTip = (delta.x * next.outward.z - delta.z * next.outward.x) / denominator;
1152 const float alongNext = (delta.x * tip.outward.z - delta.z * tip.outward.x) / denominator;
1153 const float maximumMiter = node.junctionRadius * 2.f + tip.asphaltHalf + next.asphaltHalf;
1154 if (alongTip > 0.f && alongNext > 0.f && alongTip < maximumMiter && alongNext < maximumMiter) {
1155 V3 control = counterClockwise + tip.outward * alongTip;
1156 control.y = planeHeight(junctionPlane, control.x, control.z);
1157 const float angle = std::acos(std::clamp(dot(tip.outward, next.outward), -1.f, 1.f));
1158 const int segments = quadraticSegments(counterClockwise, control, end, angle,
1159 options.junctionChordError);
1160 for (int segment = 1; segment < segments; ++segment) {
1161 const float t = static_cast<float>(segment) / static_cast<float>(segments);
1162 const float it = 1.f - t;
1163 const V3 point = counterClockwise * (it * it) + control * (2.f * it * t) + end * (t * t);
1164 pushRim(point, mixStyle(fromStyle, toStyle, t));
1165 }
1166 }
1167 }
1168 }
1169 if (rim.size() < 3)
1170 return bakeFail<void>(DiagnosticCode::InvariantViolation, "junction arm mouths do not form a valid polygon",
1171 "junction");
1172 const V3 upN = junctionPlane.up;
1173 std::vector<std::array<std::size_t, 3>> triangles;
1174 if (!triangulateBoundary(rim, triangles)) {
1175 // Dense multi-level nodes may not have enough authored radius for
1176 // every rounded return. Retain all socket mouth edges and retry
1177 // with their straight connector polygon; never accept a crossing
1178 // rounded outline or silently fill its convex hull.
1179 rim.clear();
1180 rimUps.clear();
1181 rimStyles.clear();
1182 for (const auto& arm : arms) {
1183 const RimStyle style = armStyle(arm);
1184 pushRim(arm.origin - arm.side * arm.asphaltHalf, style);
1185 pushRim(arm.origin + arm.side * arm.asphaltHalf, style);
1186 }
1187 if (!triangulateBoundary(rim, triangles))
1188 return bakeFail<void>(DiagnosticCode::InvariantViolation,
1189 "junction sockets form a self-intersecting boundary", "junction");
1190 }
1191 for (const auto& triangle : triangles)
1192 appendOrientedTri(mesh, rim[triangle[0]], rim[triangle[1]], rim[triangle[2]], upN, arms.front().uvMeters,
1194
1195 // Preserve a through-road when a third arm turns an otherwise smooth
1196 // split into a junction. The shared authored tangent selects the pair;
1197 // the two exact mouth edges bound the only surface restored here.
1198 for (std::size_t first = 0; first < arms.size(); ++first) {
1199 for (std::size_t second = first + 1; second < arms.size(); ++second) {
1200 const auto& a = arms[first];
1201 const auto& b = arms[second];
1202 if (dot(a.authoredOutward, b.authoredOutward) > -0.999f) continue;
1203 const V3 lift = upN * 0.01f;
1204 std::array<V3, 4> corners = {a.origin - a.side * a.asphaltHalf + lift,
1205 a.origin + a.side * a.asphaltHalf + lift,
1206 b.origin - b.side * b.asphaltHalf + lift,
1207 b.origin + b.side * b.asphaltHalf + lift};
1208 V3 center{};
1209 for (const auto& corner : corners) center = center + corner * 0.25f;
1210 std::sort(corners.begin(), corners.end(), [&](const V3& lhs, const V3& rhs) {
1211 return std::atan2(lhs.z - center.z, lhs.x - center.x) <
1212 std::atan2(rhs.z - center.z, rhs.x - center.x);
1213 });
1214 appendOrientedQuad(mesh, corners[0], corners[1], corners[2], corners[3], upN, 0.f, 1.f, 0.f, 1.f,
1216 }
1217 }
1218
1219 // Continue the edge profile around exposed hull sides while leaving
1220 // every road mouth open. Offset the whole convex ring first so adjacent
1221 // boundary strips share corners instead of producing one quad per arm.
1222 auto sameXZ = [](const V3& a, const V3& b) {
1223 return std::fabs(a.x - b.x) < 1e-3f && std::fabs(a.z - b.z) < 1e-3f;
1224 };
1225 auto isMouth = [&](const V3& a, const V3& b) {
1226 for (const auto& arm : arms) {
1227 const V3 left = arm.origin - arm.side * arm.asphaltHalf;
1228 const V3 right = arm.origin + arm.side * arm.asphaltHalf;
1229 if ((sameXZ(a, left) && sameXZ(b, right)) || (sameXZ(a, right) && sameXZ(b, left))) return true;
1230 }
1231 return false;
1232 };
1233 const float orientation = signedAreaXZ(rim) >= 0.f ? 1.f : -1.f;
1234 auto edgeOutward = [&](std::size_t i) {
1235 const V3 edge = rim[(i + 1) % rim.size()] - rim[i];
1236 V3 outward = normalize(cross(junctionPlane.up, edge));
1237 return orientation >= 0.f ? outward : outward * -1.f;
1238 };
1239 auto offsetRing = [&](const std::vector<float>& distances) {
1240 std::vector<V3> result;
1241 result.reserve(rim.size());
1242 for (std::size_t i = 0; i < rim.size(); ++i) {
1243 bool alignedToMouth = false;
1244 for (const auto& arm : arms) {
1245 const V3 left = arm.origin - arm.side * arm.asphaltHalf;
1246 const V3 right = arm.origin + arm.side * arm.asphaltHalf;
1247 if (sameXZ(rim[i], left)) {
1248 V3 point = arm.origin - arm.side * (arm.asphaltHalf + distances[i]);
1249 result.push_back(point);
1250 alignedToMouth = true;
1251 break;
1252 }
1253 if (sameXZ(rim[i], right)) {
1254 V3 point = arm.origin + arm.side * (arm.asphaltHalf + distances[i]);
1255 result.push_back(point);
1256 alignedToMouth = true;
1257 break;
1258 }
1259 }
1260 if (alignedToMouth) continue;
1261 const V3 previous = edgeOutward((i + rim.size() - 1) % rim.size());
1262 const V3 next = edgeOutward(i);
1263 const V3 miter = normalize(previous + next);
1264 const float projection = std::max(0.25f, dot(miter, next));
1265 result.push_back(rim[i] + miter * (distances[i] / projection));
1266 }
1267 return result;
1268 };
1269 std::vector<float> curbDistances;
1270 std::vector<float> walkDistances;
1271 curbDistances.reserve(rim.size());
1272 walkDistances.reserve(rim.size());
1273 for (const auto& style : rimStyles) {
1274 curbDistances.push_back(style.curbWidth);
1275 walkDistances.push_back(style.curbWidth + style.sidewalkWidth);
1276 }
1277 const auto curbRing = offsetRing(curbDistances);
1278 const auto walkRing = offsetRing(walkDistances);
1279 for (std::size_t i = 0; i < rim.size(); ++i) {
1280 const std::size_t next = (i + 1) % rim.size();
1281 if (isMouth(rim[i], rim[next])) continue;
1282 const V3 outward = edgeOutward(i);
1283 const V3 upA = rimUps[i];
1284 const V3 upB = rimUps[next];
1285 const auto& styleA = rimStyles[i];
1286 const auto& styleB = rimStyles[next];
1287 const V3 innerA{rim[i].x, rim[i].y, rim[i].z};
1288 const V3 innerB{rim[next].x, rim[next].y, rim[next].z};
1289 const V3 surfaceA = innerA;
1290 const V3 surfaceB = innerB;
1291 const V3 innerTopA = surfaceA + upA * styleA.curbHeight;
1292 const V3 innerTopB = surfaceB + upB * styleB.curbHeight;
1293 const V3 curbTopA = curbRing[i] + upA * styleA.curbHeight;
1294 const V3 curbTopB = curbRing[next] + upB * styleB.curbHeight;
1295 const V3 curbWalkA = curbRing[i] + upA * styleA.sidewalkHeight;
1296 const V3 curbWalkB = curbRing[next] + upB * styleB.sidewalkHeight;
1297 const V3 walkA = walkRing[i] + upA * styleA.sidewalkHeight;
1298 const V3 walkB = walkRing[next] + upB * styleB.sidewalkHeight;
1299 if (styleA.curbWidth > 1e-4f || styleB.curbWidth > 1e-4f) {
1300 appendOrientedQuad(mesh, innerA, innerB, innerTopB, innerTopA, outward * -1.f, 0.f, 1.f, 0.f, 1.f,
1302 appendOrientedQuad(mesh, innerTopA, innerTopB, curbTopB, curbTopA, upN, 0.f, 1.f, 0.f, 1.f,
1304 appendOrientedQuad(mesh, curbWalkA, curbWalkB, curbTopB, curbTopA, outward, 0.f, 1.f, 0.f, 1.f,
1306 }
1307 if (styleA.sidewalkWidth > 1e-4f || styleB.sidewalkWidth > 1e-4f) {
1308 appendOrientedQuad(mesh, curbWalkA, curbWalkB, walkB, walkA, upN, 0.f, 1.f, 0.f, 1.f,
1310 appendOrientedQuad(mesh, walkRing[i], walkRing[next], walkB, walkA, outward, 0.f, 1.f, 0.f, 1.f,
1312 }
1313 }
1314 }
1315
1316 if (!options.includeMarkings || arms.size() == 2u) return Result<void>::success();
1317
1318 // Zebra stripes on every arm tip that meets this hub.
1319 for (const auto& edge : network.edges()) {
1320 if (edge.to != node.id && edge.from != node.id) continue;
1321 auto spline = edgeToSpline(edge);
1322 if (!spline.ok()) continue;
1323 auto len = spline.value().lengthResult(16);
1324 if (!len.ok()) continue;
1325 const float trim = junctionSocketDistance(network, node, edge, len.value());
1326 if (len.value() < trim + 1.5f) continue;
1327 const float alongDist = edge.to == node.id ? (len.value() - trim - 1.6f) : (trim + 1.6f);
1328 auto frame = spline.value().travelFrameResult(alongDist, "clamp", 16);
1329 if (!frame.ok()) continue;
1330 const auto& f = frame.value();
1331 const V3 origin{f.sample.x, f.sample.y, f.sample.z};
1332 const V3 side{f.sideX, f.sideY, f.sideZ};
1333 // Stripe direction: toward the hub from this tip.
1334 V3 fwd{f.forwardX, f.forwardY, f.forwardZ};
1335 if (edge.from == node.id) fwd = fwd * -1.f;
1336 const V3 nrm = normalize(V3{f.upX, f.upY, f.upZ});
1337 const float stripeW =
1338 edge.style.laneWidth * static_cast<float>(edge.lanesForward + edge.lanesBackward) * 0.45f;
1339 for (int s = 0; s < 4; ++s) {
1340 const float along = static_cast<float>(s) * 0.5f;
1341 const V3 c = origin - fwd * along + nrm * 0.04f;
1342 const V3 a = c + side * -stripeW;
1343 const V3 b = c + side * stripeW;
1344 const V3 d = a - fwd * 0.25f;
1345 const V3 e = b - fwd * 0.25f;
1346 appendOrientedQuad(mesh, a, b, e, d, nrm, 0.f, 1.f, 0.f, 1.f, RoadMaterial::Marking);
1347 }
1348 }
1349 return Result<void>::success();
1350}
1351
1352Result<void> bakeTurnOverlays(MeshBuild& mesh, RoadOverlay& overlay, const RoadNetwork& network,
1353 const RoadBakeOptions& options,
1354 const std::unordered_set<std::uint32_t>& activeJunctionNodes) {
1355 std::unordered_map<std::uint32_t, SplinePath> paths;
1356 for (const auto& edge : network.edges()) {
1357 auto spline = edgeToSpline(edge);
1358 if (!spline.ok()) return Result<void>::failure(spline.status());
1359 paths.emplace(edge.id, std::move(spline).takeValue());
1360 }
1361
1362 for (const auto& link : network.laneLinks()) {
1363 auto inEdge = network.edgeResult(link.inEdge);
1364 auto outEdge = network.edgeResult(link.outEdge);
1365 if (!inEdge.ok() || !outEdge.ok()) continue;
1366 const std::uint32_t nodeId =
1367 link.inDirection == RoadLaneDirection::Forward ? inEdge.value().to : inEdge.value().from;
1368 auto node = network.nodeResult(nodeId);
1369 if (!node.ok()) continue;
1370
1371 auto& inPath = paths[link.inEdge];
1372 auto& outPath = paths[link.outEdge];
1373 auto inLen = inPath.lengthResult(16);
1374 auto outLen = outPath.lengthResult(16);
1375 if (!inLen.ok() || !outLen.ok()) continue;
1376
1377 const bool activeJunction = activeJunctionNodes.contains(node.value().id);
1378 const float inTrim =
1379 activeJunction ? junctionSocketDistance(network, node.value(), inEdge.value(), inLen.value()) : 0.f;
1380 const float outTrim =
1381 activeJunction ? junctionSocketDistance(network, node.value(), outEdge.value(), outLen.value()) : 0.f;
1382 const float inDist = link.inDirection == RoadLaneDirection::Forward ? inLen.value() - inTrim : inTrim;
1383 const float outDist = link.outDirection == RoadLaneDirection::Forward ? outTrim : outLen.value() - outTrim;
1384 auto inFrame = inPath.travelFrameResult(inDist, "clamp", 16);
1385 auto outFrame = outPath.travelFrameResult(outDist, "clamp", 16);
1386 if (!inFrame.ok() || !outFrame.ok()) continue;
1387 if (activeJunction) {
1388 const JunctionPlane plane = fitJunctionPlane(network, node.value());
1389 blendFrameToJunctionPlane(inFrame.value(), plane, 1.f);
1390 blendFrameToJunctionPlane(outFrame.value(), plane, 1.f);
1391 }
1392
1393 const float inLat = laneCenterOffset(inEdge.value().style, inEdge.value().lanesForward,
1394 inEdge.value().lanesBackward, link.inLane, link.inDirection);
1395 const float outLat = laneCenterOffset(outEdge.value().style, outEdge.value().lanesForward,
1396 outEdge.value().lanesBackward, link.outLane, link.outDirection);
1397
1398 const auto& fi = inFrame.value();
1399 const auto& fo = outFrame.value();
1400 const V3 p0{fi.sample.x, fi.sample.y, fi.sample.z};
1401 const V3 p1{fo.sample.x, fo.sample.y, fo.sample.z};
1402 const V3 si{fi.sideX, fi.sideY, fi.sideZ};
1403 const V3 so{fo.sideX, fo.sideY, fo.sideZ};
1404 const V3 ui{fi.upX, fi.upY, fi.upZ};
1405 const V3 uo{fo.upX, fo.upY, fo.upZ};
1406 V3 ti{fi.forwardX, fi.forwardY, fi.forwardZ};
1407 V3 to{fo.forwardX, fo.forwardY, fo.forwardZ};
1408 if (link.inDirection == RoadLaneDirection::Backward) ti = ti * -1.f;
1409 if (link.outDirection == RoadLaneDirection::Backward) to = to * -1.f;
1410 const V3 a = p0 + si * inLat + ui * 0.09f;
1411 const V3 d = p1 + so * outLat + uo * 0.09f;
1412 // A radius-sized handle works for ordinary junctions, but can overshoot
1413 // badly when two short/acute arms leave their lane endpoints close
1414 // together. Bound it by the endpoint chord so the cubic stays local.
1415 const float handle = std::min(node.value().junctionRadius * 0.65f, length(d - a) * 0.5f);
1416 const V3 b = a + ti * handle;
1417 const V3 c = d - to * handle;
1418
1419 RoadPolyline poly;
1420 poly.r = 0.25f;
1421 poly.g = 0.95f;
1422 poly.b = 0.45f;
1423 poly.width = options.navRibbonHalfWidth * 2.f;
1424 poly.inEdge = link.inEdge;
1425 poly.outEdge = link.outEdge;
1426 poly.inLane = link.inLane;
1427 poly.outLane = link.outLane;
1428 poly.inDirection = link.inDirection;
1429 poly.outDirection = link.outDirection;
1430 poly.speedLimitMps = std::min(inEdge.value().style.speedLimitMps, outEdge.value().style.speedLimitMps);
1431 poly.trafficPriority = inEdge.value().style.trafficPriority;
1432 const int samples = std::max(4, options.turnSamples);
1433 V3 prev{};
1434 bool hasPrev = false;
1435 for (int i = 0; i <= samples; ++i) {
1436 const float t = static_cast<float>(i) / static_cast<float>(samples);
1437 const float u = 1.f - t;
1438 const V3 p = a * (u * u * u) + b * (3.f * u * u * t) + c * (3.f * u * t * t) + d * (t * t * t);
1439 poly.xyz.push_back(p.x);
1440 poly.xyz.push_back(p.y);
1441 poly.xyz.push_back(p.z);
1442 if (hasPrev) {
1443 const V3 delta = p - prev;
1444 if (length(delta) > 1e-5f) {
1445 const V3 fwd = normalize(delta);
1446 const V3 up = normalize(ui + uo);
1447 const V3 lat = normalize(cross(up, fwd));
1448 const float hw = options.navRibbonHalfWidth * 0.9f;
1449 appendStripQuad(mesh, prev + lat * -hw, p + lat * -hw, p + lat * hw, prev + lat * hw, up,
1450 0.f, 1.f, 0.f, 1.f, RoadMaterial::Nav);
1451 }
1452 }
1453 if (i == samples / 2 && length(d - a) > 1e-5f) {
1454 // Follow the curve at the arrow position. Using the endpoint chord
1455 // points across a hairpin and can visibly cross the turn ribbon.
1456 const V3 derivative = (b - a) * (3.f * u * u) + (c - b) * (6.f * u * t) +
1457 (d - c) * (3.f * t * t);
1458 const V3 fwd = length(derivative) > 1e-5f ? normalize(derivative) : normalize(d - a);
1459 appendArrow(mesh, p, fwd, normalize(ui + uo), 0.7f);
1460 }
1461 prev = p;
1462 hasPrev = true;
1463 }
1464 overlay.turns.push_back(std::move(poly));
1465 if (exceedsMeshBudget(mesh, options))
1466 return bakeFail<void>(DiagnosticCode::PreconditionViolation,
1467 "road navigation exceeds the mesh element budget", "mesh");
1468 }
1469 return Result<void>::success();
1470}
1471
1472Result<void> bakePlacementPoints(PointSet& placements, const RoadNetwork& network, const RoadBakeOptions& options,
1473 const std::unordered_set<std::uint32_t>& activeJunctionNodes) {
1474 if (!std::isfinite(options.sideObjectSpacing) || options.sideObjectSpacing < 0.25f ||
1475 !std::isfinite(options.sideObjectOffset) || options.sideObjectOffset < 0.f || options.maximumPlacements < 1)
1476 return bakeFail<void>(DiagnosticCode::InvalidArgument, "road placement options are invalid", "placements");
1477 if (!std::isfinite(options.sideObjectClearance) || options.sideObjectClearance < 0.f)
1478 return bakeFail<void>(DiagnosticCode::InvalidArgument, "road side-object clearance is invalid",
1479 "placements.clearance");
1480
1481 struct EdgeSource {
1482 const RoadEdge* edge = nullptr;
1483 SplinePath spline;
1484 float length = 0.f;
1485 float halfWidth = 0.f;
1486 float sideStart = 0.f;
1487 float sideEnd = 0.f;
1488 };
1489 std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> adjacency;
1490 for (const auto& node : network.nodes()) adjacency.emplace(node.id, std::vector<std::uint32_t>{});
1491 for (const auto& edge : network.edges()) {
1492 adjacency[edge.from].push_back(edge.to);
1493 adjacency[edge.to].push_back(edge.from);
1494 }
1495
1496 std::vector<std::vector<std::uint32_t>> roundabouts;
1497 auto canonicalCycle = [](std::vector<std::uint32_t> cycle) {
1498 const auto minimum = std::min_element(cycle.begin(), cycle.end());
1499 std::rotate(cycle.begin(), minimum, cycle.end());
1500 std::vector<std::uint32_t> reversed{cycle.front()};
1501 for (auto it = cycle.rbegin(); it != cycle.rend() - 1; ++it) reversed.push_back(*it);
1502 return reversed < cycle ? reversed : cycle;
1503 };
1504 for (const auto& node : network.nodes()) {
1505 if (adjacency[node.id].size() != 3u) continue;
1506 std::vector<std::uint32_t> path{node.id};
1507 std::function<void(std::uint32_t)> visit = [&](std::uint32_t current) {
1508 for (const auto next : adjacency[current]) {
1509 if (next == path.front()) {
1510 if (path.size() < 3u || path.size() > 12u) continue;
1511 auto cycle = canonicalCycle(path);
1512 if (std::find(roundabouts.begin(), roundabouts.end(), cycle) != roundabouts.end()) continue;
1513 float centerX = 0.f, centerZ = 0.f, minY = std::numeric_limits<float>::max();
1514 float maxY = -std::numeric_limits<float>::max();
1515 bool valid = true;
1516 int entryCount = 0;
1517 for (const auto id : cycle) {
1518 if (adjacency[id].size() < 2u || adjacency[id].size() > 3u) {
1519 valid = false;
1520 break;
1521 }
1522 if (adjacency[id].size() == 3u) ++entryCount;
1523 auto value = network.nodeResult(id);
1524 if (!value.ok()) {
1525 valid = false;
1526 break;
1527 }
1528 centerX += value.value().x;
1529 centerZ += value.value().z;
1530 minY = std::min(minY, value.value().y);
1531 maxY = std::max(maxY, value.value().y);
1532 }
1533 if (!valid || entryCount < 3 || maxY - minY > 1.f) continue;
1534 for (std::size_t i = 0; i < cycle.size(); ++i) {
1535 const auto a = cycle[i];
1536 const auto b = cycle[(i + 1u) % cycle.size()];
1537 const auto ringEdge = std::find_if(network.edges().begin(), network.edges().end(),
1538 [&](const RoadEdge& edge) {
1539 return (edge.from == a && edge.to == b) ||
1540 (edge.from == b && edge.to == a);
1541 });
1542 if (ringEdge == network.edges().end() || ringEdge->controlPoints.size() < 3u) {
1543 valid = false;
1544 break;
1545 }
1546 }
1547 if (!valid) continue;
1548 centerX /= static_cast<float>(cycle.size());
1549 centerZ /= static_cast<float>(cycle.size());
1550 float minRadius = std::numeric_limits<float>::max(), maxRadius = 0.f;
1551 for (const auto id : cycle) {
1552 const auto value = network.nodeResult(id).value();
1553 const float dx = value.x - centerX, dz = value.z - centerZ;
1554 const float radius = std::sqrt(dx * dx + dz * dz);
1555 minRadius = std::min(minRadius, radius);
1556 maxRadius = std::max(maxRadius, radius);
1557 }
1558 if (minRadius >= 1.f && maxRadius <= minRadius * 1.8f) roundabouts.push_back(std::move(cycle));
1559 continue;
1560 }
1561 if (path.size() >= 12u || adjacency[next].size() < 2u || adjacency[next].size() > 3u ||
1562 std::find(path.begin(), path.end(), next) != path.end())
1563 continue;
1564 path.push_back(next);
1565 visit(next);
1566 path.pop_back();
1567 }
1568 };
1569 visit(node.id);
1570 }
1571 std::sort(roundabouts.begin(), roundabouts.end());
1572 std::unordered_set<std::uint32_t> roundaboutNodes;
1573 for (const auto& cycle : roundabouts)
1574 for (const auto id : cycle) roundaboutNodes.insert(id);
1575
1576 std::vector<EdgeSource> sources;
1577 sources.reserve(network.edges().size());
1578 std::size_t required = 0;
1579 for (const auto& cycle : roundabouts) {
1580 required += 1u;
1581 for (const auto id : cycle)
1582 if (adjacency[id].size() == 3u) ++required;
1583 }
1584 for (const auto& node : network.nodes()) {
1585 if (roundaboutNodes.contains(node.id)) continue;
1586 const auto arms = static_cast<std::size_t>(std::count_if(network.edges().begin(), network.edges().end(),
1587 [&](const RoadEdge& edge) {
1588 return edge.from == node.id || edge.to == node.id;
1589 }));
1590 if (arms >= 3u) required += arms == 3u ? 2u : 1u;
1591 }
1592 for (const auto& edge : network.edges()) {
1593 auto spline = edgeToSpline(edge);
1594 if (!spline.ok()) return Result<void>::failure(spline.status());
1595 auto lengthResult = spline.value().lengthResult(32);
1596 if (!lengthResult.ok()) return Result<void>::failure(lengthResult.status());
1598 if (!profile.ok()) return Result<void>::failure(profile.status());
1599 const float length = lengthResult.value();
1600 float sideStart = edge.style.sideObjectStartOffset;
1601 float sideEnd = length - edge.style.sideObjectEndOffset;
1602 if (activeJunctionNodes.contains(edge.from)) {
1603 const auto node = network.nodeResult(edge.from);
1604 if (!node.ok()) return Result<void>::failure(node.status());
1605 sideStart = std::max(sideStart, junctionSocketDistance(network, node.value(), edge, length));
1606 }
1607 if (activeJunctionNodes.contains(edge.to)) {
1608 const auto node = network.nodeResult(edge.to);
1609 if (!node.ok()) return Result<void>::failure(node.status());
1610 sideEnd = std::min(sideEnd, length - junctionSocketDistance(network, node.value(), edge, length));
1611 }
1612 sideEnd = std::max(sideStart, sideEnd);
1613 const float placementLength = sideEnd - sideStart;
1614 const float firstSideDistance = options.sideObjectSpacing * 0.5f;
1615 const auto sideSamples = placementLength <= firstSideDistance
1616 ? 0u
1617 : static_cast<std::size_t>(
1618 std::floor((placementLength - firstSideDistance) /
1619 options.sideObjectSpacing)) +
1620 1u;
1621 const auto enabledSides = static_cast<std::size_t>(edge.style.sideObjectsLeft) +
1622 static_cast<std::size_t>(edge.style.sideObjectsRight);
1623 required += 2u + sideSamples * enabledSides;
1624 if (required > static_cast<std::size_t>(options.maximumPlacements))
1625 return bakeFail<void>(DiagnosticCode::PreconditionViolation, "road placement budget exceeded",
1626 "placements");
1627 sources.push_back(
1628 {&edge, std::move(spline).takeValue(), length, profile.value().halfWidth, sideStart, sideEnd});
1629 }
1630 placements.reserve(required);
1631
1632 for (const auto& cycle : roundabouts) {
1633 float centerX = 0.f, centerY = 0.f, centerZ = 0.f;
1634 for (const auto id : cycle) {
1635 const auto node = network.nodeResult(id).value();
1636 centerX += node.x;
1637 centerY += node.y;
1638 centerZ += node.z;
1639 }
1640 const float divisor = static_cast<float>(cycle.size());
1641 centerX /= divisor;
1642 centerY /= divisor;
1643 centerZ /= divisor;
1644 const std::uint32_t roundaboutId = cycle.front();
1645 const auto entryCount = static_cast<std::int64_t>(
1646 std::count_if(cycle.begin(), cycle.end(), [&](std::uint32_t id) { return adjacency[id].size() == 3u; }));
1647 const auto pointNamespace = 0x524e444200000000ull | roundaboutId;
1648 auto appendRoundaboutPoint = [&](const RoadNode& node, V3 heading, const char* role,
1649 std::uint64_t ordinal) -> Result<void> {
1650 const int row = placements.add(node.x, node.y, node.z);
1651 placements.setNormal(row, 0.f, 1.f, 0.f);
1652 placements.setYaw(row, std::atan2(heading.x, heading.z) * 57.2957795f);
1653 placements.setPointSeed(row, deriveSeed(roundaboutId, std::string(role) + std::to_string(node.id)));
1654 auto status = placements.trySetPointId(row, derivePointId(pointNamespace, ordinal));
1655 if (!status.ok()) return status;
1656 status = placements.trySetStringAttribute(row, "road_role", role);
1657 if (!status.ok()) return status;
1658 status = placements.trySetIntAttribute(row, "road_node_id", node.id);
1659 if (!status.ok()) return status;
1660 status = placements.trySetIntAttribute(row, "road_roundabout_id", roundaboutId);
1661 if (!status.ok()) return status;
1662 status = placements.trySetIntAttribute(row, "road_arm_count", entryCount);
1663 if (!status.ok()) return status;
1664 return placements.trySetVectorAttribute(row, "road_direction", heading.x, heading.y, heading.z);
1665 };
1666
1667 RoadNode center;
1668 center.x = centerX;
1669 center.y = centerY;
1670 center.z = centerZ;
1671 auto status = appendRoundaboutPoint(center, V3{0.f, 0.f, 1.f}, "junction.roundabout.center", 1u);
1672 if (!status.ok()) return status;
1673 std::uint64_t entryOrdinal = 2u;
1674 for (std::size_t i = 0; i < cycle.size(); ++i) {
1675 const auto node = network.nodeResult(cycle[i]).value();
1676 if (adjacency[node.id].size() != 3u) continue;
1677 V3 heading{node.x - centerX, 0.f, node.z - centerZ};
1678 for (const auto neighbor : adjacency[node.id]) {
1679 if (std::find(cycle.begin(), cycle.end(), neighbor) != cycle.end()) continue;
1680 const auto outside = network.nodeResult(neighbor).value();
1681 heading = V3{outside.x - node.x, 0.f, outside.z - node.z};
1682 break;
1683 }
1684 status = appendRoundaboutPoint(node, normalize(heading), "junction.roundabout.entry", entryOrdinal++);
1685 if (!status.ok()) return status;
1686 }
1687 }
1688
1689 for (const auto& node : network.nodes()) {
1690 if (roundaboutNodes.contains(node.id)) continue;
1691 std::vector<V3> directions;
1692 for (const auto& edge : network.edges()) {
1693 auto appendDirection = [&](const RoadControlPoint& point) {
1694 const V3 direction{point.x - node.x, 0.f, point.z - node.z};
1695 if (dot(direction, direction) <= 1e-6f) return false;
1696 directions.push_back(normalize(direction));
1697 return true;
1698 };
1699 if (edge.from == node.id) {
1700 for (std::size_t i = 1u; i < edge.controlPoints.size(); ++i)
1701 if (appendDirection(edge.controlPoints[i])) break;
1702 } else if (edge.to == node.id) {
1703 for (std::size_t i = edge.controlPoints.size() - 1u; i-- > 0u;)
1704 if (appendDirection(edge.controlPoints[i])) break;
1705 }
1706 }
1707 if (directions.size() < 3u) continue;
1708
1709 int oppositePairs = 0;
1710 for (std::size_t a = 0; a < directions.size(); ++a)
1711 for (std::size_t b = a + 1u; b < directions.size(); ++b)
1712 if (dot(directions[a], directions[b]) < -0.85f) ++oppositePairs;
1713
1714 const char* role = "junction.multi";
1715 if (directions.size() == 3u) role = oppositePairs > 0 ? "junction.t" : "junction.y";
1716 if (directions.size() == 4u) role = oppositePairs >= 2 ? "junction.x" : "junction.multi";
1717
1718 V3 heading{};
1719 if (directions.size() == 3u && oppositePairs > 0) {
1720 float leastOpposed = -2.f;
1721 for (const auto& candidate : directions) {
1722 float closest = 1.f;
1723 for (const auto& other : directions) {
1724 if (&candidate != &other) closest = std::min(closest, dot(candidate, other));
1725 }
1726 if (closest > leastOpposed) {
1727 leastOpposed = closest;
1728 heading = candidate;
1729 }
1730 }
1731 } else {
1732 heading = directions.front();
1733 }
1734
1735 const int row = placements.add(node.x, node.y, node.z);
1736 placements.setNormal(row, 0.f, 1.f, 0.f);
1737 placements.setYaw(row, std::atan2(heading.x, heading.z) * 57.2957795f);
1738 placements.setPointSeed(row, deriveSeed(node.id, role));
1739 auto status = placements.trySetPointId(row, derivePointId(0x4a554e4300000000ull | node.id, 1u));
1740 if (!status.ok()) return status;
1741 status = placements.trySetStringAttribute(row, "road_role", role);
1742 if (!status.ok()) return status;
1743 status = placements.trySetIntAttribute(row, "road_node_id", node.id);
1744 if (!status.ok()) return status;
1745 status = placements.trySetIntAttribute(row, "road_arm_count", static_cast<std::int64_t>(directions.size()));
1746 if (!status.ok()) return status;
1747 status = placements.trySetIntAttribute(row, "road_side", 0);
1748 if (!status.ok()) return status;
1749 status = placements.trySetVectorAttribute(row, "road_direction", heading.x, heading.y, heading.z);
1750 if (!status.ok()) return status;
1751
1752 if (directions.size() == 3u) {
1753 const float islandDistance = std::max(1.f, node.junctionRadius * 0.45f);
1754 const int islandRow = placements.add(node.x + heading.x * islandDistance, node.y,
1755 node.z + heading.z * islandDistance);
1756 placements.setNormal(islandRow, 0.f, 1.f, 0.f);
1757 placements.setYaw(islandRow, std::atan2(heading.x, heading.z) * 57.2957795f);
1758 constexpr const char* islandRole = "junction.channelizing.island";
1759 placements.setPointSeed(islandRow, deriveSeed(node.id, islandRole));
1760 status = placements.trySetPointId(islandRow,
1761 derivePointId(0x4a554e4300000000ull | node.id, 2u));
1762 if (!status.ok()) return status;
1763 status = placements.trySetStringAttribute(islandRow, "road_role", islandRole);
1764 if (!status.ok()) return status;
1765 status = placements.trySetIntAttribute(islandRow, "road_node_id", node.id);
1766 if (!status.ok()) return status;
1767 status = placements.trySetIntAttribute(islandRow, "road_arm_count", 3);
1768 if (!status.ok()) return status;
1769 status = placements.trySetFloatAttribute(islandRow, "road_distance", islandDistance);
1770 if (!status.ok()) return status;
1771 status = placements.trySetVectorAttribute(islandRow, "road_direction", heading.x, heading.y,
1772 heading.z);
1773 if (!status.ok()) return status;
1774 }
1775 }
1776
1777 std::vector<V3> acceptedSidePoints;
1778 for (const auto& source : sources) {
1779 std::uint64_t ordinal = 1;
1780 const auto pointNamespace = 0x524f414400000000ull | source.edge->id;
1781 auto append = [&](float distance, float lateral, const char* role, int side,
1782 bool avoidConflicts) -> Result<bool> {
1783 auto frame = source.spline.travelFrameResult(distance, "clamp", 32);
1784 if (!frame.ok()) return Result<bool>::failure(frame.status());
1785 const auto& f = frame.value();
1786 const V3 point{f.sample.x + f.sideX * lateral, f.sample.y + f.sideY * lateral,
1787 f.sample.z + f.sideZ * lateral};
1788 if (avoidConflicts) {
1789 for (const auto& other : sources) {
1790 if (other.edge->id == source.edge->id) continue;
1791 auto closest = other.spline.closestPointResult(point.x, point.y, point.z, 32);
1792 if (!closest.ok()) return Result<bool>::failure(closest.status());
1793 const V3 delta{point.x - closest.value().x, point.y - closest.value().y,
1794 point.z - closest.value().z};
1795 if (length(delta) < other.halfWidth + options.sideObjectClearance)
1796 return Result<bool>::success(false);
1797 }
1798 for (const auto& accepted : acceptedSidePoints)
1799 if (length(point - accepted) < options.sideObjectClearance) return Result<bool>::success(false);
1800 }
1801 const int row = placements.add(point.x, point.y, point.z);
1802 placements.setNormal(row, f.upX, f.upY, f.upZ);
1803 placements.setYaw(row, std::atan2(f.forwardX, f.forwardZ) * 57.2957795f);
1804 placements.setPointSeed(row, deriveSeed(source.edge->id, std::string(role) + std::to_string(ordinal)));
1805 auto identified = placements.trySetPointId(row, derivePointId(pointNamespace, ordinal++));
1806 if (!identified.ok()) return Result<bool>::failure(identified.status());
1807 auto edgeId = placements.trySetIntAttribute(row, "road_edge_id", source.edge->id);
1808 if (!edgeId.ok()) return Result<bool>::failure(edgeId.status());
1809 auto pointRole = placements.trySetStringAttribute(row, "road_role", role);
1810 if (!pointRole.ok()) return Result<bool>::failure(pointRole.status());
1811 auto pointSide = placements.trySetIntAttribute(row, "road_side", side);
1812 if (!pointSide.ok()) return Result<bool>::failure(pointSide.status());
1813 auto along = placements.trySetFloatAttribute(row, "road_distance", distance);
1814 if (!along.ok()) return Result<bool>::failure(along.status());
1815 auto clearance = placements.trySetFloatAttribute(row, "road_clearance", options.sideObjectClearance);
1816 if (!clearance.ok()) return Result<bool>::failure(clearance.status());
1817 auto direction = placements.trySetVectorAttribute(row, "road_direction", f.forwardX, f.forwardY,
1818 f.forwardZ);
1819 if (!direction.ok()) return Result<bool>::failure(direction.status());
1820 if (avoidConflicts) acceptedSidePoints.push_back(point);
1821 return Result<bool>::success(true);
1822 };
1823
1824 auto start = append(0.f, 0.f, "transition.start", 0, false);
1825 if (!start.ok()) return Result<void>::failure(start.status());
1826 auto end = append(source.length, 0.f, "transition.end", 0, false);
1827 if (!end.ok()) return Result<void>::failure(end.status());
1828 const float lateral = source.halfWidth + options.sideObjectOffset;
1829 for (float distance = source.sideStart + options.sideObjectSpacing * 0.5f; distance < source.sideEnd;
1830 distance += options.sideObjectSpacing) {
1831 if (source.edge->style.sideObjectsLeft) {
1832 auto left = append(distance, -lateral, "side.left", -1, true);
1833 if (!left.ok()) return Result<void>::failure(left.status());
1834 }
1835 if (source.edge->style.sideObjectsRight) {
1836 auto right = append(distance, lateral, "side.right", 1, true);
1837 if (!right.ok()) return Result<void>::failure(right.status());
1838 }
1839 }
1840 }
1841 return detail::bakeTrafficControlPoints(placements, network, options);
1842}
1843
1844} // namespace
1845
1846namespace detail {
1847
1848Result<SplinePath> edgeSplineForBake(const RoadEdge& edge) { return edgeToSpline(edge); }
1849
1851 float pathLength) {
1852 return junctionSocketDistance(network, node, edge, pathLength);
1853}
1854
1855} // namespace detail
1856
1857namespace {
1858
1859struct MaterialColor {
1860 float r, g, b, a;
1861};
1862
1863MaterialColor colorForGroup(const std::string& name) {
1864 if (name == "asphalt") return {0.28f, 0.28f, 0.30f, 1.f};
1865 if (name == "curb") return {0.78f, 0.78f, 0.76f, 1.f};
1866 if (name == "sidewalk") return {0.86f, 0.86f, 0.84f, 1.f};
1867 if (name == "deck") return {0.48f, 0.48f, 0.46f, 1.f};
1868 if (name == "pier") return {0.70f, 0.68f, 0.64f, 1.f};
1869 if (name == "marking") return {0.96f, 0.96f, 0.94f, 1.f};
1870 if (name == "markingYellow") return {0.96f, 0.80f, 0.10f, 1.f};
1871 if (name == "nav") return {0.10f, 0.95f, 1.f, 1.f};
1872 return {0.55f, 0.55f, 0.55f, 1.f};
1873}
1874
1875Result<void> paintGroupVertexColors(MeshBuild& mesh) {
1876 const int verts = mesh.getVertexCount();
1877 if (verts <= 0) return Result<void>::success();
1878 std::vector<float> colors(static_cast<std::size_t>(verts) * 4u, 1.f);
1879 const int triCount = mesh.getIndexCount() / 3;
1880 for (int t = 0; t < triCount; ++t) {
1881 const int group = mesh.getTriangleGroup(t);
1882 const MaterialColor c =
1883 group >= 0 ? colorForGroup(mesh.getGroupName(group)) : MaterialColor{0.5f, 0.5f, 0.5f, 1.f};
1884 for (int k = 0; k < 3; ++k) {
1885 const int vi = mesh.getIndex(t * 3 + k);
1886 if (vi < 0 || vi >= verts) continue;
1887 const auto base = static_cast<std::size_t>(vi) * 4u;
1888 colors[base + 0] = c.r;
1889 colors[base + 1] = c.g;
1890 colors[base + 2] = c.b;
1891 colors[base + 3] = c.a;
1892 }
1893 }
1894 return mesh.setVertexColors(std::move(colors));
1895}
1896
1897} // namespace
1898
1900 auto topology = network.validate();
1901 if (!topology.ok()) return Result<RoadBakeResult>::failure(topology.status());
1902 constexpr int maximumSamples = 4096;
1903 if (options.pathSegmentsPerEdge < 2 || options.turnSamples < 2 ||
1904 options.pathSegmentsPerEdge > maximumSamples || options.turnSamples > maximumSamples)
1905 return bakeFail<RoadBakeResult>(DiagnosticCode::InvalidArgument,
1906 "road sample counts must be in [2,4096]", "options");
1907 if (options.includeNavigation &&
1908 (!std::isfinite(options.navRibbonHalfWidth) || options.navRibbonHalfWidth <= 0.f ||
1909 !std::isfinite(options.arrowSpacing) || options.arrowSpacing <= 0.f))
1910 return bakeFail<RoadBakeResult>(DiagnosticCode::InvalidArgument,
1911 "road navigation dimensions must be finite and positive", "options");
1912 if (!std::isfinite(options.junctionChordError) || options.junctionChordError <= 0.f ||
1913 options.junctionChordError > 1.f)
1914 return bakeFail<RoadBakeResult>(DiagnosticCode::InvalidArgument,
1915 "road junction chord error must be finite and in (0,1] metres", "options");
1916 if (options.maximumMeshElements == 0u)
1917 return bakeFail<RoadBakeResult>(DiagnosticCode::InvalidArgument,
1918 "road mesh element budget must be positive", "options");
1919 if (network.edgeCount() == 0)
1920 return bakeFail<RoadBakeResult>(DiagnosticCode::PreconditionViolation, "road network has no edges");
1921
1922 RoadBakeResult result;
1923 std::unordered_set<std::uint32_t> activeJunctionNodes;
1924 std::unordered_set<std::uint32_t> terminalNodes;
1925 std::unordered_map<std::uint32_t, std::vector<const RoadEdge*>> incidentEdges;
1926 incidentEdges.reserve(network.nodeCount());
1927 for (const auto& edge : network.edges()) {
1928 incidentEdges[edge.from].push_back(&edge);
1929 incidentEdges[edge.to].push_back(&edge);
1930 }
1931 for (const auto& node : network.nodes()) {
1932 const auto found = incidentEdges.find(node.id);
1933 if (found != incidentEdges.end() && found->second.size() == 1) terminalNodes.insert(node.id);
1934 }
1935 if (options.includeJunctions) {
1936 for (const auto& node : network.nodes()) {
1937 const auto found = incidentEdges.find(node.id);
1938 if (found == incidentEdges.end() || found->second.size() < 2) continue;
1939 activeJunctionNodes.insert(node.id);
1940 auto solvable = validateJunctionGeometry(network, node);
1941 if (!solvable.ok()) return Result<RoadBakeResult>::failure(solvable.status());
1942 }
1943 }
1944 for (const auto& edge : network.edges()) {
1945 auto spline = edgeToSpline(edge);
1946 if (!spline.ok()) return Result<RoadBakeResult>::failure(spline.status());
1947 auto pathLength = spline.value().lengthResult(16);
1948 if (!pathLength.ok()) return Result<RoadBakeResult>::failure(pathLength.status());
1949 const auto from = network.nodeResult(edge.from);
1950 const auto to = network.nodeResult(edge.to);
1951 if (!from.ok() || !to.ok())
1952 return bakeFail<RoadBakeResult>(DiagnosticCode::InvariantViolation,
1953 "edge endpoint disappeared during junction preflight", "edge");
1954 const float start = activeJunctionNodes.contains(edge.from)
1955 ? junctionSocketDistance(network, from.value(), edge, pathLength.value())
1956 : 0.f;
1957 const float end = activeJunctionNodes.contains(edge.to)
1958 ? junctionSocketDistance(network, to.value(), edge, pathLength.value())
1959 : 0.f;
1960 const int laneCount = edge.lanesForward + edge.lanesBackward;
1961 if (laneCount >= 6 && pathLength.value() - start - end <= 1.001f)
1962 return bakeFail<RoadBakeResult>(DiagnosticCode::PreconditionViolation,
1963 "junction socket trims consume the complete road edge", "edge");
1964 }
1965 for (const auto& edge : network.edges()) {
1966 auto baked =
1967 bakeEdgeGeometry(result.mesh, result.overlay, network, edge, options, activeJunctionNodes, terminalNodes);
1968 if (!baked.ok()) return Result<RoadBakeResult>::failure(baked.status());
1969 if (exceedsMeshBudget(result.mesh, options))
1970 return bakeFail<RoadBakeResult>(DiagnosticCode::PreconditionViolation,
1971 "road edges exceed the mesh element budget", "mesh");
1972 }
1973 if (options.includeJunctions) {
1974 for (const auto& node : network.nodes()) {
1975 if (!activeJunctionNodes.contains(node.id)) continue;
1976 auto junction = bakeJunction(result.mesh, network, node, options);
1977 if (!junction.ok()) return Result<RoadBakeResult>::failure(junction.status());
1978 if (exceedsMeshBudget(result.mesh, options))
1979 return bakeFail<RoadBakeResult>(DiagnosticCode::PreconditionViolation,
1980 "road junctions exceed the mesh element budget", "mesh");
1981 }
1982 }
1983 if (options.includeNavigation) {
1984 auto turns = bakeTurnOverlays(result.mesh, result.overlay, network, options, activeJunctionNodes);
1985 if (!turns.ok()) return Result<RoadBakeResult>::failure(turns.status());
1986 }
1987 if (options.includePlacements) {
1988 auto placements = bakePlacementPoints(result.placements, network, options, activeJunctionNodes);
1989 if (!placements.ok()) return Result<RoadBakeResult>::failure(placements.status());
1990 }
1991
1992 auto painted = paintGroupVertexColors(result.mesh);
1993 if (!painted.ok()) return Result<RoadBakeResult>::failure(painted.status());
1994
1995 result.mesh.setMeta("generator", "procgen.road");
1996 result.mesh.setMeta("schema", "eve.procgen.roadNetwork");
1997 result.mesh.setMeta("schemaVersion", "1");
1998 result.mesh.setMeta("edges", std::to_string(network.edgeCount()));
1999 result.mesh.setMeta("nodes", std::to_string(network.nodeCount()));
2000 result.mesh.setMeta("laneLinks", std::to_string(network.laneLinkCount()));
2001 if (result.mesh.getVertexCount() < 3)
2002 return bakeFail<RoadBakeResult>(DiagnosticCode::InvariantViolation, "bake produced an empty mesh");
2003 return Result<RoadBakeResult>::success(std::move(result));
2004}
2005
2006} // namespace eve::procgen::road
double value
Duration start
float y
Definition AnimClip.cpp:738
float x
Definition AnimClip.cpp:738
float z
Definition AnimClip.cpp:738
int tip
Definition AnimSmr.cpp:121
std::string from
const std::string & s
std::vector< std::uint32_t > verts
Definition Builder.cpp:27
building::EdgeCurveGroup group
float cx
Definition CardTypes.cpp:33
float phase
Definition CaveMesh.cpp:58
float length
Definition CaveMesh.cpp:94
glm::vec4 p[6]
Stable, structured diagnostics shared by engine modules.
std::string nodeId
int triangle
std::string message
DiagnosticCode code
float maximum[3]
float minimum[3]
tensor::Graph g
Definition GpuGraph.cpp:7
wgpu::PopErrorScopeStatus status
float u
Definition Grass.cpp:233
float area
Definition Grass.cpp:62
glm::vec3 n
Definition Grass.cpp:63
double r
std::vector< float > normals
HexVec3 up
HexVec3 left
HexVec3 right
std::int32_t second
std::int32_t c
std::int32_t first
HexCoordinates to
Cell the unit walks towards on this segment.
Definition HexUnits.cpp:64
TokenKind kind
std::string local
size_t offset
bool required
std::string name
bool valid
MeleePoint3 b
Definition MeleeHit.cpp:41
MeleePoint3 a
Definition MeleeHit.cpp:40
float distance
std::vector< BvhNode > nodes
std::vector< TriangleRef > triangles
MeshVfxBatchedDraw draw
Texture * normal
graphics::Canvas * previous
size_t directions
Definition OnnxLstm.cpp:29
Topology topology
float f
float radius
float halfWidth
std::string path
Definition PlayHost.cpp:110
std::string id
Definition PlayHost.cpp:108
std::shared_ptr< const std::vector< glm::vec2 > > points
PrimitiveHandle handle
float begin
float d
int detail
float t
Mesh * mesh
Material * material
std::vector< float > colors
float gradeZ
Definition RoadBake.cpp:140
float gradeX
Definition RoadBake.cpp:139
V3 origin
Definition RoadBake.cpp:138
const RoadNode * node
RoadLaneDirection direction
SplinePath spline
const RoadEdge * edge
bool found
double current
float dz
float dy
float dx
std::uint32_t count
const SquirrelValueOptions & options
CommandLogBoundary boundary
Anchor rule, see above.
ecs::EntityHandle side
float step
Definition TreeMesh.cpp:314
V3 heading
Definition TreeMesh.cpp:292
float size
Definition TreeMesh.cpp:156
std::map< std::string, std::string > paths
const UnitySourceAsset & source
std::vector< int > edges
int turns
glm::vec3 point
float angle
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Definition Diagnostic.h:125
Move-only operation result carrying either a value or Status.
Definition Result.h:155
static Result success(T value)
Construct a successful result owning value.
Definition Result.h:164
const T & value() const &
Borrow the value from a const lvalue after checking success.
Definition Result.h:308
static Result failure(Status status)
Construct a failed result from a structured status.
Definition Result.h:175
void setMeta(const std::string &key, const std::string &value)
Sets the meta.
Owning directed road graph with lane connectivity.
Definition RoadNetwork.h:19
int nodeCount() const noexcept
Node count.
int edgeCount() const noexcept
Edge count.
const std::vector< RoadEdge > & edges() const noexcept
Edges.
Result< void > validate() const
Validate graph, endpoint and lane-link invariants without mutation.
int laneLinkCount() const noexcept
Lane link count.
Result< RoadNode > nodeResult(std::uint32_t id) const
Node result.
const std::vector< RoadNode > & nodes() const noexcept
Nodes.
std::vector< double > forward(const Policy &p, const Observation &o)
Forward.
Definition Learning.h:65
Vec3 operator-(Vec3 lhs, Vec3 rhs)
Operator -.
Vec3 operator+(Vec3 lhs, Vec3 rhs)
Operator +.
Vec3 operator*(Vec3 value, float scale)
Operator *.
eve::Result< T > Result
constexpr HexDirection next(HexDirection d) noexcept
The next direction clockwise (NW wraps to NE).
Definition HexMetrics.h:76
double sample(const Heightmap &map, double u, double v)
Sample.
float junctionSocketDistanceForBake(const RoadNetwork &network, const RoadNode &node, const RoadEdge &edge, float pathLength)
Result< void > bakeTrafficControlPoints(PointSet &placements, const RoadNetwork &network, const RoadBakeOptions &options)
Result< SplinePath > edgeSplineForBake(const RoadEdge &edge)
Result< RoadProfile > makeRoadProfile(const RoadStyle &style, int lanesForward, int lanesBackward)
Build a mathematical road cross-section for the given lane counts.
const char * roadMaterialGroup(RoadMaterial material) noexcept
Map a material tag to the MeshBuild group name used by the baker. @ownership borrowed — returns a poi...
Result< RoadBakeResult > bakeRoadNetwork(const RoadNetwork &network, const RoadBakeOptions &options)
Bake every edge, junction platform, pier, marking and navigation overlay.
RoadLaneDirection
Travel direction of a lane relative to the authored edge centerline.
Definition RoadTypes.h:79
@ Forward
Travels from RoadEdge::from to RoadEdge::to.
@ Backward
Travels from RoadEdge::to to RoadEdge::from.
RoadMaterial
Profile material tags used when lofting a road cross-section.
Definition RoadTypes.h:122
Vec2 normalize(const Vec2 &a)
Normalize.
Definition UrbanTypes.h:44
double dot(const Vec2 &a, const Vec2 &b)
Dot.
Definition UrbanTypes.h:38
double cross(const Vec2 &a, const Vec2 &b)
Cross.
Definition UrbanTypes.h:36
std::uint64_t derivePointId(std::uint64_t namespaceId, std::uint64_t ordinal)
Deterministically derive a non-zero stable point identity.
Definition PointSet.cpp:469
uint32_t deriveSeed(uint32_t parent, const std::string &scope)
Stable label-based seed derivation; independent pipeline branches do not perturb each other.
Definition PointSet.cpp:459
WidgetDesc row(std::vector< WidgetDesc > children, std::string id)
Horizontal elastic layout row.
Definition Widget.cpp:679
DiagnosticCode
Stable machine-readable diagnostic codes.
Definition Diagnostic.h:47
Options for baking a road network into mesh + navigation overlays.
Definition RoadBake.h:43
Owning bake result: mesh, overlays and generic decoration placement points.
Definition RoadBake.h:62
Directed centerline edge with optional reverse lanes.
Definition RoadTypes.h:61
std::vector< RoadControlPoint > controlPoints
Definition RoadTypes.h:65
Junction node owned by a road network.
Definition RoadTypes.h:51
bool sideObjectsRight
Emit anchors on the authored centerline's right side.
Definition RoadTypes.h:39
float sideObjectEndOffset
Empty distance after the last side-object anchor.
Definition RoadTypes.h:37
float speedLimitMps
Navigation speed limit in metres per second.
Definition RoadTypes.h:34
float uvMeters
World-space metres per texture repeat.
Definition RoadTypes.h:33
float curbWidth
Jersey-barrier thickness.
Definition RoadTypes.h:21
bool sideObjectsLeft
Emit anchors on the authored centerline's left side.
Definition RoadTypes.h:38
float curbHeight
Raised barrier height (readable asphalt channel).
Definition RoadTypes.h:22
float sideObjectStartOffset
Empty distance before the first side-object anchor.
Definition RoadTypes.h:36
int trafficPriority
Higher incoming-road values win uncontrolled junction priority.
Definition RoadTypes.h:35