1050 lines
45 KiB
GDScript
1050 lines
45 KiB
GDScript
@tool
|
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
|
|
|
## Create, read, and edit VisualShader resources. Scene assignment is separate.
|
|
##
|
|
## `create_graph` builds a fully validated graph from a declarative spec;
|
|
## `get_graph` reports an existing graph (stages, nodes, params, connections,
|
|
## varyings); `edit_graph` applies a validated operation list to an existing
|
|
## resource; `node_catalog` lists the instantiable VisualShaderNode classes
|
|
## with the properties this handler accepts. Every write validates the whole
|
|
## result in memory first and saves through a same-directory temporary, so a
|
|
## failure never truncates the destination.
|
|
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
|
|
|
|
const MAX_NODES := 256
|
|
const MAX_CONNECTIONS := 1024
|
|
const MAX_OPERATIONS := 512
|
|
const MAX_CATALOG_LIMIT := 500
|
|
const MODES := {
|
|
"spatial": Shader.MODE_SPATIAL, "canvas_item": Shader.MODE_CANVAS_ITEM,
|
|
"particles": Shader.MODE_PARTICLES, "sky": Shader.MODE_SKY, "fog": Shader.MODE_FOG,
|
|
}
|
|
const MODE_NAMES := {
|
|
Shader.MODE_SPATIAL: "spatial", Shader.MODE_CANVAS_ITEM: "canvas_item",
|
|
Shader.MODE_PARTICLES: "particles", Shader.MODE_SKY: "sky", Shader.MODE_FOG: "fog",
|
|
}
|
|
const STAGES := {
|
|
"vertex": VisualShader.TYPE_VERTEX, "fragment": VisualShader.TYPE_FRAGMENT,
|
|
"light": VisualShader.TYPE_LIGHT, "start": VisualShader.TYPE_START,
|
|
"process": VisualShader.TYPE_PROCESS, "collide": VisualShader.TYPE_COLLIDE,
|
|
"start_custom": VisualShader.TYPE_START_CUSTOM, "process_custom": VisualShader.TYPE_PROCESS_CUSTOM,
|
|
"sky": VisualShader.TYPE_SKY, "fog": VisualShader.TYPE_FOG,
|
|
}
|
|
const STAGE_NAMES := {
|
|
VisualShader.TYPE_VERTEX: "vertex", VisualShader.TYPE_FRAGMENT: "fragment",
|
|
VisualShader.TYPE_LIGHT: "light", VisualShader.TYPE_START: "start",
|
|
VisualShader.TYPE_PROCESS: "process", VisualShader.TYPE_COLLIDE: "collide",
|
|
VisualShader.TYPE_START_CUSTOM: "start_custom",
|
|
VisualShader.TYPE_PROCESS_CUSTOM: "process_custom",
|
|
VisualShader.TYPE_SKY: "sky", VisualShader.TYPE_FOG: "fog",
|
|
}
|
|
const MODE_STAGES := {
|
|
"spatial": ["vertex", "fragment", "light"],
|
|
"canvas_item": ["vertex", "fragment", "light"],
|
|
"particles": ["start", "process", "collide", "start_custom", "process_custom"],
|
|
"sky": ["sky"], "fog": ["fog"],
|
|
}
|
|
## Varyings are a spatial/canvas_item feature; particles/sky/fog have no
|
|
## vertex-to-fragment or fragment-to-light stage pair to carry them.
|
|
const VARYING_MODES := {
|
|
"spatial": true, "canvas_item": true,
|
|
}
|
|
const VARYING_MODE_VALUES := {
|
|
"vertex_to_frag_light": VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT,
|
|
"frag_to_light": VisualShader.VARYING_MODE_FRAG_TO_LIGHT,
|
|
}
|
|
const VARYING_TYPE_VALUES := {
|
|
"float": VisualShader.VARYING_TYPE_FLOAT,
|
|
"int": VisualShader.VARYING_TYPE_INT,
|
|
"uint": VisualShader.VARYING_TYPE_UINT,
|
|
"vector2": VisualShader.VARYING_TYPE_VECTOR_2D,
|
|
"vector3": VisualShader.VARYING_TYPE_VECTOR_3D,
|
|
"vector4": VisualShader.VARYING_TYPE_VECTOR_4D,
|
|
"boolean": VisualShader.VARYING_TYPE_BOOLEAN,
|
|
"transform": VisualShader.VARYING_TYPE_TRANSFORM,
|
|
}
|
|
const VARYING_MODE_NAMES := {
|
|
VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT: "vertex_to_frag_light",
|
|
VisualShader.VARYING_MODE_FRAG_TO_LIGHT: "frag_to_light",
|
|
}
|
|
const VARYING_TYPE_NAMES := {
|
|
VisualShader.VARYING_TYPE_FLOAT: "float",
|
|
VisualShader.VARYING_TYPE_INT: "int",
|
|
VisualShader.VARYING_TYPE_UINT: "uint",
|
|
VisualShader.VARYING_TYPE_VECTOR_2D: "vector2",
|
|
VisualShader.VARYING_TYPE_VECTOR_3D: "vector3",
|
|
VisualShader.VARYING_TYPE_VECTOR_4D: "vector4",
|
|
VisualShader.VARYING_TYPE_BOOLEAN: "boolean",
|
|
VisualShader.VARYING_TYPE_TRANSFORM: "transform",
|
|
}
|
|
## Preserve useful aliases from the original #869 implementation.
|
|
const ALIASES := {
|
|
"VisualShaderNodeScalarOp": "VisualShaderNodeFloatOp",
|
|
"VisualShaderNodeScalarFunc": "VisualShaderNodeFloatFunc",
|
|
"VisualShaderNodeVectorConstant": "VisualShaderNodeVec3Constant",
|
|
"VisualShaderNodeVectorParameter": "VisualShaderNodeVec3Parameter",
|
|
"VisualShaderNodeTime": "VisualShaderNodeInput",
|
|
"VisualShaderNodeSin": "VisualShaderNodeFloatFunc",
|
|
"VisualShaderNodeCos": "VisualShaderNodeFloatFunc",
|
|
"VisualShaderNodeLength": "VisualShaderNodeVectorLen",
|
|
}
|
|
const IMPLICIT := {
|
|
"VisualShaderNodeTime": {"input_name": "time"},
|
|
"VisualShaderNodeSin": {"function": "sin"},
|
|
"VisualShaderNodeCos": {"function": "cos"},
|
|
}
|
|
## Only node-authoring properties, never script/resource ownership or arbitrary code.
|
|
const PROPERTIES := [
|
|
"constant", "texture", "operator", "function", "op_type", "input_name",
|
|
"parameter_name", "varying_name", "varying_type",
|
|
"default_value_enabled", "default_value", "qualifier",
|
|
"source", "texture_type", "texture_filter", "texture_repeat",
|
|
"hint", "hint_range_min", "hint_range_max", "hint_range_step",
|
|
]
|
|
const UNSUPPORTED_NODES := [
|
|
"VisualShaderNodeOutput", "VisualShaderNodeCustom",
|
|
"VisualShaderNodeExpression", "VisualShaderNodeGlobalExpression",
|
|
]
|
|
|
|
|
|
func create_graph(params: Dictionary) -> Dictionary:
|
|
var raw_path: Variant = params.get("resource_path", null)
|
|
if not raw_path is String or raw_path.is_empty():
|
|
return _invalid("resource_path must be a nonempty res:// .tres path")
|
|
var path: String = raw_path
|
|
var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true)
|
|
if path_error != null:
|
|
return path_error
|
|
if path.get_extension().to_lower() != "tres":
|
|
return _invalid("resource_path must end in .tres: %s" % path)
|
|
var overwrite: Variant = params.get("overwrite", false)
|
|
if not overwrite is bool:
|
|
return _invalid("overwrite must be a boolean")
|
|
if FileAccess.file_exists(path) and not overwrite:
|
|
return _invalid("Resource already exists at %s (pass overwrite=true to replace)" % path)
|
|
var mode: Variant = params.get("shader_type", "spatial")
|
|
if not mode is String or not MODES.has(mode):
|
|
return _invalid("Unknown shader_type: %s" % str(mode))
|
|
var stages: Variant = params.get("stages", null)
|
|
if not stages is Array or stages.is_empty() or stages.size() > MODE_STAGES[mode].size():
|
|
return _invalid("stages must be a nonempty array of applicable, unique shader stages")
|
|
var varyings := _parse_varyings(params.get("varyings", []), mode)
|
|
if varyings.has("error"):
|
|
return varyings
|
|
var count_nodes := 0
|
|
var count_connections := 0
|
|
var seen := {}
|
|
for stage in stages:
|
|
if not stage is Dictionary or not stage.get("stage") is String:
|
|
return _invalid("Each stages entry requires a stage name")
|
|
var name: String = stage.stage
|
|
if not name in MODE_STAGES[mode] or seen.has(name):
|
|
return _invalid("Duplicate or incompatible stage '%s' for %s" % [name, mode])
|
|
seen[name] = true
|
|
if not stage.get("nodes") is Array or not stage.get("connections") is Array:
|
|
return _invalid("Stage %s requires nodes and connections arrays" % name)
|
|
count_nodes += stage.nodes.size()
|
|
count_connections += stage.connections.size()
|
|
if count_nodes > MAX_NODES or count_connections > MAX_CONNECTIONS:
|
|
return _invalid("Graph has %d nodes and %d connections; limits are %d and %d" % [count_nodes, count_connections, MAX_NODES, MAX_CONNECTIONS])
|
|
var shader := VisualShader.new()
|
|
shader.set_mode(MODES[mode])
|
|
var maps := {}
|
|
for stage in stages:
|
|
var built := _build_stage(shader, stage)
|
|
if built.has("error"):
|
|
return built
|
|
maps[stage.stage] = built.id_map
|
|
for varying in varyings.specs:
|
|
shader.add_varying(varying.name, varying.mode, varying.type)
|
|
var saved := _save_atomic(shader, path, overwrite)
|
|
if saved.has("error"):
|
|
return saved
|
|
return {"data": {
|
|
"resource_path": path, "shader_type": mode, "id_map": maps,
|
|
"node_count": count_nodes, "connection_count": count_connections,
|
|
"varyings": varyings.specs.map(func(spec): return spec.name),
|
|
"undoable": false, "reason": "Creating or replacing a resource file is not undoable; assign the material separately.",
|
|
}}
|
|
|
|
|
|
func get_graph(params: Dictionary) -> Dictionary:
|
|
var path: String = params.get("path", "")
|
|
var path_error: Variant = McpPathValidator.path_error(path, "path")
|
|
if path_error != null:
|
|
return path_error
|
|
if not FileAccess.file_exists(path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path)
|
|
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
if not (loaded is VisualShader):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path)
|
|
var shader := loaded as VisualShader
|
|
var mode := str(MODE_NAMES.get(shader.get_mode(), ""))
|
|
var stages: Array[Dictionary] = []
|
|
var node_count := 0
|
|
var connection_count := 0
|
|
for stage_name in MODE_STAGES.get(mode, []):
|
|
var stage: int = STAGES[stage_name]
|
|
var nodes: Array[Dictionary] = []
|
|
for node_id in shader.get_node_list(stage):
|
|
if node_id == 0:
|
|
continue
|
|
var node := shader.get_node(stage, node_id)
|
|
if node == null:
|
|
continue
|
|
var position := shader.get_node_position(stage, node_id)
|
|
nodes.append({
|
|
"id": node_id,
|
|
"type": node.get_class(),
|
|
"position": {"x": position.x, "y": position.y},
|
|
"params": _serialize_node_params(node),
|
|
})
|
|
var connections: Array[Dictionary] = []
|
|
for connection in shader.get_node_connections(stage):
|
|
connections.append({
|
|
"from_node": int(connection.get("from_node", -1)),
|
|
"from_port": int(connection.get("from_port", -1)),
|
|
"to_node": int(connection.get("to_node", -1)),
|
|
"to_port": int(connection.get("to_port", -1)),
|
|
})
|
|
node_count += nodes.size()
|
|
connection_count += connections.size()
|
|
if nodes.is_empty() and connections.is_empty():
|
|
continue
|
|
stages.append({
|
|
"stage": stage_name, "nodes": nodes, "connections": connections,
|
|
})
|
|
return {"data": {
|
|
"path": path, "shader_type": mode, "stages": stages,
|
|
"node_count": node_count, "connection_count": connection_count,
|
|
"varyings": _read_varyings(shader, path),
|
|
}}
|
|
|
|
|
|
func node_catalog(params: Dictionary) -> Dictionary:
|
|
var filter: String = str(params.get("filter", "")).to_lower()
|
|
var offset: int = max(0, int(params.get("offset", 0)))
|
|
var limit: int = clampi(int(params.get("limit", 100)), 1, MAX_CATALOG_LIMIT)
|
|
var matches: Array[String] = []
|
|
for class_name_value in ClassDB.get_class_list():
|
|
var class_name_string := str(class_name_value)
|
|
if not filter.is_empty() and not class_name_string.to_lower().contains(filter):
|
|
continue
|
|
if class_name_string in UNSUPPORTED_NODES:
|
|
continue
|
|
if not ClassDB.can_instantiate(class_name_string):
|
|
continue
|
|
if not ClassDB.is_parent_class(class_name_string, "VisualShaderNode"):
|
|
continue
|
|
matches.append(class_name_string)
|
|
matches.sort()
|
|
var total := matches.size()
|
|
var page: Array[Dictionary] = []
|
|
var end := mini(total, offset + limit)
|
|
for i in range(offset, end):
|
|
var class_name_string: String = matches[i]
|
|
var node: VisualShaderNode = ClassDB.instantiate(class_name_string)
|
|
var supported: Array[String] = []
|
|
if node != null:
|
|
var present := {}
|
|
for property in node.get_property_list():
|
|
present[str(property.name)] = property
|
|
for property_name in PROPERTIES:
|
|
if not present.has(property_name):
|
|
continue
|
|
var property: Dictionary = present[property_name]
|
|
if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE):
|
|
continue
|
|
supported.append(property_name)
|
|
page.append({"type": class_name_string, "params": supported})
|
|
return {"data": {
|
|
"nodes": page, "count": page.size(), "total": total,
|
|
"offset": offset, "limit": limit, "aliases": ALIASES,
|
|
}}
|
|
|
|
|
|
func edit_graph(params: Dictionary) -> Dictionary:
|
|
var path: String = params.get("resource_path", "")
|
|
var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true)
|
|
if path_error != null:
|
|
return path_error
|
|
if path.get_extension().to_lower() != "tres":
|
|
return _invalid("resource_path must end in .tres: %s" % path)
|
|
if not FileAccess.file_exists(path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path)
|
|
var operations: Variant = params.get("operations", null)
|
|
if not operations is Array or operations.is_empty():
|
|
return _invalid("operations must be a nonempty array")
|
|
if operations.size() > MAX_OPERATIONS:
|
|
return _invalid("operations exceeds the %d-entry limit" % MAX_OPERATIONS)
|
|
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
if not (loaded is VisualShader):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path)
|
|
var shader := loaded as VisualShader
|
|
var mode := str(MODE_NAMES.get(shader.get_mode(), ""))
|
|
|
|
## Alias table for IDs introduced by this call: string ids are resolved to
|
|
## their allocated integer so later operations can reference them.
|
|
var aliases := {}
|
|
var added: Array[Dictionary] = []
|
|
var inputs := _collect_inputs(shader, mode)
|
|
for i in operations.size():
|
|
var operation: Variant = operations[i]
|
|
if not operation is Dictionary or not operation.get("op") is String:
|
|
return _invalid("operations[%d] requires an op name" % i)
|
|
var applied := _apply_edit(shader, mode, operation, aliases, inputs)
|
|
if applied.has("error"):
|
|
return ErrorCodes.prefix_message(applied, "operations[%d] (%s)" % [i, operation.op])
|
|
if applied.has("added"):
|
|
added.append(applied.added)
|
|
|
|
var total_nodes := 0
|
|
var total_connections := 0
|
|
for stage_name in MODE_STAGES.get(mode, []):
|
|
var stage: int = STAGES[stage_name]
|
|
for node_id in shader.get_node_list(stage):
|
|
if node_id != 0:
|
|
total_nodes += 1
|
|
total_connections += shader.get_node_connections(stage).size()
|
|
if total_nodes > MAX_NODES or total_connections > MAX_CONNECTIONS:
|
|
return _invalid("Resulting graph has %d nodes and %d connections; limits are %d and %d" % [total_nodes, total_connections, MAX_NODES, MAX_CONNECTIONS])
|
|
|
|
var saved := _save_atomic(shader, path, true)
|
|
if saved.has("error"):
|
|
return saved
|
|
return {"data": {
|
|
"resource_path": path,
|
|
"operations_applied": operations.size(),
|
|
"added": added,
|
|
"node_count": total_nodes,
|
|
"connection_count": total_connections,
|
|
"varyings": _read_varyings(shader, path),
|
|
"undoable": false,
|
|
"reason": "Editing a resource file is not undoable; assign the material separately.",
|
|
}}
|
|
|
|
|
|
## Apply one edit operation to `shader` in place. Returns {} on success (plus
|
|
## `added` for add_node) or an error dict. `aliases` maps this call's string
|
|
## ids to allocated integers; `inputs` tracks occupied (node, port) inputs.
|
|
func _apply_edit(
|
|
shader: VisualShader, mode: String, operation: Dictionary,
|
|
aliases: Dictionary, inputs: Dictionary,
|
|
) -> Dictionary:
|
|
match operation.op:
|
|
"add_node":
|
|
return _edit_add_node(shader, mode, operation, aliases)
|
|
"remove_node":
|
|
var stage := _edit_stage(operation, mode)
|
|
if stage.has("error"):
|
|
return stage
|
|
var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases)
|
|
if node_id.has("error"):
|
|
return node_id
|
|
if node_id.value < 2:
|
|
return _invalid("cannot remove the built-in output node")
|
|
shader.remove_node(stage.value, node_id.value)
|
|
inputs.clear()
|
|
inputs.merge(_collect_inputs(shader, mode))
|
|
return {}
|
|
"replace_node":
|
|
var stage := _edit_stage(operation, mode)
|
|
if stage.has("error"):
|
|
return stage
|
|
var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases)
|
|
if node_id.has("error"):
|
|
return node_id
|
|
if node_id.value < 2:
|
|
return _invalid("cannot replace the built-in output node")
|
|
var class_name_value: Variant = operation.get("type")
|
|
if not class_name_value is String:
|
|
return _invalid("replace_node requires a type")
|
|
var real_type := _resolve_type(class_name_value)
|
|
if real_type.is_empty():
|
|
return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value))
|
|
var values: Variant = operation.get("params", {})
|
|
if not values is Dictionary:
|
|
return _invalid("params must be an object")
|
|
var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate()
|
|
merged.merge(values, true)
|
|
if ClassDB.is_parent_class(real_type, "VisualShaderNodeInput"):
|
|
## Engine gap: `VisualShader.replace_node` swaps the class but never
|
|
## wires `shader_mode`/`shader_type` on the new input node (only
|
|
## `add_node` does), so `input_name` reads as unavailable and codegen
|
|
## is wrong until a save+reload. Rebuild the node at the same id and
|
|
## restore its edges after the params land, when the port type is final.
|
|
var position := shader.get_node_position(stage.value, node_id.value)
|
|
var edges := _node_edges(shader, stage.value, node_id.value)
|
|
var input_node: VisualShaderNode = ClassDB.instantiate(real_type)
|
|
shader.remove_node(stage.value, node_id.value)
|
|
shader.add_node(stage.value, input_node, position, node_id.value)
|
|
var input_applied := _apply_properties(input_node, merged, str(operation.id))
|
|
if input_applied.has("error"):
|
|
return input_applied
|
|
for edge in edges:
|
|
var connect_code := shader.connect_nodes(
|
|
stage.value, int(edge.from_node), int(edge.from_port), int(edge.to_node), int(edge.to_port)
|
|
)
|
|
if connect_code != OK:
|
|
return _invalid("node %s cannot keep connection %d:%d -> %d:%d: %s" % [
|
|
str(operation.id), int(edge.from_node), int(edge.from_port),
|
|
int(edge.to_node), int(edge.to_port), error_string(connect_code),
|
|
])
|
|
return {}
|
|
shader.replace_node(stage.value, node_id.value, real_type)
|
|
return _apply_properties(shader.get_node(stage.value, node_id.value), merged, str(operation.id))
|
|
"set_node_params":
|
|
var stage := _edit_stage(operation, mode)
|
|
if stage.has("error"):
|
|
return stage
|
|
var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases)
|
|
if node_id.has("error"):
|
|
return node_id
|
|
var node := shader.get_node(stage.value, node_id.value)
|
|
if node == null:
|
|
return _invalid("node %s is unavailable" % str(operation.id))
|
|
var values: Variant = operation.get("params", {})
|
|
if not values is Dictionary or values.is_empty():
|
|
return _invalid("set_node_params requires a nonempty params object")
|
|
return _apply_properties(node, values, str(operation.id))
|
|
"set_node_position":
|
|
var stage := _edit_stage(operation, mode)
|
|
if stage.has("error"):
|
|
return stage
|
|
var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases)
|
|
if node_id.has("error"):
|
|
return node_id
|
|
var position := _typed_value(TYPE_VECTOR2, operation.get("position", null))
|
|
if not position.has("value"):
|
|
return _invalid("position requires finite x/y numbers")
|
|
shader.set_node_position(stage.value, node_id.value, position.value)
|
|
return {}
|
|
"connect", "disconnect":
|
|
return _edit_connection(shader, mode, operation, aliases, inputs)
|
|
"add_varying":
|
|
if not VARYING_MODES.has(mode):
|
|
return _invalid("varyings are only supported for spatial/canvas_item shaders")
|
|
var parsed := _parse_varying(operation, "")
|
|
if parsed.has("error"):
|
|
return parsed
|
|
if shader.has_varying(parsed.name):
|
|
return _invalid("varying '%s' already exists" % parsed.name)
|
|
shader.add_varying(parsed.name, parsed.mode, parsed.type)
|
|
return {}
|
|
"remove_varying":
|
|
var varying_name: Variant = operation.get("name")
|
|
if not varying_name is String or not (varying_name as String).is_valid_identifier():
|
|
return _invalid("remove_varying requires an identifier name")
|
|
if not shader.has_varying(varying_name):
|
|
return _invalid("varying '%s' does not exist" % varying_name)
|
|
shader.remove_varying(varying_name)
|
|
return {}
|
|
return _invalid("unknown edit op '%s'" % operation.op)
|
|
|
|
|
|
func _edit_add_node(
|
|
shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary,
|
|
) -> Dictionary:
|
|
var stage_result := _edit_stage(operation, mode)
|
|
if stage_result.has("error"):
|
|
return stage_result
|
|
var stage: int = stage_result.value
|
|
var class_name_value: Variant = operation.get("type")
|
|
if not class_name_value is String:
|
|
return _invalid("add_node requires a type")
|
|
var real_type := _resolve_type(class_name_value)
|
|
if real_type.is_empty():
|
|
return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value))
|
|
var raw_id: Variant = operation.get("id", null)
|
|
var node_id := 0
|
|
var public_id: Variant = null
|
|
if raw_id == null:
|
|
node_id = shader.get_valid_node_id(stage)
|
|
public_id = node_id
|
|
else:
|
|
var canonical := _canonical_id(raw_id)
|
|
if not _valid_id(canonical):
|
|
return _invalid("missing, reserved or invalid node ID %s" % str(canonical))
|
|
if canonical is int:
|
|
if shader.get_node(stage, canonical) != null or canonical < 2:
|
|
return _invalid("node ID %s is already used" % str(canonical))
|
|
node_id = canonical
|
|
public_id = canonical
|
|
else:
|
|
var alias_key := _alias_key(stage, canonical)
|
|
if aliases.has(alias_key):
|
|
return _invalid("node ID '%s' is already used in this stage" % canonical)
|
|
node_id = shader.get_valid_node_id(stage)
|
|
aliases[alias_key] = node_id
|
|
public_id = canonical
|
|
var position := _typed_value(TYPE_VECTOR2, operation.get("position", {"x": 0, "y": 0}))
|
|
if not position.has("value"):
|
|
return _invalid("position requires finite x/y numbers")
|
|
var node: VisualShaderNode = ClassDB.instantiate(real_type)
|
|
shader.add_node(stage, node, position.value, node_id)
|
|
var values: Variant = operation.get("params", {})
|
|
if not values is Dictionary:
|
|
return _invalid("params must be an object")
|
|
var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate()
|
|
merged.merge(values, true)
|
|
var applied := _apply_properties(node, merged, str(public_id))
|
|
if applied.has("error"):
|
|
return applied
|
|
return {"added": {
|
|
"id": public_id, "node_id": node_id, "stage": STAGE_NAMES[stage],
|
|
}}
|
|
|
|
|
|
func _edit_connection(
|
|
shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, inputs: Dictionary,
|
|
) -> Dictionary:
|
|
var stage_result := _edit_stage(operation, mode)
|
|
if stage_result.has("error"):
|
|
return stage_result
|
|
var stage: int = stage_result.value
|
|
var source := _edit_endpoint(shader, stage, operation.get("from_node"), aliases)
|
|
if source.has("error"):
|
|
return source
|
|
var target := _edit_endpoint(shader, stage, operation.get("to_node"), aliases)
|
|
if target.has("error"):
|
|
return target
|
|
var from_port := _integral_number(operation.get("from_port"))
|
|
var to_port := _integral_number(operation.get("to_port"))
|
|
if from_port == null or to_port == null or from_port < 0 or to_port < 0 or from_port > 64 or to_port > 64:
|
|
return _invalid("connection ports must be integers from 0 through 64")
|
|
if operation.op == "disconnect":
|
|
shader.disconnect_nodes(stage, source.value, from_port, target.value, to_port)
|
|
inputs.erase("%d:%d:%d" % [stage, target.value, to_port])
|
|
inputs.erase("%d:%d:%d:%d" % [stage, target.value, to_port, source.value])
|
|
return {}
|
|
return _connect_nodes(shader, stage, source.value, from_port, target.value, to_port, inputs)
|
|
|
|
|
|
func _edit_stage(operation: Dictionary, mode: String) -> Dictionary:
|
|
var stage_name: Variant = operation.get("stage")
|
|
if not stage_name is String or not STAGES.has(stage_name):
|
|
return _invalid("stage must be one of: %s" % ", ".join(STAGES.keys()))
|
|
var allowed: Array = MODE_STAGES.get(mode, [])
|
|
if not allowed.has(stage_name):
|
|
return _invalid("stage '%s' is not available for %s shaders; use: %s" % [stage_name, mode, ", ".join(allowed)])
|
|
return {"value": STAGES[stage_name]}
|
|
|
|
|
|
func _edit_node_id(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary:
|
|
var canonical := _canonical_id(raw)
|
|
if canonical is String:
|
|
if canonical == "output":
|
|
return {"value": 0}
|
|
var alias_key := _alias_key(stage, canonical)
|
|
if not aliases.has(alias_key):
|
|
return _invalid("unknown node ID %s" % canonical)
|
|
return {"value": int(aliases[alias_key])}
|
|
var number := _integral_number(canonical)
|
|
if number == null:
|
|
return _invalid("node id must be an integer or a string used by add_node in this call")
|
|
if number == 0:
|
|
return {"value": 0}
|
|
if shader.get_node(stage, number) == null:
|
|
return _invalid("node ID %d does not exist in this stage" % number)
|
|
return {"value": number}
|
|
|
|
|
|
func _edit_endpoint(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary:
|
|
var canonical := _canonical_id(raw)
|
|
if (canonical is String and canonical == "output") or (canonical is int and canonical == 0):
|
|
return {"value": 0}
|
|
if canonical is String:
|
|
var alias_key := _alias_key(stage, canonical)
|
|
if not aliases.has(alias_key):
|
|
return _invalid("unknown node ID %s" % canonical)
|
|
return {"value": int(aliases[alias_key])}
|
|
var number := _integral_number(canonical)
|
|
if number == null:
|
|
return _invalid("connection endpoints must be node IDs")
|
|
if shader.get_node(stage, number) == null:
|
|
return _invalid("node ID %d does not exist in this stage" % number)
|
|
return {"value": number}
|
|
|
|
|
|
## Snapshot of occupied input ports per stage, keyed stage:target:port:source
|
|
## so a disconnect can release the exact edge and connect can reject duplicates.
|
|
func _collect_inputs(shader: VisualShader, mode: String) -> Dictionary:
|
|
var inputs := {}
|
|
for stage_name in MODE_STAGES.get(mode, []):
|
|
var stage: int = STAGES[stage_name]
|
|
for connection in shader.get_node_connections(stage):
|
|
var target := int(connection.get("to_node", -1))
|
|
var to_port := int(connection.get("to_port", -1))
|
|
var source := int(connection.get("from_node", -1))
|
|
inputs["%d:%d:%d" % [stage, target, to_port]] = true
|
|
inputs["%d:%d:%d:%d" % [stage, target, to_port, source]] = true
|
|
return inputs
|
|
|
|
|
|
## Every edge touching `node_id` in one stage, captured before a node rebuild
|
|
## drops them so the replacement can restore the same connections.
|
|
static func _node_edges(shader: VisualShader, stage: int, node_id: int) -> Array[Dictionary]:
|
|
var edges: Array[Dictionary] = []
|
|
for connection in shader.get_node_connections(stage):
|
|
if int(connection.get("from_node", -1)) == node_id or int(connection.get("to_node", -1)) == node_id:
|
|
edges.append(connection)
|
|
return edges
|
|
|
|
|
|
func _build_stage(shader: VisualShader, spec: Dictionary) -> Dictionary:
|
|
var stage: int = STAGES[spec.stage]
|
|
var ids := {}
|
|
var used := {0: true, 1: true}
|
|
## Reserve all explicit integers before allocating any string ID.
|
|
for node_spec in spec.nodes:
|
|
if not node_spec is Dictionary:
|
|
return _invalid("Stage %s: every node must be an object" % spec.stage)
|
|
var raw: Variant = _canonical_id(node_spec.get("id"))
|
|
if not _valid_id(raw) or ids.has(raw):
|
|
return _invalid("Stage %s: missing, reserved or duplicate node ID %s" % [spec.stage, str(raw)])
|
|
ids[raw] = -1
|
|
if raw is int:
|
|
used[raw] = true
|
|
ids[raw] = raw
|
|
var next_id := 2
|
|
for raw in ids:
|
|
if raw is String:
|
|
while used.has(next_id):
|
|
next_id += 1
|
|
ids[raw] = next_id
|
|
used[next_id] = true
|
|
var public_ids := []
|
|
for node_spec in spec.nodes:
|
|
var raw: Variant = _canonical_id(node_spec.id)
|
|
var id: int = ids[raw]
|
|
var class_name_value: Variant = node_spec.get("type")
|
|
if not class_name_value is String:
|
|
return _invalid("Node %s requires a VisualShaderNode type" % str(raw))
|
|
var real_type: String = ALIASES.get(class_name_value, class_name_value)
|
|
if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type):
|
|
return _invalid("Node %s: %s is not an instantiable VisualShaderNode" % [str(raw), real_type])
|
|
if real_type in UNSUPPORTED_NODES:
|
|
return _invalid("Node %s: %s is not supported in declarative graphs" % [str(raw), real_type])
|
|
var position_value: Variant = node_spec.get("position", {"x": 0, "y": 0})
|
|
var position_result := _typed_value(TYPE_VECTOR2, position_value)
|
|
if not position_result.has("value"):
|
|
return _invalid("Node %s: position requires finite x/y numbers" % str(raw))
|
|
var node: VisualShaderNode = ClassDB.instantiate(real_type)
|
|
shader.add_node(stage, node, position_result.value, id)
|
|
var values: Variant = node_spec.get("params", {})
|
|
if not values is Dictionary:
|
|
return _invalid("Node %s: params must be an object" % str(raw))
|
|
var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate()
|
|
merged.merge(values, true)
|
|
var applied := _apply_properties(node, merged, str(raw))
|
|
if applied.has("error"):
|
|
return applied
|
|
public_ids.append({"id": raw, "node_id": id})
|
|
var inputs := {}
|
|
for edge in spec.connections:
|
|
if not edge is Dictionary or not edge.has_all(["from_node", "from_port", "to_node", "to_port"]):
|
|
return _invalid("Stage %s: connection requires from_node/from_port/to_node/to_port" % spec.stage)
|
|
if edge.has("stage") or edge.has("from_stage") or edge.has("to_stage"):
|
|
return _invalid("Connections belong to their enclosing stage; cross-stage edges are unsupported")
|
|
var source := _endpoint(edge.from_node, ids)
|
|
var target := _endpoint(edge.to_node, ids)
|
|
if source < 2 or target < 0:
|
|
return _invalid("Stage %s: unknown or invalid connection endpoints %s -> %s" % [spec.stage, str(edge.from_node), str(edge.to_node)])
|
|
var from_port_value := _integral_number(edge.from_port)
|
|
var to_port_value := _integral_number(edge.to_port)
|
|
if from_port_value == null or to_port_value == null or from_port_value < 0 or to_port_value < 0:
|
|
return _invalid("Connection ports must be nonnegative integers")
|
|
var connected := _connect_nodes(shader, stage, source, from_port_value, target, to_port_value, inputs)
|
|
if connected.has("error"):
|
|
return _invalid("Stage %s: %s" % [spec.stage, connected.error.message])
|
|
return {"id_map": public_ids}
|
|
|
|
|
|
## Validate and create one connection. `inputs` maps `stage:target:port` to
|
|
## true; the caller owns the dict so duplicate-input detection spans a whole
|
|
## graph build or edit call.
|
|
func _connect_nodes(
|
|
shader: VisualShader, stage: int, source: int, from_port: int,
|
|
target: int, to_port: int, inputs: Dictionary,
|
|
) -> Dictionary:
|
|
var source_node := shader.get_node(stage, source)
|
|
if from_port > 64 or to_port > 64:
|
|
return _invalid("Connection port exceeds the supported maximum of 64")
|
|
if source_node == null:
|
|
return _invalid("source node %d is unavailable" % source)
|
|
## This hidden Array names base output ports whose vector components are
|
|
## exposed. Expand only existing base ports needed to reach a component.
|
|
var raw_expanded: Variant = source_node.get("expanded_output_ports")
|
|
var expanded: Array = raw_expanded if raw_expanded is Array else Array(raw_expanded)
|
|
for port in range(from_port):
|
|
if not expanded.has(port):
|
|
expanded.append(port)
|
|
expanded.sort()
|
|
source_node.set("expanded_output_ports", expanded)
|
|
var input_key := "%d:%d:%d" % [stage, target, to_port]
|
|
if inputs.has(input_key) or not shader.can_connect_nodes(stage, source, from_port, target, to_port):
|
|
return _invalid("invalid, duplicate-input or cyclic connection")
|
|
var err := shader.connect_nodes(stage, source, from_port, target, to_port)
|
|
if err != OK:
|
|
return _invalid("cannot connect: %s" % error_string(err))
|
|
inputs[input_key] = true
|
|
return {}
|
|
|
|
|
|
## Parse a top-level `varyings` array for create_graph. Returns
|
|
## {specs: [{name, mode, type}]} or an error dict.
|
|
func _parse_varyings(raw: Variant, mode: String) -> Dictionary:
|
|
if not raw is Array:
|
|
return _invalid("varyings must be an array")
|
|
if raw.is_empty():
|
|
return {"specs": []}
|
|
if not VARYING_MODES.has(mode):
|
|
return _invalid("varyings are only supported for spatial/canvas_item shaders")
|
|
var specs: Array[Dictionary] = []
|
|
var seen := {}
|
|
for entry in raw:
|
|
if not entry is Dictionary:
|
|
return _invalid("each varying must be an object")
|
|
var parsed := _parse_varying(entry, "")
|
|
if parsed.has("error"):
|
|
return parsed
|
|
if seen.has(parsed.name):
|
|
return _invalid("duplicate varying '%s'" % parsed.name)
|
|
seen[parsed.name] = true
|
|
specs.append({"name": parsed.name, "mode": parsed.mode, "type": parsed.type})
|
|
return {"specs": specs}
|
|
|
|
|
|
## Parse one {name, mode, type} varying spec. `label` is unused today but keeps
|
|
## call sites explicit about which entry failed.
|
|
func _parse_varying(entry: Dictionary, _label: String) -> Dictionary:
|
|
var varying_name: Variant = entry.get("name")
|
|
if not varying_name is String or not (varying_name as String).is_valid_identifier():
|
|
return _invalid("varying name must be a shader identifier")
|
|
var mode := _enum_lookup(VARYING_MODE_VALUES, entry.get("mode"), "varying_mode_")
|
|
if mode == null:
|
|
return _invalid("varying mode must be one of: %s" % ", ".join(VARYING_MODE_VALUES.keys()))
|
|
var varying_type := _enum_lookup(VARYING_TYPE_VALUES, entry.get("type"), "varying_type_")
|
|
if varying_type == null:
|
|
return _invalid("varying type must be one of: %s" % ", ".join(VARYING_TYPE_VALUES.keys()))
|
|
return {"name": varying_name, "mode": mode, "type": varying_type}
|
|
|
|
|
|
## Resolve a friendly or engine enum name / raw integer against `table`.
|
|
static func _enum_lookup(table: Dictionary, value: Variant, prefix: String) -> Variant:
|
|
if value is int:
|
|
return value if table.values().has(value) else null
|
|
if not value is String:
|
|
return null
|
|
var normalized: String = value.to_lower().replace(prefix, "").replace("_", "")
|
|
for key in table:
|
|
if key.replace("_", "") == normalized:
|
|
return table[key]
|
|
return null
|
|
|
|
|
|
## Report a VisualShader's varyings. The engine exposes varying names through
|
|
## dynamic `varyings/<name>` properties but no mode/type getters, so the
|
|
## serialized resource text carries the `"<mode>,<type>"` values; fall back to
|
|
## names only when a value cannot be read.
|
|
func _read_varyings(shader: VisualShader, path: String) -> Array[Dictionary]:
|
|
var names: Array[String] = []
|
|
for property in shader.get_property_list():
|
|
var property_name := str(property.name)
|
|
if property_name.begins_with("varyings/"):
|
|
names.append(property_name.substr("varyings/".length()))
|
|
names.sort()
|
|
var text := ""
|
|
if not names.is_empty():
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file != null:
|
|
text = file.get_as_text()
|
|
file.close()
|
|
var out: Array[Dictionary] = []
|
|
for varying_name in names:
|
|
var entry := {"name": varying_name}
|
|
var raw: Variant = shader.get("varyings/" + varying_name)
|
|
if raw is String:
|
|
var parts: PackedStringArray = (raw as String).split(",")
|
|
if parts.size() == 2:
|
|
entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "")
|
|
entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "")
|
|
if not entry.has("mode"):
|
|
var from_text := _varying_from_text(text, varying_name)
|
|
if not from_text.is_empty():
|
|
var parts: PackedStringArray = from_text.split(",")
|
|
if parts.size() == 2:
|
|
entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "")
|
|
entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "")
|
|
out.append(entry)
|
|
return out
|
|
|
|
|
|
static func _varying_from_text(text: String, varying_name: String) -> String:
|
|
var needle := "varyings/%s" % varying_name
|
|
for raw_line in text.split("\n"):
|
|
var line := raw_line.strip_edges()
|
|
var eq := line.find("=")
|
|
if eq < 0:
|
|
continue
|
|
var left := line.substr(0, eq).strip_edges().trim_prefix("\"").trim_suffix("\"")
|
|
if left != needle:
|
|
continue
|
|
var value := line.substr(eq + 1).strip_edges()
|
|
return value.trim_prefix("\"").trim_suffix("\"")
|
|
return ""
|
|
|
|
|
|
func _serialize_node_params(node: VisualShaderNode) -> Dictionary:
|
|
var present := {}
|
|
for property in node.get_property_list():
|
|
present[str(property.name)] = property
|
|
var params := {}
|
|
for property_name in PROPERTIES:
|
|
if not present.has(property_name):
|
|
continue
|
|
var property: Dictionary = present[property_name]
|
|
if not (int(property.usage) & PROPERTY_USAGE_STORAGE):
|
|
continue
|
|
params[property_name] = MaterialValues.serialize_value(node.get(property_name))
|
|
return params
|
|
|
|
|
|
static func _resolve_type(value: String) -> String:
|
|
var real_type: String = ALIASES.get(value, value)
|
|
if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type):
|
|
return ""
|
|
if real_type in UNSUPPORTED_NODES:
|
|
return ""
|
|
return real_type
|
|
|
|
|
|
static func _valid_id(value: Variant) -> bool:
|
|
return (value is int and value >= 2 and value <= 2147483647) or (value is String and not value.is_empty() and value != "output")
|
|
|
|
|
|
static func _canonical_id(value: Variant) -> Variant:
|
|
if value is String:
|
|
return value
|
|
var number := _integral_number(value)
|
|
return number if number != null else value
|
|
|
|
|
|
## Aliases are scoped to the stage that introduced them: the same string id in
|
|
## two stages is two different nodes, and a duplicate inside one stage is
|
|
## ambiguous, so registration rejects it instead of overwriting.
|
|
static func _alias_key(stage: int, canonical: String) -> String:
|
|
return "%d:%s" % [stage, canonical]
|
|
|
|
|
|
static func _endpoint(value: Variant, ids: Dictionary) -> int:
|
|
value = _canonical_id(value)
|
|
if (value is String and value == "output") or (value is int and value == 0):
|
|
return 0
|
|
return int(ids.get(value, -1)) if value is int or value is String else -1
|
|
|
|
|
|
func _apply_properties(node: VisualShaderNode, values: Dictionary, id: String) -> Dictionary:
|
|
if node == null:
|
|
return _invalid("node %s is unavailable" % id)
|
|
var properties := {}
|
|
for property in node.get_property_list():
|
|
properties[str(property.name)] = property
|
|
for key in values:
|
|
if not key is String or not key in PROPERTIES or not properties.has(key):
|
|
return _invalid("Node %s (%s): unsupported property %s" % [id, node.get_class(), str(key)])
|
|
var property: Dictionary = properties[key]
|
|
if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE):
|
|
return _invalid("Node %s: property %s is not writable" % [id, key])
|
|
var value: Variant = values[key]
|
|
var converted := {}
|
|
if key == "texture":
|
|
if not value is String:
|
|
return _invalid("Node %s: texture requires a resource path" % id)
|
|
var path_error: Variant = McpPathValidator.path_error(value, "texture")
|
|
if path_error != null:
|
|
return path_error
|
|
if not ResourceLoader.exists(value):
|
|
return _invalid("Node %s: texture not found: %s" % [id, value])
|
|
var texture := ResourceLoader.load(value)
|
|
var expected: String = str(property.get("class_name", property.get("hint_string", "")))
|
|
if not (texture is Texture2D or texture is Texture3D) or (not expected.is_empty() and not texture.is_class(expected)):
|
|
return _invalid("Node %s: incompatible texture %s (expected %s)" % [id, value, expected])
|
|
converted = {"value": texture}
|
|
elif int(property.hint) == PROPERTY_HINT_ENUM and int(property.type) == TYPE_INT:
|
|
converted = _enum_value(str(property.hint_string), value)
|
|
else:
|
|
converted = _typed_value(int(property.type), value)
|
|
if not converted.has("value"):
|
|
return _invalid("Node %s: invalid %s value %s" % [id, key, str(value)])
|
|
if key == "parameter_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()):
|
|
return _invalid("Node %s: parameter_name must be a shader identifier" % id)
|
|
if key == "varying_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()):
|
|
return _invalid("Node %s: varying_name must be a shader identifier" % id)
|
|
node.set(key, converted.value)
|
|
if key == "input_name" and node.get_input_real_name().is_empty():
|
|
return _invalid("Node %s: input %s is unavailable in this shader stage" % [id, str(value)])
|
|
return {}
|
|
|
|
|
|
static func _enum_value(hint: String, value: Variant) -> Dictionary:
|
|
var aliases := {"sub": "subtract", "mul": "multiply", "div": "divide", "mod": "remainder", "pow": "power"}
|
|
var normalized := str(aliases.get(value, value)).replace("_", "").replace(" ", "").to_lower()
|
|
var index := 0
|
|
for item in hint.split(","):
|
|
var parts := item.split(":")
|
|
if parts.size() > 1:
|
|
index = int(parts[1])
|
|
var numeric := _integral_number(value)
|
|
if (numeric != null and numeric == index) or (value is String and parts[0].replace("_", "").replace(" ", "").to_lower() == normalized):
|
|
return {"value": index}
|
|
index += 1
|
|
return {}
|
|
|
|
|
|
static func _typed_value(type: int, value: Variant) -> Dictionary:
|
|
match type:
|
|
TYPE_BOOL:
|
|
return {"value": value} if value is bool else {}
|
|
TYPE_INT:
|
|
var integer := _integral_number(value)
|
|
return {"value": integer} if integer != null else {}
|
|
TYPE_FLOAT:
|
|
return {"value": float(value)} if _number(value) else {}
|
|
TYPE_STRING, TYPE_STRING_NAME:
|
|
return {"value": value} if value is String else {}
|
|
TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4, TYPE_COLOR:
|
|
var keys: Array = ["x", "y"]
|
|
if type == TYPE_VECTOR3:
|
|
keys = ["x", "y", "z"]
|
|
elif type == TYPE_VECTOR4:
|
|
keys = ["x", "y", "z", "w"]
|
|
elif type == TYPE_COLOR:
|
|
keys = ["r", "g", "b"]
|
|
if not value is Dictionary or not value.has_all(keys):
|
|
return {}
|
|
for key in keys:
|
|
if not _number(value[key]):
|
|
return {}
|
|
match type:
|
|
TYPE_VECTOR2: return {"value": Vector2(value.x, value.y)}
|
|
TYPE_VECTOR3: return {"value": Vector3(value.x, value.y, value.z)}
|
|
TYPE_VECTOR4: return {"value": Vector4(value.x, value.y, value.z, value.w)}
|
|
TYPE_COLOR:
|
|
if not _number(value.get("a", 1.0)):
|
|
return {}
|
|
return {"value": Color(value.r, value.g, value.b, value.get("a", 1.0))}
|
|
return {}
|
|
|
|
|
|
static func _number(value: Variant) -> bool:
|
|
return (value is int or value is float) and is_finite(float(value))
|
|
|
|
|
|
## JSON.parse_string represents every number as float. Accept only values that
|
|
## round-trip exactly to an integer; direct GDScript callers may still pass int.
|
|
static func _integral_number(value: Variant) -> Variant:
|
|
if value is int:
|
|
return value
|
|
if value is float and is_finite(value) and value == floor(value):
|
|
if value >= -2147483648.0 and value <= 2147483647.0:
|
|
return int(value)
|
|
return null
|
|
|
|
|
|
## Parse `code` through the engine's shader compiler and report whether the
|
|
## appended sentinel uniform is reflected back. Uniform reflection proves the
|
|
## source parsed for the current renderer; it is not a final GPU pipeline
|
|
## compile and says nothing about other renderers.
|
|
static func _source_parses(code: String, sentinel: String) -> bool:
|
|
var probe := Shader.new()
|
|
probe.code = code + "\nuniform float %s;\n" % sentinel
|
|
for uniform in probe.get_shader_uniform_list():
|
|
if str(uniform.get("name", "")) == sentinel:
|
|
return true
|
|
return false
|
|
|
|
|
|
## The `uniform` and `varying` lines the engine emitted for the graph. Used
|
|
## when the current renderer cannot compile the shader type: declarations are
|
|
## mode-independent, so they still parse under a type the renderer accepts.
|
|
static func _generated_declarations(generated: String) -> String:
|
|
var declarations := PackedStringArray()
|
|
for line in generated.split("\n"):
|
|
var stripped := line.strip_edges()
|
|
if stripped.begins_with("uniform ") or stripped.begins_with("varying "):
|
|
declarations.append(stripped)
|
|
return "\n".join(declarations)
|
|
|
|
|
|
## Godot's shader compiler is the authority on declaration legality: a
|
|
## parameter named after a shader keyword passes GDScript's
|
|
## `is_valid_identifier()` but generates `uniform float float;`, which the
|
|
## compiler rejects. Force a copy of the graph to generate its source, then
|
|
## parse that source through the engine. The appended sentinel uniform only
|
|
## appears in the uniform list when the parsed source is accepted, so a missing
|
|
## sentinel means the graph would fail to compile.
|
|
##
|
|
## Renderers differ in which shader types they compile — the Compatibility
|
|
## renderer rejects `shader_type fog` before the graph's content matters — so
|
|
## the type is probed first. When the current renderer cannot compile the type,
|
|
## the generated declarations are parsed under a supported type instead: the
|
|
## advertised modes stay accepted and identifier validation still applies.
|
|
static func _validate_generated_graph(shader: VisualShader) -> Dictionary:
|
|
var probe := shader.duplicate(true) as VisualShader
|
|
if probe == null:
|
|
return _invalid("Cannot copy the VisualShader for compile validation")
|
|
probe._update_shader()
|
|
var generated := probe.get_code()
|
|
var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec()
|
|
var mode_name := str(MODE_NAMES.get(probe.get_mode(), ""))
|
|
if mode_name.is_empty():
|
|
return _invalid("Cannot resolve the VisualShader mode for compile validation")
|
|
var source := generated
|
|
if not _source_parses("shader_type %s;" % mode_name, sentinel):
|
|
source = "shader_type spatial;\n" + _generated_declarations(generated)
|
|
if _source_parses(source, sentinel):
|
|
return {}
|
|
return _invalid(
|
|
"The graph's generated shader does not compile; check parameter and varying "
|
|
+ "identifiers and declarations (reserved shader keywords are not legal names)"
|
|
)
|
|
|
|
|
|
## Stage in the destination directory, then use the OS rename/replace primitive.
|
|
## A failed save or rename never removes/truncates the existing destination.
|
|
## The generated shader is compile-checked first, so an uncompilable graph is
|
|
## rejected before the destination is created or replaced.
|
|
func _save_atomic(shader: VisualShader, path: String, overwrite: bool) -> Dictionary:
|
|
var compiled := _validate_generated_graph(shader)
|
|
if compiled.has("error"):
|
|
return compiled
|
|
var directory := path.get_base_dir()
|
|
if not DirAccess.dir_exists_absolute(directory):
|
|
return _invalid("Destination directory does not exist: %s" % directory)
|
|
var temporary := directory.path_join(".godot-ai-shader-%d-%d.tres" % [OS.get_process_id(), Time.get_ticks_usec()])
|
|
var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID
|
|
var err := ResourceSaver.save(shader, temporary)
|
|
if err == OK and prior_uid != ResourceUID.INVALID_ID:
|
|
err = ResourceSaver.set_uid(temporary, prior_uid)
|
|
if err == OK and FileAccess.file_exists(path) and not overwrite:
|
|
err = ERR_ALREADY_EXISTS
|
|
if err == OK:
|
|
err = DirAccess.rename_absolute(temporary, path)
|
|
if err != OK:
|
|
if FileAccess.file_exists(temporary):
|
|
DirAccess.remove_absolute(temporary)
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot save VisualShader at %s: %s" % [path, error_string(err)])
|
|
shader.take_over_path(path)
|
|
var filesystem := EditorInterface.get_resource_filesystem()
|
|
if filesystem != null:
|
|
filesystem.update_file(path)
|
|
return {}
|
|
|
|
|
|
static func _invalid(message: String) -> Dictionary:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
|