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.
- package/README.md +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/showcase-spec-evidence.js +5 -1
- package/dist/showcase-spec-evidence.js.map +1 -1
- package/dist/showcase-spec-game-geometry-extractor.d.ts +8 -1
- package/dist/showcase-spec-game-geometry-extractor.d.ts.map +1 -1
- package/dist/showcase-spec-game-geometry-extractor.js +191 -31
- 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 +7 -0
- package/dist/showcase-spec-game-template-evidence.js.map +1 -1
- package/dist/showcase-spec-parser.js +1 -1
- package/dist/showcase-spec-parser.js.map +1 -1
- package/dist/showcase-spec-types.d.ts +50 -0
- package/dist/showcase-spec-types.d.ts.map +1 -1
- package/package.json +2 -2
- package/templates/animation-channel/package.json +1 -1
- package/templates/animation-channel/src/experimental/README.md +1 -1
- package/templates/animation-channel/tests/storyboard-playback.spec.ts +2 -0
- package/templates/animation-studio/package.json +2 -5
- package/templates/animation-studio/scripts/asset-motion-probe.ts +1 -1
- package/templates/animation-studio/scripts/build-characters.ts +1 -1
- package/templates/animation-studio/scripts/motion-evidence.ts +1 -1
- package/templates/animation-studio/scripts/render-core.ts +4 -2
- package/templates/animation-studio/scripts/render-live.ts +3 -1
- package/templates/animation-studio/scripts/validate-characters.ts +0 -0
- package/templates/animation-studio/src/scene-player.ts +2 -2
- package/templates/character-controller/README.md +15 -2
- package/templates/character-controller/aura.assets.json +7 -0
- package/templates/character-controller/package.json +3 -3
- package/templates/character-controller/src/aura-assets.ts +3 -0
- package/templates/cinematic-scene/aura.assets.json +31 -0
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/episode-builder/aura.assets.json +7 -0
- package/templates/episode-builder/package.json +1 -1
- package/templates/episode-builder/tests/route-health.spec.ts +2 -2
- package/templates/falling-blocks-starter/aura.assets.json +37 -0
- package/templates/falling-blocks-starter/package.json +2 -1
- package/templates/falling-blocks-starter/tests/playable.spec.ts +2 -3
- package/templates/falling-blocks-starter/tsconfig.json +1 -1
- package/templates/fighting-game/aura.assets.json +7 -0
- package/templates/fighting-game/package.json +1 -1
- package/templates/fighting-game/src/main.ts +5 -7
- package/templates/fighting-game/tests/gameplay-smoke.spec.ts +4 -1
- package/templates/fighting-game/tests/route-health.spec.ts +8 -2
- package/templates/mini-game/README.md +3 -2
- package/templates/mini-game/aura.assets.json +31 -0
- package/templates/mini-game/package.json +1 -1
- package/templates/mini-game/src/aura-assets.ts +1 -1
- package/templates/mini-game/src/main.ts +69 -141
- package/templates/product-viewer/README.md +7 -5
- package/templates/product-viewer/aura.assets.json +34 -0
- package/templates/product-viewer/index.html +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/product-viewer/src/aura-assets.ts +1 -1
- package/templates/product-viewer/src/main.ts +29 -23
- package/templates/product-viewer/tests/route-health.spec.ts +2 -2
- package/templates/product-viewer/tests/screenshot.spec.ts +2 -2
- package/templates/prompt-animation-channel/aura.assets.json +7 -0
- package/templates/prompt-animation-channel/package.json +1 -1
- package/templates/prompt-animation-channel/src/main.ts +4 -66
- package/templates/prompt-animation-channel/src/render-plan.ts +1 -1
- package/templates/prompt-animation-channel/tests/route-health.spec.ts +4 -2
- package/templates/prompt-animation-channel/tests/storyboard-playback.spec.ts +13 -2
- package/templates/racing-starter/aura.assets.json +64 -0
- package/templates/racing-starter/package.json +2 -1
- package/templates/racing-starter/src/main.ts +2 -2
- package/templates/racing-starter/tsconfig.json +1 -1
- package/templates/three-compat-architecture-interior/aura.assets.json +7 -0
- package/templates/three-compat-architecture-interior/package.json +1 -1
- package/templates/three-compat-architecture-interior/src/aura-assets.ts +3 -0
- package/templates/three-compat-asset-inspector/aura.assets.json +7 -0
- package/templates/three-compat-asset-inspector/package.json +1 -1
- package/templates/three-compat-asset-inspector/src/aura-assets.ts +3 -0
- package/templates/three-compat-character-viewer/aura.assets.json +7 -0
- package/templates/three-compat-character-viewer/package.json +1 -1
- package/templates/three-compat-character-viewer/src/aura-assets.ts +3 -0
- package/templates/three-compat-custom-threejs-migration/aura.assets.json +7 -0
- package/templates/three-compat-custom-threejs-migration/package.json +1 -1
- package/templates/three-compat-custom-threejs-migration/src/aura-assets.ts +3 -0
- package/templates/three-compat-large-scene/aura.assets.json +7 -0
- package/templates/three-compat-large-scene/package.json +1 -1
- package/templates/three-compat-large-scene/src/aura-assets.ts +3 -0
- package/templates/three-compat-material-authoring/aura.assets.json +7 -0
- package/templates/three-compat-material-authoring/package.json +1 -1
- package/templates/three-compat-material-authoring/src/aura-assets.ts +3 -0
- package/templates/three-compat-postprocess-scene/aura.assets.json +7 -0
- package/templates/three-compat-postprocess-scene/package.json +1 -1
- package/templates/three-compat-postprocess-scene/src/aura-assets.ts +3 -0
- package/templates/three-compat-premium-product-viewer/aura.assets.json +7 -0
- package/templates/three-compat-premium-product-viewer/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
1051
|
-
|
|
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;
|