diff --git a/addons/spx_tilemap_exporter/README.md b/addons/spx_tilemap_exporter/README.md new file mode 100644 index 000000000000..8300eb3dbc1a --- /dev/null +++ b/addons/spx_tilemap_exporter/README.md @@ -0,0 +1,206 @@ +# SPX TileMap Exporter + +A Godot editor plugin for exporting TileMap scenes and decorators to SPX JSON format for dynamic loading in the SPX runtime. + +## Quick Start + +```bash +# 1. Copy the plugin to your project's addons directory +cp -r spx_tilemap_exporter /path/to/your/project/addons/ + +# 2. Navigate to your project directory +cd /path/to/your/project + +# 3. Export the scene +python addons/spx_tilemap_exporter/export.py --godot /path/to/godot --scene main.tscn + +# Export files are saved in the `res://_export//` directory +``` + +## Features + +- **TileMap Export**: Export TileMapLayer, TileSet, and physics collision data +- **Decorator Export**: Export Sprite2D nodes and prefab instances from scenes +- **Preview PNG Export**: Export TileMapLayer content as a PNG preview image (Ctrl+Shift+E shortcut) +- **Automatic Texture Copy**: Automatically copy related textures to the export directory +- **Coordinate System Conversion**: Automatically convert Godot coordinates to SPX coordinates (Y-axis flip) +- **Collision Shape Support**: Support for exporting rectangle, circle, capsule, and polygon colliders +- **CLI Support**: Provides CLI scripts for batch automation exports + +## Installation + +1. Copy the `spx_tilemap_exporter` folder to the `addons/` directory of the Godot project you want to export + +> **Note**: If you only use command-line export, the above steps complete the installation. +> To use the editor menu export feature, you also need to enable the "SPX TileMap Exporter" plugin in the Godot editor under **Project > Project Settings > Plugins**. + +## Usage + +### Command Line Export (Recommended) + +Using the Python script for export is the simplest method: + +**Method 1: Specify Godot path via command line argument** + +```bash +python export.py --godot /path/to/godot --scene levels/level1.tscn +``` + +**Method 2: Specify Godot path via environment variable** + +```bash +# Linux/macOS +export GODOT_PATH=/path/to/godot + +# Windows +set GODOT_PATH=C:\path\to\godot.exe + +# Export a specific scene +python export.py --scene levels/level1.tscn +``` + +**Godot Path Priority:** +1. `--godot` command line argument (highest priority) +2. `GODOT_PATH` environment variable +3. If neither is specified, an error message will be displayed + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `--godot PATH` | Specify the Godot executable path (takes priority over environment variable) | +| `--scene PATH` | Specify the scene file path to export, `res://` prefix is optional (default: `res://main.tscn`) | +| `--no-preview` | Disable preview PNG export (runs Godot in headless mode, no window). By default, preview is enabled. | + + +### Editor Menu Export + +> **Prerequisite**: You need to enable the plugin in the Godot editor first (**Project > Project Settings > Plugins**) + +After enabling the plugin, you can access export features through the **Project > Tools** menu: + +| Menu Item | Function | +|-----------|----------| +| **SPX Export TileMap...** | Export only the TileMap data from the current scene | +| **SPX Export Decorators...** | Export only the decorator data from the current scene | +| **SPX Export All...** | Export both TileMap and decorator data | +| **SPX Export Preview PNG (Ctrl+Shift+E)** | Export the scene as a PNG preview image | + +### Keyboard Shortcut + +| Shortcut | Function | +|----------|----------| +| **Ctrl+Shift+E** | Quickly export the current scene as a PNG preview image | + +The shortcut works when editing 2D nodes in the canvas editor. The exported preview image includes only TileMapLayer content (Sprite2D and other nodes are excluded). + + +### Direct Godot CLI Usage + +For more granular control, you can use the Godot command line directly: + +```bash +# Basic usage (exports default scene res://main.tscn) +godot --headless --path -s addons/spx_tilemap_exporter/export_cli.gd + +# Export a specific scene +godot --headless --path -s addons/spx_tilemap_exporter/export_cli.gd -- --scene res://levels/level1.tscn +``` + +| Parameter | Description | +|-----------|-------------| +| `--scene ` | Specify the scene path to export (must be placed after `--`) | + +## Export Output + +Export files are saved in the `res://_export//` directory: + +``` +_export/ +└── / + ├── tilemap.json # TileMap data + ├── tilemap/ # TileMap texture directory + │ └── *.png + ├── decorator.json # Decorator data + ├── decorator/ # Decorator texture directory + │ └── *.png + └── preview.png # Scene preview image +``` + +## Configuration + +### CLI Script Configuration + +Edit the constants in `export_cli.gd` to modify the default configuration: + +```gdscript +const DEFAULT_SCENE_PATH = "res://main.tscn" # Default scene path to export +const EXPORT_TILEMAP = true # Whether to export TileMap +const EXPORT_DECORATORS = true # Whether to export decorators +const EXPORT_PREVIEW = true # Whether to export preview PNG +const PREVIEW_RENDER_DELAY = 0.5 # Delay for viewport rendering (seconds) +``` + +> **Note**: Preview PNG export requires rendering, which may not work properly in `--headless` mode on systems without a GPU. If preview export fails in headless mode, you can disable it by setting `EXPORT_PREVIEW = false`. + +### Excluding Nodes + +The following nodes are automatically excluded during decorator export: + +- Nodes belonging to the `spx_ignore` group +- TileMapLayer and TileMap nodes (exported separately) +- Nodes with names containing `_ignore` or `_skip` + +## File Descriptions + +| File | Description | +|------|-------------| +| `plugin.cfg` | Plugin configuration file | +| `spx_tilemap_exporter.gd` | Main plugin script, provides editor menu functionality | +| `tilemap_extractor.gd` | TileMap data extraction and export logic | +| `decorator_extractor.gd` | Decorator data extraction and export logic | +| `preview_exporter.gd` | Scene preview PNG export logic (TileMapLayer only, provides static utility functions) | +| `export_cli.gd` | Command-line export script (Godot --headless mode) | +| `export.py` | Python automation export script | + + +## Requirements + +- Godot 4.x +- Python 3.8+ (when using the Python export script) + +--- + +## For Engine Developers + +> The following content is for SPX engine developers only. Regular users can skip this section. + +### Plugin Rapid Iteration + +When developing the `spx_tilemap_exporter` plugin, you can use the `--copy` parameter to copy the latest plugin code from the engine source directory to the target project: + +```bash +# Export with addon copy (copy latest plugin from source to project directory) +python export.py --copy + +# Combined usage: copy plugin and export a specific scene +python export.py --copy --scene my_scene.tscn + +# Combined usage: specify Godot path, copy plugin, and export a specific scene +python export.py --godot /path/to/godot --copy --scene my_scene.tscn +``` + +| Parameter | Description | +|-----------|-------------| +| `--godot PATH` | Specify the Godot executable path (takes priority over `GODOT_PATH` environment variable) | +| `--copy` | Copy the latest plugin from `pkg/gdspx/godot/addons/` to the current project directory for rapid plugin development iteration | + +**Workflow:** + +1. Modify the plugin code in `pkg/gdspx/godot/addons/spx_tilemap_exporter/` +2. Navigate to the test project directory (e.g., `tutorial/AA-00Town/`) +3. Run `python export.py --copy` to automatically copy the plugin and test the export + +## License + +SPX Team © 2026 diff --git a/addons/spx_tilemap_exporter/README.zh.md b/addons/spx_tilemap_exporter/README.zh.md new file mode 100644 index 000000000000..b08c6c9ada4c --- /dev/null +++ b/addons/spx_tilemap_exporter/README.zh.md @@ -0,0 +1,206 @@ +# SPX TileMap Exporter + +Godot 编辑器插件,用于将 TileMap 场景和装饰器导出为 SPX JSON 格式,以便在 SPX 运行时动态加载。 + +## 快速开始 + +```bash +# 1. 将插件复制到项目的 addons 目录 +cp -r spx_tilemap_exporter /path/to/your/project/addons/ + +# 2. 进入项目目录 +cd /path/to/your/project + +# 3. 导出场景 +python addons/spx_tilemap_exporter/export.py --godot /path/to/godot --scene main.tscn + +# 导出文件保存在 `res://_export/<场景名>/` 目录: +``` + +## 功能特性 + +- **TileMap 导出**:导出 TileMapLayer、TileSet 及物理碰撞数据 +- **装饰器导出**:导出场景中的 Sprite2D 节点和预制体实例 +- **预览图导出**:将 TileMapLayer 内容导出为 PNG 预览图(Ctrl+Shift+E 快捷键) +- **纹理自动复制**:自动将相关纹理复制到导出目录 +- **坐标系转换**:自动将 Godot 坐标系转换为 SPX 坐标系(Y 轴翻转) +- **碰撞形状支持**:支持导出矩形、圆形、胶囊体和多边形碰撞器 +- **命令行支持**:提供 CLI 脚本用于批量自动化导出 + +## 安装 + +1. 将 `spx_tilemap_exporter` 文件夹复制到需要导出的 Godot 项目的 `addons/` 目录 + +> **注意**:如果只使用命令行导出,以上步骤即可完成安装。 +> 如需使用编辑器菜单导出功能,还需在 Godot 编辑器中 **项目 > 项目设置 > 插件** 启用 "SPX TileMap Exporter" 插件。 + +## 使用方法 + +### 命令行导出(推荐) + +使用 Python 脚本进行导出是最简单的方式: + +**方式一:通过命令行参数指定 Godot 路径** + +```bash +python export.py --godot /path/to/godot --scene levels/level1.tscn +``` + +**方式二:通过环境变量指定 Godot 路径** + +```bash +# Linux/macOS +export GODOT_PATH=/path/to/godot + +# Windows +set GODOT_PATH=C:\path\to\godot.exe + +# 导出指定场景 +python export.py --scene levels/level1.tscn +``` + +**Godot 路径优先级:** +1. `--godot` 命令行参数(最高优先级) +2. `GODOT_PATH` 环境变量 +3. 如果都未指定,将显示错误提示 + +**参数说明:** + +| 参数 | 说明 | +|------|------| +| `--godot PATH` | 指定 Godot 可执行文件路径(优先级高于环境变量) | +| `--scene PATH` | 指定要导出的场景文件路径,`res://` 前缀可选(默认:`res://main.tscn`) | +| `--no-preview` | 禁用预览图 PNG 导出(以 headless 模式运行 Godot,不显示窗口)。默认情况下预览是启用的。 | + + +### 编辑器菜单导出 + +> **前提条件**:需要先在 Godot 编辑器中启用插件(**项目 > 项目设置 > 插件**) + +启用插件后,可通过 **项目 > 工具** 菜单访问导出功能: + +| 菜单项 | 功能 | +|--------|------| +| **SPX Export TileMap...** | 仅导出当前场景的 TileMap 数据 | +| **SPX Export Decorators...** | 仅导出当前场景的装饰器数据 | +| **SPX Export All...** | 同时导出 TileMap 和装饰器数据 | +| **SPX Export Preview PNG (Ctrl+Shift+E)** | 将场景导出为 PNG 预览图 | + +### 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| **Ctrl+Shift+E** | 快速将当前场景导出为 PNG 预览图 | + +在画布编辑器中编辑 2D 节点时可使用此快捷键。导出的预览图仅包含 TileMapLayer 内容(不包含 Sprite2D 和其他节点)。 + + +### 直接使用 Godot CLI + +如果需要更精细的控制,也可以直接使用 Godot 命令行: + +```bash +# 基础用法(导出默认场景 res://main.tscn) +godot --headless --path <项目路径> -s addons/spx_tilemap_exporter/export_cli.gd + +# 导出指定场景 +godot --headless --path <项目路径> -s addons/spx_tilemap_exporter/export_cli.gd -- --scene res://levels/level1.tscn +``` + +| 参数 | 说明 | +|------|------| +| `--scene ` | 指定要导出的场景路径(必须放在 `--` 之后) | + +## 导出输出 + +导出文件保存在 `res://_export/<场景名>/` 目录: + +``` +_export/ +└── <场景名>/ + ├── tilemap.json # TileMap 数据 + ├── tilemap/ # TileMap 纹理目录 + │ └── *.png + ├── decorator.json # 装饰器数据 + ├── decorator/ # 装饰器纹理目录 + │ └── *.png + └── preview.png # 场景预览图 +``` + +## 配置 + +### CLI 脚本配置 + +编辑 `export_cli.gd` 中的常量来修改默认配置: + +```gdscript +const DEFAULT_SCENE_PATH = "res://main.tscn" # 默认导出的场景路径 +const EXPORT_TILEMAP = true # 是否导出 TileMap +const EXPORT_DECORATORS = true # 是否导出装饰器 +const EXPORT_PREVIEW = true # 是否导出预览图 PNG +const PREVIEW_RENDER_DELAY = 0.5 # 视口渲染等待时间(秒) +``` + +> **注意**:预览图导出需要渲染支持,在没有 GPU 的系统上使用 `--headless` 模式可能无法正常工作。如果 headless 模式下预览导出失败,可以设置 `EXPORT_PREVIEW = false` 禁用此功能。 + +### 排除节点 + +装饰器导出时会自动排除以下节点: + +- 属于 `spx_ignore` 组的节点 +- TileMapLayer 和 TileMap 节点(单独导出) +- 名称包含 `_ignore` 或 `_skip` 的节点 + +## 文件说明 + +| 文件 | 描述 | +|------|------| +| `plugin.cfg` | 插件配置文件 | +| `spx_tilemap_exporter.gd` | 主插件脚本,提供编辑器菜单功能 | +| `tilemap_extractor.gd` | TileMap 数据提取和导出逻辑 | +| `decorator_extractor.gd` | 装饰器数据提取和导出逻辑 | +| `preview_exporter.gd` | 场景预览图 PNG 导出逻辑(仅 TileMapLayer,提供静态工具函数) | +| `export_cli.gd` | 命令行导出脚本(Godot --headless 模式) | +| `export.py` | Python 自动化导出脚本 | + + +## 环境要求 + +- Godot 4.x +- Python 3.8+(使用 Python 导出脚本时) + +--- + +## 引擎开发者 + +> 以下内容仅供 SPX 引擎开发者参考,普通用户可忽略此章节。 + +### 插件快速迭代 + +在开发 `spx_tilemap_exporter` 插件时,可以使用 `--copy` 参数将最新的插件代码从引擎源码目录复制到目标项目: + +```bash +# 带 addon 复制的导出(从源码复制最新插件到项目目录) +python export.py --copy + +# 组合使用:复制插件并导出指定场景 +python export.py --copy --scene my_scene.tscn + +# 组合使用:指定 Godot 路径、复制插件并导出指定场景 +python export.py --godot /path/to/godot --copy --scene my_scene.tscn +``` + +| 参数 | 说明 | +|------|------| +| `--godot PATH` | 指定 Godot 可执行文件路径(优先级高于 `GODOT_PATH` 环境变量) | +| `--copy` | 从 `pkg/gdspx/godot/addons/` 复制最新的插件到当前项目目录,用于快速迭代插件开发 | + +**工作流程:** + +1. 在 `pkg/gdspx/godot/addons/spx_tilemap_exporter/` 修改插件代码 +2. 进入测试项目目录(如 `tutorial/AA-00Town/`) +3. 运行 `python export.py --copy` 自动复制插件并测试导出 + +## 许可证 + +SPX Team © 2026 diff --git a/addons/spx_tilemap_exporter/export.py b/addons/spx_tilemap_exporter/export.py new file mode 100644 index 000000000000..48bc076b1f82 --- /dev/null +++ b/addons/spx_tilemap_exporter/export.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Copy the spx_tilemap_exporter addon from pkg/gdspx/godot/addons to the current +project's addons directory, and use Godot to run the export script for automatic +spx_tilemap data export. + +Godot Path: + --godot: Command line argument to specify Godot executable path (highest priority) + GODOT_PATH: Environment variable to specify Godot executable path + + If neither is specified, an error message will be displayed with instructions. +""" + +import argparse +import os +import shutil +import subprocess +from pathlib import Path + + +def copy_addon(project_root: Path, script_dir: Path) -> bool: + """Copy the spx_tilemap_exporter addon to the project's addons directory""" + # Source path: pkg/gdspx/godot/addons/spx_tilemap_exporter + src_path = project_root / "pkg" / "gdspx" / "godot" / "addons" / "spx_tilemap_exporter" + + # Destination path: tutorial/AA-00Town/addons/spx_tilemap_exporter + dst_path = script_dir / "addons" / "spx_tilemap_exporter" + + # Check if source directory exists + if not src_path.exists(): + print(f"Error: Source directory does not exist: {src_path}") + return False + + # Ensure parent directory of destination exists + dst_path.parent.mkdir(parents=True, exist_ok=True) + + # If destination directory already exists, remove it first + if dst_path.exists(): + print(f"Removing existing directory: {dst_path}") + shutil.rmtree(dst_path) + + # Copy directory + print(f"Copying addon:") + print(f" Source: {src_path}") + print(f" Destination: {dst_path}") + shutil.copytree(src_path, dst_path) + print("Addon copy completed!") + + return True + + +# Global variable to store Godot path from command line argument +_godot_path_override: Path | None = None + + +def set_godot_path_override(path: str | None) -> None: + """Set the Godot path override from command line argument""" + global _godot_path_override + if path: + _godot_path_override = Path(path) + else: + _godot_path_override = None + + +def get_godot_path() -> Path | None: + """Get the path to the Godot executable + + Priority: + 1. Command line argument (--godot) + 2. Environment variable (GODOT_PATH) + 3. Error message with instructions + """ + global _godot_path_override + + # Priority 1: Command line argument + if _godot_path_override: + if not _godot_path_override.exists(): + print(f"Error: Godot executable does not exist: {_godot_path_override}") + return None + return _godot_path_override + + # Priority 2: Environment variable + godot_path = os.environ.get("GODOT_PATH") + if godot_path: + godot_path = Path(godot_path) + if not godot_path.exists(): + print(f"Error: Godot executable does not exist: {godot_path}") + return None + return godot_path + + # Priority 3: Error message + print("Error: Godot path not specified") + print("") + print("Please specify the Godot executable path using one of the following methods:") + print(" 1. Command line argument: python export.py --godot /path/to/godot") + print(" 2. Environment variable: export GODOT_PATH=/path/to/godot") + print(" set GODOT_PATH=C:\\path\\to\\godot.exe (Windows)") + return None + + +def import_project(script_dir: Path) -> bool: + """Use Godot to import the project (rescan and import resources)""" + godot_path = get_godot_path() + if not godot_path: + return False + + # Build import command + # godot --headless --path --import + cmd = [ + str(godot_path), + "--headless", + "--path", str(script_dir), + "--import" + ] + + print("") + print("Importing project resources:") + print(f" Command: {' '.join(cmd)}") + print("") + + # Execute command + try: + result = subprocess.run(cmd, cwd=str(script_dir)) + if result.returncode != 0: + print(f"Error: Project import failed with return code: {result.returncode}") + return False + print("Project import completed!") + return True + except Exception as e: + print(f"Error: Failed to execute Godot import: {e}") + return False + + +def run_export(script_dir: Path, scene_path: str | None = None, enable_preview: bool = True) -> bool: + """Use Godot to run the export script + + Args: + script_dir: Path to the project directory + scene_path: Optional scene path to export (e.g., "main.tscn" or "res://main.tscn") + If None, uses export_cli.gd's default (res://main.tscn) + The "res://" prefix is automatically added if not present. + enable_preview: If True, run without --headless to enable preview PNG rendering. + Note: This will briefly show a Godot window. + """ + godot_path = get_godot_path() + if not godot_path: + return False + + # Auto-add res:// prefix if not present + if scene_path and not scene_path.startswith("res://"): + scene_path = "res://" + scene_path + + # Build command + # godot [--headless] --path -s addons/spx_tilemap_exporter/export_cli.gd [-- --scene ] + export_script = "addons/spx_tilemap_exporter/export_cli.gd" + + cmd = [str(godot_path)] + + # Only use headless mode when preview is disabled + if not enable_preview: + cmd.append("--headless") + + cmd.extend([ + "--path", str(script_dir), + "-s", export_script + ]) + + # Add scene path argument if specified + if scene_path: + cmd.extend(["--", "--scene", scene_path]) + + print("") + print("Running export script:") + print(f" Command: {' '.join(cmd)}") + if scene_path: + print(f" Scene: {scene_path}") + else: + print(" Scene: (using default: res://main.tscn)") + if enable_preview: + print(" Preview: enabled (default, non-headless mode)") + else: + print(" Preview: disabled (--no-preview, headless mode)") + print("") + + # Execute command + try: + result = subprocess.run(cmd, cwd=str(script_dir)) + if result.returncode != 0: + print(f"Error: Export script execution failed with return code: {result.returncode}") + return False + return True + except Exception as e: + print(f"Error: Failed to execute Godot: {e}") + return False + + +def main(): + # Parse command line arguments + parser = argparse.ArgumentParser( + description="SPX TileMap Export Tool - Export TileMap data from Godot project to SPX format.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Godot Path (one of the following is required): + --godot Command line argument to specify Godot executable path + GODOT_PATH Environment variable to specify Godot executable path + + Priority: --godot argument > GODOT_PATH environment variable + + Example: python export.py --godot /path/to/godot + export GODOT_PATH=/path/to/godot (Linux/macOS) + set GODOT_PATH=C:\\path\\to\\godot.exe (Windows) + +Usage Examples: + # Basic export with Godot path from environment variable + python export.py + + # Specify Godot executable path via command line + python export.py --godot /path/to/godot + python export.py --godot C:\\path\\to\\godot.exe + + # Export with addon copy (copies spx_tilemap_exporter to project first) + python export.py --copy + + # Export a specific scene (res:// prefix is optional) + python export.py --scene levels/level1.tscn + python export.py --scene res://levels/level1.tscn + + # Export without preview PNG (headless mode, no window) + python export.py --no-preview + python export.py --scene my_scene.tscn --no-preview + + # Combine multiple options (with preview disabled) + python export.py --godot /path/to/godot --copy --scene my_scene.tscn --no-preview + +Workflow: + 1. [Optional] Copy spx_tilemap_exporter addon to project (with --copy) + 2. Import project resources using Godot + 3. Run export script to generate SPX TileMap data + +Note: + This script should be placed in the target project directory + (e.g., tutorial/AA-00Town/). It will automatically detect the + project structure and export TileMap data accordingly. +""" + ) + parser.add_argument( + "--godot", + type=str, + default=None, + metavar="PATH", + help="Path to the Godot executable (overrides GODOT_PATH environment variable)" + ) + parser.add_argument( + "--copy", + action="store_true", + help="Copy spx_tilemap_exporter addon to project addons directory (not copied by default)" + ) + parser.add_argument( + "--scene", + type=str, + default=None, + metavar="PATH", + help="Path to the scene file to export, res:// prefix is optional (default: res://main.tscn)" + ) + parser.add_argument( + "--no-preview", + action="store_true", + dest="no_preview", + help="Disable preview PNG export (runs Godot in headless mode, no window)" + ) + args = parser.parse_args() + + # Set Godot path override from command line argument + set_godot_path_override(args.godot) + + # Get the directory where this script is located (works regardless of where it's executed from) + script_dir = Path(__file__).resolve().parent.parent.parent + + # Calculate project root directory (script is under tutorial/AA-00Town, go up two levels) + project_root = script_dir.parent.parent + + # Determine number of steps based on whether copying is enabled + total_steps = 3 if args.copy else 2 + current_step = 0 + + print("=" * 50) + print("SPX TileMap Export Tool") + print("=" * 50) + print("") + + # Step 1: Copy addon (only when --copy argument is specified) + if args.copy: + current_step += 1 + print(f"[Step {current_step}/{total_steps}] Copying spx_tilemap_exporter addon") + print("-" * 50) + if not copy_addon(project_root, script_dir): + return 1 + print("") + + # Step 2: Import project (let Godot rescan and import resources) + current_step += 1 + print(f"[Step {current_step}/{total_steps}] Importing project resources") + print("-" * 50) + if not import_project(script_dir): + return 1 + + print("") + + # Step 3: Run export script + current_step += 1 + print(f"[Step {current_step}/{total_steps}] Running Godot export script") + print("-" * 50) + if not run_export(script_dir, args.scene, not args.no_preview): + return 1 + + print("") + print("=" * 50) + print("All done!") + print("=" * 50) + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/addons/spx_tilemap_exporter/export_cli.gd b/addons/spx_tilemap_exporter/export_cli.gd index e4da1d8d1862..5d5dfdb27152 100644 --- a/addons/spx_tilemap_exporter/export_cli.gd +++ b/addons/spx_tilemap_exporter/export_cli.gd @@ -2,86 +2,202 @@ extends SceneTree ## Command-line export script for SPX TileMap and Decorator data ## -## Usage: godot --headless --path -s addons/spx_tilemap_exporter/export_cli.gd +## Usage: godot --headless --path -s addons/spx_tilemap_exporter/export_cli.gd [-- --scene ] ## -## Configuration is done via constants below: +## Arguments: +## --scene Path to the scene file to export (default: res://main.tscn) +## +## Examples: +## godot --headless --path . -s addons/spx_tilemap_exporter/export_cli.gd +## godot --headless --path . -s addons/spx_tilemap_exporter/export_cli.gd -- --scene res://levels/level1.tscn const TileMapExtractor = preload("res://addons/spx_tilemap_exporter/tilemap_extractor.gd") const DecoratorExtractor = preload("res://addons/spx_tilemap_exporter/decorator_extractor.gd") +const PreviewExporter = preload("res://addons/spx_tilemap_exporter/preview_exporter.gd") # ============================================================================ -# Configuration - modify these as needed +# Configuration - default values (can be overridden via command line) # ============================================================================ -const SCENE_PATH = "res://main.tscn" +const DEFAULT_SCENE_PATH = "res://main.tscn" const EXPORT_TILEMAP = true const EXPORT_DECORATORS = true +const EXPORT_PREVIEW = true # Export scene preview PNG +const PREVIEW_RENDER_DELAY = 0.5 # Delay in seconds for viewport rendering # ============================================================================ +# State for async export +var _scene_root: Node = null +var _export_base: String = "" +var _has_error: bool = false +var _node_offset: Vector2 = Vector2.ZERO +var _export_phase: int = 0 # 0=init, 1=waiting_preview, 2=done +var _preview_viewport: SubViewport = null +var _preview_elapsed: float = 0.0 + + func _init() -> void: - # Get scene name for export directory - var scene_name = SCENE_PATH.get_file().get_basename() - var export_base = "res://_export/" + scene_name + # Parse command line arguments + var scene_path = _parse_scene_argument() + + # Get export directory path (preserves scene path structure) + # e.g., "res://main.tscn" -> "res://_export/main" + # e.g., "res://levels/level1.tscn" -> "res://_export/levels/level1" + var export_path = scene_path.get_file().get_basename() + _export_base = "res://_export/" + export_path print("SPX Export CLI") print("==============") - print("Scene: ", SCENE_PATH) - print("Output: ", export_base) + print("Scene: ", scene_path) + print("Output: ", _export_base) print("Export TileMap: ", EXPORT_TILEMAP) print("Export Decorators: ", EXPORT_DECORATORS) + print("Export Preview: ", EXPORT_PREVIEW) print("") # Load the scene - var packed_scene = load(SCENE_PATH) + var packed_scene = load(scene_path) if not packed_scene: - printerr("ERROR: Failed to load scene: ", SCENE_PATH) + printerr("ERROR: Failed to load scene: ", scene_path) quit(1) return if not packed_scene is PackedScene: - printerr("ERROR: Loaded resource is not a PackedScene: ", SCENE_PATH) + printerr("ERROR: Loaded resource is not a PackedScene: ", scene_path) quit(1) return - var scene_root = packed_scene.instantiate() - if not scene_root: + _scene_root = packed_scene.instantiate() + if not _scene_root: printerr("ERROR: Failed to instantiate scene") quit(1) return # Ensure export directory exists - var global_export_dir = ProjectSettings.globalize_path(export_base) + var global_export_dir = ProjectSettings.globalize_path(_export_base) if not DirAccess.dir_exists_absolute(global_export_dir): var err = DirAccess.make_dir_recursive_absolute(global_export_dir) if err != OK: printerr("ERROR: Failed to create export directory: ", global_export_dir) - scene_root.queue_free() + _scene_root.queue_free() quit(1) return - var has_error = false - var node_offset = Vector2.ZERO - - # Export TileMap + # Export TileMap (synchronous) if EXPORT_TILEMAP: - var tilemap_path = export_base + "/tilemap.json" + var tilemap_path = _export_base + "/tilemap.json" var global_tilemap_path = ProjectSettings.globalize_path(tilemap_path) - var tilemap_result = _export_tilemap(scene_root, global_tilemap_path) + var tilemap_result = _export_tilemap(_scene_root, global_tilemap_path) if tilemap_result.success: - node_offset = tilemap_result.node_offset + _node_offset = tilemap_result.node_offset elif tilemap_result.error != "skipped": - has_error = true + _has_error = true - # Export Decorators + # Export Decorators (synchronous) if EXPORT_DECORATORS: - var decorator_path = export_base + "/decorator.json" + var decorator_path = _export_base + "/decorator.json" var global_decorator_path = ProjectSettings.globalize_path(decorator_path) - var decorator_result = _export_decorators(scene_root, global_decorator_path, node_offset) + var decorator_result = _export_decorators(_scene_root, global_decorator_path, _node_offset) if not decorator_result.success and decorator_result.error != "skipped": - has_error = true + _has_error = true + + # Export Preview PNG (requires async rendering) + if EXPORT_PREVIEW: + _start_preview_export() + else: + _finish_export() + + +func _process(delta: float) -> bool: + if _export_phase == 1: # Waiting for preview render + _preview_elapsed += delta + if _preview_elapsed >= PREVIEW_RENDER_DELAY: + _complete_preview_export() + _finish_export() + return false # Continue processing + + +func _start_preview_export() -> void: + # Use PreviewExporter static functions for TileMap-only rendering + var layers = PreviewExporter.find_tilemap_layers(_scene_root) + var bounds = PreviewExporter.calc_tilemap_bounds(layers) + + if not bounds.has_area(): + print("Preview: No TileMapLayer content found (skipped)") + _finish_export() + return + + # Create SubViewport for rendering + _preview_viewport = SubViewport.new() + _preview_viewport.size = Vector2i(bounds.size) + _preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + _preview_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS + _preview_viewport.transparent_bg = true + + # Create a copy with only TileMapLayer nodes (uses PreviewExporter static function) + var copy = PreviewExporter.create_tilemap_only_copy(_scene_root) + if not copy: + print("Preview: Failed to create scene copy (skipped)") + _preview_viewport.queue_free() + _preview_viewport = null + _finish_export() + return - scene_root.queue_free() + # Adjust position so content starts at (0, 0) + if copy is Node2D: + copy.position = copy.global_position - bounds.position + + _preview_viewport.add_child(copy) + root.add_child(_preview_viewport) + + _export_phase = 1 + _preview_elapsed = 0.0 + print("Preview: Rendering...") + + +func _complete_preview_export() -> void: + if not _preview_viewport: + return + + var preview_path = _export_base + "/preview.png" + var global_preview_path = ProjectSettings.globalize_path(preview_path) + + # Get viewport texture (may be null in headless mode) + var texture = _preview_viewport.get_texture() + if not texture: + print("Preview: Viewport texture unavailable (headless mode?), skipped") + _preview_viewport.queue_free() + _preview_viewport = null + _export_phase = 2 + return + + var image = texture.get_image() + if not image: + print("Preview: Failed to capture image (headless mode?), skipped") + _preview_viewport.queue_free() + _preview_viewport = null + _export_phase = 2 + return + + var err = image.save_png(global_preview_path) + if err != OK: + printerr("ERROR: Failed to save preview PNG: ", err) + _has_error = true + else: + print("Preview Export successful!") + print(" Output: ", preview_path) + print(" Size: ", _preview_viewport.size.x, "x", _preview_viewport.size.y) + + _preview_viewport.queue_free() + _preview_viewport = null + _export_phase = 2 + + +func _finish_export() -> void: + if _scene_root: + _scene_root.queue_free() + _scene_root = null - if has_error: + if _has_error: print("") print("Export completed with errors") quit(1) @@ -180,6 +296,25 @@ func _export_decorators(scene_root: Node, output_path: String, node_offset: Vect # Helper Functions # ============================================================================ +func _parse_scene_argument() -> String: + """Parse --scene argument from command line, return default if not specified""" + # Use get_cmdline_user_args() for arguments after "--" separator + var args = OS.get_cmdline_user_args() + + # Look for --scene argument + for i in range(args.size()): + if args[i] == "--scene" and i + 1 < args.size(): + var scene_arg = args[i + 1] + # Validate the scene path + if not scene_arg.begins_with("res://"): + scene_arg = "res://" + scene_arg + print("Using scene from command line: ", scene_arg) + return scene_arg + + # Return default if not specified + return DEFAULT_SCENE_PATH + + func _find_tilemap_layers(node: Node) -> Array[TileMapLayer]: var layers: Array[TileMapLayer] = [] _find_tilemap_layers_recursive(node, layers) @@ -240,3 +375,78 @@ func _calculate_tilemap_offset(layers: Array[TileMapLayer]) -> Vector2: var center_y: int = (min_y + max_y) / 2 return Vector2(-center_x * tile_size.x, -center_y * tile_size.y) + + +# ============================================================================ +# Preview Export Helper Functions +# ============================================================================ + +## Calculate the bounding rectangle of all renderable content in the scene +func _get_scene_bounds(node: Node, layers: Array[TileMapLayer]) -> Rect2: + var total_rect = Rect2() + + # Calculate TileMapLayer bounds + for layer in layers: + var layer_rect = _get_tilemap_bounds(layer) + if layer_rect.has_area(): + var global_rect = layer.get_global_transform() * layer_rect + total_rect = _merge_rects(total_rect, global_rect) + + # Collect Sprite2D bounds recursively + total_rect = _collect_sprite_bounds_recursive(node, total_rect) + + return total_rect + + +## Calculate bounds of a single TileMapLayer +func _get_tilemap_bounds(layer: TileMapLayer) -> Rect2: + if not layer or not layer.tile_set: + return Rect2() + + var used_rect = layer.get_used_rect() + if used_rect.size == Vector2i.ZERO: + return Rect2() + + var tile_size = layer.tile_set.tile_size + + # Convert tile coordinates to local pixel coordinates + var top_left = layer.map_to_local(used_rect.position) + var bottom_right = layer.map_to_local(used_rect.position + used_rect.size) + + # Adjust for half-tile offset (tiles are centered) + var half_tile = Vector2(tile_size) / 2.0 + var rect = Rect2(top_left - half_tile, bottom_right - top_left) + + return rect + + +func _collect_sprite_bounds_recursive(node: Node, total_rect: Rect2) -> Rect2: + # Skip TileMapLayer nodes (already handled) + if node is TileMapLayer: + return total_rect + + if node is Sprite2D: + var sprite = node as Sprite2D + var texture = sprite.texture + if texture: + var size = texture.get_size() + var offset = -size / 2.0 if sprite.centered else Vector2.ZERO + offset += sprite.offset + var local_rect = Rect2(offset, size) + var global_rect = sprite.get_global_transform() * local_rect + total_rect = _merge_rects(total_rect, global_rect) + + for child in node.get_children(): + total_rect = _collect_sprite_bounds_recursive(child, total_rect) + + return total_rect + + +func _merge_rects(a: Rect2, b: Rect2) -> Rect2: + if not a.has_area(): + return b + if not b.has_area(): + return a + return a.merge(b) + + diff --git a/addons/spx_tilemap_exporter/preview_exporter.gd b/addons/spx_tilemap_exporter/preview_exporter.gd new file mode 100644 index 000000000000..ad33c17f1fbb --- /dev/null +++ b/addons/spx_tilemap_exporter/preview_exporter.gd @@ -0,0 +1,304 @@ +@tool +extends RefCounted +## TileMap scene preview exporter - exports the scene as a PNG image +## +## Mirrors the C++ SpxSceneMgr::export_scene_as_png() functionality +## for use in the editor plugin environment. +## +## This class provides both instance methods (for editor use) and static +## utility functions (for CLI use) to avoid code duplication. + +class_name PreviewExporter + +## Export result structure +class ExportResult: + var success: bool = false + var error: String = "" + var export_path: String = "" + var image_size: Vector2i = Vector2i.ZERO + + +## Delay in seconds to wait for viewport rendering +const RENDER_DELAY_SECONDS = 0.5 + + +## Reference to editor interface (needed for async operations) +var _editor_interface: EditorInterface +var _base_control: Control + + +func _init(editor_interface: EditorInterface = null): + if editor_interface: + _editor_interface = editor_interface + _base_control = editor_interface.get_base_control() + + +# ============================================================================ +# Main Export Function +# ============================================================================ + +## Export the current edited scene as a PNG preview image (async version) +## Waits for rendering and saves the image automatically +func export_scene_as_png_async(scene_root: Node) -> ExportResult: + var result = ExportResult.new() + + if not scene_root: + result.error = "No scene is currently open" + return result + + # Find all TileMapLayers (use static function) + var layers = PreviewExporter.find_tilemap_layers(scene_root) + + # Calculate scene bounds (TileMapLayers only, use static function) + var bounds = PreviewExporter.calc_tilemap_bounds(layers) + + if not bounds.has_area(): + result.error = "No TileMapLayer content found in scene" + return result + + # Generate export path + var scene_name = scene_root.scene_file_path.get_file().get_basename() + if scene_name.is_empty(): + scene_name = scene_root.name + + var export_dir = "res://_export/" + scene_name + var export_path = export_dir + "/preview.png" + var global_export_path = ProjectSettings.globalize_path(export_path) + + # Ensure export directory exists + if not _ensure_export_dir(export_dir): + result.error = "Failed to create export directory: " + export_dir + return result + + # Create SubViewport for rendering + var viewport = _create_preview_viewport(scene_root, bounds) + if not viewport: + result.error = "Failed to create preview viewport" + return result + + # Add viewport to scene tree + _base_control.add_child(viewport) + + # Wait for rendering to complete (multiple frames for safety) + await _base_control.get_tree().create_timer(RENDER_DELAY_SECONDS).timeout + + # Get the rendered image and save + var image = viewport.get_texture().get_image() + if not image: + viewport.queue_free() + result.error = "Failed to capture viewport image" + return result + + var err = image.save_png(global_export_path) + + # Cleanup + viewport.queue_free() + + if err != OK: + result.error = "Failed to save PNG: error code " + str(err) + return result + + result.success = true + result.export_path = export_path + result.image_size = Vector2i(bounds.size) + + print_rich("[color=green]Preview PNG saved to: %s[/color]" % global_export_path) + + return result + + +# ============================================================================ +# Static Utility Functions (can be called from CLI without EditorInterface) +# ============================================================================ + +## Calculate the bounding rectangle of all TileMapLayers +## Note: Only includes TileMapLayer bounds, excludes Sprite2D and other nodes +static func calc_tilemap_bounds(layers: Array[TileMapLayer]) -> Rect2: + var total_rect = Rect2() + + for layer in layers: + var layer_rect = calc_single_layer_bounds(layer) + if layer_rect.has_area(): + var global_rect = layer.get_global_transform() * layer_rect + total_rect = merge_rects(total_rect, global_rect) + + return total_rect + + +## Calculate bounds of a single TileMapLayer (mirrors SpxSceneMgr::get_tilemap_bounds) +static func calc_single_layer_bounds(layer: TileMapLayer) -> Rect2: + if not layer or not layer.tile_set: + return Rect2() + + var used_rect = layer.get_used_rect() + if used_rect.size == Vector2i.ZERO: + return Rect2() + + var tile_size = layer.tile_set.tile_size + + # Convert tile coordinates to local pixel coordinates + var top_left = layer.map_to_local(used_rect.position) + var bottom_right = layer.map_to_local(used_rect.position + used_rect.size) + + # Adjust for half-tile offset (tiles are centered) + var half_tile = Vector2(tile_size) / 2.0 + var rect = Rect2(top_left - half_tile, bottom_right - top_left) + + return rect + + +static func merge_rects(a: Rect2, b: Rect2) -> Rect2: + if not a.has_area(): + return b + if not b.has_area(): + return a + return a.merge(b) + + +## Find all TileMapLayer nodes in the scene tree +static func find_tilemap_layers(node: Node) -> Array[TileMapLayer]: + var layers: Array[TileMapLayer] = [] + _find_tilemap_layers_recursive(node, layers) + return layers + + +static func _find_tilemap_layers_recursive(node: Node, layers: Array[TileMapLayer]) -> void: + if node is TileMapLayer: + layers.append(node) + elif node is TileMap: + for child in node.get_children(true): + if child is TileMapLayer: + layers.append(child) + + for child in node.get_children(): + _find_tilemap_layers_recursive(child, layers) + + +## Create a duplicate of the scene with only TileMapLayer nodes (all others removed) +## Returns the cleaned scene copy, or null on failure +## Set debug_log=true to print scene tree before/after cleanup +static func create_tilemap_only_copy(scene_root: Node, debug_log: bool = false) -> Node: + var copy = scene_root.duplicate(Node.DUPLICATE_USE_INSTANTIATION) + if not copy: + return null + + if debug_log: + print("=== Original Scene Tree (before cleanup) ===") + print_scene_tree(copy, 0) + + # Remove all non-TileMapLayer nodes + remove_non_tilemap_nodes(copy, debug_log) + + if debug_log: + print("=== Cleaned Scene Tree (after cleanup) ===") + print_scene_tree(copy, 0) + + return copy + + +## Print the scene tree for debugging +static func print_scene_tree(node: Node, indent: int) -> void: + var indent_str = " ".repeat(indent) + var node_type = node.get_class() + var visible_str = "" + if node is CanvasItem: + visible_str = " [visible=%s]" % str(node.visible) + print("%s%s (%s)%s" % [indent_str, node.name, node_type, visible_str]) + + for child in node.get_children(): + print_scene_tree(child, indent + 1) + + +## Remove all nodes that are not TileMapLayer or necessary parent containers +static func remove_non_tilemap_nodes(root: Node, debug_log: bool = false) -> void: + # First, collect all TileMapLayer nodes and their ancestor paths + var nodes_to_keep: Dictionary = {} + _collect_tilemap_ancestors(root, nodes_to_keep) + + if debug_log: + print("Nodes to keep: %d" % nodes_to_keep.size()) + for node in nodes_to_keep.keys(): + print(" - %s (%s)" % [node.name, node.get_class()]) + + # Remove all nodes not in the keep list + _delete_unwanted_nodes(root, nodes_to_keep, debug_log) + + +static func _collect_tilemap_ancestors(node: Node, nodes_to_keep: Dictionary) -> bool: + var has_tilemap_descendant = false + + if node is TileMapLayer or node is TileMap: + has_tilemap_descendant = true + + for child in node.get_children(): + if _collect_tilemap_ancestors(child, nodes_to_keep): + has_tilemap_descendant = true + + if has_tilemap_descendant: + nodes_to_keep[node] = true + + return has_tilemap_descendant + + +static func _delete_unwanted_nodes(node: Node, nodes_to_keep: Dictionary, debug_log: bool) -> void: + var children = node.get_children() + for child in children: + if nodes_to_keep.has(child): + _delete_unwanted_nodes(child, nodes_to_keep, debug_log) + else: + if debug_log: + print("Deleting: %s (%s)" % [child.name, child.get_class()]) + node.remove_child(child) + child.free() + + +# ============================================================================ +# Instance Methods (for editor use with EditorInterface) +# ============================================================================ + +## Calculate the bounding rectangle of all TileMapLayers in the scene (instance method wrapper) +func get_scene_bounds(node: Node, layers: Array[TileMapLayer] = []) -> Rect2: + return PreviewExporter.calc_tilemap_bounds(layers) + + +## Calculate bounds of a single TileMapLayer (instance method wrapper) +func get_tilemap_bounds(layer: TileMapLayer) -> Rect2: + return PreviewExporter.calc_single_layer_bounds(layer) + + +# ============================================================================ +# SubViewport Creation (uses static utility functions) +# ============================================================================ + +## Create a SubViewport configured for preview rendering +## Only renders TileMapLayer nodes, all other nodes are removed +func _create_preview_viewport(scene_root: Node, bounds: Rect2) -> SubViewport: + var viewport = SubViewport.new() + viewport.size = Vector2i(bounds.size) + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS + viewport.transparent_bg = true + + # Create a copy with only TileMapLayer nodes + var copy = PreviewExporter.create_tilemap_only_copy(scene_root) + if not copy: + viewport.queue_free() + return null + + # Adjust position so content starts at (0, 0) + if copy is Node2D: + copy.position = copy.global_position - bounds.position + + viewport.add_child(copy) + + return viewport + + +func _ensure_export_dir(export_dir: String) -> bool: + var global_path = ProjectSettings.globalize_path(export_dir) + if not DirAccess.dir_exists_absolute(global_path): + var err = DirAccess.make_dir_recursive_absolute(global_path) + if err != OK: + push_error("Failed to create export directory: %s (error: %d)" % [global_path, err]) + return false + return true diff --git a/addons/spx_tilemap_exporter/spx_tilemap_exporter.gd b/addons/spx_tilemap_exporter/spx_tilemap_exporter.gd index 37c37350b99a..15de5eda70e5 100644 --- a/addons/spx_tilemap_exporter/spx_tilemap_exporter.gd +++ b/addons/spx_tilemap_exporter/spx_tilemap_exporter.gd @@ -3,27 +3,70 @@ extends EditorPlugin const TileMapExtractor = preload("res://addons/spx_tilemap_exporter/tilemap_extractor.gd") const DecoratorExtractor = preload("res://addons/spx_tilemap_exporter/decorator_extractor.gd") +const PreviewExporter = preload("res://addons/spx_tilemap_exporter/preview_exporter.gd") var _tilemap_extractor: TileMapExtractor var _decorator_extractor: DecoratorExtractor +var _preview_exporter: PreviewExporter +var _preview_shortcut: Shortcut func _enter_tree() -> void: _tilemap_extractor = TileMapExtractor.new() _decorator_extractor = DecoratorExtractor.new() + _preview_exporter = PreviewExporter.new(get_editor_interface()) add_tool_menu_item("SPX Export TileMap...", _on_export_tilemap_pressed) add_tool_menu_item("SPX Export Decorators...", _on_export_decorators_pressed) add_tool_menu_item("SPX Export All...", _on_export_all_pressed) + add_tool_menu_item("SPX Export Preview PNG (Ctrl+Shift+E)", _on_export_preview_pressed) + + # Setup keyboard shortcut for preview export + _setup_preview_shortcut() func _exit_tree() -> void: remove_tool_menu_item("SPX Export TileMap...") remove_tool_menu_item("SPX Export Decorators...") remove_tool_menu_item("SPX Export All...") + remove_tool_menu_item("SPX Export Preview PNG (Ctrl+Shift+E)") if _tilemap_extractor: _tilemap_extractor = null if _decorator_extractor: _decorator_extractor = null + if _preview_exporter: + _preview_exporter = null + + +## Setup the keyboard shortcut (Ctrl+Shift+E) for preview export +func _setup_preview_shortcut() -> void: + _preview_shortcut = Shortcut.new() + var key_event = InputEventKey.new() + key_event.keycode = KEY_E + key_event.ctrl_pressed = true + key_event.shift_pressed = true + _preview_shortcut.events = [key_event] + + +## Handle keyboard shortcut input +func _shortcut_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + if _preview_shortcut and _preview_shortcut.matches_event(event): + get_viewport().set_input_as_handled() + _on_export_preview_pressed() + + +## Alternative: Handle input when editing 2D nodes in canvas +func _handles(object: Object) -> bool: + return object is Node2D or object is TileMapLayer or object is TileMap + + +func _forward_canvas_gui_input(event: InputEvent) -> bool: + if event is InputEventKey and event.pressed and not event.echo: + var key = event as InputEventKey + if key.keycode == KEY_E and key.ctrl_pressed and key.shift_pressed: + _on_export_preview_pressed() + return true # Consume the event + return false # ============================================================================ @@ -288,3 +331,34 @@ func _show_info(message: String) -> void: dialog.canceled.connect(dialog.queue_free) get_editor_interface().get_base_control().add_child(dialog) dialog.popup_centered() + + +# ============================================================================ +# Preview PNG Export (Ctrl+Shift+E) +# ============================================================================ + +func _on_export_preview_pressed() -> void: + var edited_scene = get_editor_interface().get_edited_scene_root() + if not edited_scene: + _show_error("No scene is currently open.") + return + + _export_preview(edited_scene) + + +func _export_preview(scene_root: Node) -> void: + # Show exporting message in console + print("Exporting preview PNG...") + + # Use async version to wait for rendering + var result = await _preview_exporter.export_scene_as_png_async(scene_root) + + if result.success: + var full_path = ProjectSettings.globalize_path(result.export_path) + _show_info("Preview PNG exported successfully!\n\nPath: %s\nSize: %d x %d pixels" % [ + full_path, + result.image_size.x, + result.image_size.y + ]) + else: + _show_error("Preview export failed:\n" + result.error)