export type Vec3 = [number, number, number]; export type Component = Record; export interface Transform { position: Vec3; rotation: Vec3; scale: Vec3; } export interface Entity { id: string; name: string; parentId: string | null; enabled: boolean; transform: Transform; components: Record; } export interface Geometry { positions: number[]; indices: number[]; normals?: number[]; uvs?: number[]; } export interface Asset { id: string; name: string; kind: "model" | "geometry" | "prefab"; uri?: string; geometry?: Geometry; entities?: Entity[]; metadata?: any; } export interface ScriptAsset { id: string; name: string; source: string; fields: Record< string, { type: "number" | "boolean" | "string" | "entity"; default: any; label?: string; min?: number; max?: number; } >; } export interface Project { format: "forma"; version: 1; id: string; name: string; revision: number; activeSceneId: string; scenes: { id: string; name: string; entities: Entity[] }[]; assets: Asset[]; scripts: ScriptAsset[]; settings: { background: string; ambient: number; shadows: boolean; renderScale: number; }; } export interface Command { op: string; args: any; } export interface Transaction { commands: Command[]; expectedRevision?: number; requestId?: string; label?: string; source?: string; } export const clone = (v: T): T => structuredClone(v); export const uid = (p = "obj") => p + "_" + crypto.randomUUID().replaceAll("-", "").slice(0, 12); export const transform = (): Transform => ({ position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }); export const entity = ( name: string, components: Entity["components"] = {}, position: Vec3 = [0, 0, 0], id = uid(), ): Entity => ({ id, name, parentId: null, enabled: true, transform: { ...transform(), position }, components, }); export function emptyProject(name = "Без названия"): Project { const id = uid("scene"); return { format: "forma", version: 1, id: uid("project"), name, revision: 0, activeSceneId: id, scenes: [{ id, name: "Основная сцена", entities: [] }], assets: [], scripts: [], settings: { background: "#dedbd2", ambient: 0.85, shadows: true, renderScale: 1, }, }; } export const activeScene = (p: Project) => p.scenes.find((s) => s.id === p.activeSceneId)!; const forbidden = new Set(["__proto__", "constructor", "prototype"]); export function assertJson(v: any, depth = 0) { if (depth > 28) throw Error("Слишком глубокая структура"); if ( v === undefined || typeof v === "function" || typeof v === "bigint" || (typeof v === "number" && !Number.isFinite(v)) ) throw Error("Требуются конечные JSON-данные"); if (v && typeof v === "object") for (const [k, n] of Object.entries(v)) { if (forbidden.has(k)) throw Error("Недопустимое имя свойства"); assertJson(n, depth + 1); } } export function deepMerge(a: any, b: any) { for (const [k, v] of Object.entries(b)) { if (forbidden.has(k)) throw Error("Недопустимое имя свойства"); if (v && typeof v === "object" && !Array.isArray(v)) a[k] = deepMerge( a[k] && typeof a[k] === "object" && !Array.isArray(a[k]) ? a[k] : {}, v, ); else a[k] = clone(v); } return a; } export function validateGeometry(g: Geometry) { if ( !g || !Array.isArray(g.positions) || !Array.isArray(g.indices) || g.positions.length < 9 || g.positions.length % 3 || g.indices.length < 3 || g.indices.length % 3 ) throw Error("Геометрия: positions и indices должны описывать треугольники"); if (g.positions.length > 900000 || g.indices.length > 1800000) throw Error("Лимит геометрии: 300 000 вершин, 600 000 треугольников"); if ( !g.positions.every(Number.isFinite) || !g.indices.every( (i) => Number.isInteger(i) && i >= 0 && i < g.positions.length / 3, ) ) throw Error("Некорректные вершины или индексы"); if ( g.normals && (g.normals.length !== g.positions.length || !g.normals.every(Number.isFinite)) ) throw Error("Некорректные нормали"); if ( g.uvs && (g.uvs.length !== (g.positions.length / 3) * 2 || !g.uvs.every(Number.isFinite)) ) throw Error("Некорректные UV"); } const identifier = (id: any) => typeof id === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(id); const color = (s: any) => typeof s === "string" && /^#[\da-fA-F]{6}$/.test(s); const vector = (v: any) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite); export function validateProject(p: Project) { assertJson(p); if (p?.format !== "forma" || p.version !== 1) throw Error("Поддерживается формат Forma v1"); if ( !identifier(p.id) || typeof p.name !== "string" || p.name.length > 200 || !Number.isInteger(p.revision) || p.revision < 0 ) throw Error("Некорректные метаданные"); if ( !Array.isArray(p.scenes) || !p.scenes.length || !p.scenes.some((s) => s.id === p.activeSceneId) || !Array.isArray(p.assets) || !Array.isArray(p.scripts) ) throw Error("Неполный проект"); if ( !p.settings || !color(p.settings.background) || !(p.settings.ambient >= 0 && p.settings.ambient <= 10) || !(p.settings.renderScale >= 0.4 && p.settings.renderScale <= 1.5) || typeof p.settings.shadows !== "boolean" ) throw Error("Некорректные настройки сцены"); const unique = (list: any[]) => { const ids = new Set(); for (const v of list) { if (!identifier(v.id) || ids.has(v.id)) throw Error("Требуется уникальный ID"); ids.add(v.id); } }; unique(p.scenes); unique(p.assets); unique(p.scripts); const nodes = (list: Entity[]) => { if (!Array.isArray(list) || list.length > 3000) throw Error("Лимит: 3000 объектов"); unique(list); const map = new Map(list.map((n) => [n.id, n])); for (const n of list) { if ( typeof n.name !== "string" || n.name.length > 200 || typeof n.enabled !== "boolean" || !n.components || Array.isArray(n.components) || !(n.parentId === null || identifier(n.parentId)) ) throw Error("Некорректный объект"); if ( !vector(n.transform?.position) || !vector(n.transform?.rotation) || !vector(n.transform?.scale) || n.transform.scale.some((v) => Math.abs(v) < 0.0001) ) throw Error("Некорректная трансформация"); for (const c of Object.values(n.components)) if (!c || typeof c !== "object" || Array.isArray(c)) throw Error("Компонент должен быть объектом"); const c = n.components, m = c.mesh; if (m?.type === "custom") validateGeometry(m.geometry); if ( m?.assetId && !p.assets.some((a) => a.id === m.assetId && a.kind !== "prefab") ) throw Error("Не найден ресурс " + m.assetId); if ( c.script?.scriptId && !p.scripts.some((s) => s.id === c.script.scriptId) ) throw Error("Не найден скрипт " + c.script.scriptId); if (c.material?.color && !color(c.material.color)) throw Error("Цвет должен быть #RRGGBB"); if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0))) throw Error("Размеры должны быть положительными"); if ( c.collider?.size && (!vector(c.collider.size) || c.collider.size.some((v: number) => v <= 0)) ) throw Error("Размер коллайдера должен быть положительным"); if (c.collider?.radius !== undefined && c.collider.radius <= 0) throw Error("Радиус должен быть положительным"); let cur = n; const seen = new Set([n.id]); while (cur.parentId) { const parent = map.get(cur.parentId); if (!parent) throw Error("Родитель не найден"); if (seen.has(parent.id)) throw Error("Цикл в иерархии"); seen.add(parent.id); cur = parent; } } }; for (const s of p.scenes) nodes(s.entities); for (const a of p.assets) { if ( typeof a.name !== "string" || !["model", "geometry", "prefab"].includes(a.kind) ) throw Error("Некорректный ресурс"); if (a.kind === "geometry") validateGeometry(a.geometry!); if (a.kind === "prefab") { nodes(a.entities!); if (a.entities!.filter((n) => !n.parentId).length !== 1) throw Error("Префабу нужен один корневой объект"); } if (a.uri && !a.uri.startsWith("data:")) { if (!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf)$/i.test(a.uri)) throw Error("Импортируйте ресурс в assets/, внешние пути запрещены"); } } for (const s of p.scripts) { if ( typeof s.source !== "string" || s.source.length > 250000 || !s.fields || typeof s.fields !== "object" ) throw Error("Некорректный скрипт"); for (const f of Object.values(s.fields)) { if ( !f || !["number", "boolean", "string", "entity"].includes(f.type) || typeof f.default !== (f.type === "entity" ? "string" : f.type) ) throw Error("Некорректные поля скрипта"); } } } export function remapEntityReferences( n: Entity, ids: Map, scripts: ScriptAsset[], ) { if (ids.has(n.components.camera?.targetId)) n.components.camera.targetId = ids.get(n.components.camera.targetId); const binding = n.components.script, script = scripts.find((s) => s.id === binding?.scriptId); if (binding && script) for (const [k, f] of Object.entries(script.fields)) if (f.type === "entity") { const value = binding.params?.[k] ?? f.default; if (ids.has(value)) { binding.params ??= {}; binding.params[k] = ids.get(value); } } return n; } export const componentDefaults: Record = { mesh: { type: "box", size: [1, 1, 1] }, material: { color: "#91a697", roughness: 0.8, metallic: 0 }, collider: { shape: "box", size: [1, 1, 1], radius: 0.4 }, rigidbody: { type: "fixed", mass: 1, restitution: 0.1 }, character: { gravity: 24, autostep: 0.25 }, camera: { targetId: "", offset: [0, 13, -10], fov: 0.72 }, light: { color: "#fff1da", intensity: 2 }, animator: { idle: "Idle", run: "Run", attack: "Attack", death: "Death", speed: 1, }, data: {}, };