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.
Files changed (39) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/showcase-spec-game-geometry-extractor.d.ts +2 -0
  3. package/dist/showcase-spec-game-geometry-extractor.d.ts.map +1 -1
  4. package/dist/showcase-spec-game-geometry-extractor.js +781 -46
  5. package/dist/showcase-spec-game-geometry-extractor.js.map +1 -1
  6. package/dist/showcase-spec-game-template-evidence.d.ts.map +1 -1
  7. package/dist/showcase-spec-game-template-evidence.js +24 -4
  8. package/dist/showcase-spec-game-template-evidence.js.map +1 -1
  9. package/dist/showcase-spec-platformer-artifacts.d.ts.map +1 -1
  10. package/dist/showcase-spec-platformer-artifacts.js +28 -17
  11. package/dist/showcase-spec-platformer-artifacts.js.map +1 -1
  12. package/dist/showcase-spec-racing-artifacts.d.ts.map +1 -1
  13. package/dist/showcase-spec-racing-artifacts.js +10 -5
  14. package/dist/showcase-spec-racing-artifacts.js.map +1 -1
  15. package/dist/showcase-spec-replacement-candidates.js +26 -0
  16. package/dist/showcase-spec-replacement-candidates.js.map +1 -1
  17. package/package.json +2 -2
  18. package/templates/animation-channel/package.json +1 -1
  19. package/templates/animation-studio/package.json +5 -5
  20. package/templates/animation-studio/studio/index.html +1 -6
  21. package/templates/character-controller/README.md +1 -1
  22. package/templates/character-controller/package.json +3 -3
  23. package/templates/cinematic-scene/package.json +1 -1
  24. package/templates/episode-builder/package.json +1 -1
  25. package/templates/falling-blocks-starter/package.json +1 -1
  26. package/templates/fighting-game/package.json +1 -1
  27. package/templates/mini-game/package.json +1 -1
  28. package/templates/product-viewer/package.json +1 -1
  29. package/templates/prompt-animation-channel/package.json +1 -1
  30. package/templates/racing-starter/package.json +1 -1
  31. package/templates/racing-starter/src/main.ts +1 -1
  32. package/templates/three-compat-architecture-interior/package.json +1 -1
  33. package/templates/three-compat-asset-inspector/package.json +1 -1
  34. package/templates/three-compat-character-viewer/package.json +1 -1
  35. package/templates/three-compat-custom-threejs-migration/package.json +1 -1
  36. package/templates/three-compat-large-scene/package.json +1 -1
  37. package/templates/three-compat-material-authoring/package.json +1 -1
  38. package/templates/three-compat-postprocess-scene/package.json +1 -1
  39. 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
- const ROAD_PATTERN = /\b(asph|asphalt|road|track|circuit|route|lane|kerb|curb)\b/i;
13
- const ROAD_EXCLUDE_PATTERN = /\b(grass|water|lake|mount|terrain|wall|fence|tree|building|sky)\b/i;
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 geometry = loadAssetGeometry(assetId, options.projectDir ?? process.cwd());
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 centerline = createRoadCenterline(roadPrimitives, roadBounds);
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
- modelPoint: [center(roadBounds, 0), roadBounds.min[1], center(roadBounds, 2)],
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, roadBounds),
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 createRoadCenterline(primitives, bounds) {
551
- const allVertices = primitives.flatMap((primitive) => primitive.vertices);
552
- const centerX = center(bounds, 0);
553
- const centerZ = center(bounds, 2);
554
- const bins = 20;
555
- const samples = Array.from({ length: bins }, (_unused, bin) => {
556
- const minAngle = -Math.PI + (bin / bins) * Math.PI * 2;
557
- const maxAngle = -Math.PI + ((bin + 1) / bins) * Math.PI * 2;
558
- const vertices = allVertices.filter((vertex) => {
559
- const angle = Math.atan2(vertex[2] - centerZ, vertex[0] - centerX);
560
- return angle >= minAngle && angle < maxAngle;
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
- if (vertices.length < 4)
563
- return undefined;
564
- const averageRadius = average(vertices.map((vertex) => Math.hypot(vertex[0] - centerX, vertex[2] - centerZ))) * 0.68;
565
- const angle = (minAngle + maxAngle) / 2;
566
- return {
567
- x: round3(centerX + Math.cos(angle) * averageRadius),
568
- z: round3(centerZ + Math.sin(angle) * averageRadius),
569
- width: averageRoadWidth(boundsSize(bounds))
570
- };
571
- }).filter((point) => Boolean(point));
572
- if (samples.length > 0) {
573
- const first = samples[0];
574
- if (first)
575
- return [...samples, first];
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
- y: round3((topSurfaces[Math.min(2, topSurfaces.length - 1)]?.y ?? 0.4) + 0.34),
628
- width: 0.48,
629
- height: 0.24,
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
- function createRacingAnchorPairs(centerline, bounds) {
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
- return point
676
- ? [{
677
- id: `mesh-road-anchor-${anchorIndex + 1}`,
678
- modelPoint: [point.x, bounds.min[1], point.z],
679
- gamePoint: { x: point.x, z: point.z }
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
- return Math.round(value * 1000) / 1000;
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(",")}]`;