Skip to content

feat(spx_tilemap_exporter): add preview PNG export functionality#244

Open
JiepengTan wants to merge 2 commits intogoplus:spx4.4.1from
JiepengTan:pr_addon_export_tilemap_add_preview_png
Open

feat(spx_tilemap_exporter): add preview PNG export functionality#244
JiepengTan wants to merge 2 commits intogoplus:spx4.4.1from
JiepengTan:pr_addon_export_tilemap_add_preview_png

Conversation

@JiepengTan
Copy link
Copy Markdown

@JiepengTan JiepengTan commented Jan 23, 2026

  • Add preview_exporter.gd for rendering TileMapLayer-only scene snapshots
  • Support Ctrl+Shift+E keyboard shortcut in editor
  • Add --no-preview CLI option to disable preview in headless mode
  • Update export.py to run non-headless by default for preview support
  • Update README documentation (EN/ZH)

Quick Start

# 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/<scene_name>/` directory

Export preview png is saved in the res://_export/<scene_name>/preview.png :

_export/
└── <scene_name>/
    ├── tilemap.json        # TileMap data
    ├── tilemap/            # TileMap texture directory
    │   └── *.png
    ├── decorator.json      # Decorator data
    ├── decorator/          # Decorator texture directory
    │   └── *.png
    └── preview.png         # Scene preview image

test project: AA-01Platform.zip

eg:
preview

- Add export.py for automated TileMap data export workflow
- Add README.md and README.zh.md with complete usage documentation
- Improve export_cli.gd with better CLI support
- Support Godot path via --godot arg or GODOT_PATH env var
- Add preview_exporter.gd for rendering TileMapLayer-only scene snapshots
- Support Ctrl+Shift+E keyboard shortcut in editor
- Add --no-preview CLI option to disable preview in headless mode
- Update export.py to run non-headless by default for preview support
- Update README documentation (EN/ZH)
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @JiepengTan, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the SPX TileMap Exporter plugin by introducing a preview PNG export feature. This allows users to generate a visual snapshot of their TileMap layers, which is useful for quick verification or documentation. The feature is seamlessly integrated into the Godot editor with a dedicated menu option and a convenient keyboard shortcut, and also supports command-line usage with an option to disable previews for headless operations. The underlying export logic has been refactored into a new PreviewExporter class, ensuring clean separation of concerns and reusability.

Highlights

  • Preview PNG Export: Added functionality to export a PNG preview image of TileMap layers from a Godot scene.
  • Editor Integration: Implemented a new editor menu option "SPX Export Preview PNG (Ctrl+Shift+E)" and a keyboard shortcut (Ctrl+Shift+E) for quick preview generation.
  • CLI Option for Headless Mode: Introduced a --no-preview command-line option for export.py to disable preview export and run Godot in headless mode, optimizing for environments without a display.
  • Default Non-Headless Export: The export.py script now runs Godot in non-headless mode by default to support preview rendering.
  • Updated Documentation: Comprehensive updates to both English (README.md) and Chinese (README.zh.md) documentation, detailing the new preview feature, usage, and configuration.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@JiepengTan JiepengTan requested a review from joeykchen January 23, 2026 08:50
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request introduces a new feature for exporting a preview PNG of TileMapLayer content, along with corresponding updates to the Python export script and documentation. The implementation includes a new PreviewExporter class to handle the rendering and saving of the preview image, and integrates this functionality into both the editor plugin and the command-line export script. The documentation has been updated to reflect these new features, including a keyboard shortcut for quick preview export. Overall, the changes are well-structured and add valuable functionality. However, there are a few areas that require attention regarding path resolution in the Python script, the accuracy of export paths in the CLI script, and a potential issue with bounding box calculation in the preview exporter.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The calculation for project_root here seems incorrect for the --copy functionality. If script_dir correctly resolves to the game project root (e.g., tutorial/AA-00Town/), then script_dir.parent.parent would point two levels above the game project, which is unlikely to be the engine source root where pkg/gdspx/godot/addons/ resides. The --copy functionality is intended to copy the plugin from the engine source to the current project. This path needs to be resolved correctly, perhaps by introducing a new command-line argument or environment variable for the engine source path, or by assuming a fixed relative path from the current working directory when --copy is used.

# 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment on this line indicates that res://levels/level1.tscn should result in an export path like res://_export/levels/level1. However, scene_path.get_file().get_basename() will only return level1, leading to an export path of res://_export/level1. This loses the directory structure of the scene path. To preserve the directory structure, you might want to use scene_path.get_base_dir().trim_prefix("res://") + "/" + scene_path.get_file().get_basename() or a similar approach to construct the export_path.

