create-aura3d 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/showcase-spec-game-geometry-extractor.d.ts +2 -0
- package/dist/showcase-spec-game-geometry-extractor.d.ts.map +1 -1
- package/dist/showcase-spec-game-geometry-extractor.js +781 -46
- package/dist/showcase-spec-game-geometry-extractor.js.map +1 -1
- package/dist/showcase-spec-game-template-evidence.d.ts.map +1 -1
- package/dist/showcase-spec-game-template-evidence.js +24 -4
- package/dist/showcase-spec-game-template-evidence.js.map +1 -1
- package/dist/showcase-spec-platformer-artifacts.d.ts.map +1 -1
- package/dist/showcase-spec-platformer-artifacts.js +28 -17
- package/dist/showcase-spec-platformer-artifacts.js.map +1 -1
- package/dist/showcase-spec-racing-artifacts.d.ts.map +1 -1
- package/dist/showcase-spec-racing-artifacts.js +10 -5
- package/dist/showcase-spec-racing-artifacts.js.map +1 -1
- package/dist/showcase-spec-replacement-candidates.js +26 -0
- package/dist/showcase-spec-replacement-candidates.js.map +1 -1
- package/package.json +2 -2
- package/templates/animation-channel/package.json +1 -1
- package/templates/animation-studio/package.json +5 -5
- package/templates/animation-studio/studio/index.html +1 -6
- package/templates/character-controller/README.md +1 -1
- package/templates/character-controller/package.json +3 -3
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/episode-builder/package.json +1 -1
- package/templates/falling-blocks-starter/package.json +1 -1
- package/templates/fighting-game/package.json +1 -1
- package/templates/mini-game/package.json +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/prompt-animation-channel/package.json +1 -1
- package/templates/racing-starter/package.json +1 -1
- package/templates/racing-starter/src/main.ts +1 -1
- package/templates/three-compat-architecture-interior/package.json +1 -1
- package/templates/three-compat-asset-inspector/package.json +1 -1
- package/templates/three-compat-character-viewer/package.json +1 -1
- package/templates/three-compat-custom-threejs-migration/package.json +1 -1
- package/templates/three-compat-large-scene/package.json +1 -1
- package/templates/three-compat-material-authoring/package.json +1 -1
- package/templates/three-compat-postprocess-scene/package.json +1 -1
- package/templates/three-compat-premium-product-viewer/package.json +1 -1
|
@@ -9,8 +9,30 @@ const IDENTITY_MATRIX = [
|
|
|
9
9
|
];
|
|
10
10
|
const RACING_CERTIFIED_GAME_UNITS_PER_SECOND = 1.1;
|
|
11
11
|
const RACING_MAX_AUTHORED_LAP_SECONDS = 60;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Maximum share of a derived racing centreline permitted to sit off the road
|
|
14
|
+
* surface. Small excursions are tolerated where kerb triangles are modelled with
|
|
15
|
+
* gaps; a route that is mostly off-road is rejected outright.
|
|
16
|
+
*/
|
|
17
|
+
const RACING_MAX_OFF_ROAD_RATIO = 0.08;
|
|
18
|
+
/**
|
|
19
|
+
* Maximum ratio between the widest and tightest radius of a derived racing loop.
|
|
20
|
+
* Real circuits vary; a route that bulges out across an attached apron does not.
|
|
21
|
+
*/
|
|
22
|
+
const RACING_MAX_LOOP_RADIUS_RATIO = 2;
|
|
23
|
+
/**
|
|
24
|
+
* Road-surface material/node naming.
|
|
25
|
+
*
|
|
26
|
+
* The trailing boundary is `(?![a-z])` rather than `\b` on purpose: real assets name
|
|
27
|
+
* their primary driving surface with numbered variants such as `ASPH2`, `Asphalt_01`,
|
|
28
|
+
* or `ROAD2`, and `\b` does not match between `H` and `2` because a digit is a word
|
|
29
|
+
* character. Defect 32 was exactly that — Tsukuba's largest driving surface (`ASPH2`,
|
|
30
|
+
* 2,264 vertices) was silently dropped, so the loop tracer circled the paddock service
|
|
31
|
+
* road instead of the circuit. A leading `(?<![a-z])` keeps `Grass` from matching
|
|
32
|
+
* nothing while still rejecting words that merely contain a token (`broadway`).
|
|
33
|
+
*/
|
|
34
|
+
const ROAD_PATTERN = /(?<![a-z])(asph|asphalt|road|track|circuit|route|lane|kerb|curb|tarmac)(?![a-z])/i;
|
|
35
|
+
const ROAD_EXCLUDE_PATTERN = /(?<![a-z])(grass|water|lake|mount|terrain|wall|fence|tree|building|sky|barrier|warehouse|forest|foliage|foilage|aqua)(?![a-z])/i;
|
|
14
36
|
const PLATFORM_PATTERN = /\b(platform|walkway|ground|floor|level|ledge|bridge|runway|road|grass|rock|terrain)\b/i;
|
|
15
37
|
const PLATFORM_EXCLUDE_PATTERN = /\b(wall|cloud|sky|tree|character|prop|rail|pole)\b/i;
|
|
16
38
|
const PLATFORMER_TARGET_GAME_LENGTH = 38;
|
|
@@ -26,8 +48,33 @@ const PLATFORMER_MAX_HORIZONTAL_TRAVERSAL_GAP = 8;
|
|
|
26
48
|
const PLATFORMER_MAX_UPWARD_TRAVERSAL_STEP = 2.5;
|
|
27
49
|
const PLATFORMER_MAX_RETAINED_MESH_SURFACES = 16;
|
|
28
50
|
const PLATFORMER_DECORATIVE_PATTERN = /\b(column|pillar|tower|decor|background|backdrop)\b/i;
|
|
51
|
+
/**
|
|
52
|
+
* Memoizes racing extraction per asset. Ranking a replacement candidate list calls
|
|
53
|
+
* this once per candidate, and exact road containment plus the raster loop trace is
|
|
54
|
+
* far more work than the old radius estimate, so repeated calls are cached.
|
|
55
|
+
*/
|
|
56
|
+
const racingTopologyCache = new Map();
|
|
29
57
|
export function extractRacingTrackTopologyFromAsset(assetId, options = {}) {
|
|
30
|
-
const
|
|
58
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
59
|
+
const cacheKey = JSON.stringify([
|
|
60
|
+
projectDir,
|
|
61
|
+
assetId,
|
|
62
|
+
options.renderedProbePath ?? "",
|
|
63
|
+
options.routeOverlayPath ?? ""
|
|
64
|
+
]);
|
|
65
|
+
const cached = racingTopologyCache.get(cacheKey);
|
|
66
|
+
if (cached)
|
|
67
|
+
return cached;
|
|
68
|
+
const computed = computeRacingTrackTopology(assetId, options, projectDir);
|
|
69
|
+
racingTopologyCache.set(cacheKey, computed);
|
|
70
|
+
return computed;
|
|
71
|
+
}
|
|
72
|
+
/** Clears the racing extraction memo. Exposed for tests that rewrite asset files in place. */
|
|
73
|
+
export function clearRacingTrackTopologyCache() {
|
|
74
|
+
racingTopologyCache.clear();
|
|
75
|
+
}
|
|
76
|
+
function computeRacingTrackTopology(assetId, options, projectDir) {
|
|
77
|
+
const geometry = loadAssetGeometry(assetId, projectDir);
|
|
31
78
|
if (!geometry.ok)
|
|
32
79
|
return geometry;
|
|
33
80
|
const roadPrimitives = geometry.value.primitives.filter(isRoadPrimitive);
|
|
@@ -39,10 +86,27 @@ export function extractRacingTrackTopologyFromAsset(assetId, options = {}) {
|
|
|
39
86
|
if (Math.max(roadSize[0], roadSize[2]) < 1 || Math.min(roadSize[0], roadSize[2]) < 0.5) {
|
|
40
87
|
return failure([`asset-extraction:racing-road-footprint-too-small:${assetId}`], [`Road candidate footprint ${formatSize(roadSize)} is not large enough to derive a public racing route.`]);
|
|
41
88
|
}
|
|
42
|
-
const
|
|
89
|
+
const roadSurface = createRoadSurface(roadPrimitives);
|
|
90
|
+
if (roadSurface.triangleCount === 0) {
|
|
91
|
+
return failure([`asset-extraction:racing-road-triangles-unreadable:${assetId}`], [`Road primitives in ${assetId} have no readable indexed triangles, so road containment cannot be proven.`]);
|
|
92
|
+
}
|
|
93
|
+
// Radial sweep first: it is cheap and exact for star-convex circuits. Fall back to
|
|
94
|
+
// a rasterized loop trace for circuits that double back on themselves.
|
|
95
|
+
const sweptCenterline = createRoadCenterline(roadPrimitives, roadBounds, roadSurface);
|
|
96
|
+
const sweptUsable = sweptCenterline.length >= 8
|
|
97
|
+
&& measureOffRoadRatio(sweptCenterline, roadSurface) <= RACING_MAX_OFF_ROAD_RATIO
|
|
98
|
+
&& isPlausibleRacingLoop(sweptCenterline);
|
|
99
|
+
const centerline = sweptUsable ? sweptCenterline : traceRoadLoop(roadPrimitives, roadSurface);
|
|
100
|
+
const centerlineMethod = sweptUsable ? "radial-band-sweep" : "raster-loop-trace";
|
|
43
101
|
if (centerline.length < 8) {
|
|
44
102
|
return failure([`asset-extraction:racing-road-centerline-ambiguous:${assetId}`], [`Road mesh in ${assetId} produced only ${centerline.length} reliable centerline samples.`]);
|
|
45
103
|
}
|
|
104
|
+
// A route that leaves the asphalt is not a certified racing line, regardless of
|
|
105
|
+
// how confident the surrounding metrics look. See defect 31.
|
|
106
|
+
const offRoadRatio = measureOffRoadRatio(centerline, roadSurface);
|
|
107
|
+
if (offRoadRatio > RACING_MAX_OFF_ROAD_RATIO) {
|
|
108
|
+
return failure([`asset-extraction:racing-road-centerline-off-road:${assetId}`], [`Derived centreline leaves the road surface for ${(offRoadRatio * 100).toFixed(1)}% of its length (max ${(RACING_MAX_OFF_ROAD_RATIO * 100).toFixed(0)}%).`]);
|
|
109
|
+
}
|
|
46
110
|
const lapLength = measureClosedRouteLength(centerline.map((point) => ({ x: point.x, y: point.z })));
|
|
47
111
|
if (lapLength <= 0) {
|
|
48
112
|
return failure([`asset-extraction:racing-road-centerline-zero-length:${assetId}`], [`Road mesh in ${assetId} did not produce a measurable closed route.`]);
|
|
@@ -63,12 +127,18 @@ export function extractRacingTrackTopologyFromAsset(assetId, options = {}) {
|
|
|
63
127
|
modelAlignment: {
|
|
64
128
|
source: "asset-mesh-extracted",
|
|
65
129
|
modelBounds: geometry.value.bounds,
|
|
66
|
-
|
|
130
|
+
// The fallback single anchor is also surface-sampled: it is used when fewer than two
|
|
131
|
+
// anchor pairs survive, and a bounding-box floor would mis-seat the car there too.
|
|
132
|
+
modelPoint: [
|
|
133
|
+
center(roadBounds, 0),
|
|
134
|
+
round3(roadSurface.elevationAt(center(roadBounds, 0), center(roadBounds, 2)) ?? roadSurface.medianElevation),
|
|
135
|
+
center(roadBounds, 2)
|
|
136
|
+
],
|
|
67
137
|
gamePoint: {
|
|
68
138
|
x: center(roadBounds, 0),
|
|
69
139
|
z: center(roadBounds, 2)
|
|
70
140
|
},
|
|
71
|
-
anchorPairs: createRacingAnchorPairs(centerline,
|
|
141
|
+
anchorPairs: createRacingAnchorPairs(centerline, roadSurface),
|
|
72
142
|
evidence: {
|
|
73
143
|
...(options.routeOverlayPath ? { routeOverlay: options.routeOverlayPath } : {}),
|
|
74
144
|
notes: "Mesh-derived anchors are computed from road/asphalt/kerb primitives in the current GLB and bind the racing route to the visible track asset."
|
|
@@ -87,7 +157,10 @@ export function extractRacingTrackTopologyFromAsset(assetId, options = {}) {
|
|
|
87
157
|
reasons: [
|
|
88
158
|
`mesh-derived racing topology from ${roadPrimitives.length} road primitive(s)`,
|
|
89
159
|
`lapLengthMeters:${topology.lapLengthMeters}`,
|
|
90
|
-
`estimatedLapSeconds:${topology.estimatedLapSeconds}
|
|
160
|
+
`estimatedLapSeconds:${topology.estimatedLapSeconds}`,
|
|
161
|
+
`centerlineMethod:${centerlineMethod}`,
|
|
162
|
+
`centerlineOffRoadRatio:${offRoadRatio.toFixed(4)}`,
|
|
163
|
+
`roadTriangles:${roadSurface.triangleCount}`
|
|
91
164
|
]
|
|
92
165
|
};
|
|
93
166
|
}
|
|
@@ -473,7 +546,8 @@ function collectMeshPrimitives(document, meshIndex, transform, nodeName) {
|
|
|
473
546
|
bounds,
|
|
474
547
|
center: [center(bounds, 0), center(bounds, 1), center(bounds, 2)],
|
|
475
548
|
size,
|
|
476
|
-
vertices: vertices.length > 800 ? decimateVertices(vertices, 800) : vertices
|
|
549
|
+
vertices: vertices.length > 800 ? decimateVertices(vertices, 800) : vertices,
|
|
550
|
+
triangles: readTriangles(document, primitive, vertices)
|
|
477
551
|
}];
|
|
478
552
|
});
|
|
479
553
|
}
|
|
@@ -503,6 +577,52 @@ function readPositionAccessor(document, accessorIndex) {
|
|
|
503
577
|
}
|
|
504
578
|
return vertices;
|
|
505
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Reads a primitive's triangle list in world space. Triangles are what make an
|
|
582
|
+
* exact point-on-road test possible; a vertex cloud alone cannot distinguish the
|
|
583
|
+
* interior of a ring road from the hole in its middle.
|
|
584
|
+
*/
|
|
585
|
+
function readTriangles(document, primitive, vertices) {
|
|
586
|
+
const mode = primitive.mode ?? 4;
|
|
587
|
+
if (mode !== 4)
|
|
588
|
+
return [];
|
|
589
|
+
const indices = primitive.indices === undefined
|
|
590
|
+
? vertices.map((_vertex, index) => index)
|
|
591
|
+
: readIndexAccessor(document, primitive.indices);
|
|
592
|
+
const triangles = [];
|
|
593
|
+
for (let index = 0; index + 2 < indices.length; index += 3) {
|
|
594
|
+
const a = vertices[indices[index]];
|
|
595
|
+
const b = vertices[indices[index + 1]];
|
|
596
|
+
const c = vertices[indices[index + 2]];
|
|
597
|
+
if (a && b && c)
|
|
598
|
+
triangles.push({ a, b, c });
|
|
599
|
+
}
|
|
600
|
+
return triangles;
|
|
601
|
+
}
|
|
602
|
+
function readIndexAccessor(document, accessorIndex) {
|
|
603
|
+
const accessor = document.json.accessors?.[accessorIndex];
|
|
604
|
+
const binaryChunk = document.binaryChunk;
|
|
605
|
+
if (!accessor || !binaryChunk || accessor.bufferView === undefined)
|
|
606
|
+
return [];
|
|
607
|
+
const view = document.json.bufferViews?.[accessor.bufferView];
|
|
608
|
+
if (!view || view.buffer !== 0)
|
|
609
|
+
return [];
|
|
610
|
+
const componentSize = accessor.componentType === 5125 ? 4 : accessor.componentType === 5123 ? 2 : accessor.componentType === 5121 ? 1 : 0;
|
|
611
|
+
if (componentSize === 0)
|
|
612
|
+
return [];
|
|
613
|
+
const start = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
|
|
614
|
+
const count = accessor.count ?? 0;
|
|
615
|
+
const indices = [];
|
|
616
|
+
for (let index = 0; index < count; index += 1) {
|
|
617
|
+
const offset = start + index * componentSize;
|
|
618
|
+
if (offset + componentSize > binaryChunk.length)
|
|
619
|
+
break;
|
|
620
|
+
indices.push(componentSize === 4
|
|
621
|
+
? binaryChunk.readUInt32LE(offset)
|
|
622
|
+
: componentSize === 2 ? binaryChunk.readUInt16LE(offset) : binaryChunk.readUInt8(offset));
|
|
623
|
+
}
|
|
624
|
+
return indices;
|
|
625
|
+
}
|
|
506
626
|
function isRoadPrimitive(primitive) {
|
|
507
627
|
const label = `${primitive.nodeName} ${primitive.meshName} ${primitive.materialName}`;
|
|
508
628
|
return ROAD_PATTERN.test(label) && !ROAD_EXCLUDE_PATTERN.test(label);
|
|
@@ -547,35 +667,623 @@ function sortPlayableSurfaceCandidate(a, b) {
|
|
|
547
667
|
return areaDelta;
|
|
548
668
|
return a.bounds.min[0] - b.bounds.min[0];
|
|
549
669
|
}
|
|
550
|
-
function
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
670
|
+
function createRoadSurface(primitives) {
|
|
671
|
+
const triangles = primitives.flatMap((primitive) => primitive.triangles);
|
|
672
|
+
if (triangles.length === 0) {
|
|
673
|
+
return { contains: () => false, elevationAt: () => undefined, medianElevation: 0, triangleCount: 0 };
|
|
674
|
+
}
|
|
675
|
+
const cells = new Map();
|
|
676
|
+
const bounds = boundsForPrimitives(primitives);
|
|
677
|
+
const size = boundsSize(bounds);
|
|
678
|
+
const cellSize = Math.max(1e-6, Math.max(size[0], size[2]) / 96);
|
|
679
|
+
const keyFor = (x, z) => `${Math.floor(x / cellSize)}:${Math.floor(z / cellSize)}`;
|
|
680
|
+
for (const triangle of triangles) {
|
|
681
|
+
const minX = Math.min(triangle.a[0], triangle.b[0], triangle.c[0]);
|
|
682
|
+
const maxX = Math.max(triangle.a[0], triangle.b[0], triangle.c[0]);
|
|
683
|
+
const minZ = Math.min(triangle.a[2], triangle.b[2], triangle.c[2]);
|
|
684
|
+
const maxZ = Math.max(triangle.a[2], triangle.b[2], triangle.c[2]);
|
|
685
|
+
for (let cx = Math.floor(minX / cellSize); cx <= Math.floor(maxX / cellSize); cx += 1) {
|
|
686
|
+
for (let cz = Math.floor(minZ / cellSize); cz <= Math.floor(maxZ / cellSize); cz += 1) {
|
|
687
|
+
const key = `${cx}:${cz}`;
|
|
688
|
+
const bucket = cells.get(key);
|
|
689
|
+
if (bucket)
|
|
690
|
+
bucket.push(triangle);
|
|
691
|
+
else
|
|
692
|
+
cells.set(key, [triangle]);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const elevations = triangles
|
|
697
|
+
.flatMap((triangle) => [triangle.a[1], triangle.b[1], triangle.c[1]])
|
|
698
|
+
.sort((a, b) => a - b);
|
|
699
|
+
const medianElevation = elevations.length > 0
|
|
700
|
+
? round3(elevations[Math.floor(elevations.length / 2)] ?? 0)
|
|
701
|
+
: 0;
|
|
702
|
+
return {
|
|
703
|
+
triangleCount: triangles.length,
|
|
704
|
+
medianElevation,
|
|
705
|
+
contains: (x, z) => {
|
|
706
|
+
const bucket = cells.get(keyFor(x, z));
|
|
707
|
+
if (!bucket)
|
|
708
|
+
return false;
|
|
709
|
+
return bucket.some((triangle) => triangleContainsXZ(triangle, x, z));
|
|
710
|
+
},
|
|
711
|
+
elevationAt: (x, z) => {
|
|
712
|
+
const bucket = cells.get(keyFor(x, z));
|
|
713
|
+
if (!bucket)
|
|
714
|
+
return undefined;
|
|
715
|
+
let highest;
|
|
716
|
+
for (const triangle of bucket) {
|
|
717
|
+
if (!triangleContainsXZ(triangle, x, z))
|
|
718
|
+
continue;
|
|
719
|
+
const y = triangleElevationAtXZ(triangle, x, z);
|
|
720
|
+
if (y === undefined)
|
|
721
|
+
continue;
|
|
722
|
+
if (highest === undefined || y > highest)
|
|
723
|
+
highest = y;
|
|
724
|
+
}
|
|
725
|
+
return highest;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Interpolate a triangle's Y at `(x, z)` using barycentric weights.
|
|
731
|
+
*
|
|
732
|
+
* Returns `undefined` for a triangle that is degenerate in plan view (a vertical wall seen edge-on),
|
|
733
|
+
* which carries no usable surface elevation.
|
|
734
|
+
*/
|
|
735
|
+
function triangleElevationAtXZ(triangle, x, z) {
|
|
736
|
+
const { a, b, c } = triangle;
|
|
737
|
+
const denominator = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
|
|
738
|
+
if (Math.abs(denominator) < 1e-12)
|
|
739
|
+
return undefined;
|
|
740
|
+
const u = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / denominator;
|
|
741
|
+
const v = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / denominator;
|
|
742
|
+
const w = 1 - u - v;
|
|
743
|
+
return u * a[1] + v * b[1] + w * c[1];
|
|
744
|
+
}
|
|
745
|
+
function triangleContainsXZ(triangle, x, z) {
|
|
746
|
+
const { a, b, c } = triangle;
|
|
747
|
+
const denominator = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
|
|
748
|
+
if (Math.abs(denominator) < 1e-12)
|
|
749
|
+
return false;
|
|
750
|
+
const u = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / denominator;
|
|
751
|
+
const v = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / denominator;
|
|
752
|
+
return u >= 0 && v >= 0 && u + v <= 1;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Rejects an on-road polyline that is not plausibly a racing line.
|
|
756
|
+
*
|
|
757
|
+
* Staying on asphalt is necessary but not sufficient: a circuit with an attached
|
|
758
|
+
* paddock or pit apron gives the radial sweep somewhere legal but wrong to bulge into,
|
|
759
|
+
* producing a lap that drives out across the apron and back. A racing line has a
|
|
760
|
+
* roughly consistent distance from the centre it encircles, so a route whose radius
|
|
761
|
+
* more than doubles between its tightest and widest point is not one. (Defect 32.)
|
|
762
|
+
*/
|
|
763
|
+
function isPlausibleRacingLoop(centerline) {
|
|
764
|
+
if (centerline.length < 8)
|
|
765
|
+
return false;
|
|
766
|
+
const centerX = average(centerline.map((point) => point.x));
|
|
767
|
+
const centerZ = average(centerline.map((point) => point.z));
|
|
768
|
+
const radii = centerline.map((point) => Math.hypot(point.x - centerX, point.z - centerZ));
|
|
769
|
+
const minRadius = Math.min(...radii);
|
|
770
|
+
const maxRadius = Math.max(...radii);
|
|
771
|
+
if (minRadius <= 0)
|
|
772
|
+
return false;
|
|
773
|
+
return maxRadius / minRadius <= RACING_MAX_LOOP_RADIUS_RATIO;
|
|
774
|
+
}
|
|
775
|
+
/** Fraction of a closed polyline that lies off the road surface, sampled uniformly. */
|
|
776
|
+
function measureOffRoadRatio(centerline, surface) {
|
|
777
|
+
if (centerline.length < 2 || surface.triangleCount === 0)
|
|
778
|
+
return 1;
|
|
779
|
+
let off = 0;
|
|
780
|
+
let total = 0;
|
|
781
|
+
for (let index = 0; index < centerline.length; index += 1) {
|
|
782
|
+
const from = centerline[index];
|
|
783
|
+
const to = centerline[(index + 1) % centerline.length];
|
|
784
|
+
const samples = 12;
|
|
785
|
+
for (let step = 0; step < samples; step += 1) {
|
|
786
|
+
const t = step / samples;
|
|
787
|
+
total += 1;
|
|
788
|
+
if (!surface.contains(from.x + (to.x - from.x) * t, from.z + (to.z - from.z) * t))
|
|
789
|
+
off += 1;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return total === 0 ? 1 : off / total;
|
|
793
|
+
}
|
|
794
|
+
function createRoadRaster(primitives, surface) {
|
|
795
|
+
const bounds = boundsForPrimitives(primitives);
|
|
796
|
+
const size = boundsSize(bounds);
|
|
797
|
+
const target = 200;
|
|
798
|
+
const cellSize = Math.max(1e-6, Math.max(size[0], size[2]) / target);
|
|
799
|
+
const width = Math.ceil(size[0] / cellSize) + 3;
|
|
800
|
+
const height = Math.ceil(size[2] / cellSize) + 3;
|
|
801
|
+
const originX = bounds.min[0] - cellSize;
|
|
802
|
+
const originZ = bounds.min[2] - cellSize;
|
|
803
|
+
const occupied = new Uint8Array(width * height);
|
|
804
|
+
for (let z = 0; z < height; z += 1) {
|
|
805
|
+
for (let x = 0; x < width; x += 1) {
|
|
806
|
+
if (surface.contains(originX + x * cellSize, originZ + z * cellSize))
|
|
807
|
+
occupied[z * width + x] = 1;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
width,
|
|
812
|
+
height,
|
|
813
|
+
cellSize,
|
|
814
|
+
originX,
|
|
815
|
+
originZ,
|
|
816
|
+
occupied,
|
|
817
|
+
toWorld: (cell) => {
|
|
818
|
+
const x = cell % width;
|
|
819
|
+
const z = (cell - x) / width;
|
|
820
|
+
return { x: originX + x * cellSize, z: originZ + z * cellSize };
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Chamfer distance from each road cell to the nearest off-road cell. */
|
|
825
|
+
function roadDistanceField(raster) {
|
|
826
|
+
const { width, height, occupied } = raster;
|
|
827
|
+
const distance = new Float64Array(occupied.length);
|
|
828
|
+
const INFINITE = 1e9;
|
|
829
|
+
for (let index = 0; index < occupied.length; index += 1)
|
|
830
|
+
distance[index] = occupied[index] ? INFINITE : 0;
|
|
831
|
+
const forward = [[-1, 0, 1], [0, -1, 1], [-1, -1, 1.4142], [1, -1, 1.4142]];
|
|
832
|
+
const backward = [[1, 0, 1], [0, 1, 1], [1, 1, 1.4142], [-1, 1, 1.4142]];
|
|
833
|
+
const relax = (x, z, offsets) => {
|
|
834
|
+
const index = z * width + x;
|
|
835
|
+
if (distance[index] === 0)
|
|
836
|
+
return;
|
|
837
|
+
for (const [dx, dz, cost] of offsets) {
|
|
838
|
+
const nx = x + dx;
|
|
839
|
+
const nz = z + dz;
|
|
840
|
+
const neighbour = (nx < 0 || nx >= width || nz < 0 || nz >= height) ? 0 : distance[nz * width + nx];
|
|
841
|
+
distance[index] = Math.min(distance[index], neighbour + cost);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
for (let z = 0; z < height; z += 1)
|
|
845
|
+
for (let x = 0; x < width; x += 1)
|
|
846
|
+
relax(x, z, forward);
|
|
847
|
+
for (let z = height - 1; z >= 0; z -= 1)
|
|
848
|
+
for (let x = width - 1; x >= 0; x -= 1)
|
|
849
|
+
relax(x, z, backward);
|
|
850
|
+
return distance;
|
|
851
|
+
}
|
|
852
|
+
/** All connected road regions, largest first. */
|
|
853
|
+
function largestRoadComponents(raster) {
|
|
854
|
+
const { width, height, occupied } = raster;
|
|
855
|
+
const seen = new Uint8Array(occupied.length);
|
|
856
|
+
const components = [];
|
|
857
|
+
for (let start = 0; start < occupied.length; start += 1) {
|
|
858
|
+
if (!occupied[start] || seen[start])
|
|
859
|
+
continue;
|
|
860
|
+
const stack = [start];
|
|
861
|
+
const component = [];
|
|
862
|
+
seen[start] = 1;
|
|
863
|
+
while (stack.length > 0) {
|
|
864
|
+
const cell = stack.pop();
|
|
865
|
+
component.push(cell);
|
|
866
|
+
const x = cell % width;
|
|
867
|
+
const z = (cell - x) / width;
|
|
868
|
+
for (let dz = -1; dz <= 1; dz += 1) {
|
|
869
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
870
|
+
const nx = x + dx;
|
|
871
|
+
const nz = z + dz;
|
|
872
|
+
if (nx < 0 || nx >= width || nz < 0 || nz >= height)
|
|
873
|
+
continue;
|
|
874
|
+
const neighbour = nz * width + nx;
|
|
875
|
+
if (occupied[neighbour] && !seen[neighbour]) {
|
|
876
|
+
seen[neighbour] = 1;
|
|
877
|
+
stack.push(neighbour);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
components.push(component);
|
|
883
|
+
}
|
|
884
|
+
return components.sort((a, b) => b.length - a.length);
|
|
885
|
+
}
|
|
886
|
+
/** Background regions fully enclosed by road, largest first. These are infields. */
|
|
887
|
+
function roadInteriorHoles(raster, road) {
|
|
888
|
+
const inverted = Uint8Array.from(road, (value) => (value ? 0 : 1));
|
|
889
|
+
const { width, height } = raster;
|
|
890
|
+
const regions = largestRoadComponents({ ...raster, occupied: inverted });
|
|
891
|
+
return regions
|
|
892
|
+
.filter((region) => !region.some((cell) => {
|
|
893
|
+
const x = cell % width;
|
|
894
|
+
const z = (cell - x) / width;
|
|
895
|
+
return x === 0 || z === 0 || x === width - 1 || z === height - 1;
|
|
896
|
+
}))
|
|
897
|
+
.sort((a, b) => b.length - a.length);
|
|
898
|
+
}
|
|
899
|
+
/** Signed area of a closed polyline, used to pick the loop that encloses the most track. */
|
|
900
|
+
function polygonArea(points) {
|
|
901
|
+
let total = 0;
|
|
902
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
903
|
+
const from = points[index];
|
|
904
|
+
const to = points[(index + 1) % points.length];
|
|
905
|
+
total += from.x * to.z - to.x * from.z;
|
|
906
|
+
}
|
|
907
|
+
return Math.abs(total) / 2;
|
|
908
|
+
}
|
|
909
|
+
/** Turning number of a closed polyline about a point; ±1 means the loop encircles it. */
|
|
910
|
+
function windsAround(points, x, z) {
|
|
911
|
+
let total = 0;
|
|
912
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
913
|
+
const from = points[index];
|
|
914
|
+
const to = points[(index + 1) % points.length];
|
|
915
|
+
let delta = Math.atan2(to.z - z, to.x - x) - Math.atan2(from.z - z, from.x - x);
|
|
916
|
+
while (delta > Math.PI)
|
|
917
|
+
delta -= Math.PI * 2;
|
|
918
|
+
while (delta < -Math.PI)
|
|
919
|
+
delta += Math.PI * 2;
|
|
920
|
+
total += delta;
|
|
921
|
+
}
|
|
922
|
+
return Math.abs(total) > Math.PI;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Traces a closed racing line for circuits the radial sweep cannot handle.
|
|
926
|
+
*
|
|
927
|
+
* The racing line is the road ribbon that *encircles the infield*, so the seam is cut
|
|
928
|
+
* from an enclosed background region (the infield) outward across the ribbon, and the
|
|
929
|
+
* loop is the cheapest road-only path from one side of that seam back to the other.
|
|
930
|
+
* Cutting the seam at the widest road cell instead — as an earlier version did — put
|
|
931
|
+
* the seam in Tsukuba's paddock apron and traced a loop around the service road rather
|
|
932
|
+
* than the circuit (defect 32). Candidate infields are tried largest-first and the
|
|
933
|
+
* result must actually wind around the infield it was cut from.
|
|
934
|
+
*/
|
|
935
|
+
function traceRoadLoop(primitives, surface) {
|
|
936
|
+
const raster = createRoadRaster(primitives, surface);
|
|
937
|
+
const { width, height } = raster;
|
|
938
|
+
const components = largestRoadComponents(raster);
|
|
939
|
+
const component = components[0];
|
|
940
|
+
if (!component || component.length < 64)
|
|
941
|
+
return [];
|
|
942
|
+
const road = new Uint8Array(raster.occupied.length);
|
|
943
|
+
for (const cell of component)
|
|
944
|
+
road[cell] = 1;
|
|
945
|
+
const distance = roadDistanceField({ ...raster, occupied: road });
|
|
946
|
+
let maxDistance = 0;
|
|
947
|
+
for (const value of distance)
|
|
948
|
+
if (value > maxDistance)
|
|
949
|
+
maxDistance = value;
|
|
950
|
+
if (maxDistance <= 0)
|
|
951
|
+
return [];
|
|
952
|
+
const holes = roadInteriorHoles(raster, road);
|
|
953
|
+
let best;
|
|
954
|
+
for (const hole of holes.slice(0, 4)) {
|
|
955
|
+
let holeX = 0;
|
|
956
|
+
let holeZ = 0;
|
|
957
|
+
for (const cell of hole) {
|
|
958
|
+
const x = cell % width;
|
|
959
|
+
holeX += x;
|
|
960
|
+
holeZ += (cell - x) / width;
|
|
961
|
+
}
|
|
962
|
+
holeX = Math.round(holeX / hole.length);
|
|
963
|
+
holeZ = Math.round(holeZ / hole.length);
|
|
964
|
+
// Cut a complete seam: every road cell on the ray running from the infield centroid
|
|
965
|
+
// outward to the edge of the raster. Stopping at the first gap only nicks the nearest
|
|
966
|
+
// ribbon, which leaves a way around and collapses the "loop" to a few cells.
|
|
967
|
+
const seam = [];
|
|
968
|
+
for (let x = holeX; x < width; x += 1) {
|
|
969
|
+
if (road[holeZ * width + x])
|
|
970
|
+
seam.push(holeZ * width + x);
|
|
971
|
+
}
|
|
972
|
+
if (seam.length === 0)
|
|
973
|
+
continue;
|
|
974
|
+
const loop = shortestRoadLoop(raster, road, distance, maxDistance, seam);
|
|
975
|
+
if (loop.length < 24)
|
|
976
|
+
continue;
|
|
977
|
+
const points = loop.map((cell) => {
|
|
978
|
+
const point = raster.toWorld(cell);
|
|
979
|
+
return { x: point.x, z: point.z, width: Math.max(0.12, distance[cell] * raster.cellSize * 2) };
|
|
561
980
|
});
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
981
|
+
const holeWorld = raster.toWorld(holeZ * width + holeX);
|
|
982
|
+
if (!windsAround(points, holeWorld.x, holeWorld.z))
|
|
983
|
+
continue;
|
|
984
|
+
const area = polygonArea(points);
|
|
985
|
+
if (!best || area > best.area)
|
|
986
|
+
best = { points, area };
|
|
987
|
+
}
|
|
988
|
+
if (!best)
|
|
989
|
+
return [];
|
|
990
|
+
// Resampling a traced loop chords across corners, and on a tight hairpin that chord can
|
|
991
|
+
// clip the apex. Take the densest count that still keeps every emitted point and every
|
|
992
|
+
// interpolated step on the road, rather than loosening the off-road gate.
|
|
993
|
+
for (const count of [32, 28, 24, 20]) {
|
|
994
|
+
const resampled = resampleLoop(best.points, count);
|
|
995
|
+
if (resampled.length < 8)
|
|
996
|
+
continue;
|
|
997
|
+
if (resampled.some((point) => !surface.contains(point.x, point.z)))
|
|
998
|
+
continue;
|
|
999
|
+
const centerline = resampled.map((point) => ({
|
|
1000
|
+
x: round3(point.x),
|
|
1001
|
+
z: round3(point.z),
|
|
1002
|
+
width: round3(point.width)
|
|
1003
|
+
}));
|
|
1004
|
+
if (measureOffRoadRatio(centerline, surface) > RACING_MAX_OFF_ROAD_RATIO)
|
|
1005
|
+
continue;
|
|
1006
|
+
const first = centerline[0];
|
|
1007
|
+
return [...centerline, { x: first.x, z: first.z, width: first.width }];
|
|
576
1008
|
}
|
|
577
1009
|
return [];
|
|
578
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Cheapest road-only cycle through a seam: Dijkstra from the cells on one side of the
|
|
1013
|
+
* seam to the cells on the other, with the seam itself forbidden so the path is forced
|
|
1014
|
+
* the long way around. Cost prefers cells far from the road edge, which keeps the
|
|
1015
|
+
* result near the middle of the ribbon.
|
|
1016
|
+
*/
|
|
1017
|
+
function shortestRoadLoop(raster, road, distance, maxDistance, seam) {
|
|
1018
|
+
const { width, height } = raster;
|
|
1019
|
+
const blocked = new Uint8Array(road.length);
|
|
1020
|
+
for (const cell of seam)
|
|
1021
|
+
blocked[cell] = 1;
|
|
1022
|
+
// Also block the full raster row outward from the seam, so a path cannot slip around
|
|
1023
|
+
// the seam's far end. Without this the "loop" degenerates to a few cells that hop
|
|
1024
|
+
// straight over the seam tip (observed on showcaseMiniRaceTrack: seam 33, loop 4).
|
|
1025
|
+
const seamRow = seam.length > 0 ? Math.floor(seam[0] / width) : -1;
|
|
1026
|
+
const seamStartX = Math.min(...seam.map((cell) => cell % width));
|
|
1027
|
+
if (seamRow >= 0) {
|
|
1028
|
+
for (let x = seamStartX; x < width; x += 1)
|
|
1029
|
+
blocked[seamRow * width + x] = 1;
|
|
1030
|
+
}
|
|
1031
|
+
// Start and finish must be the *same* crossing of the ribbon, otherwise the cheapest
|
|
1032
|
+
// "loop" is a two-cell hop between opposite faces of the seam near its inner end.
|
|
1033
|
+
// Anchor both to the widest point of the seam (the middle of the road band) and force
|
|
1034
|
+
// the search to travel all the way around.
|
|
1035
|
+
let anchor = seam[0];
|
|
1036
|
+
for (const cell of seam)
|
|
1037
|
+
if (distance[cell] > distance[anchor])
|
|
1038
|
+
anchor = cell;
|
|
1039
|
+
const anchorX = anchor % width;
|
|
1040
|
+
const anchorZ = (anchor - anchorX) / width;
|
|
1041
|
+
const above = (anchorZ - 1) * width + anchorX;
|
|
1042
|
+
const below = (anchorZ + 1) * width + anchorX;
|
|
1043
|
+
if (anchorZ - 1 < 0 || anchorZ + 1 >= height)
|
|
1044
|
+
return [];
|
|
1045
|
+
if (!road[above] || blocked[above] || !road[below] || blocked[below])
|
|
1046
|
+
return [];
|
|
1047
|
+
const starts = [above];
|
|
1048
|
+
const goals = new Set([below]);
|
|
1049
|
+
const cellCost = (cell) => {
|
|
1050
|
+
const openness = Math.min(1, distance[cell] / maxDistance);
|
|
1051
|
+
return 1 + 8 * (1 - openness) ** 2;
|
|
1052
|
+
};
|
|
1053
|
+
const best = new Float64Array(road.length).fill(Number.POSITIVE_INFINITY);
|
|
1054
|
+
const previous = new Int32Array(road.length).fill(-1);
|
|
1055
|
+
const heap = [];
|
|
1056
|
+
const push = (cell, cost) => {
|
|
1057
|
+
heap.push({ cell, cost });
|
|
1058
|
+
let index = heap.length - 1;
|
|
1059
|
+
while (index > 0) {
|
|
1060
|
+
const parent = (index - 1) >> 1;
|
|
1061
|
+
if (heap[parent].cost <= heap[index].cost)
|
|
1062
|
+
break;
|
|
1063
|
+
[heap[parent], heap[index]] = [heap[index], heap[parent]];
|
|
1064
|
+
index = parent;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
const pop = () => {
|
|
1068
|
+
const top = heap[0];
|
|
1069
|
+
const last = heap.pop();
|
|
1070
|
+
if (heap.length > 0 && last) {
|
|
1071
|
+
heap[0] = last;
|
|
1072
|
+
let index = 0;
|
|
1073
|
+
for (;;) {
|
|
1074
|
+
const left = index * 2 + 1;
|
|
1075
|
+
const right = left + 1;
|
|
1076
|
+
let smallest = index;
|
|
1077
|
+
if (left < heap.length && heap[left].cost < heap[smallest].cost)
|
|
1078
|
+
smallest = left;
|
|
1079
|
+
if (right < heap.length && heap[right].cost < heap[smallest].cost)
|
|
1080
|
+
smallest = right;
|
|
1081
|
+
if (smallest === index)
|
|
1082
|
+
break;
|
|
1083
|
+
[heap[smallest], heap[index]] = [heap[index], heap[smallest]];
|
|
1084
|
+
index = smallest;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return top;
|
|
1088
|
+
};
|
|
1089
|
+
for (const start of starts) {
|
|
1090
|
+
best[start] = cellCost(start);
|
|
1091
|
+
push(start, best[start]);
|
|
1092
|
+
}
|
|
1093
|
+
let goalCell = -1;
|
|
1094
|
+
while (heap.length > 0) {
|
|
1095
|
+
const entry = pop();
|
|
1096
|
+
if (!entry)
|
|
1097
|
+
break;
|
|
1098
|
+
if (entry.cost > best[entry.cell])
|
|
1099
|
+
continue;
|
|
1100
|
+
if (goals.has(entry.cell)) {
|
|
1101
|
+
goalCell = entry.cell;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
const x = entry.cell % width;
|
|
1105
|
+
const z = (entry.cell - x) / width;
|
|
1106
|
+
for (let dz = -1; dz <= 1; dz += 1) {
|
|
1107
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
1108
|
+
if (dx === 0 && dz === 0)
|
|
1109
|
+
continue;
|
|
1110
|
+
const nx = x + dx;
|
|
1111
|
+
const nz = z + dz;
|
|
1112
|
+
if (nx < 0 || nx >= width || nz < 0 || nz >= height)
|
|
1113
|
+
continue;
|
|
1114
|
+
const neighbour = nz * width + nx;
|
|
1115
|
+
if (!road[neighbour] || blocked[neighbour])
|
|
1116
|
+
continue;
|
|
1117
|
+
const step = dx !== 0 && dz !== 0 ? 1.4142 : 1;
|
|
1118
|
+
const cost = entry.cost + step * cellCost(neighbour);
|
|
1119
|
+
if (cost < best[neighbour]) {
|
|
1120
|
+
best[neighbour] = cost;
|
|
1121
|
+
previous[neighbour] = entry.cell;
|
|
1122
|
+
push(neighbour, cost);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (goalCell < 0)
|
|
1128
|
+
return [];
|
|
1129
|
+
const path = [];
|
|
1130
|
+
for (let cell = goalCell; cell >= 0; cell = previous[cell])
|
|
1131
|
+
path.push(cell);
|
|
1132
|
+
path.reverse();
|
|
1133
|
+
path.push(seam[Math.floor(seam.length / 2)]);
|
|
1134
|
+
return path;
|
|
1135
|
+
}
|
|
1136
|
+
/** Resamples a closed polyline to `count` evenly spaced points. */
|
|
1137
|
+
function resampleLoop(points, count) {
|
|
1138
|
+
if (points.length < 2)
|
|
1139
|
+
return [];
|
|
1140
|
+
const loop = [...points, points[0]];
|
|
1141
|
+
const cumulative = [0];
|
|
1142
|
+
for (let index = 1; index < loop.length; index += 1) {
|
|
1143
|
+
cumulative.push(cumulative[index - 1] + Math.hypot(loop[index].x - loop[index - 1].x, loop[index].z - loop[index - 1].z));
|
|
1144
|
+
}
|
|
1145
|
+
const total = cumulative[cumulative.length - 1];
|
|
1146
|
+
if (total <= 0)
|
|
1147
|
+
return [];
|
|
1148
|
+
const output = [];
|
|
1149
|
+
for (let step = 0; step < count; step += 1) {
|
|
1150
|
+
const target = (step / count) * total;
|
|
1151
|
+
let index = 1;
|
|
1152
|
+
while (index < cumulative.length && cumulative[index] < target)
|
|
1153
|
+
index += 1;
|
|
1154
|
+
const from = loop[index - 1];
|
|
1155
|
+
const to = loop[Math.min(index, loop.length - 1)];
|
|
1156
|
+
const span = cumulative[index] - cumulative[index - 1];
|
|
1157
|
+
const t = span > 1e-9 ? (target - cumulative[index - 1]) / span : 0;
|
|
1158
|
+
output.push({
|
|
1159
|
+
x: from.x + (to.x - from.x) * t,
|
|
1160
|
+
z: from.z + (to.z - from.z) * t,
|
|
1161
|
+
width: from.width + (to.width - from.width) * t
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return output;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Derives a drivable centreline by sweeping rays from the road centroid and taking
|
|
1168
|
+
* the midpoint of the road *band* the ray crosses, then verifying every emitted
|
|
1169
|
+
* point and every interpolated step actually lands on road geometry.
|
|
1170
|
+
*/
|
|
1171
|
+
function createRoadCenterline(primitives, bounds, surface) {
|
|
1172
|
+
const roadBounds = boundsForPrimitives(primitives);
|
|
1173
|
+
const centerX = center(roadBounds, 0);
|
|
1174
|
+
const centerZ = center(roadBounds, 2);
|
|
1175
|
+
const size = boundsSize(roadBounds);
|
|
1176
|
+
const maxRadius = Math.hypot(size[0], size[2]) / 2;
|
|
1177
|
+
const bins = 72;
|
|
1178
|
+
const radialStep = Math.max(1e-4, maxRadius / 400);
|
|
1179
|
+
const samples = [];
|
|
1180
|
+
for (let bin = 0; bin < bins; bin += 1) {
|
|
1181
|
+
const angle = -Math.PI + ((bin + 0.5) / bins) * Math.PI * 2;
|
|
1182
|
+
const dirX = Math.cos(angle);
|
|
1183
|
+
const dirZ = Math.sin(angle);
|
|
1184
|
+
// Collect contiguous on-road spans along the ray, then keep the widest one.
|
|
1185
|
+
const spans = [];
|
|
1186
|
+
let spanStart;
|
|
1187
|
+
for (let radius = radialStep; radius <= maxRadius; radius += radialStep) {
|
|
1188
|
+
const on = surface.contains(centerX + dirX * radius, centerZ + dirZ * radius);
|
|
1189
|
+
if (on && spanStart === undefined)
|
|
1190
|
+
spanStart = radius;
|
|
1191
|
+
if (!on && spanStart !== undefined) {
|
|
1192
|
+
spans.push({ from: spanStart, to: radius - radialStep });
|
|
1193
|
+
spanStart = undefined;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (spanStart !== undefined)
|
|
1197
|
+
spans.push({ from: spanStart, to: maxRadius });
|
|
1198
|
+
const widest = spans.reduce((best, span) => (!best || span.to - span.from > best.to - best.from ? span : best), undefined);
|
|
1199
|
+
if (!widest)
|
|
1200
|
+
continue;
|
|
1201
|
+
const radius = (widest.from + widest.to) / 2;
|
|
1202
|
+
const x = centerX + dirX * radius;
|
|
1203
|
+
const z = centerZ + dirZ * radius;
|
|
1204
|
+
if (!surface.contains(x, z))
|
|
1205
|
+
continue;
|
|
1206
|
+
samples.push({
|
|
1207
|
+
x: round3(x),
|
|
1208
|
+
z: round3(z),
|
|
1209
|
+
width: round3(Math.max(0.12, widest.to - widest.from))
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
if (samples.length < 8)
|
|
1213
|
+
return [];
|
|
1214
|
+
const simplified = simplifyRoadCenterline(samples, surface);
|
|
1215
|
+
if (simplified.length < 8)
|
|
1216
|
+
return [];
|
|
1217
|
+
const first = simplified[0];
|
|
1218
|
+
return [...simplified, { x: first.x, z: first.z, width: first.width }];
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Reduces the dense ray-swept samples to a compact route. A point is dropped only
|
|
1222
|
+
* when the shortcut it creates still lies entirely on the road *and* the shortcut
|
|
1223
|
+
* does not materially lengthen the straight-line step, which keeps the emitted
|
|
1224
|
+
* route evenly spaced instead of collapsing whole corners into one long chord.
|
|
1225
|
+
*/
|
|
1226
|
+
function simplifyRoadCenterline(samples, surface) {
|
|
1227
|
+
const minPoints = 16;
|
|
1228
|
+
const maxPoints = 24;
|
|
1229
|
+
const kept = [...samples];
|
|
1230
|
+
const spacing = (points) => {
|
|
1231
|
+
let total = 0;
|
|
1232
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
1233
|
+
const from = points[index];
|
|
1234
|
+
const to = points[(index + 1) % points.length];
|
|
1235
|
+
total += Math.hypot(to.x - from.x, to.z - from.z);
|
|
1236
|
+
}
|
|
1237
|
+
return total / points.length;
|
|
1238
|
+
};
|
|
1239
|
+
// Drop the point whose removal costs the least deviation, until the route is compact.
|
|
1240
|
+
while (kept.length > maxPoints) {
|
|
1241
|
+
let bestIndex = -1;
|
|
1242
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
1243
|
+
for (let index = 0; index < kept.length; index += 1) {
|
|
1244
|
+
const previous = kept[(index - 1 + kept.length) % kept.length];
|
|
1245
|
+
const candidate = kept[index];
|
|
1246
|
+
const next = kept[(index + 1) % kept.length];
|
|
1247
|
+
if (!segmentStaysOnRoad(previous, next, surface))
|
|
1248
|
+
continue;
|
|
1249
|
+
const cost = perpendicularDistance(candidate, previous, next);
|
|
1250
|
+
if (cost < bestCost) {
|
|
1251
|
+
bestCost = cost;
|
|
1252
|
+
bestIndex = index;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (bestIndex < 0)
|
|
1256
|
+
break;
|
|
1257
|
+
kept.splice(bestIndex, 1);
|
|
1258
|
+
}
|
|
1259
|
+
const averageSpacing = spacing(kept);
|
|
1260
|
+
// Reject any remaining chord that is wildly longer than the typical step; such a
|
|
1261
|
+
// chord means the sweep skipped a section of track rather than simplifying it.
|
|
1262
|
+
for (let index = 0; index < kept.length && kept.length > minPoints; index += 1) {
|
|
1263
|
+
const from = kept[index];
|
|
1264
|
+
const to = kept[(index + 1) % kept.length];
|
|
1265
|
+
if (Math.hypot(to.x - from.x, to.z - from.z) > averageSpacing * 4)
|
|
1266
|
+
return [];
|
|
1267
|
+
}
|
|
1268
|
+
return kept;
|
|
1269
|
+
}
|
|
1270
|
+
function perpendicularDistance(point, from, to) {
|
|
1271
|
+
const dx = to.x - from.x;
|
|
1272
|
+
const dz = to.z - from.z;
|
|
1273
|
+
const length = Math.hypot(dx, dz);
|
|
1274
|
+
if (length < 1e-9)
|
|
1275
|
+
return Math.hypot(point.x - from.x, point.z - from.z);
|
|
1276
|
+
return Math.abs(dz * (point.x - from.x) - dx * (point.z - from.z)) / length;
|
|
1277
|
+
}
|
|
1278
|
+
function segmentStaysOnRoad(from, to, surface) {
|
|
1279
|
+
const samples = 16;
|
|
1280
|
+
for (let step = 0; step <= samples; step += 1) {
|
|
1281
|
+
const t = step / samples;
|
|
1282
|
+
if (!surface.contains(from.x + (to.x - from.x) * t, from.z + (to.z - from.z) * t))
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
579
1287
|
function createPlayableSurfaces(primitives, bounds) {
|
|
580
1288
|
const minX = bounds.min[0];
|
|
581
1289
|
const modelToGameScale = platformerModelToGameScale(bounds);
|
|
@@ -621,12 +1329,20 @@ function createPlayableSurfaces(primitives, bounds) {
|
|
|
621
1329
|
height: 1.1,
|
|
622
1330
|
kind: "checkpoint"
|
|
623
1331
|
}));
|
|
1332
|
+
const widestGap = topSurfaces.slice(0, -1).map((surface, index) => {
|
|
1333
|
+
const next = topSurfaces[index + 1];
|
|
1334
|
+
const right = surface.x + surface.width / 2;
|
|
1335
|
+
const left = next.x - next.width / 2;
|
|
1336
|
+
return { left, right, width: Math.max(0, left - right), floorY: Math.min(surface.y, next.y) };
|
|
1337
|
+
}).sort((a, b) => b.width - a.width)[0];
|
|
1338
|
+
const hazardWidth = round3(Math.max(0.12, Math.min(0.3, (widestGap?.width ?? 0.18) * 0.8)));
|
|
624
1339
|
const hazard = {
|
|
625
1340
|
id: "asset-hazard-gap",
|
|
626
|
-
x: round3(maxX * 0.58),
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1341
|
+
x: round3(widestGap ? (widestGap.left + widestGap.right) / 2 : maxX * 0.58),
|
|
1342
|
+
// Keep hazards beneath landing surfaces so they punish a missed jump without creating a respawn trap on the route.
|
|
1343
|
+
y: round3((widestGap?.floorY ?? 0) - 0.34),
|
|
1344
|
+
width: hazardWidth,
|
|
1345
|
+
height: 0.18,
|
|
630
1346
|
kind: "hazard"
|
|
631
1347
|
};
|
|
632
1348
|
return [...topSurfaces, finish, hazard, ...checkpoints];
|
|
@@ -668,17 +1384,33 @@ function uniquePlayableSurfacePrimitives(primitives) {
|
|
|
668
1384
|
return true;
|
|
669
1385
|
});
|
|
670
1386
|
}
|
|
671
|
-
|
|
1387
|
+
/**
|
|
1388
|
+
* Build route-to-model anchors whose Y is the *measured drivable surface* under each anchor point.
|
|
1389
|
+
*
|
|
1390
|
+
* Previously every anchor used `bounds.min[1]` -- the lowest vertex in the entire road/kerb/asphalt
|
|
1391
|
+
* family. On Tsukuba that floor is 0.05 model units below the tarmac at the anchor points, which the
|
|
1392
|
+
* 2.55x track fit scale magnifies to 0.128 scene units. The route solver then placed the track so its
|
|
1393
|
+
* *bounding-box floor* met the car's contact plane, seating the car 0.128 units below the visible
|
|
1394
|
+
* road: about 77% of the hero car's wheel diameter, which reads as a car with no wheels sliced off at
|
|
1395
|
+
* the tarmac line.
|
|
1396
|
+
*
|
|
1397
|
+
* Sampling `surface.elevationAt` makes the anchor describe the surface the car actually drives on, so
|
|
1398
|
+
* grounding is correct for any track asset regardless of what stray geometry sits in its road family.
|
|
1399
|
+
* When a point has no surface triangle beneath it the median drivable elevation is used, which is
|
|
1400
|
+
* still a real measurement of the tarmac rather than a bounding-box artefact.
|
|
1401
|
+
*/
|
|
1402
|
+
function createRacingAnchorPairs(centerline, surface) {
|
|
672
1403
|
const indices = [0, Math.floor(centerline.length / 3), Math.floor((centerline.length * 2) / 3)];
|
|
673
1404
|
return indices.flatMap((index, anchorIndex) => {
|
|
674
1405
|
const point = centerline[index];
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1406
|
+
if (!point)
|
|
1407
|
+
return [];
|
|
1408
|
+
const elevation = surface.elevationAt(point.x, point.z) ?? surface.medianElevation;
|
|
1409
|
+
return [{
|
|
1410
|
+
id: `mesh-road-anchor-${anchorIndex + 1}`,
|
|
1411
|
+
modelPoint: [point.x, round3(elevation), point.z],
|
|
1412
|
+
gamePoint: { x: point.x, z: point.z }
|
|
1413
|
+
}];
|
|
682
1414
|
});
|
|
683
1415
|
}
|
|
684
1416
|
function createPlatformerAnchorPairs(surfaces, bounds) {
|
|
@@ -936,7 +1668,10 @@ function average(values) {
|
|
|
936
1668
|
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
937
1669
|
}
|
|
938
1670
|
function round3(value) {
|
|
939
|
-
|
|
1671
|
+
const rounded = Math.round(value * 1000) / 1000;
|
|
1672
|
+
// Normalise negative zero: `-0` survives JSON.stringify as `-0`, which makes otherwise identical
|
|
1673
|
+
// regenerated evidence differ byte-for-byte and breaks content-hash comparisons.
|
|
1674
|
+
return rounded === 0 ? 0 : rounded;
|
|
940
1675
|
}
|
|
941
1676
|
function formatSize(size) {
|
|
942
1677
|
return `[${size.map((value) => value.toFixed(3)).join(",")}]`;
|