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
@@ -0,0 +1,260 @@
"""组合资产清单 + 模板生成器。
将执行案中的组合资产标记展开为:
1. 组合资产清单 Markdown(供用户审查)
2. 组合资产模板 JSON(确认后回馈到 asset-catalog 模板库)
组合资产 = 无法用单个 BSP StaticMesh 匹配的物件,需要多个 Primitives 拼搭。
"""
import json
import re
import logging
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from execution_parser import ExecutionPlanData, CompositeItem
logger = logging.getLogger("execution_to_guide.composite")
# ============================================================
# 数据结构
# ============================================================
@dataclass
class CompositePart:
"""组合资产的一个构件。"""
primitive: str # Cube / Cylinder / CirclePlatform
scale: tuple # (X, Y, Z)
offset: tuple # (X, Y, Z) 相对组合资产原点 cm
material: str # MI_DevXxx
@dataclass
class CompositeTemplate:
"""组合资产模板(确认后回馈到 asset-catalog)。"""
template_name: str # 英文标识 OldRadio
display_name: str # 中文名 旧收音机
source_level: str # 来源版本 v02
parts: list # list[CompositePart]
total_bounds: dict # {"size": [W, D, H]}
tags: list = field(default_factory=list) # ["prop", "electronics"]
# ============================================================
# 拼搭方案解析
# ============================================================
def parse_build_plan(plan_text: str, target_size: tuple) -> list[CompositePart]:
"""解析拼搭方案字符串为构件列表。
支持的格式:
- "Cube(0.2,0.15,0.12) + Cylinder天线"
- "Cube主体 + Cylinder(0.02,0.02,0.15)天线 offset(0.05,0,0.12)"
如果方案不够具体,生成一个默认的 Cube 占位。
"""
parts = []
if not plan_text:
# 默认:单个 Cube
w, d, h = target_size if target_size != (0, 0, 0) else (20, 20, 20)
parts.append(CompositePart(
primitive="Cube",
scale=(w / 100, d / 100, h / 100),
offset=(0, 0, 0),
material="MI_DevBrown",
))
return parts
# 按 + 分割
segments = re.split(r'\s*\+\s*', plan_text)
for seg in segments:
seg = seg.strip()
if not seg:
continue
# 提取 Primitives 类型
prim_match = re.match(r'(Cube|Cylinder|CirclePlatform)', seg)
prim = prim_match.group(1) if prim_match else "Cube"
remaining = seg[prim_match.end():] if prim_match else seg
# 提取缩放
scale_match = re.search(r'\(([\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:
scale = (0.1, 0.1, 0.1)
# 提取偏移
offset_match = re.search(r'offset\s*\(([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\)', remaining, re.IGNORECASE)
offset = (float(offset_match.group(1)), float(offset_match.group(2)), float(offset_match.group(3))) if offset_match else (0, 0, 0)
# 确定材质
material = "MI_DevBrown"
if "天线" in seg:
material = "MI_DevGray"
elif "玻璃" in seg or "镜" in seg:
material = "MI_DevGlassWhite"
elif "绿" in seg:
material = "MI_DevGreen"
elif "红" in seg:
material = "MI_DevRed"
parts.append(CompositePart(
primitive=prim,
scale=scale,
offset=offset,
material=material,
))
return parts if parts else [CompositePart(
primitive="Cube",
scale=(0.1, 0.1, 0.1),
offset=(0, 0, 0),
material="MI_DevBrown",
)]
# ============================================================
# 组合资产文档生成
# ============================================================
class CompositeWriter:
"""组合资产清单 + 模板生成器。"""
def __init__(self, plan_data: ExecutionPlanData):
self.plan = plan_data
self.templates: list[CompositeTemplate] = []
def has_composites(self) -> bool:
"""是否有组合资产。"""
return sum(len(v) for v in self.plan.zone_composites.values()) > 0
def generate_markdown(self) -> str:
"""生成组合资产清单 Markdown。"""
lines = [
f"# v{self.plan.version} 组合资产清单 — {self.plan.scene_name}",
"",
"> 本文档列出了执行案中需要用 Primitives 拼搭的组合资产。",
"> 请审查每个拼搭方案,确认后可回馈到 asset-catalog 模板库。",
"",
"---",
"",
]
for zone_id, composites in self.plan.zone_composites.items():
if not composites:
continue
zone_name = self._get_zone_name(zone_id)
lines.append(f"## {zone_id} {zone_name}")
lines.append("")
for comp in composites:
lines.append(f"### {comp.item_id} {comp.name}")
lines.append("")
lines.append(f"**期望尺寸**: {comp.target_size[0]:.0f}×{comp.target_size[1]:.0f}×{comp.target_size[2]:.0f} cm")
lines.append(f"**说明**: {comp.note}")
lines.append("")
# 解析拼搭方案
parts = parse_build_plan(comp.build_plan, comp.target_size)
lines.append("| 构件 | Primitive | 缩放 (X,Y,Z) | 偏移 (X,Y,Z) | 材质 |")
lines.append("|------|-----------|-------------|-------------|------|")
for i, part in enumerate(parts):
lines.append(
f"| 构件{i + 1} | {part.primitive} | "
f"({part.scale[0]:.3f},{part.scale[1]:.3f},{part.scale[2]:.3f}) | "
f"({part.offset[0]:.1f},{part.offset[1]:.1f},{part.offset[2]:.1f}) | "
f"{part.material} |"
)
lines.append("")
# 生成模板
template = self._make_template(comp, parts)
self.templates.append(template)
return "\n".join(lines)
def generate_templates_json(self) -> str:
"""生成组合资产模板 JSON(回馈到 asset-catalog)。"""
data = {
"version": "1.0",
"source_level": self.plan.version,
"composite_templates": [asdict(t) for t in self.templates],
}
# 将 tuple 转为 list 以便 JSON 序列化
def convert_tuples(obj):
if isinstance(obj, dict):
return {k: convert_tuples(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_tuples(i) for i in obj]
elif isinstance(obj, tuple):
return list(obj)
return obj
data = convert_tuples(data)
return json.dumps(data, indent=2, ensure_ascii=False)
def write_to_files(self, md_path: str, json_path: str = None):
"""写入 Markdown 和 JSON 文件。"""
# Markdown
md_content = self.generate_markdown()
md = Path(md_path)
md.parent.mkdir(parents=True, exist_ok=True)
md.write_text(md_content, encoding="utf-8")
logger.info(f"组合资产清单已写入: {md_path}")
# JSON(可选)
if json_path:
json_content = self.generate_templates_json()
jp = Path(json_path)
jp.parent.mkdir(parents=True, exist_ok=True)
jp.write_text(json_content, encoding="utf-8")
logger.info(f"组合资产模板已写入: {json_path}")
def _make_template(self, comp: CompositeItem, parts: list[CompositePart]) -> CompositeTemplate:
"""从组合资产条目生成模板。"""
# 生成英文标识
template_name = self._slugify(comp.name)
# 计算总包围盒
max_x = max(p.offset[0] + p.scale[0] * 100 for p in parts) if parts else 0
max_y = max(p.offset[1] + p.scale[1] * 100 for p in parts) if parts else 0
max_z = max(p.offset[2] + p.scale[2] * 100 for p in parts) if parts else 0
return CompositeTemplate(
template_name=template_name,
display_name=comp.name,
source_level=self.plan.version,
parts=[asdict(p) for p in parts],
total_bounds={"size": [max_x, max_y, max_z]},
tags=["prop", "composite"],
)
def _get_zone_name(self, zone_id: str) -> str:
for z in self.plan.zones:
if z.zone_id == zone_id:
return z.zone_name
return ""
def _slugify(self, name: str) -> str:
"""将中文名称转为英文标识。"""
# 简单映射
_MAP = {
"旧": "Old", "新": "New", "大": "Big", "小": "Small",
"收音机": "Radio", "电视": "TV", "冰箱": "Fridge",
"空调": "AC", "电话": "Phone", "自行车": "Bicycle",
"摩托": "Motorcycle", "柜": "Cabinet", "桌": "Table",
"椅": "Chair", "灯": "Lamp", "招牌": "Sign",
}
result = ""
for key, val in sorted(_MAP.items(), key=lambda x: -len(x[0])):
if key in name:
result += val
return result if result else "Composite"