create-aura3d 1.5.2 → 2.0.1

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 (94) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +10 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/showcase-spec-evidence.js +5 -1
  6. package/dist/showcase-spec-evidence.js.map +1 -1
  7. package/dist/showcase-spec-game-geometry-extractor.d.ts +8 -1
  8. package/dist/showcase-spec-game-geometry-extractor.d.ts.map +1 -1
  9. package/dist/showcase-spec-game-geometry-extractor.js +191 -31
  10. package/dist/showcase-spec-game-geometry-extractor.js.map +1 -1
  11. package/dist/showcase-spec-game-template-evidence.d.ts.map +1 -1
  12. package/dist/showcase-spec-game-template-evidence.js +7 -0
  13. package/dist/showcase-spec-game-template-evidence.js.map +1 -1
  14. package/dist/showcase-spec-parser.js +1 -1
  15. package/dist/showcase-spec-parser.js.map +1 -1
  16. package/dist/showcase-spec-types.d.ts +50 -0
  17. package/dist/showcase-spec-types.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/templates/animation-channel/package.json +1 -1
  20. package/templates/animation-channel/src/experimental/README.md +1 -1
  21. package/templates/animation-channel/tests/storyboard-playback.spec.ts +2 -0
  22. package/templates/animation-studio/package.json +2 -5
  23. package/templates/animation-studio/scripts/asset-motion-probe.ts +1 -1
  24. package/templates/animation-studio/scripts/build-characters.ts +1 -1
  25. package/templates/animation-studio/scripts/motion-evidence.ts +1 -1
  26. package/templates/animation-studio/scripts/render-core.ts +4 -2
  27. package/templates/animation-studio/scripts/render-live.ts +3 -1
  28. package/templates/animation-studio/scripts/validate-characters.ts +0 -0
  29. package/templates/animation-studio/src/scene-player.ts +2 -2
  30. package/templates/character-controller/README.md +15 -2
  31. package/templates/character-controller/aura.assets.json +7 -0
  32. package/templates/character-controller/package.json +3 -3
  33. package/templates/character-controller/src/aura-assets.ts +3 -0
  34. package/templates/cinematic-scene/aura.assets.json +31 -0
  35. package/templates/cinematic-scene/package.json +1 -1
  36. package/templates/episode-builder/aura.assets.json +7 -0
  37. package/templates/episode-builder/package.json +1 -1
  38. package/templates/episode-builder/tests/route-health.spec.ts +2 -2
  39. package/templates/falling-blocks-starter/aura.assets.json +37 -0
  40. package/templates/falling-blocks-starter/package.json +2 -1
  41. package/templates/falling-blocks-starter/tests/playable.spec.ts +2 -3
  42. package/templates/falling-blocks-starter/tsconfig.json +1 -1
  43. package/templates/fighting-game/aura.assets.json +7 -0
  44. package/templates/fighting-game/package.json +1 -1
  45. package/templates/fighting-game/src/main.ts +5 -7
  46. package/templates/fighting-game/tests/gameplay-smoke.spec.ts +4 -1
  47. package/templates/fighting-game/tests/route-health.spec.ts +8 -2
  48. package/templates/mini-game/README.md +3 -2
  49. package/templates/mini-game/aura.assets.json +31 -0
  50. package/templates/mini-game/package.json +1 -1
  51. package/templates/mini-game/src/aura-assets.ts +1 -1
  52. package/templates/mini-game/src/main.ts +69 -141
  53. package/templates/product-viewer/README.md +7 -5
  54. package/templates/product-viewer/aura.assets.json +34 -0
  55. package/templates/product-viewer/index.html +1 -1
  56. package/templates/product-viewer/package.json +1 -1
  57. package/templates/product-viewer/src/aura-assets.ts +1 -1
  58. package/templates/product-viewer/src/main.ts +29 -23
  59. package/templates/product-viewer/tests/route-health.spec.ts +2 -2
  60. package/templates/product-viewer/tests/screenshot.spec.ts +2 -2
  61. package/templates/prompt-animation-channel/aura.assets.json +7 -0
  62. package/templates/prompt-animation-channel/package.json +1 -1
  63. package/templates/prompt-animation-channel/src/main.ts +4 -66
  64. package/templates/prompt-animation-channel/src/render-plan.ts +1 -1
  65. package/templates/prompt-animation-channel/tests/route-health.spec.ts +4 -2
  66. package/templates/prompt-animation-channel/tests/storyboard-playback.spec.ts +13 -2
  67. package/templates/racing-starter/aura.assets.json +64 -0
  68. package/templates/racing-starter/package.json +2 -1
  69. package/templates/racing-starter/src/main.ts +2 -2
  70. package/templates/racing-starter/tsconfig.json +1 -1
  71. package/templates/three-compat-architecture-interior/aura.assets.json +7 -0
  72. package/templates/three-compat-architecture-interior/package.json +1 -1
  73. package/templates/three-compat-architecture-interior/src/aura-assets.ts +3 -0
  74. package/templates/three-compat-asset-inspector/aura.assets.json +7 -0
  75. package/templates/three-compat-asset-inspector/package.json +1 -1
  76. package/templates/three-compat-asset-inspector/src/aura-assets.ts +3 -0
  77. package/templates/three-compat-character-viewer/aura.assets.json +7 -0
  78. package/templates/three-compat-character-viewer/package.json +1 -1
  79. package/templates/three-compat-character-viewer/src/aura-assets.ts +3 -0
  80. package/templates/three-compat-custom-threejs-migration/aura.assets.json +7 -0
  81. package/templates/three-compat-custom-threejs-migration/package.json +1 -1
  82. package/templates/three-compat-custom-threejs-migration/src/aura-assets.ts +3 -0
  83. package/templates/three-compat-large-scene/aura.assets.json +7 -0
  84. package/templates/three-compat-large-scene/package.json +1 -1
  85. package/templates/three-compat-large-scene/src/aura-assets.ts +3 -0
  86. package/templates/three-compat-material-authoring/aura.assets.json +7 -0
  87. package/templates/three-compat-material-authoring/package.json +1 -1
  88. package/templates/three-compat-material-authoring/src/aura-assets.ts +3 -0
  89. package/templates/three-compat-postprocess-scene/aura.assets.json +7 -0
  90. package/templates/three-compat-postprocess-scene/package.json +1 -1
  91. package/templates/three-compat-postprocess-scene/src/aura-assets.ts +3 -0
  92. package/templates/three-compat-premium-product-viewer/aura.assets.json +7 -0
  93. package/templates/three-compat-premium-product-viewer/package.json +1 -1
  94. package/templates/three-compat-premium-product-viewer/src/aura-assets.ts +3 -0
