Files
ChildHood/tools/execution-to-guide/composite_writer.py
T
starmen1265 6f8b599629 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>
2026-06-11 00:44:46 +08:00

261 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合资产清单 + 模板生成器。
将执行案中的组合资产标记展开为:
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"