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>
597 lines
22 KiB
Python
597 lines
22 KiB
Python
"""搭建指南 Markdown 生成器。
|
||
|
||
将 ActorSpec 列表转换为搭建指南 Markdown 文档。
|
||
输出格式精确匹配 build_guide_parser.py 的解析预期。
|
||
|
||
搭建指南结构(8章):
|
||
〇、全局信息(场景参数、路径前缀、命名规范、Outliner 结构)
|
||
一、BSP 资产映射表
|
||
二、Phase B 地面与边界
|
||
三、Phase C 逐区域搭建
|
||
四、Phase C 全局物件
|
||
五、Phase D 光照
|
||
六、Phase E 验证清单
|
||
"""
|
||
|
||
import logging
|
||
from dataclasses import asdict
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from execution_parser import (
|
||
ExecutionPlanData, ZoneInfo, StructureItem, PropItem,
|
||
CompositeItem, GlobalObject, BoundaryItem, LightItem,
|
||
)
|
||
from asset_expander import AssetExpander, ActorSpec
|
||
|
||
logger = logging.getLogger("execution_to_guide.writer")
|
||
|
||
|
||
# ============================================================
|
||
# 搭建指南生成器
|
||
# ============================================================
|
||
|
||
class GuideWriter:
|
||
"""搭建指南 Markdown 生成器。"""
|
||
|
||
def __init__(self, plan_data: ExecutionPlanData):
|
||
"""
|
||
Args:
|
||
plan_data: 解析后的执行案数据
|
||
"""
|
||
self.plan = plan_data
|
||
self.expander = AssetExpander(plan_data.zones)
|
||
self.all_actors: list[ActorSpec] = []
|
||
self.asset_refs: dict[str, dict] = {} # 用到的资产 key → info
|
||
|
||
def generate(self) -> str:
|
||
"""生成完整的搭建指南 Markdown。"""
|
||
sections = []
|
||
|
||
# 〇、全局信息
|
||
sections.append(self._write_global_info())
|
||
|
||
# 一、BSP 资产映射表
|
||
sections.append(self._write_asset_mapping())
|
||
|
||
# 二、Phase B — 地面与边界
|
||
sections.append(self._write_ground_and_boundary())
|
||
|
||
# 三、Phase C — 逐区域搭建
|
||
sections.append(self._write_zone_builds())
|
||
|
||
# 四、Phase C — 全局物件
|
||
sections.append(self._write_global_objects())
|
||
|
||
# 五、Phase D — 光照
|
||
sections.append(self._write_lighting())
|
||
|
||
# 六、Phase E — 验证清单
|
||
sections.append(self._write_checklist())
|
||
|
||
return "\n\n".join(sections)
|
||
|
||
def write_to_file(self, filepath: str):
|
||
"""生成并写入文件。"""
|
||
content = self.generate()
|
||
path = Path(filepath)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(content, encoding="utf-8")
|
||
logger.info(f"搭建指南已写入: {filepath}")
|
||
|
||
# ----------------------------------------------------------
|
||
# 〇、全局信息
|
||
# ----------------------------------------------------------
|
||
def _write_global_info(self) -> str:
|
||
"""生成全局信息章节。"""
|
||
w, d = self.plan.scene_size
|
||
lines = [
|
||
f"# v{self.plan.version} 白模搭建操作指南 — {self.plan.scene_name}",
|
||
"",
|
||
f"> 本文档由 execution-to-guide 工具自动生成,供 level-builder 搭建白模关卡使用。",
|
||
f"> 基于: 关卡-v{self.plan.version}-{self.plan.scene_name}-执行案.md",
|
||
"",
|
||
"---",
|
||
"",
|
||
"## 〇、全局信息",
|
||
"",
|
||
"### 场景参数",
|
||
"",
|
||
"| 参数 | 值 |",
|
||
"|------|-----|",
|
||
f"| 场景总尺寸 | {w}cm × {d}cm({w // 100}m × {d // 100}m) |",
|
||
f"| 地面高度基准 | Z = {int(self.plan.ground_z)} |",
|
||
f"| 主角身高 | ~{int(self.plan.character_height)}cm |",
|
||
f"| 摄像机高度 | ~{int(self.plan.camera_height)}cm |",
|
||
f"| 时间设定 | {self.plan.time_setting} |",
|
||
"| 坐标原点 | 场景西南角地平面 |",
|
||
"",
|
||
"### UE5 路径前缀",
|
||
"",
|
||
"- **Mesh**: `Content/BlockingStarterPack/Meshes/`",
|
||
"- **Material**: `Content/BlockingStarterPack/Materials/`",
|
||
"- **Material (Basics)**: `Content/BlockingStarterPack/Materials/Blocking/Basics/`",
|
||
"",
|
||
"### 命名规范",
|
||
"",
|
||
"```",
|
||
"LVL_<ZoneID>_<Category>_<Description>",
|
||
"Category: STR | PROP | COMP | BND | LGT",
|
||
"```",
|
||
"",
|
||
"### Outliner 文件夹结构",
|
||
"",
|
||
"```",
|
||
f"LVL_{self._level_slug()}/",
|
||
]
|
||
|
||
for zone in self.plan.zones:
|
||
zone_slug = f"{zone.zone_id}_{zone.zone_name}"
|
||
lines.append(f" {zone_slug}/")
|
||
lines.append(" Structure/ Props/")
|
||
|
||
lines.append(" Global/")
|
||
lines.append(" Boundaries/ Lighting/ PlayerStart/")
|
||
lines.append("```")
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 一、BSP 资产映射表
|
||
# ----------------------------------------------------------
|
||
def _write_asset_mapping(self) -> str:
|
||
"""收集用到的所有资产,生成映射表。"""
|
||
# 先展开所有 Actor 以收集资产引用
|
||
self._collect_all_actors()
|
||
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 一、BSP 资产映射表",
|
||
"",
|
||
]
|
||
|
||
# 按类别分组
|
||
categories = {}
|
||
for key, info in self.asset_refs.items():
|
||
cat = info.get("category", "other")
|
||
if cat not in categories:
|
||
categories[cat] = []
|
||
categories[cat].append((key, info))
|
||
|
||
# 类别标题映射
|
||
cat_titles = {
|
||
"wall": "建筑 — 墙体",
|
||
"wall_door": "建筑 — 门墙",
|
||
"wall_window": "建筑 — 窗墙",
|
||
"floor": "建筑 — 地板",
|
||
"door": "建筑 — 门",
|
||
"window": "建筑 — 窗户",
|
||
"barrier": "建筑 — 围挡",
|
||
"fence": "建筑 — 围栏",
|
||
"beam": "建筑 — 横梁",
|
||
"building": "建筑 — 大型建筑",
|
||
"furniture": "家具",
|
||
"industrial": "工业/装饰",
|
||
"vehicle": "载具",
|
||
"primitive": "基础体",
|
||
"vegetation": "植被",
|
||
"light_fixture": "灯具",
|
||
"decoration": "装饰",
|
||
"other": "其他",
|
||
}
|
||
|
||
for cat_key in ["wall", "wall_door", "wall_window", "floor", "door", "window",
|
||
"barrier", "fence", "beam", "building", "furniture",
|
||
"industrial", "vehicle", "vegetation", "light_fixture",
|
||
"decoration", "other"]:
|
||
if cat_key not in categories:
|
||
continue
|
||
items = categories[cat_key]
|
||
title = cat_titles.get(cat_key, cat_key)
|
||
|
||
lines.append(f"### {title}")
|
||
lines.append("")
|
||
lines.append("| 设计文档引用 | UE5 资产路径 | 实际尺寸 (cm) |")
|
||
lines.append("|-------------|-------------|--------------|")
|
||
|
||
for key, info in items:
|
||
path_short = info.get("path", "")
|
||
# 缩短路径显示
|
||
if path_short.startswith("/Game/BlockingStarterPack/Meshes/"):
|
||
path_short = path_short.replace("/Game/BlockingStarterPack/Meshes/", "")
|
||
path_short = path_short.split(".")[0] # 去掉 .SM_xxx 后缀
|
||
|
||
bounds = info.get("actual_bounds", ((0, 0, 0), (0, 0, 0)))
|
||
size_str = self._format_bounds_size(bounds)
|
||
|
||
lines.append(f"| {key} | `{path_short}` | {size_str} |")
|
||
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 二、Phase B — 地面与边界
|
||
# ----------------------------------------------------------
|
||
def _write_ground_and_boundary(self) -> str:
|
||
"""生成地面铺设和边界围挡。"""
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 二、Phase B — 地面与边界",
|
||
"",
|
||
"### B1. 铺设地面",
|
||
"",
|
||
]
|
||
|
||
# 全局地面
|
||
w, d = self.plan.scene_size
|
||
lines.append("#### 全局地面(MI_DevGray)")
|
||
lines.append("")
|
||
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 |")
|
||
lines.append("|---|-----------|------|-------------|------|------|")
|
||
|
||
# 用 Floor_1000x1000 铺满
|
||
tiles_x = max(1, w // 1000)
|
||
tiles_y = max(1, d // 1000)
|
||
seq = 1
|
||
for tx in range(tiles_x):
|
||
for ty in range(tiles_y):
|
||
cx = tx * 1000 + 500
|
||
cy = ty * 1000 + 500
|
||
lines.append(
|
||
f"| {seq} | LVL_Global_STR_GroundFloor_{seq:02d} | Floor_1000x1000 | "
|
||
f"({cx},{cy},{int(self.plan.ground_z)}) | — | MI_DevGray |"
|
||
)
|
||
seq += 1
|
||
|
||
lines.append("")
|
||
|
||
# 边界围挡
|
||
lines.append("### B2. 放置边界围挡")
|
||
lines.append("")
|
||
|
||
for boundary in self.plan.boundaries:
|
||
seg = boundary.segment
|
||
lines.append(f"#### {seg}")
|
||
lines.append("")
|
||
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 旋转 (P,Y,R) | 缩放 | 材质 |")
|
||
lines.append("|---|-----------|------|-------------|-------------|------|------|")
|
||
|
||
# 根据边界方向生成围挡
|
||
bnd_actors = self._generate_boundary_actors(boundary)
|
||
for i, actor in enumerate(bnd_actors):
|
||
rot_str = self._fmt_rotation(actor.rotation)
|
||
scale_str = self._fmt_scale(actor.scale)
|
||
lines.append(
|
||
f"| {i + 1} | {actor.actor_name} | {actor.asset_key} | "
|
||
f"{self._fmt_location(actor.location)} | {rot_str} | {scale_str} | "
|
||
f"{actor.material} |"
|
||
)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
def _generate_boundary_actors(self, boundary: BoundaryItem) -> list[ActorSpec]:
|
||
"""根据边界描述生成围挡 Actor。"""
|
||
w, d = self.plan.scene_size
|
||
actors = []
|
||
|
||
# 确定边界的起止坐标和方向
|
||
if "西" in boundary.segment:
|
||
# X=0, 沿 Y 轴
|
||
x, y_start, y_end = 0, 0, d
|
||
yaw = 90.0
|
||
elif "东" in boundary.segment:
|
||
x, y_start, y_end = w, 0, d
|
||
yaw = 90.0
|
||
elif "南" in boundary.segment:
|
||
x_start, x_end, y = 0, w, 0
|
||
yaw = 0.0
|
||
elif "北" in boundary.segment:
|
||
x_start, x_end, y = 0, w, d
|
||
yaw = 0.0
|
||
else:
|
||
return []
|
||
|
||
# 使用 BarrierWall_600 或 Wall_400x300
|
||
asset_key = "BarrierWall_600"
|
||
wall_len = 600 # cm
|
||
|
||
seq = 1
|
||
if "西" in boundary.segment or "东" in boundary.segment:
|
||
length = y_end - y_start
|
||
count = max(1, int(length / wall_len))
|
||
for i in range(count):
|
||
y_pos = y_start + i * wall_len + wall_len // 2
|
||
z_pos = 105 # 半高
|
||
actors.append(ActorSpec(
|
||
actor_name=f"LVL_Global_BND_{boundary.segment}_{seq:02d}",
|
||
asset_key=asset_key,
|
||
asset_path="",
|
||
location=(x, y_pos, z_pos),
|
||
rotation=(0.0, yaw, 0.0),
|
||
scale=(1.0, 1.0, 1.0),
|
||
material=boundary.tag_color or "MI_DevBrown",
|
||
material_path="",
|
||
category="BND",
|
||
zone_id="GLOBAL",
|
||
folder="LVL_Level/Global/Boundaries",
|
||
))
|
||
seq += 1
|
||
else:
|
||
length = x_end - x_start
|
||
count = max(1, int(length / wall_len))
|
||
for i in range(count):
|
||
x_pos = x_start + i * wall_len + wall_len // 2
|
||
y_pos = y
|
||
z_pos = 105
|
||
actors.append(ActorSpec(
|
||
actor_name=f"LVL_Global_BND_{boundary.segment}_{seq:02d}",
|
||
asset_key=asset_key,
|
||
asset_path="",
|
||
location=(x_pos, y_pos, z_pos),
|
||
rotation=(0.0, yaw, 0.0),
|
||
scale=(1.0, 1.0, 1.0),
|
||
material=boundary.tag_color or "MI_DevBrown",
|
||
material_path="",
|
||
category="BND",
|
||
zone_id="GLOBAL",
|
||
folder="LVL_Level/Global/Boundaries",
|
||
))
|
||
seq += 1
|
||
|
||
return actors
|
||
|
||
# ----------------------------------------------------------
|
||
# 三、Phase C — 逐区域搭建
|
||
# ----------------------------------------------------------
|
||
def _write_zone_builds(self) -> str:
|
||
"""生成逐区域搭建章节。"""
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 三、Phase C — 逐区域搭建",
|
||
"",
|
||
]
|
||
|
||
for idx, zone in enumerate(self.plan.zones, 1):
|
||
lines.append(f"### C{idx}. {zone.zone_id} — {zone.zone_name}")
|
||
lines.append("")
|
||
|
||
# 结构体
|
||
structures = self.plan.zone_structures.get(zone.zone_id, [])
|
||
if structures:
|
||
lines.append(f"#### C{idx}.1 结构体(Structure)")
|
||
lines.append("")
|
||
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 旋转 (P,Y,R) | 缩放 | 材质 |")
|
||
lines.append("|---|-----------|------|-------------|-------------|------|------|")
|
||
|
||
for item in structures:
|
||
actors = self.expander.expand_structure(item, zone.zone_id)
|
||
for actor in actors:
|
||
rot_str = self._fmt_rotation(actor.rotation)
|
||
scale_str = self._fmt_scale(actor.scale)
|
||
lines.append(
|
||
f"| | {actor.actor_name} | {actor.asset_key} | "
|
||
f"{self._fmt_location(actor.location)} | {rot_str} | {scale_str} | "
|
||
f"{actor.material} |"
|
||
)
|
||
self._track_asset(actor)
|
||
lines.append("")
|
||
|
||
# 布景
|
||
props = self.plan.zone_props.get(zone.zone_id, [])
|
||
if props:
|
||
lines.append(f"#### C{idx}.2 布景物(Props)")
|
||
lines.append("")
|
||
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
|
||
lines.append("|---|-----------|------|-------------|------|------|------|")
|
||
|
||
for item in props:
|
||
actors = self.expander.expand_prop(item, zone.zone_id)
|
||
for actor in actors:
|
||
scale_str = self._fmt_scale(actor.scale)
|
||
lines.append(
|
||
f"| | {actor.actor_name} | {actor.asset_key} | "
|
||
f"{self._fmt_location(actor.location)} | {scale_str} | "
|
||
f"{actor.material} | {actor.note} |"
|
||
)
|
||
self._track_asset(actor)
|
||
lines.append("")
|
||
|
||
# 组合资产(如有)
|
||
composites = self.plan.zone_composites.get(zone.zone_id, [])
|
||
if composites:
|
||
lines.append(f"#### C{idx}.3 组合资产(Composite)")
|
||
lines.append("")
|
||
lines.append("| # | Actor 名称 | Primitives | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
|
||
lines.append("|---|-----------|-----------|-------------|------|------|------|")
|
||
for comp in composites:
|
||
lines.append(
|
||
f"| | ⚠️ {comp.name} | {comp.build_plan} | "
|
||
f"(待展开) | — | — | {comp.note} |"
|
||
)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 四、全局物件
|
||
# ----------------------------------------------------------
|
||
def _write_global_objects(self) -> str:
|
||
"""生成全局物件章节。"""
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 四、Phase C — 全局物件",
|
||
"",
|
||
]
|
||
|
||
if not self.plan.global_objects:
|
||
lines.append("> 无全局物件。")
|
||
return "\n".join(lines)
|
||
|
||
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
|
||
lines.append("|---|-----------|------|-------------|------|------|------|")
|
||
|
||
for item in self.plan.global_objects:
|
||
actors = self.expander.expand_global(item)
|
||
for actor in actors:
|
||
scale_str = self._fmt_scale(actor.scale)
|
||
lines.append(
|
||
f"| | {actor.actor_name} | {actor.asset_key} | "
|
||
f"{self._fmt_location(actor.location)} | {scale_str} | "
|
||
f"{actor.material} | {actor.note} |"
|
||
)
|
||
self._track_asset(actor)
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 五、光照
|
||
# ----------------------------------------------------------
|
||
def _write_lighting(self) -> str:
|
||
"""生成光照章节。"""
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 五、Phase D — 光照",
|
||
"",
|
||
]
|
||
|
||
for light in self.plan.lights:
|
||
if "定向" in light.light_type or "Directional" in light.light_type:
|
||
lines.append("### D1. 定向光(Directional Light)")
|
||
lines.append("")
|
||
lines.append("| 类型 | 位置/方向 | 参数 |")
|
||
lines.append("|------|---------|------|")
|
||
lines.append(f"| Directional Light | {light.position} | {light.params} |")
|
||
lines.append(f"> {light.note}")
|
||
lines.append("")
|
||
|
||
elif "天空" in light.light_type or "Sky" in light.light_type:
|
||
lines.append("### D2. 天空光(Sky Light)")
|
||
lines.append("")
|
||
lines.append("| 类型 | 位置/方向 | 参数 |")
|
||
lines.append("|------|---------|------|")
|
||
lines.append(f"| Sky Light | 全局 | {light.params} |")
|
||
lines.append(f"> {light.note}")
|
||
lines.append("")
|
||
|
||
elif "路灯" in light.light_type or "点光" in light.light_type:
|
||
lines.append("### D3. 路灯点光源")
|
||
lines.append("")
|
||
lines.append(f"> {light.note},数量: {light.count}")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 六、验证清单
|
||
# ----------------------------------------------------------
|
||
def _write_checklist(self) -> str:
|
||
"""生成验证清单章节。"""
|
||
total = len(self.all_actors)
|
||
zones = len(self.plan.zones)
|
||
|
||
lines = [
|
||
"---",
|
||
"",
|
||
"## 六、Phase E — 验证清单",
|
||
"",
|
||
"| # | 检查项 | 状态 |",
|
||
"|---|--------|------|",
|
||
f"| 1 | 场景尺寸 {self.plan.scene_size[0]}×{self.plan.scene_size[1]}cm 正确 | ⬜ |",
|
||
f"| 2 | {zones} 个区域均已搭建 | ⬜ |",
|
||
"| 3 | 所有边界围挡无缺口 | ⬜ |",
|
||
"| 4 | 玩家起点可到达 | ⬜ |",
|
||
"| 5 | 所有区域可通行 | ⬜ |",
|
||
"| 6 | 建筑入口与流线对齐 | ⬜ |",
|
||
"| 7 | 光照方向与场景一致 | ⬜ |",
|
||
f"| 8 | Actor 总数: {total} | ⬜ |",
|
||
"",
|
||
"---",
|
||
"",
|
||
f"> 搭建指南生成完成。共 {zones} 个区域,{total} 个 Actor。",
|
||
]
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ----------------------------------------------------------
|
||
# 辅助方法
|
||
# ----------------------------------------------------------
|
||
def _collect_all_actors(self):
|
||
"""收集所有区域的 Actor(用于资产映射表)。"""
|
||
if self.all_actors:
|
||
return # 已收集
|
||
|
||
# 结构体
|
||
for zone_id, items in self.plan.zone_structures.items():
|
||
for item in items:
|
||
actors = self.expander.expand_structure(item, zone_id)
|
||
self.all_actors.extend(actors)
|
||
for a in actors:
|
||
self._track_asset(a)
|
||
|
||
# 布景
|
||
for zone_id, items in self.plan.zone_props.items():
|
||
for item in items:
|
||
actors = self.expander.expand_prop(item, zone_id)
|
||
self.all_actors.extend(actors)
|
||
for a in actors:
|
||
self._track_asset(a)
|
||
|
||
# 全局物件
|
||
for item in self.plan.global_objects:
|
||
actors = self.expander.expand_global(item)
|
||
self.all_actors.extend(actors)
|
||
for a in actors:
|
||
self._track_asset(a)
|
||
|
||
def _track_asset(self, actor: ActorSpec):
|
||
"""跟踪用到的资产。"""
|
||
if actor.asset_key and actor.asset_key not in self.asset_refs:
|
||
try:
|
||
from config.asset_mapping import MESH_ASSETS
|
||
info = MESH_ASSETS.get(actor.asset_key, {})
|
||
except ImportError:
|
||
info = {}
|
||
self.asset_refs[actor.asset_key] = info
|
||
|
||
def _level_slug(self) -> str:
|
||
"""生成关卡标识符。"""
|
||
name = self.plan.scene_name.replace(" ", "")
|
||
return name
|
||
|
||
@staticmethod
|
||
def _fmt_location(loc: tuple) -> str:
|
||
"""格式化位置。"""
|
||
return f"({int(loc[0])},{int(loc[1])},{int(loc[2])})"
|
||
|
||
@staticmethod
|
||
def _fmt_rotation(rot: tuple) -> str:
|
||
"""格式化旋转。"""
|
||
if rot == (0, 0, 0):
|
||
return "—"
|
||
return f"({int(rot[0])},{int(rot[1])},{int(rot[2])})"
|
||
|
||
@staticmethod
|
||
def _fmt_scale(scale: tuple) -> str:
|
||
"""格式化缩放。"""
|
||
if scale == (1, 1, 1):
|
||
return "—"
|
||
return f"({scale[0]:.2f},{scale[1]:.2f},{scale[2]:.2f})"
|
||
|
||
@staticmethod
|
||
def _format_bounds_size(bounds) -> str:
|
||
"""格式化包围盒尺寸。"""
|
||
if not bounds or len(bounds) < 2:
|
||
return "—"
|
||
mn, mx = bounds[0], bounds[1]
|
||
w = mx[0] - mn[0] if len(mx) > 0 else 0
|
||
d = mx[1] - mn[1] if len(mx) > 1 else 0
|
||
h = mx[2] - mn[2] if len(mx) > 2 else 0
|
||
return f"{int(w)}×{int(d)}×{int(h)}"
|