"""BSP 资产建议展开器。 将执行案中的"BSP 资产建议"字符串拆分为可放置的 Actor 规格。 核心转换: - "Wall_400x300 × 2" → 2 个 Actor,间隔 400cm - "Wall_400x300 + Wall_200x300" → 2 种资产各 1 个 - "Cube 缩放(0.3,0.3,1.6)" → 1 个 Cube,带缩放参数 - 区域原点 + 相对偏移 → 绝对坐标 - 构件名称/方位 → 旋转推断 同时复用 level-builder 的 asset_mapping / material_mapping / substitution_rules。 """ import re import math import logging from dataclasses import dataclass from typing import Optional # 允许从包内或直接运行 from pathlib import Path try: from config.asset_mapping import MESH_ASSETS, MESH_ROOT, get_pivot_compensation from config.material_mapping import MATERIAL_PATHS from config.substitution_rules import SUBSTITUTION_RULES except ImportError: import sys _lb = str(Path(__file__).resolve().parent.parent / "level-builder") if _lb not in sys.path: sys.path.insert(0, _lb) from config.asset_mapping import MESH_ASSETS, MESH_ROOT, get_pivot_compensation from config.material_mapping import MATERIAL_PATHS from config.substitution_rules import SUBSTITUTION_RULES logger = logging.getLogger("execution_to_guide.expander") # ============================================================ # 数据结构 # ============================================================ @dataclass class ActorSpec: """展开后的单个 Actor 规格。""" actor_name: str # LVL_Z1_STR_SouthWall_01 asset_key: str # Wall_400x300 asset_path: str # UE5 资产路径 location: tuple # (X, Y, Z) 绝对坐标 cm rotation: tuple # (Pitch, Yaw, Roll) 度 scale: tuple # (X, Y, Z) 缩放 material: str # MI_DevRed material_path: str # UE5 材质路径 category: str # STR | PROP | COMP zone_id: str # Z1 | GLOBAL folder: str # Outliner 文件夹路径 note: str = "" # ============================================================ # 方向→旋转映射 # ============================================================ # 从构件名称中的方位词推断旋转 # UE5: Yaw 绕 Z 轴旋转,沿 X 正方向为 0° # 南墙(沿 X 轴)= Yaw 0°,东墙(沿 Y 轴)= Yaw 90° DIRECTION_YAW = { "南": 0.0, # 沿 X 轴,面朝南(-Y) "东": 90.0, # 沿 Y 轴,面朝东(+X) "西": -90.0, # 沿 Y 轴,面朝西(-X) "北": 180.0, # 沿 X 轴,面朝北(+Y) "左": 90.0, "右": -90.0, "上": 0.0, "下": 0.0, } def infer_rotation_from_name(name: str) -> tuple: """从构件名称推断旋转。 查找名称中的方位词(南/东/西/北),返回对应的 (Pitch, Yaw, Roll)。 如果没有方位词,返回 (0, 0, 0)。 """ for direction, yaw in DIRECTION_YAW.items(): if direction in name: return (0.0, yaw, 0.0) return (0.0, 0.0, 0.0) def infer_rotation_from_offset(offset: tuple, zone_size: tuple) -> tuple: """从偏移位置推断旋转(备用方案)。 根据物件在区域中的位置判断朝向: - 偏移 Y ≈ 0 → 南墙,Y ≈ max → 北墙 - 偏移 X ≈ 0 → 西墙,X ≈ max → 东墙 """ dx, dy, dz = offset w, d = zone_size[0], zone_size[1] if len(zone_size) > 1 else 0 if w > 0 and d > 0: # 判断更接近哪条边 x_ratio = dx / w if w else 0 y_ratio = dy / d if d else 0 if y_ratio < 0.1: return (0.0, 0.0, 0.0) # 南 elif y_ratio > 0.9: return (0.0, 180.0, 0.0) # 北 elif x_ratio < 0.1: return (0.0, -90.0, 0.0) # 西 elif x_ratio > 0.9: return (0.0, 90.0, 0.0) # 东 return (0.0, 0.0, 0.0) # ============================================================ # BSP 建议解析 # ============================================================ def parse_bsp_suggestion(bsp_text: str) -> list[dict]: """解析 BSP 资产建议字符串为资产列表。 支持的格式: - "Wall_400x300" → [{"key": "Wall_400x300", "count": 1}] - "Wall_400x300 × 2" → [{"key": "Wall_400x300", "count": 2}] - "Wall_400x300 × 2 + Wall_200x300" → [{"key": "Wall_400x300", "count": 2}, {"key": "Wall_200x300", "count": 1}] - "Cube 缩放(0.3,0.3,1.6)" → [{"key": "Cube", "count": 1, "scale": (0.3,0.3,1.6)}] - "Car01 缩放50%" → [{"key": "Car01", "count": 1, "scale": (0.5,0.5,0.5)}] - "Floor_1000x1000 缩放(8,6,1)" → [{"key": "Floor_1000x1000", "count": 1, "scale": (8,6,1)}] - "Primitives 组合" → [{"key": "_composite", "count": 1}] - "BarrierWall_600" → [{"key": "BarrierWall_600", "count": 1}] Returns: list of dicts, each with: key: asset key string count: int scale: optional tuple (X, Y, Z) """ if not bsp_text or not bsp_text.strip(): return [] bsp_text = bsp_text.strip() results = [] # 按 + 分割多个资产段 segments = re.split(r'\s*\+\s*', bsp_text) for seg in segments: seg = seg.strip() if not seg: continue # 检测 Primitives 组合标记 if "Primitives 组合" in seg or "组合" in seg and not re.search(r'[A-Z]', seg): results.append({"key": "_composite", "count": 1}) continue # 提取资产 key key_match = re.match(r'([A-Za-z_]\w*(?:\d+x\d+)?)', seg) if not key_match: continue asset_key = key_match.group(1) remaining = seg[key_match.end():] # 提取数量:× 2 或 *2 或 x2 count = 1 count_match = re.search(r'[×x*]\s*(\d+)', remaining) if count_match: count = int(count_match.group(1)) # 提取缩放:缩放(X,Y,Z) 或 缩放NN% scale = None scale_match = re.search(r'缩放\s*\(([\d.]+)\s*[,,]\s*([\d.]+)\s*[,,]\s*([\d.]+)\)', remaining) if scale_match: scale = (float(scale_match.group(1)), float(scale_match.group(2)), float(scale_match.group(3))) else: pct_match = re.search(r'缩放\s*(\d+)%', remaining) if pct_match: s = int(pct_match.group(1)) / 100.0 scale = (s, s, s) results.append({"key": asset_key, "count": count, "scale": scale}) return results # ============================================================ # 资产展开 # ============================================================ class AssetExpander: """将执行案条目展开为 Actor 规格。""" def __init__(self, zones: list): """ Args: zones: list[ZoneInfo] 区域信息列表 """ self.zones = {z.zone_id: z for z in zones} self.actor_counters = {} # zone_id -> {category: counter} def expand_structure(self, item, zone_id: str) -> list[ActorSpec]: """展开结构体条目。 Args: item: StructureItem zone_id: 区域 ID Returns: 展开后的 ActorSpec 列表 """ zone = self.zones.get(zone_id) if not zone: logger.warning(f"未找到区域 {zone_id},跳过结构体 {item.item_id}") return [] return self._expand_item( item_id=item.item_id, name=item.name, size=item.size, offset=item.offset, bsp_suggestion=item.bsp_suggestion, tag_color=item.tag_color, zone_id=zone_id, zone=zone, category="STR", note="", ) def expand_prop(self, item, zone_id: str) -> list[ActorSpec]: """展开布景物条目。""" zone = self.zones.get(zone_id) if not zone: logger.warning(f"未找到区域 {zone_id},跳过布景 {item.item_id}") return [] return self._expand_item( item_id=item.item_id, name=item.name, size=item.size, offset=item.offset, bsp_suggestion=item.bsp_suggestion, tag_color="", zone_id=zone_id, zone=zone, category="PROP", note=item.note, ) def expand_global(self, item) -> list[ActorSpec]: """展开全局物件条目。全局物件使用绝对坐标,不需要区域原点。""" parts = parse_bsp_suggestion(item.bsp_suggestion) if not parts: # 尝试 substitution_rules sub = SUBSTITUTION_RULES.get(item.name) if sub: parts = [{"key": sub["asset"], "count": item.count, "scale": sub.get("scale")}] else: parts = [{"key": "Cube", "count": item.count, "scale": None}] actors = [] for i in range(item.count): for part in parts: asset_key = part["key"] if asset_key == "_composite": continue actor_name = f"LVL_Global_PROP_{self._slugify(item.name)}" if item.count > 1: actor_name += f"_{i + 1:02d}" elif len(parts) > 1: actor_name += f"_{parts.index(part) + 1:02d}" scale = part.get("scale") if scale is None: scale = (1.0, 1.0, 1.0) asset_path = MESH_ASSETS.get(asset_key, {}).get("path", "") material = "" mat_path = "" actors.append(ActorSpec( actor_name=actor_name, asset_key=asset_key, asset_path=asset_path, location=item.location, rotation=(0.0, 0.0, 0.0), scale=scale, material=material, material_path=mat_path, category="PROP", zone_id="GLOBAL", folder=self._get_folder("GLOBAL", "PROP"), note=item.note, )) return actors def _expand_item(self, item_id: str, name: str, size: tuple, offset: tuple, bsp_suggestion: str, tag_color: str, zone_id: str, zone, category: str, note: str) -> list[ActorSpec]: """通用展开逻辑。""" parts = parse_bsp_suggestion(bsp_suggestion) if not parts: # 尝试 substitution_rules sub = SUBSTITUTION_RULES.get(name) if sub: parts = [{"key": sub["asset"], "count": 1, "scale": sub.get("scale")}] if not tag_color and sub.get("material"): tag_color = sub["material"] else: parts = [{"key": "Cube", "count": 1, "scale": None}] actors = [] # 跟踪资产段的累积偏移(用于 × N 的资产沿主轴排列) cum_offset_x = 0.0 for part in parts: asset_key = part["key"] count = part.get("count", 1) part_scale = part.get("scale") for idx in range(count): # 计算绝对位置 abs_x = zone.origin[0] + offset[0] + cum_offset_x abs_y = zone.origin[1] + offset[1] abs_z = zone.origin[2] + offset[2] # 确定缩放 if part_scale: scale = part_scale else: scale = (1.0, 1.0, 1.0) # 确定旋转 rotation = infer_rotation_from_name(name) # 确定资产路径 asset_info = MESH_ASSETS.get(asset_key, {}) asset_path = asset_info.get("path", "") # 确定材质 material = tag_color mat_path = MATERIAL_PATHS.get(material, "") if material else "" # 生成 Actor 名称 actor_name = self._make_actor_name(zone_id, category, name, len(actors) + 1) actors.append(ActorSpec( actor_name=actor_name, asset_key=asset_key, asset_path=asset_path, location=(abs_x, abs_y, abs_z), rotation=rotation, scale=scale, material=material, material_path=mat_path, category=category, zone_id=zone_id, folder=self._get_folder(zone_id, category), note=note, )) # 累积偏移:沿资产主轴方向排列 if asset_info: default_size = asset_info.get("default_size", (0, 0, 0)) if default_size and default_size[0] > 0: cum_offset_x += default_size[0] * scale[0] elif size and size[0] > 0 and count > 1: cum_offset_x += size[0] / count return actors def _make_actor_name(self, zone_id: str, category: str, name: str, seq: int) -> str: """生成 Actor 名称:LVL____""" slug = self._slugify(name) return f"LVL_{zone_id}_{category}_{slug}_{seq:02d}" def _slugify(self, name: str) -> str: """将中文名称转为 Actor 名称用的英文缩写。""" # 中文方位→英文 _DIR_MAP = {"南": "South", "北": "North", "东": "East", "西": "West", "左": "Left", "右": "Right", "上": "Top", "下": "Bottom"} _TYPE_MAP = { "墙": "Wall", "围": "Wall", "地": "Floor", "顶": "Roof", "门": "Door", "窗": "Window", "栏": "Rail", "柱": "Pillar", "梁": "Beam", "台": "Counter", "架": "Shelf", "柜": "Cabinet", "桌": "Table", "椅": "Chair", "床": "Bed", "箱": "Box", "树": "Tree", "灯": "Lamp", "车": "Car", "桶": "Barrel", } result = "" for ch in name: if ch in _DIR_MAP: result += _DIR_MAP[ch] elif ch in _TYPE_MAP: result += _TYPE_MAP[ch] elif ch.isalnum(): result += ch # 跳过其他中文字符 return result if result else "Item" def _get_folder(self, zone_id: str, category: str) -> str: """获取 Outliner 文件夹路径。""" try: from config.zone_config import get_folder_path return get_folder_path(zone_id, category) except ImportError: zone = self.zones.get(zone_id) zone_name = zone.zone_name if zone else "Unknown" return f"LVL_Level/{zone_id}_{zone_name}/{category}" def _next_seq(self, zone_id: str, category: str) -> int: """获取下一个序号。""" key = f"{zone_id}_{category}" if key not in self.actor_counters: self.actor_counters[key] = 0 self.actor_counters[key] += 1 return self.actor_counters[key]