For example:

var relative_scene_path = scene_path.trim_prefix("res://")
var export_subpath = relative_scene_path.get_basename()
if relative_scene_path.get_base_dir() != "":
export_subpath = relative_scene_path.get_base_dir() + "/" + export_subpath
_export_base = "res://_export/" + export_subpath

var relative_scene_path = scene_path.trim_prefix("res://")	
var export_subpath = relative_scene_path.get_basename()	
if relative_scene_path.get_base_dir() != "":	
    export_subpath = relative_scene_path.get_base_dir() + "/" + export_subpath	
_export_base = "res://_export/" + export_subpath

Comment on lines +384 to +450
## 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The functions _get_scene_bounds, _get_tilemap_bounds, _collect_sprite_bounds_recursive, and _merge_rects are defined here but appear to be unused. The _start_preview_export function correctly calls the static methods from PreviewExporter for calculating bounds. These unused functions should be removed to avoid dead code and potential confusion.

Comment on lines +145 to +146
var rect = Rect2(top_left - half_tile, bottom_right - top_left)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment "Adjust for half-tile offset (tiles are centered)" and the half_tile adjustment might be incorrect or unnecessary. Godot's map_to_local typically returns the top-left corner of a tile when given tile coordinates. If top_left is the top-left of the first tile and bottom_right is the bottom-right of the last tile, then Rect2(top_left, bottom_right - top_left) should already represent the correct bounding box. Adding half_tile to top_left might shift the origin of the calculated rectangle, potentially leading to an inaccurate bounding box for the preview image. Please verify if this adjustment is truly needed for accurate bounding box calculation.

Comment on lines +384 to +451
## 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead Code - Should Remove

These helper functions (_get_scene_bounds, _get_tilemap_bounds, _collect_sprite_bounds_recursive, _merge_rects) are never used. The code already uses PreviewExporter static methods instead (lines 121-122, 137).

Recommendation: Remove these 68 lines to reduce maintenance burden.

Comment on lines +299 to +315
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Path Traversal Risk

Scene path from command line is not validated for path traversal sequences (../). While Godot's res:// protocol provides some protection, explicit validation is recommended as defense-in-depth.

Example Attack:

godot -s export_cli.gd -- --scene "../../other_project/secret.tscn"

Recommendation: Validate that the resolved path contains no .. sequences and stays within the project directory.

Comment on lines +276 to +280
# 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusing Path Calculation

The comment says "go up two levels" but the code actually goes up 5 levels total (parent.parent.parent for script_dir, then parent.parent for project_root). This assumes a very specific directory structure that may not match all project layouts.

Recommendation: Either:

  1. Document this directory structure requirement clearly in the help text
  2. Or make path calculation more robust (e.g., search for project.godot file)

Comment on lines +52 to +62
# 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary Global Variable

Using a global variable for configuration adds complexity and could cause issues if the script is imported as a module.

Recommendation: Pass godot_path as a parameter through the function chain instead.

Comment on lines +259 to +267
## 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary Wrapper Methods

These instance method wrappers add no value - they simply delegate to static methods. Note that get_scene_bounds ignores its node parameter entirely.

Recommendation: Remove these wrappers and commit to a fully static utility class design.

Comment on lines +193 to +203
| 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation Inaccuracy

The --copy parameter documentation doesn't clearly explain the specific directory structure requirement. The export.py script assumes a specific layout where it goes up 5 directory levels to find pkg/gdspx/godot/addons/, which will only work in particular project structures.

Recommendation: Document the exact required directory structure or make the script more flexible.


## License

SPX Team © 2026
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: License Year

The license states "© 2026" which appears to be a future year. Should this be 2024 or 2025?

@xgopilot
Copy link
Copy Markdown

xgopilot bot commented Jan 23, 2026

Code Review Summary

This PR adds useful preview PNG export functionality with comprehensive documentation. However, there are several noteworthy issues:

Critical Issues:

  • ~154 lines of dead/duplicated code should be removed (export_cli.gd lines 384-451, duplicate helper functions)
  • Path traversal security risk in scene path handling needs validation

Important Issues:

  • Confusing directory structure assumptions in export.py
  • Documentation inaccuracies regarding --copy parameter and collider types

Positive Aspects:

  • Well-documented with bilingual README
  • Clean API design with static utility methods
  • Proper error handling throughout

Overall, the functionality is solid but code duplication and dead code should be addressed before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant