290 lines
11 KiB
GDScript
290 lines
11 KiB
GDScript
@tool
|
||
extends RefCounted
|
||
## texture.generate — procedural 2D placeholder textures (PNG → Texture2D).
|
||
##
|
||
## Closes half of the can't-produce-assets blindspot: the LLM can't draw, so
|
||
## prototypes are hand-built coloured rects. This synthesises distinguishable
|
||
## placeholder sprites — shapes/patterns with a 3-layer colour model
|
||
## (fill / outline / background, alpha-absent = layer omitted) plus an optional
|
||
## text label overlay — and writes a PNG that imports as a Texture2D.
|
||
|
||
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
||
const Helpers = Modules.CommandHelpers
|
||
|
||
const ALLOWED_EXTS := ["png"]
|
||
const MAX_DIM := 1024
|
||
const DEFAULT_DIM := 64
|
||
const SHAPES := ["solid", "circle", "triangle", "diamond", "arrow", "checkerboard", "grid"]
|
||
const DIRECTIONS := ["up", "down", "left", "right"]
|
||
|
||
|
||
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
|
||
registry.add("texture.generate", func(parameters: Dictionary) -> Dictionary:
|
||
return await _cmd_generate(parameters)
|
||
, MCPToolkitCommandOptions.new()
|
||
.mark_scene_independent()
|
||
.guard_project_path("file_path")
|
||
.with_timeout_ms(40000)
|
||
.with_success_hint(
|
||
"Assign the texture: node_set_property on Sprite2D.texture / "
|
||
+ "TextureRect.texture / Button.icon, or feed spriteframes_create."))
|
||
|
||
|
||
static func _cmd_generate(parameters: Dictionary) -> Dictionary:
|
||
var file_path := str(parameters.get("file_path", ""))
|
||
if file_path == "":
|
||
return MCPToolkitError.fail("INVALID_PARAMS",
|
||
"file_path is required (res:// destination ending in .png)", MCPToolkitError.HINT_FILE_PATH)
|
||
|
||
var shape := str(parameters.get("shape", "solid")).to_lower()
|
||
if shape not in SHAPES:
|
||
return MCPToolkitError.fail("INVALID_PARAMS",
|
||
"shape must be one of: %s (got '%s')" % [", ".join(SHAPES), shape])
|
||
|
||
var width: int = clampi(int(parameters.get("width", DEFAULT_DIM)), 1, MAX_DIM)
|
||
var height: int = clampi(int(parameters.get("height", DEFAULT_DIM)), 1, MAX_DIM)
|
||
|
||
var fill_color := _parse_color(parameters.get("fill_color", null), Color(0.8, 0.8, 0.8, 1.0))
|
||
var outline_color := _parse_color(parameters.get("outline_color", null), Color(0, 0, 0, 0))
|
||
var background_color := _parse_color(parameters.get("background_color", null), Color(0, 0, 0, 0))
|
||
var outline_width: int = maxi(0, int(parameters.get("outline_width", 1)))
|
||
|
||
# Blank-result guard: at least one layer must be visible.
|
||
if fill_color.a <= 0.0 and outline_color.a <= 0.0 and background_color.a <= 0.0:
|
||
return MCPToolkitError.fail("INVALID_PARAMS",
|
||
"result would be fully transparent — set fill_color, outline_color, or background_color (alpha > 0)")
|
||
|
||
var direction := str(parameters.get("direction", "right")).to_lower()
|
||
if direction not in DIRECTIONS:
|
||
return MCPToolkitError.fail("INVALID_PARAMS",
|
||
"direction must be one of: %s (got '%s')" % [", ".join(DIRECTIONS), direction])
|
||
var cell_size: int = maxi(1, int(parameters.get("cell_size", maxi(8, mini(width, height) / 8))))
|
||
|
||
# --- Build the image ---
|
||
var image := Image.create(width, height, false, Image.FORMAT_RGBA8)
|
||
image.fill(background_color)
|
||
_draw_shape(image, shape, fill_color, outline_color, outline_width, background_color, cell_size, direction)
|
||
|
||
# --- Optional label overlay ---
|
||
var label := str(parameters.get("label", ""))
|
||
var warnings: Array[String] = []
|
||
if label != "":
|
||
if Modules.VersionUtils.is_headless():
|
||
warnings.append("label overlay skipped: text rasterization needs a display (unavailable in --headless)")
|
||
else:
|
||
var label_color := _parse_color(parameters.get("label_color", null), Color(0, 0, 0, 1))
|
||
var label_img = await _render_label(label, width, height, label_color)
|
||
if label_img != null:
|
||
image.blend_rect(label_img, Rect2i(0, 0, width, height), Vector2i(0, 0))
|
||
else:
|
||
warnings.append("label overlay could not be rendered")
|
||
|
||
# --- Write + settle (shared bracket) ---
|
||
var write_fn := func(write_path: String) -> Dictionary:
|
||
var err := image.save_png(write_path)
|
||
if err != OK:
|
||
return MCPToolkitError.fail("WRITE_FAILED",
|
||
"Image.save_png failed for %s (err %d)" % [write_path, err])
|
||
return {}
|
||
|
||
var if_exists := str(parameters.get("if_exists", "return"))
|
||
# Default 0 = no import-settle poll: a generated PNG is a Texture2D by
|
||
# construction, so the class is reported directly (no FS round-trip). Pass an
|
||
# explicit wait_for_scan_ms > 0 to force a settle.
|
||
var wait_for_scan_ms := int(parameters.get("wait_for_scan_ms", 0))
|
||
var result := await Helpers.write_asset_with_settle(
|
||
file_path, PackedStringArray(ALLOWED_EXTS), if_exists, wait_for_scan_ms, "texture.generate", write_fn, "Texture2D")
|
||
if not result.get("success", false):
|
||
return result
|
||
|
||
if result.get("status") != "returned":
|
||
result["shape"] = shape
|
||
result["width"] = width
|
||
result["height"] = height
|
||
if not warnings.is_empty():
|
||
var existing: Array = result.get("warnings", [])
|
||
existing.append_array(warnings)
|
||
result["warnings"] = existing
|
||
return result
|
||
|
||
|
||
# -- Drawing ------------------------------------------------------------------
|
||
|
||
|
||
static func _draw_shape(
|
||
image: Image, shape: String, fill: Color, outline: Color,
|
||
outline_width: int, background: Color, cell_size: int, direction: String,
|
||
) -> void:
|
||
var w := image.get_width()
|
||
var h := image.get_height()
|
||
match shape:
|
||
"solid":
|
||
# Fast path: rectangle fills, no per-pixel loop (large images stay quick).
|
||
var ow := outline_width if outline.a > 0.0 else 0
|
||
if ow > 0 and w > 2 * ow and h > 2 * ow:
|
||
image.fill(outline)
|
||
image.fill_rect(Rect2i(ow, ow, w - 2 * ow, h - 2 * ow), fill if fill.a > 0.0 else background)
|
||
else:
|
||
image.fill(fill if fill.a > 0.0 else (outline if outline.a > 0.0 else background))
|
||
return
|
||
"checkerboard":
|
||
# Per-cell fills (fill_color squares over the already-filled background).
|
||
var cells_x := int(ceil(float(w) / cell_size))
|
||
var cells_y := int(ceil(float(h) / cell_size))
|
||
for cy in range(cells_y):
|
||
for cx in range(cells_x):
|
||
if (cx + cy) % 2 == 0:
|
||
var rx := cx * cell_size
|
||
var ry := cy * cell_size
|
||
image.fill_rect(Rect2i(rx, ry, mini(cell_size, w - rx), mini(cell_size, h - ry)), fill)
|
||
return
|
||
"grid":
|
||
# Lines (outline_color, falling back to fill) over the background.
|
||
var line_color := outline if outline.a > 0.0 else fill
|
||
var lw := maxi(1, outline_width)
|
||
var gx := 0
|
||
while gx < w:
|
||
image.fill_rect(Rect2i(gx, 0, mini(lw, w - gx), h), line_color)
|
||
gx += cell_size
|
||
var gy := 0
|
||
while gy < h:
|
||
image.fill_rect(Rect2i(0, gy, w, mini(lw, h - gy)), line_color)
|
||
gy += cell_size
|
||
return
|
||
_:
|
||
# Convex shapes via inside-tests at two margins (fill inset by the
|
||
# outline width); pixels in the band become the outline.
|
||
var ow := outline_width if outline.a > 0.0 else 0
|
||
for y in range(h):
|
||
for x in range(w):
|
||
var inside_outer := _in_shape(shape, x, y, w, h, direction, 0)
|
||
if not inside_outer:
|
||
continue
|
||
var inside_inner := _in_shape(shape, x, y, w, h, direction, ow)
|
||
if inside_inner:
|
||
# Interior: fill, or leave background when fill is absent (hollow).
|
||
if fill.a > 0.0:
|
||
image.set_pixel(x, y, fill)
|
||
elif outline.a > 0.0:
|
||
# Outline band (inside outer shape, outside the inset inner shape).
|
||
image.set_pixel(x, y, outline)
|
||
|
||
|
||
## True when pixel (x,y) is inside the shape, shrunk inward by `margin` pixels.
|
||
static func _in_shape(shape: String, x: int, y: int, w: int, h: int, direction: String, margin: float) -> bool:
|
||
var px := x + 0.5
|
||
var py := y + 0.5
|
||
match shape:
|
||
"solid":
|
||
return px >= margin and px <= w - margin and py >= margin and py <= h - margin
|
||
"circle":
|
||
var cx := w / 2.0
|
||
var cy := h / 2.0
|
||
var r := minf(w, h) / 2.0 - margin
|
||
return r > 0.0 and Vector2(px - cx, py - cy).length() <= r
|
||
"diamond":
|
||
var hw := w / 2.0 - margin
|
||
var hh := h / 2.0 - margin
|
||
if hw <= 0.0 or hh <= 0.0:
|
||
return false
|
||
return abs(px - w / 2.0) / hw + abs(py - h / 2.0) / hh <= 1.0
|
||
"triangle":
|
||
return _in_directional_triangle(px, py, w, h, "up", margin)
|
||
"arrow":
|
||
return _in_directional_triangle(px, py, w, h, direction, margin)
|
||
return false
|
||
|
||
|
||
## Triangle inscribed in the box with its apex toward `direction`, inset by margin.
|
||
static func _in_directional_triangle(px: float, py: float, w: float, h: float, direction: String, margin: float) -> bool:
|
||
# Map to a canonical "apex up" coordinate (a, b) in the same box.
|
||
var a := px
|
||
var b := py
|
||
var ww := w
|
||
var hh := h
|
||
match direction:
|
||
"up":
|
||
a = px; b = py; ww = w; hh = h
|
||
"down":
|
||
a = px; b = h - py; ww = w; hh = h
|
||
"left":
|
||
a = py; b = px; ww = h; hh = w
|
||
"right":
|
||
a = py; b = w - px; ww = h; hh = w
|
||
# Canonical apex-up: apex at (ww/2, 0), base at b = hh.
|
||
var top := margin
|
||
var bottom := hh - margin
|
||
if b < top or b > bottom:
|
||
return false
|
||
var t := (b - top) / maxf(bottom - top, 0.0001) # 0 at apex, 1 at base
|
||
var half := (ww / 2.0 - margin) * t
|
||
return half > 0.0 and abs(a - ww / 2.0) <= half
|
||
|
||
|
||
# -- Label rasterization (editor only) ----------------------------------------
|
||
|
||
|
||
## Render `text` centred into a transparent SubViewport and return its Image,
|
||
## or null on failure. Requires a display server (guarded by the caller).
|
||
static func _render_label(text: String, width: int, height: int, color: Color) -> Variant:
|
||
var tree := Engine.get_main_loop() as SceneTree
|
||
if tree == null or tree.root == null:
|
||
return null
|
||
var viewport := SubViewport.new()
|
||
viewport.size = Vector2i(width, height)
|
||
viewport.transparent_bg = true
|
||
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||
viewport.disable_3d = true
|
||
var label := Label.new()
|
||
label.text = text
|
||
label.add_theme_color_override("font_color", color)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
# Fill the fixed-size SubViewport via anchors. No explicit .size — setting it
|
||
# alongside FULL_RECT anchors emits a control.cpp:1440 "size overridden after
|
||
# _ready" warning, and the anchors already produce the same width×height rect.
|
||
label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
viewport.add_child(label)
|
||
tree.root.add_child(viewport)
|
||
# Let the viewport render once.
|
||
await tree.process_frame
|
||
await RenderingServer.frame_post_draw
|
||
var texture := viewport.get_texture()
|
||
var img: Variant = null
|
||
if texture != null:
|
||
img = texture.get_image()
|
||
tree.root.remove_child(viewport)
|
||
viewport.queue_free()
|
||
return img
|
||
|
||
|
||
# -- Colour parsing -----------------------------------------------------------
|
||
|
||
|
||
## Parse a colour from a hex/named string, an [r,g,b(,a)] array (0-1 or 0-255),
|
||
## or null (returns default_color). Alpha 0 = "layer absent".
|
||
static func _parse_color(raw: Variant, default_color: Color) -> Color:
|
||
match typeof(raw):
|
||
TYPE_NIL:
|
||
return default_color
|
||
TYPE_COLOR:
|
||
return raw
|
||
TYPE_STRING:
|
||
return Color.from_string(str(raw), default_color)
|
||
TYPE_ARRAY:
|
||
var a := raw as Array
|
||
if a.size() >= 3:
|
||
var r := float(a[0])
|
||
var g := float(a[1])
|
||
var b := float(a[2])
|
||
var alpha := float(a[3]) if a.size() >= 4 else 1.0
|
||
if r > 1.0 or g > 1.0 or b > 1.0 or alpha > 1.0:
|
||
r /= 255.0
|
||
g /= 255.0
|
||
b /= 255.0
|
||
if alpha > 1.0:
|
||
alpha /= 255.0
|
||
return Color(r, g, b, alpha)
|
||
return default_color
|