feat(tools): 新增execution-to-guide执行案转搭建指南工具

- execution_parser.py: 解析关卡执行案Markdown提取区域/结构体/布景/边界/光照
- asset_expander.py: BSP资产建议展开+绝对坐标计算+旋转推断
- guide_writer.py: 生成精确匹配build_guide_parser格式的搭建指南
- composite_writer.py: 组合资产清单+JSON模板生成
- converter.py: 主入口CLI工具
- 更新level-execution-template.md: 移除交互物/收集品章节,新增组合资产标记

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:44:46 +08:00
parent f86ffb511e
commit 6f8b599629
9 changed files with 2731 additions and 29 deletions
+423
View File
@@ -0,0 +1,423 @@
"""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_<ZoneID>_<CAT>_<Slug>_<Seq>"""
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]