import type { HierarchicalMapNode, MapNode, SceneData, } from "@/types/map/mapScene"; import { logger } from "@/utils/core/Logger"; import { parseMapData } from "@/utils/map/mapNodeValidation"; import { createPotagerMapNode, isPotagerSourceMapNode, POTAGER_MAP_NAME, } from "@/utils/map/potagerMapNodes"; const MAP_JSON_PATH = "/map.json"; const MODEL_FILE_NAMES = ["model.glb", "model.gltf"]; const HTML_CONTENT_TYPE = "text/html"; const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]); const POSITION_PRECISION = 3; type ModelEntry = [modelName: string, modelUrl: string]; let cachedSceneData: SceneData | null = null; let loadingPromise: Promise | null = null; const modelEntryCache = new Map(); export async function loadMapSceneData(): Promise { if (cachedSceneData) { return cachedSceneData; } if (loadingPromise) { return loadingPromise; } loadingPromise = loadMapSceneDataInternal(); try { cachedSceneData = await loadingPromise; } finally { loadingPromise = null; } return cachedSceneData; } export function getMapNodes(): MapNode[] | null { return cachedSceneData?.mapNodes ?? null; } export function getMapNodesByName(name: string): MapNode[] { const nodes = cachedSceneData?.mapNodes; if (!nodes) return []; return nodes.filter((node) => node.name === name); } async function loadMapSceneDataInternal(): Promise { const response = await fetch(MAP_JSON_PATH); if (!response.ok) { return null; } const mapPayload: unknown = await response.json(); return createSceneDataFromMapPayload(mapPayload); } export async function createSceneDataFromMapPayload( mapPayload: unknown, ): Promise { const { mapTree } = parseMapData(mapPayload); const mapTreeWithPotagers = ensurePotagerMapTree(mapTree); const mapNodes = flattenMapTree(mapTreeWithPotagers); const deduplicatedNodes = deduplicateMapNodes(mapNodes); return createSceneData(deduplicatedNodes, mapTreeWithPotagers); } function isSamePosition(a: MapNode, b: MapNode): boolean { return a.position.every((value, index) => { const otherValue = b.position[index]; return otherValue !== undefined && Math.abs(value - otherValue) < 0.0001; }); } function cloneMapTree( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): HierarchicalMapNode | HierarchicalMapNode[] { return JSON.parse(JSON.stringify(mapTree)) as | HierarchicalMapNode | HierarchicalMapNode[]; } function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { const childNodes = node.children?.flatMap((child, index) => flattenMapNode(child, [...path, index]), ) ?? []; if (node.role === "group" || node.type === "Mesh") { return childNodes; } return [ { ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, rotation: node.rotation, scale: node.scale, sourcePath: path, }, ...childNodes, ]; } function flattenMapTree( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): MapNode[] { return Array.isArray(mapTree) ? mapTree.flatMap((node, index) => flattenMapNode(node, [index])) : flattenMapNode(mapTree, []); } function collectExplicitPotagerNodes( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): MapNode[] { return flattenMapTree(mapTree).filter( (node) => node.name === POTAGER_MAP_NAME, ); } function ensurePotagerMapTree( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): HierarchicalMapNode | HierarchicalMapNode[] { const nextTree = cloneMapTree(mapTree); const explicitPotagers = collectExplicitPotagerNodes(nextTree); function visit(node: HierarchicalMapNode): void { if (!node.children) return; const nextChildren: HierarchicalMapNode[] = []; node.children.forEach((child) => { nextChildren.push(child); visit(child); if (!isPotagerSourceMapNode(child)) return; const hasMatchingPotager = explicitPotagers.some((potager) => isSamePosition(potager, child), ); if (hasMatchingPotager) return; nextChildren.push(createPotagerMapNode(child)); }); node.children = nextChildren; } if (Array.isArray(nextTree)) { nextTree.forEach((node) => visit(node)); } else { visit(nextTree); } return nextTree; } function createPositionKey(node: MapNode): string { const [x, y, z] = node.position; const px = x.toFixed(POSITION_PRECISION); const py = y.toFixed(POSITION_PRECISION); const pz = z.toFixed(POSITION_PRECISION); return `${node.name}:${px},${py},${pz}`; } function deduplicateMapNodes(nodes: MapNode[]): MapNode[] { const seen = new Set(); const result: MapNode[] = []; const sortedNodes = [...nodes].sort((a, b) => { if (a.type === "Object3D" && b.type !== "Object3D") return -1; if (a.type !== "Object3D" && b.type === "Object3D") return 1; return 0; }); for (const node of sortedNodes) { if (MAP_STRUCTURE_NODE_NAMES.has(node.name)) { result.push(node); continue; } const key = createPositionKey(node); if (!seen.has(key)) { seen.add(key); result.push(node); } } return result; } async function createSceneData( mapNodes: MapNode[], mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): Promise { const models = await loadMapModelUrls(mapNodes); return { mapNodes, models, mapTree }; } async function loadMapModelUrls( mapNodes: MapNode[], ): Promise> { const uniqueModelNames = [ ...new Set( mapNodes .filter((node) => !MAP_STRUCTURE_NODE_NAMES.has(node.name)) .map((node) => node.name), ), ]; const modelEntries = await Promise.all( uniqueModelNames.map((modelName) => loadModelEntry(modelName)), ); return new Map(modelEntries.filter((entry) => entry !== null)); } async function loadModelEntry(modelName: string): Promise { if (modelEntryCache.has(modelName)) { return modelEntryCache.get(modelName) ?? null; } const modelUrls = [...MODEL_FILE_NAMES, `${modelName}.gltf`].map( (fileName) => `/models/${modelName}/${fileName}`, ); const results = await Promise.all( modelUrls.map(async (modelUrl) => { try { const response = await fetch(modelUrl, { method: "HEAD" }); const contentType = response.headers.get("content-type") ?? ""; return response.ok && !contentType.includes(HTML_CONTENT_TYPE); } catch (error) { logger.warn("MapSceneData", "Failed to probe map model URL", { modelName, modelUrl, error: error instanceof Error ? error : String(error), }); return false; } }), ); const modelUrl = modelUrls[results.findIndex(Boolean)] ?? null; const entry = modelUrl ? ([modelName, modelUrl] satisfies ModelEntry) : null; modelEntryCache.set(modelName, entry); return entry; }