@@ -14,7 +14,7 @@ const RACING_MAX_AUTHORED_LAP_SECONDS = 60;
14
14
  * surface. Small excursions are tolerated where kerb triangles are modelled with
15
15
  * gaps; a route that is mostly off-road is rejected outright.
16
16
  */
17
- const RACING_MAX_OFF_ROAD_RATIO = 0.08;
17
+ const RACING_MAX_OFF_ROAD_RATIO = 0.01;
18
18
  /**
19
19
  * Maximum ratio between the widest and tightest radius of a derived racing loop.
20
20
  * Real circuits vary; a route that bulges out across an attached apron does not.
@@ -47,6 +47,8 @@ const PLATFORMER_DEFAULT_CHARACTER_FOOTPRINT_WIDTH = 0.72;
47
47
  const PLATFORMER_MAX_HORIZONTAL_TRAVERSAL_GAP = 8;
48
48
  const PLATFORMER_MAX_UPWARD_TRAVERSAL_STEP = 2.5;
49
49
  const PLATFORMER_MAX_RETAINED_MESH_SURFACES = 16;
50
+ const PLATFORMER_MAX_DECLARED_RETAINED_MESH_SURFACES = 256;
51
+ const PLATFORMER_AUTHORED_WORLD_GENERATOR = "Aura3D Skyline Level 1 deterministic GLB compositor";
50
52
  const PLATFORMER_DECORATIVE_PATTERN = /\b(column|pillar|tower|decor|background|backdrop)\b/i;
51
53
  /**
52
54
  * Memoizes racing extraction per asset. Ranking a replacement candidate list calls
@@ -73,6 +75,23 @@ export function extractRacingTrackTopologyFromAsset(assetId, options = {}) {
73
75
  export function clearRacingTrackTopologyCache() {
74
76
  racingTopologyCache.clear();
75
77
  }
78
+ /** Internal evidence probe used by the top-down racing audit and regression tests. */
79
+ export function diagnoseRacingPrimitiveCoverage(assetId, options = {}) {
80
+ const projectDir = options.projectDir ?? process.cwd();
81
+ const geometry = loadAssetGeometry(assetId, projectDir);
82
+ const topology = extractRacingTrackTopologyFromAsset(assetId, options);
83
+ if (!geometry.ok || !topology.ok)
84
+ return [];
85
+ return geometry.value.primitives.filter(isRoadPrimitive).map((primitive) => {
86
+ const surface = createRoadSurface([primitive]);
87
+ return {
88
+ materialName: primitive.materialName,
89
+ triangleCount: primitive.triangles.length,
90
+ routeCoverage: round4(1 - measureOffRoadRatio(topology.value.roadCenterline, surface)),
91
+ bounds: primitive.bounds
92
+ };
93
+ }).sort((a, b) => b.routeCoverage - a.routeCoverage || b.triangleCount - a.triangleCount);
94
+ }
76
95
  function computeRacingTrackTopology(assetId, options, projectDir) {
77
96
  const geometry = loadAssetGeometry(assetId, projectDir);
78
97
  if (!geometry.ok)
@@ -112,15 +131,35 @@ function computeRacingTrackTopology(assetId, options, projectDir) {
112
131
  return failure([`asset-extraction:racing-road-centerline-zero-length:${assetId}`], [`Road mesh in ${assetId} did not produce a measurable closed route.`]);
113
132
  }
114
133
  const checkpointCount = 6;
134
+ const drivableMesh = createDrivableMesh(roadPrimitives);
115
135
  const topology = {
116
136
  assetId,
117
137
  assetHash: requireAssetHash(geometry.value.asset),
118
138
  source: "asset-mesh-extracted",
119
- roadCenterline: centerline,
139
+ /*
140
+ * Centreline points carry the measured surface elevation under each point.
141
+ *
142
+ * Sampling the drivable surface here is what lets a route drop its frozen
143
+ * `TRACK_SURFACE_Y` constant: the vertical road profile becomes data extracted from the
144
+ * asset rather than a number a route author had to guess and then defend in a comment.
145
+ */
146
+ roadCenterline: centerline.map((point) => {
147
+ const surfaceY = roadSurface.elevationAt(point.x, point.z);
148
+ return surfaceY === undefined ? point : { ...point, surfaceY: round3(surfaceY) };
149
+ }),
120
150
  checkpoints: Array.from({ length: checkpointCount }, (_unused, index) => ({
121
151
  progress: round3((index + 1) / checkpointCount),
122
152
  width: centerline[index % centerline.length]?.width ?? averageRoadWidth(roadSize)
123
153
  })),
154
+ /*
155
+ * The drivable surface itself, so a route can ground each wheel independently.
156
+ *
157
+ * The centreline profile above still describes the racing line, but a curve cannot
158
+ * express camber or banking across the road's width. Emitting triangles is what lets
159
+ * the racing route delete its surface constants outright instead of trading one
160
+ * approximation for a slightly better one.
161
+ */
162
+ ...(drivableMesh ? { drivableMesh } : {}),
124
163
  lapLengthMeters: round3(lapLength),
125
164
  estimatedLapSeconds: Math.min(RACING_MAX_AUTHORED_LAP_SECONDS, Math.max(30, Math.ceil(lapLength / RACING_CERTIFIED_GAME_UNITS_PER_SECOND))),
126
165
  confidence: 0.76,
@@ -160,7 +199,8 @@ function computeRacingTrackTopology(assetId, options, projectDir) {
160
199
  `estimatedLapSeconds:${topology.estimatedLapSeconds}`,
161
200
  `centerlineMethod:${centerlineMethod}`,
162
201
  `centerlineOffRoadRatio:${offRoadRatio.toFixed(4)}`,
163
- `roadTriangles:${roadSurface.triangleCount}`
202
+ `roadTriangles:${roadSurface.triangleCount}`,
203
+ `drivableMeshTriangles:${drivableMesh?.triangleCount ?? 0}`
164
204
  ]
165
205
  };
166
206
  }
