6f8b599629
- 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>
261 lines
9.4 KiB
Python
261 lines
9.4 KiB
Python
"""组合资产清单 + 模板生成器。
|
||
|
||
将执行案中的组合资产标记展开为:
|
||
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"
|