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