@@ -183,7 +223,7 @@ export function extractPlatformerPlayableSurfaceMapFromAsset(assetId, options =
183
223
  if (horizontalCandidates.length === 0) {
184
224
  return failure([`asset-extraction:platformer-horizontal-surfaces-not-found:${assetId}`], [`${assetId} has ${surfaceSelection.primitives.length} candidate primitive(s), but none are flat enough for playable surfaces.`]);
185
225
  }
186
- const prepared = preparePlatformerSurfacePrimitives(assetId, horizontalCandidates, surfaceBounds, platformerCharacterFootprintWidth(options, options.projectDir ?? process.cwd()));
226
+ const prepared = preparePlatformerSurfacePrimitives(assetId, horizontalCandidates, surfaceBounds, platformerCharacterFootprintWidth(options, options.projectDir ?? process.cwd()), geometry.value.platformerAuthoring);
187
227
  if (prepared.blockers.length > 0) {
188
228
  return failure(prepared.blockers, [
189
229
  `${assetId} produced ${horizontalCandidates.length} flat candidate primitive(s), but no retained depth-coherent traversable chain met the public platformer geometry floor.`,
@@ -191,7 +231,7 @@ export function extractPlatformerPlayableSurfaceMapFromAsset(assetId, options =
191
231
  ...surfaceSelection.reasons
192
232
  ]);
193
233
  }
194
- const surfaces = createPlayableSurfaces(prepared.primitives, surfaceBounds);
234
+ const surfaces = createPlayableSurfaces(prepared.primitives, surfaceBounds, geometry.value.platformerAuthoring);
195
235
  const meshPlayableSurfaces = meshBoundPlayableSurfaces(surfaces);
196
236
  const qualityBlockers = platformerMeshSurfaceQualityBlockers(assetId, meshPlayableSurfaces);
197
237
  if (qualityBlockers.length > 0) {
@@ -209,7 +249,8 @@ export function extractPlatformerPlayableSurfaceMapFromAsset(assetId, options =
209
249
  source: "asset-mesh-extracted",
210
250
  surfaces,
211
251
  levelLength: round3(levelLength),
212
- estimatedCompletionSeconds: Math.max(30, Math.ceil(levelLength / 0.9)),
252
+ estimatedCompletionSeconds: geometry.value.platformerAuthoring?.authoredCompletionSeconds
253
+ ?? Math.max(30, Math.ceil(levelLength / 0.9)),
213
254
  characterScaleRatio: 0.42,
214
255
  confidence: 0.74,
215
256
  modelAlignment: {
@@ -220,7 +261,7 @@ export function extractPlatformerPlayableSurfaceMapFromAsset(assetId, options =
220
261
  x: round3(levelLength / 2),
221
262
  y: 0
222
263
  },
223
- anchorPairs: createPlatformerAnchorPairs(surfaces, surfaceBounds),
264
+ anchorPairs: createPlatformerAnchorPairs(surfaces, surfaceBounds, geometry.value.platformerAuthoring),
224
265
  evidence: {
225
266
  ...(options.routeOverlayPath ? { routeOverlay: options.routeOverlayPath } : {}),
226
267
  notes: "Mesh-derived playable-surface anchors are computed from flat platform/world primitives and bind the generated level to the visible world asset with the same model-to-game scale used by the generated level."
@@ -246,8 +287,8 @@ export function extractPlatformerPlayableSurfaceMapFromAsset(assetId, options =
246
287
  ]
247
288
  };
248
289
  }
249
- function preparePlatformerSurfacePrimitives(assetId, primitives, bounds, characterFootprintWidth) {
250
- const modelToGameScale = platformerModelToGameScale(bounds);
290
+ function preparePlatformerSurfacePrimitives(assetId, primitives, bounds, characterFootprintWidth, authoring) {
291
+ const modelToGameScale = platformerModelToGameScale(bounds, authoring);
251
292
  const minimumWalkableWidth = Math.max(PLATFORMER_DEFAULT_CHARACTER_FOOTPRINT_WIDTH, characterFootprintWidth);
252
293
  const uniquePrimitives = uniquePlayableSurfacePrimitives(primitives);
253
294
  const walkable = uniquePrimitives.filter((primitive) => primitive.size[0] * modelToGameScale >= minimumWalkableWidth);
@@ -268,11 +309,11 @@ function preparePlatformerSurfacePrimitives(assetId, primitives, bounds, charact
268
309
  reasons: []
269
310
  };
270
311
  }
271
- const familySurfaces = candidatePlatformerSurfaces(selectedFamily.primitives, bounds);
312
+ const familySurfaces = candidatePlatformerSurfaces(selectedFamily.primitives, bounds, authoring);
272
313
  const columnResolution = resolveAmbiguousPlatformerColumns(assetId, familySurfaces);
273
314
  const retainedPrimitiveKeys = new Set(columnResolution.surfaces.map((surface) => surface.primitiveKey));
274
315
  const columnResolvedPrimitives = selectedFamily.primitives.filter((primitive) => retainedPrimitiveKeys.has(platformerPrimitiveKey(primitive)));
275
- const traversal = selectPlatformerTraversalComponent(columnResolvedPrimitives, bounds);
316
+ const traversal = selectPlatformerTraversalComponent(columnResolvedPrimitives, bounds, authoring);
276
317
  const familyReasons = families
277
318
  .filter((family) => family !== selectedFamily)
278
319
  .map((family, index) => `excluded depth family ${index + 1} with ${family.primitives.length} primitive(s) at model z ${round3(family.minZ)}..${round3(family.maxZ)}`);
@@ -337,8 +378,8 @@ function platformerDepthFamilyScore(family, modelToGameScale) {
337
378
  }, 0);
338
379
  return ((maxX - minX) * modelToGameScale + family.primitives.length * 0.5) * 1_000 + semanticScore;
339
380
  }
340
- function candidatePlatformerSurfaces(primitives, bounds) {
341
- const modelToGameScale = platformerModelToGameScale(bounds);
381
+ function candidatePlatformerSurfaces(primitives, bounds, authoring) {
382
+ const modelToGameScale = platformerModelToGameScale(bounds, authoring);
342
383
  const baseline = Math.min(...primitives.map((primitive) => primitive.bounds.max[1]));
343
384
  return primitives.map((primitive, index) => ({
344
385
  id: `candidate-platform-${String(index).padStart(2, "0")}`,
@@ -385,8 +426,8 @@ function resolveAmbiguousPlatformerColumns(assetId, surfaces) {
385
426
  : [];
386
427
  return { surfaces: resolved, blockers, reasons };
387
428
  }
388
- function selectPlatformerTraversalComponent(primitives, bounds) {
389
- const surfaces = [...candidatePlatformerSurfaces(primitives, bounds)].sort((a, b) => (a.x - a.width / 2) - (b.x - b.width / 2) || a.y - b.y);
429
+ function selectPlatformerTraversalComponent(primitives, bounds, authoring) {
430
+ const surfaces = [...candidatePlatformerSurfaces(primitives, bounds, authoring)].sort((a, b) => (a.x - a.width / 2) - (b.x - b.width / 2) || a.y - b.y);
390
431
  const components = [];
391
432
  let current = [];
392
433
  let currentRight = Number.NEGATIVE_INFINITY;
@@ -469,7 +510,10 @@ function loadAssetGeometry(assetId, projectDir) {
469
510
  value: {
470
511
  asset,
471
512
  primitives,
472
- bounds: boundsForPrimitives(primitives)
513
+ bounds: boundsForPrimitives(primitives),
514
+ ...(readPlatformerAuthoringMetadata(document.value) ? {
515
+ platformerAuthoring: readPlatformerAuthoringMetadata(document.value)
516
+ } : {})
473
517
  },
474
518
  reasons: [`read ${primitives.length} GLB primitive(s) from ${assetPath}`]
475
519
  };
@@ -632,7 +676,7 @@ function isPlatformPrimitive(primitive) {
632
676
  return PLATFORM_PATTERN.test(label) && !PLATFORM_EXCLUDE_PATTERN.test(label);
633
677
  }
634
678
  function selectPlatformerSurfacePrimitives(geometry) {
635
- const geometric = geometry.primitives.filter((primitive) => isGenericPlatformerSurfacePrimitive(primitive, geometry.bounds));
679
+ const geometric = geometry.primitives.filter((primitive) => isGenericPlatformerSurfacePrimitive(primitive, geometry.bounds, geometry.platformerAuthoring));
636
680
  const positive = geometric.filter(isPlatformPrimitive).length;
637
681
  const decorative = geometric.filter((primitive) => PLATFORMER_DECORATIVE_PATTERN.test(`${primitive.nodeName} ${primitive.meshName} ${primitive.materialName}`)).length;
638
682
  return {
@@ -643,16 +687,23 @@ function selectPlatformerSurfacePrimitives(geometry) {
643
687
  : ["no flat horizontal primitive passed the game-surface footprint filter"]
644
688
  };
645
689
  }
646
- function isGenericPlatformerSurfacePrimitive(primitive, modelBounds) {
690
+ function isGenericPlatformerSurfacePrimitive(primitive, modelBounds, authoring) {
647
691
  if (!isHorizontalSurface(primitive))
648
692
  return false;
649
693
  const modelSize = boundsSize(modelBounds);
650
- const modelFootprint = Math.max(0.001, modelSize[0] * modelSize[2]);
694
+ // A composed authored level repeats local platform modules across a much longer
695
+ // X span. Comparing each module with the entire 923-unit world incorrectly
696
+ // rejects the smaller floating ledges purely because the level has ten districts.
697
+ // The signed compositor metadata supplies the immutable source-section span so
698
+ // local walkability is judged against one authored district, while the normal
699
+ // extractor remains unchanged for arbitrary third-party GLBs.
700
+ const effectiveModelWidth = authoring?.sectionModelSpan ?? modelSize[0];
701
+ const modelFootprint = Math.max(0.001, effectiveModelWidth * modelSize[2]);
651
702
  const primitiveFootprint = primitive.size[0] * primitive.size[2];
652
703
  const longestAxis = Math.max(primitive.size[0], primitive.size[2]);
653
704
  const shortestAxis = Math.min(primitive.size[0], primitive.size[2]);
654
705
  return primitiveFootprint >= Math.max(0.08, modelFootprint * 0.00045) &&
655
- longestAxis >= Math.max(0.7, modelSize[0] * 0.006) &&
706
+ longestAxis >= Math.max(0.7, effectiveModelWidth * 0.006) &&
656
707
  shortestAxis >= 0.16;
657
708
  }
658
709
  function isHorizontalSurface(primitive) {
@@ -667,6 +718,66 @@ function sortPlayableSurfaceCandidate(a, b) {
667
718
  return areaDelta;
668
719
  return a.bounds.min[0] - b.bounds.min[0];
669
720
  }
721
+ /**
722
+ * Build an indexed triangle mesh from the road primitives' triangles.
723
+ *
724
+ * Vertices are welded on a quantised key so shared corners collapse, which typically cuts
725
+ * the position array by about a third and, more importantly, makes the mesh watertight
726
+ * enough that a downward ray cannot slip through a seam between two adjacent road quads.
727
+ *
728
+ * `maxTriangles` bounds the committed artifact. Decimation keeps an evenly spaced subset
729
+ * rather than a contiguous prefix, because dropping a contiguous run would delete a whole
730
+ * corner of the circuit and leave a hole a wheel could fall through.
731
+ */
732
+ function createDrivableMesh(primitives, maxTriangles = 4000) {
733
+ const triangles = primitives.flatMap((primitive) => primitive.triangles);
734
+ if (triangles.length === 0)
735
+ return undefined;
736
+ const stride = triangles.length > maxTriangles ? Math.ceil(triangles.length / maxTriangles) : 1;
737
+ const kept = [];
738
+ for (let index = 0; index < triangles.length; index += stride) {
739
+ const triangle = triangles[index];
740
+ if (triangle)
741
+ kept.push(triangle);
742
+ }
743
+ const positions = [];
744
+ const indices = [];
745
+ const indexByKey = new Map();
746
+ const pushVertex = (vertex) => {
747
+ // Quantise to millimetre-ish precision in model units so float noise does not defeat
748
+ // welding, while staying far finer than any wheel radius.
749
+ const key = `${vertex[0].toFixed(4)}:${vertex[1].toFixed(4)}:${vertex[2].toFixed(4)}`;
750
+ const existing = indexByKey.get(key);
751
+ if (existing !== undefined)
752
+ return existing;
753
+ const next = positions.length / 3;
754
+ positions.push(round4(vertex[0]), round4(vertex[1]), round4(vertex[2]));
755
+ indexByKey.set(key, next);
756
+ return next;
757
+ };
758
+ for (const triangle of kept) {
759
+ const a = pushVertex(triangle.a);
760
+ const b = pushVertex(triangle.b);
761
+ const c = pushVertex(triangle.c);
762
+ // Skip triangles that collapsed to a line or point during welding: they carry no
763
+ // surface and would make a raycast return a degenerate normal.
764
+ if (a === b || b === c || a === c)
765
+ continue;
766
+ indices.push(a, b, c);
767
+ }
768
+ if (indices.length === 0)
769
+ return undefined;
770
+ return {
771
+ positions,
772
+ indices,
773
+ sourceTriangleCount: triangles.length,
774
+ triangleCount: indices.length / 3
775
+ };
776
+ }
777
+ /** Round to 4dp. Model-space road coordinates need finer precision than `round3` gives. */
778
+ function round4(value) {
779
+ return Math.round(value * 1e4) / 1e4;
780
+ }
670
781
  function createRoadSurface(primitives) {
671
782
  const triangles = primitives.flatMap((primitive) => primitive.triangles);
672
783
  if (triangles.length === 0) {
@@ -949,6 +1060,16 @@ function traceRoadLoop(primitives, surface) {
949
1060
  maxDistance = value;
950
1061
  if (maxDistance <= 0)
951
1062
  return [];
1063
+ // The maximum distance belongs to the middle of a paddock/apron on real assets,
1064
+ // not to the centre of the racing ribbon. Using it as the cost target actively
1065
+ // rewarded the Tsukuba shortcut across the service apron. A lower quantile of all
1066
+ // occupied cells represents the repeatable half-width of the actual road: edge
1067
+ // cells occupy the lower tail, circuit centres the middle, and broad aprons the
1068
+ // upper tail. The path cost below is then U-shaped around this lane-width target.
1069
+ const occupiedDistances = Array.from(distance)
1070
+ .filter((value, index) => road[index] === 1 && value > 0 && value < 1e8)
1071
+ .sort((a, b) => a - b);
1072
+ const preferredDistance = occupiedDistances[Math.min(occupiedDistances.length - 1, Math.floor(occupiedDistances.length * 0.58))] ?? Math.max(1, maxDistance * 0.2);
952
1073
  const holes = roadInteriorHoles(raster, road);
953
1074
  let best;
954
1075
  for (const hole of holes.slice(0, 4)) {
@@ -971,7 +1092,7 @@ function traceRoadLoop(primitives, surface) {
971
1092
  }
972
1093
  if (seam.length === 0)
973
1094
  continue;
974
- const loop = shortestRoadLoop(raster, road, distance, maxDistance, seam);
1095
+ const loop = shortestRoadLoop(raster, road, distance, preferredDistance, seam);
975
1096
  if (loop.length < 24)
976
1097
  continue;
977
1098
  const points = loop.map((cell) => {
@@ -990,7 +1111,7 @@ function traceRoadLoop(primitives, surface) {
990
1111
  // Resampling a traced loop chords across corners, and on a tight hairpin that chord can
991
1112
  // clip the apex. Take the densest count that still keeps every emitted point and every
992
1113
  // interpolated step on the road, rather than loosening the off-road gate.
993
- for (const count of [32, 28, 24, 20]) {
1114
+ for (const count of [72, 64, 56, 48, 40, 32, 28, 24, 20]) {
994
1115
  const resampled = resampleLoop(best.points, count);
995
1116
  if (resampled.length < 8)
996
1117
  continue;
@@ -1014,7 +1135,7 @@ function traceRoadLoop(primitives, surface) {
1014
1135
  * the long way around. Cost prefers cells far from the road edge, which keeps the
1015
1136
  * result near the middle of the ribbon.
1016
1137
  */
1017
- function shortestRoadLoop(raster, road, distance, maxDistance, seam) {
1138
+ function shortestRoadLoop(raster, road, distance, preferredDistance, seam) {
1018
1139
  const { width, height } = raster;
1019
1140
  const blocked = new Uint8Array(road.length);
1020
1141
  for (const cell of seam)
@@ -1047,8 +1168,13 @@ function shortestRoadLoop(raster, road, distance, maxDistance, seam) {
1047
1168
  const starts = [above];
1048
1169
  const goals = new Set([below]);
1049
1170
  const cellCost = (cell) => {
1050
- const openness = Math.min(1, distance[cell] / maxDistance);
1051
- return 1 + 8 * (1 - openness) ** 2;
1171
+ const laneRatio = distance[cell] / Math.max(1e-6, preferredDistance);
1172
+ const edgePenalty = laneRatio < 1 ? 8 * (1 - laneRatio) ** 2 : 0;
1173
+ // A little extra width at a corner is normal; a path several lane-widths from
1174
+ // any edge is an apron, paddock or parking surface and must not become a racing
1175
+ // shortcut merely because it is asphalt-labelled.
1176
+ const apronPenalty = laneRatio > 1.6 ? 5 * (laneRatio - 1.6) ** 2 : 0;
1177
+ return 1 + edgePenalty + apronPenalty;
1052
1178
  };
1053
1179
  const best = new Float64Array(road.length).fill(Number.POSITIVE_INFINITY);
1054
1180
  const previous = new Int32Array(road.length).fill(-1);
@@ -1284,9 +1410,9 @@ function segmentStaysOnRoad(from, to, surface) {
1284
1410
  }
1285
1411
  return true;
1286
1412
  }
1287
- function createPlayableSurfaces(primitives, bounds) {
1413
+ function createPlayableSurfaces(primitives, bounds, authoring) {
1288
1414
  const minX = bounds.min[0];
1289
- const modelToGameScale = platformerModelToGameScale(bounds);
1415
+ const modelToGameScale = platformerModelToGameScale(bounds, authoring);
1290
1416
  const uniquePrimitives = uniquePlayableSurfacePrimitives(primitives);
1291
1417
  const candidateSurfaces = uniquePrimitives
1292
1418
  .map((primitive, index) => {
@@ -1304,7 +1430,7 @@ function createPlayableSurfaces(primitives, bounds) {
1304
1430
  });
1305
1431
  const selectedSurfaces = [...dedupePlayableSurfaces(candidateSurfaces)]
1306
1432
  .sort((a, b) => a.x - b.x)
1307
- .slice(0, PLATFORMER_MAX_RETAINED_MESH_SURFACES);
1433
+ .slice(0, authoring?.maxRetainedPlayableSurfaces ?? PLATFORMER_MAX_RETAINED_MESH_SURFACES);
1308
1434
  const selectedBaselineTopY = selectedPlayableSurfaceBaseline(selectedSurfaces);
1309
1435
  const topSurfaces = selectedSurfaces.map((surface, index) => ({
1310
1436
  ...surface,
@@ -1413,8 +1539,8 @@ function createRacingAnchorPairs(centerline, surface) {
1413
1539
  }];
1414
1540
  });
1415
1541
  }
1416
- function createPlatformerAnchorPairs(surfaces, bounds) {
1417
- const modelToGameScale = platformerModelToGameScale(bounds);
1542
+ function createPlatformerAnchorPairs(surfaces, bounds, authoring) {
1543
+ const modelToGameScale = platformerModelToGameScale(bounds, authoring);
1418
1544
  const meshBoundSurfaces = meshBoundPlayableSurfaces(surfaces);
1419
1545
  const anchorSource = meshBoundSurfaces.length >= 3 ? meshBoundSurfaces : surfaces;
1420
1546
  const anchorSurfaces = pickPlatformerAnchorSurfaces(anchorSource
@@ -1515,11 +1641,45 @@ function pickPlatformerAnchorSurfaces(surfaces) {
1515
1641
  }
1516
1642
  return anchors;
1517
1643
  }
1518
- function platformerModelToGameScale(bounds) {
1644
+ function platformerModelToGameScale(bounds, authoring) {
1645
+ if (authoring)
1646
+ return authoring.modelToGameScale;
1519
1647
  const size = boundsSize(bounds);
1520
1648
  const widthScale = PLATFORMER_TARGET_GAME_LENGTH / Math.max(0.001, size[0]);
1521
1649
  return round3(Math.min(PLATFORMER_MAX_GAME_SCALE, Math.max(PLATFORMER_MIN_GAME_SCALE, widthScale)));
1522
1650
  }
1651
+ function readPlatformerAuthoringMetadata(document) {
1652
+ if (document.json.asset?.generator !== PLATFORMER_AUTHORED_WORLD_GENERATOR)
1653
+ return undefined;
1654
+ const value = document.json.asset.extras?.platformerGeometry;
1655
+ if (!value || typeof value !== "object" || Array.isArray(value))
1656
+ return undefined;
1657
+ const metadata = value;
1658
+ const modelToGameScale = metadata.modelToGameScale;
1659
+ const sectionModelSpan = metadata.sectionModelSpan;
1660
+ const maxRetainedPlayableSurfaces = metadata.maxRetainedPlayableSurfaces;
1661
+ const authoredLevelLength = metadata.authoredLevelLength;
1662
+ const authoredCompletionSeconds = metadata.authoredCompletionSeconds;
1663
+ if (typeof modelToGameScale !== "number" || !Number.isFinite(modelToGameScale) ||
1664
+ modelToGameScale <= 0 || modelToGameScale > PLATFORMER_MAX_GAME_SCALE ||
1665
+ typeof sectionModelSpan !== "number" || !Number.isFinite(sectionModelSpan) || sectionModelSpan <= 0 ||
1666
+ typeof maxRetainedPlayableSurfaces !== "number" || !Number.isInteger(maxRetainedPlayableSurfaces) ||
1667
+ maxRetainedPlayableSurfaces < PLATFORMER_MAX_RETAINED_MESH_SURFACES ||
1668
+ maxRetainedPlayableSurfaces > PLATFORMER_MAX_DECLARED_RETAINED_MESH_SURFACES ||
1669
+ (authoredLevelLength !== undefined && (typeof authoredLevelLength !== "number" || !Number.isFinite(authoredLevelLength) || authoredLevelLength <= 0)) ||
1670
+ (authoredCompletionSeconds !== undefined && (typeof authoredCompletionSeconds !== "number" || !Number.isFinite(authoredCompletionSeconds) ||
1671
+ authoredCompletionSeconds < 30 || authoredCompletionSeconds > 300)))
1672
+ return undefined;
1673
+ return {
1674
+ modelToGameScale: round3(modelToGameScale),
1675
+ sectionModelSpan: round3(sectionModelSpan),
1676
+ maxRetainedPlayableSurfaces,
1677
+ ...(typeof authoredLevelLength === "number" ? { authoredLevelLength: round3(authoredLevelLength) } : {}),
1678
+ ...(typeof authoredCompletionSeconds === "number" ? {
1679
+ authoredCompletionSeconds: round3(authoredCompletionSeconds)
1680
+ } : {})
1681
+ };
1682
+ }
1523
1683
  function platformerCharacterFootprintWidth(options, projectDir) {
1524
1684
  if (options.characterFootprintWidth !== undefined)
1525
1685
  return options.characterFootprintWidth;