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:
@@ -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"
|
||||
Reference in New Issue
Block a user