import type { LoadedModelAsset } from "../assets/gltf-model-import"; import { getModelInstances } from "../assets/model-instances"; import type { Brush } from "../document/brushes"; import type { ProjectDocument, SceneDocument } from "../document/scene-document"; import { assertProjectSchedulingResourcesAreValid, assertSceneDocumentLocalBuildContentIsValid, assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary, type SceneDiagnostic } from "../document/scene-document-validation"; import { getPrimaryEnabledPlayerStartEntity } from "../entities/entity-instances"; import { validateBoxBrushGeometry } from "../geometry/box-brush-mesh"; import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation"; export interface RuntimeSceneBuildValidationResult { diagnostics: SceneDiagnostic[]; errors: SceneDiagnostic[]; warnings: SceneDiagnostic[]; } interface ValidateRuntimeSceneBuildOptions { navigationMode: "firstPerson" | "thirdPerson"; loadedModelAssets?: Record; projectDocument?: ProjectDocument; } function validateBrushGeometry(brush: Brush, path: string, diagnostics: SceneDiagnostic[]) { for (const diagnostic of validateBoxBrushGeometry(brush)) { diagnostics.push(createDiagnostic("error", diagnostic.code, diagnostic.message, `${path}.geometry`, "build")); } } export function validateRuntimeSceneBuild( document: SceneDocument, options: ValidateRuntimeSceneBuildOptions ): RuntimeSceneBuildValidationResult { const diagnostics: SceneDiagnostic[] = []; if (options.navigationMode === "firstPerson" && getPrimaryEnabledPlayerStartEntity(document.entities) === null) { diagnostics.push( createDiagnostic( "error", "missing-player-start", "First-person run requires an authored Player Start. Place one or switch to Third Person.", "entities", "build" ) ); } for (const brush of Object.values(document.brushes)) { if (!brush.enabled) { continue; } validateBrushGeometry(brush, `brushes.${brush.id}`, diagnostics); } for (const modelInstance of getModelInstances(document.modelInstances)) { if (!modelInstance.enabled) { continue; } const path = `modelInstances.${modelInstance.id}.collision.mode`; const asset = document.assets[modelInstance.assetId]; if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") { continue; } try { const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]); if (generatedCollider?.mode === "dynamic") { diagnostics.push( createDiagnostic( "warning", "dynamic-model-collider-fixed-query-only", "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", path, "build" ) ); } } catch (error) { const message = error instanceof Error ? error.message : "Imported model collision generation failed."; const code = error instanceof ModelColliderGenerationError ? error.code : "invalid-model-instance-collision-mode"; diagnostics.push(createDiagnostic("error", code, message, path, "build")); } } return { diagnostics, errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning") }; } export function assertRuntimeSceneBuildable(document: SceneDocument, options: ValidateRuntimeSceneBuildOptions) { if (options.projectDocument === undefined) { assertSceneDocumentIsValid(document); } else { assertProjectSchedulingResourcesAreValid(options.projectDocument); assertSceneDocumentLocalBuildContentIsValid(document); } const validation = validateRuntimeSceneBuild(document, options); if (validation.errors.length > 0) { throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`); } }