Files
webeditor3d/src/runtime-three/runtime-scene-validation.ts

119 lines
4.2 KiB
TypeScript
Raw Normal View History

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<string, LoadedModelAsset>;
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)}`);
}
}