diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts new file mode 100644 index 00000000..428e44aa --- /dev/null +++ b/src/document/scene-document-validation.ts @@ -0,0 +1,171 @@ +import type { EntityInstance } from "../entities/entity-instances"; +import { BOX_FACE_IDS, hasPositiveBoxSize } from "./brushes"; +import type { SceneDocument } from "./scene-document"; + +export type SceneDiagnosticSeverity = "error" | "warning"; +export type SceneDiagnosticScope = "document" | "build"; + +export interface SceneDiagnostic { + code: string; + severity: SceneDiagnosticSeverity; + scope: SceneDiagnosticScope; + message: string; + path?: string; +} + +export interface SceneDocumentValidationResult { + diagnostics: SceneDiagnostic[]; + errors: SceneDiagnostic[]; + warnings: SceneDiagnostic[]; +} + +function createDiagnostic( + severity: SceneDiagnosticSeverity, + code: string, + message: string, + path?: string, + scope: SceneDiagnosticScope = "document" +): SceneDiagnostic { + return { + code, + severity, + scope, + message, + path + }; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isFiniteVec3(vector: { x: unknown; y: unknown; z: unknown }): boolean { + return isFiniteNumber(vector.x) && isFiniteNumber(vector.y) && isFiniteNumber(vector.z); +} + +function validatePlayerStartEntity(entity: EntityInstance, path: string, diagnostics: SceneDiagnostic[]) { + if (!isFiniteVec3(entity.position)) { + diagnostics.push( + createDiagnostic("error", "invalid-player-start-position", "Player Start position must remain finite on every axis.", `${path}.position`) + ); + } + + if (!isFiniteNumber(entity.yawDegrees)) { + diagnostics.push(createDiagnostic("error", "invalid-player-start-yaw", "Player Start yaw must remain a finite number.", `${path}.yawDegrees`)); + } +} + +function registerAuthoredId(id: string, path: string, seenIds: Map, diagnostics: SceneDiagnostic[]) { + const previousPath = seenIds.get(id); + + if (previousPath !== undefined) { + diagnostics.push( + createDiagnostic("error", "duplicate-authored-id", `Duplicate authored id ${id} is already used at ${previousPath}.`, path) + ); + return; + } + + seenIds.set(id, path); +} + +export function formatSceneDiagnostic(diagnostic: SceneDiagnostic): string { + return diagnostic.path === undefined ? diagnostic.message : `${diagnostic.path}: ${diagnostic.message}`; +} + +export function formatSceneDiagnosticSummary(diagnostics: SceneDiagnostic[], limit = 3): string { + if (diagnostics.length === 0) { + return "No diagnostics."; + } + + const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit)); + const summary = visibleDiagnostics.map((diagnostic) => formatSceneDiagnostic(diagnostic)).join("; "); + const remainingCount = diagnostics.length - visibleDiagnostics.length; + + return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary; +} + +export function validateSceneDocument(document: SceneDocument): SceneDocumentValidationResult { + const diagnostics: SceneDiagnostic[] = []; + const seenIds = new Map(); + + for (const [materialKey, material] of Object.entries(document.materials)) { + const path = `materials.${materialKey}`; + + if (material.id !== materialKey) { + diagnostics.push( + createDiagnostic("error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id`) + ); + } + + registerAuthoredId(material.id, path, seenIds, diagnostics); + } + + for (const [brushKey, brush] of Object.entries(document.brushes)) { + const path = `brushes.${brushKey}`; + + if (brush.id !== brushKey) { + diagnostics.push(createDiagnostic("error", "brush-id-mismatch", "Brush ids must match their registry key.", `${path}.id`)); + } + + registerAuthoredId(brush.id, path, seenIds, diagnostics); + + if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) { + diagnostics.push( + createDiagnostic("error", "invalid-box-size", "Box brush sizes must remain finite and positive on every axis.", `${path}.size`) + ); + } + + for (const faceId of BOX_FACE_IDS) { + const materialId = brush.faces[faceId].materialId; + + if (materialId !== null && document.materials[materialId] === undefined) { + diagnostics.push( + createDiagnostic( + "error", + "missing-material-ref", + `Face material reference ${materialId} does not exist in the document material registry.`, + `${path}.faces.${faceId}.materialId` + ) + ); + } + } + } + + for (const [entityKey, entity] of Object.entries(document.entities)) { + const path = `entities.${entityKey}`; + + if (entity.id !== entityKey) { + diagnostics.push(createDiagnostic("error", "entity-id-mismatch", "Entity ids must match their registry key.", `${path}.id`)); + } + + registerAuthoredId(entity.id, path, seenIds, diagnostics); + + if (entity.kind === "playerStart") { + validatePlayerStartEntity(entity, path, diagnostics); + continue; + } + + diagnostics.push( + createDiagnostic( + "error", + "unsupported-entity-kind", + `Unsupported entity kind ${(entity as { kind: string }).kind}.`, + `${path}.kind` + ) + ); + } + + return { + diagnostics, + errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), + warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning") + }; +} + +export function assertSceneDocumentIsValid(document: SceneDocument) { + const validation = validateSceneDocument(document); + + if (validation.errors.length > 0) { + throw new Error(`Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}`); + } +}