762 lines
26 KiB
GDScript
762 lines
26 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
|
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|
|
|
## Handles particle emitter authoring (GPU + CPU, 2D + 3D).
|
|
##
|
|
## All write operations bundle node creation and sub-resource spawns
|
|
## (ParticleProcessMaterial, default QuadMesh) in a single create_action
|
|
## so Ctrl-Z rolls back the whole effect atomically.
|
|
|
|
const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd")
|
|
const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd")
|
|
|
|
const _VALID_TYPES := {
|
|
"gpu_3d": "GPUParticles3D",
|
|
"gpu_2d": "GPUParticles2D",
|
|
"cpu_3d": "CPUParticles3D",
|
|
"cpu_2d": "CPUParticles2D",
|
|
}
|
|
|
|
const _MAIN_KEYS := [
|
|
"amount",
|
|
"lifetime",
|
|
"one_shot",
|
|
"explosiveness",
|
|
"preprocess",
|
|
"speed_scale",
|
|
"randomness",
|
|
"fixed_fps",
|
|
"emitting",
|
|
"local_coords",
|
|
"interp_to_end",
|
|
]
|
|
|
|
|
|
var _undo_redo: EditorUndoRedoManager
|
|
|
|
|
|
func _init(undo_redo: EditorUndoRedoManager) -> void:
|
|
_undo_redo = undo_redo
|
|
|
|
|
|
# ============================================================================
|
|
# particle_create
|
|
# ============================================================================
|
|
|
|
func create_particle(params: Dictionary) -> Dictionary:
|
|
var parent_path: String = params.get("parent_path", "")
|
|
var node_name: String = params.get("name", "Particles")
|
|
var type_str: String = params.get("type", "gpu_3d")
|
|
|
|
if not _VALID_TYPES.has(type_str):
|
|
return ErrorCodes.make(
|
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
|
|
)
|
|
|
|
var _scene_check := McpNodeValidator.require_scene_or_error()
|
|
if _scene_check.has("error"):
|
|
return _scene_check
|
|
var scene_root: Node = _scene_check.scene_root
|
|
|
|
var parent: Node = scene_root
|
|
if not parent_path.is_empty():
|
|
parent = McpScenePath.resolve(parent_path, scene_root)
|
|
if parent == null:
|
|
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
|
|
|
|
var node := _instantiate_particle(type_str)
|
|
if node == null:
|
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node")
|
|
if not node_name.is_empty():
|
|
node.name = node_name
|
|
|
|
var process_mat: ParticleProcessMaterial = null
|
|
var process_material_created := false
|
|
var draw_mesh: Mesh = null
|
|
var draw_material: StandardMaterial3D = null
|
|
var draw_pass_mesh_created := false
|
|
var draw_material_created := false
|
|
|
|
if type_str == "gpu_3d" or type_str == "gpu_2d":
|
|
process_mat = ParticleProcessMaterial.new()
|
|
process_material_created = true
|
|
if type_str == "gpu_3d":
|
|
draw_mesh = QuadMesh.new()
|
|
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
|
|
# Without a material, the mesh renders flat white — ignoring
|
|
# ParticleProcessMaterial.color_ramp entirely. Give it the standard
|
|
# billboard + vertex-color-as-albedo setup so color_ramp works.
|
|
draw_material = ParticleValues.build_draw_material({})
|
|
(draw_mesh as QuadMesh).material = draw_material
|
|
draw_pass_mesh_created = true
|
|
draw_material_created = true
|
|
|
|
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
|
|
_undo_redo.add_do_method(parent, "add_child", node, true)
|
|
_undo_redo.add_do_method(node, "set_owner", scene_root)
|
|
if process_mat != null:
|
|
_undo_redo.add_do_property(node, "process_material", process_mat)
|
|
_undo_redo.add_do_reference(process_mat)
|
|
if draw_mesh != null:
|
|
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
|
|
_undo_redo.add_do_reference(draw_mesh)
|
|
if draw_material != null:
|
|
_undo_redo.add_do_reference(draw_material)
|
|
_undo_redo.add_do_reference(node)
|
|
_undo_redo.add_undo_method(parent, "remove_child", node)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": McpScenePath.from_node(node, scene_root),
|
|
"parent_path": McpScenePath.from_node(parent, scene_root),
|
|
"name": String(node.name),
|
|
"type": type_str,
|
|
"class": _VALID_TYPES[type_str],
|
|
"process_material_created": process_material_created,
|
|
"draw_pass_mesh_created": draw_pass_mesh_created,
|
|
"draw_material_created": draw_material_created,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_set_main
|
|
# ============================================================================
|
|
|
|
func set_main(params: Dictionary) -> Dictionary:
|
|
var resolved := _resolve_particle(params)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
|
|
var properties: Dictionary = params.get("properties", {})
|
|
if properties.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
|
|
|
|
var coerced: Dictionary = {}
|
|
var old_values: Dictionary = {}
|
|
for property in properties:
|
|
var prop_name: String = String(property)
|
|
if not (prop_name in _MAIN_KEYS):
|
|
return ErrorCodes.make(
|
|
ErrorCodes.INVALID_PARAMS,
|
|
"Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)]
|
|
)
|
|
var prop_type := _node_property_type(node, prop_name)
|
|
if prop_type == TYPE_NIL:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
|
"Property '%s' not present on %s" % [prop_name, node.get_class()]
|
|
)
|
|
var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
|
|
if not coerce_result.ok:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
|
|
coerced[prop_name] = coerce_result.value
|
|
old_values[prop_name] = node.get(prop_name)
|
|
|
|
_undo_redo.create_action("MCP: Set particle main on %s" % node.name)
|
|
for prop_name in coerced:
|
|
_undo_redo.add_do_property(node, prop_name, coerced[prop_name])
|
|
_undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
|
|
_undo_redo.commit_action()
|
|
|
|
var applied: Array[String] = []
|
|
var serialized_values: Dictionary = {}
|
|
for prop_name in coerced:
|
|
applied.append(prop_name)
|
|
serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name])
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"applied": applied,
|
|
"values": serialized_values,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_set_process
|
|
# ============================================================================
|
|
|
|
func set_process(params: Dictionary) -> Dictionary:
|
|
var resolved := _resolve_particle(params)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
|
|
var properties: Dictionary = params.get("properties", {})
|
|
if properties.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
|
|
|
|
# GPU: work through process_material; CPU: properties live on node directly.
|
|
if node is GPUParticles3D or node is GPUParticles2D:
|
|
return _set_process_gpu(node, node_path, properties)
|
|
return _set_process_cpu(node, node_path, properties)
|
|
|
|
|
|
func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
|
|
var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
|
|
var process_material_created := false
|
|
var mat: ParticleProcessMaterial = existing_mat
|
|
if mat == null:
|
|
mat = ParticleProcessMaterial.new()
|
|
process_material_created = true
|
|
|
|
var coerced: Dictionary = {}
|
|
for property in properties:
|
|
var prop_name: String = String(property)
|
|
var prop_type := _object_property_type(mat, prop_name)
|
|
if prop_type == TYPE_NIL:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
|
"Property '%s' not present on ParticleProcessMaterial" % prop_name
|
|
)
|
|
var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
|
|
if not coerce_result.ok:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
|
|
coerced[prop_name] = coerce_result.value
|
|
|
|
_undo_redo.create_action("MCP: Set particle process on %s" % node.name)
|
|
if process_material_created:
|
|
_undo_redo.add_do_property(node, "process_material", mat)
|
|
_undo_redo.add_undo_property(node, "process_material", null)
|
|
_undo_redo.add_do_reference(mat)
|
|
# Apply new values directly on the (newly created) material. No old values to restore.
|
|
for prop_name in coerced:
|
|
mat.set(prop_name, coerced[prop_name])
|
|
else:
|
|
# Use the reusable apply/restore pattern for existing material.
|
|
var old_values: Dictionary = {}
|
|
for prop_name in coerced:
|
|
old_values[prop_name] = mat.get(prop_name)
|
|
for prop_name in coerced:
|
|
_undo_redo.add_do_property(mat, prop_name, coerced[prop_name])
|
|
_undo_redo.add_undo_property(mat, prop_name, old_values[prop_name])
|
|
_undo_redo.commit_action()
|
|
|
|
var applied: Array[String] = []
|
|
var serialized: Dictionary = {}
|
|
for prop_name in coerced:
|
|
applied.append(prop_name)
|
|
serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name))
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"applied": applied,
|
|
"values": serialized,
|
|
"process_material_created": process_material_created,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
|
|
# CPU particles expose the same property vocabulary directly on the node,
|
|
# so property names pass through unchanged.
|
|
var coerced: Dictionary = {}
|
|
var old_values: Dictionary = {}
|
|
|
|
for property in properties:
|
|
var prop_name: String = String(property)
|
|
var prop_type := _node_property_type(node, prop_name)
|
|
if prop_type == TYPE_NIL:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
|
"Property '%s' not present on %s" % [prop_name, node.get_class()]
|
|
)
|
|
var coerce_result := ParticleValues.coerce(prop_name, properties[property], prop_type)
|
|
if not coerce_result.ok:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
|
|
coerced[prop_name] = coerce_result.value
|
|
old_values[prop_name] = node.get(prop_name)
|
|
|
|
_undo_redo.create_action("MCP: Set particle process on %s" % node.name)
|
|
for prop_name in coerced:
|
|
_undo_redo.add_do_property(node, prop_name, coerced[prop_name])
|
|
_undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
|
|
_undo_redo.commit_action()
|
|
|
|
var applied: Array[String] = []
|
|
var serialized: Dictionary = {}
|
|
for prop_name in coerced:
|
|
applied.append(prop_name)
|
|
serialized[prop_name] = ParticleValues.serialize(coerced[prop_name])
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"applied": applied,
|
|
"values": serialized,
|
|
"process_material_created": false,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_set_draw_pass
|
|
# ============================================================================
|
|
|
|
func set_draw_pass(params: Dictionary) -> Dictionary:
|
|
var resolved := _resolve_particle(params)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
|
|
var pass_idx: int = int(params.get("pass", 1))
|
|
var mesh_path: String = params.get("mesh", "")
|
|
var texture_path: String = params.get("texture", "")
|
|
var material_path: String = params.get("material", "")
|
|
|
|
if node is GPUParticles3D:
|
|
return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path)
|
|
if node is CPUParticles3D:
|
|
return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path)
|
|
if node is GPUParticles2D or node is CPUParticles2D:
|
|
return _set_draw_pass_2d(node, node_path, texture_path)
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class())
|
|
|
|
|
|
func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary:
|
|
if pass_idx < 1 or pass_idx > 4:
|
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx)
|
|
|
|
var mesh: Mesh = null
|
|
var mesh_created := false
|
|
var property_name := "draw_pass_%d" % pass_idx
|
|
# draw_pass_N is only a live property when draw_passes >= N. Probe via
|
|
# get_property_list so we don't read a ghost value.
|
|
var existing_mesh: Mesh = null
|
|
if int(node.draw_passes) >= pass_idx:
|
|
existing_mesh = node.get(property_name) as Mesh
|
|
if not mesh_path.is_empty():
|
|
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
|
|
if mesh_path_err != null:
|
|
return mesh_path_err
|
|
if not ResourceLoader.exists(mesh_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
|
|
var loaded := ResourceLoader.load(mesh_path)
|
|
if not (loaded is Mesh):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
|
|
mesh = loaded
|
|
else:
|
|
if existing_mesh == null:
|
|
mesh = QuadMesh.new()
|
|
(mesh as QuadMesh).size = Vector2(0.25, 0.25)
|
|
mesh_created = true
|
|
else:
|
|
mesh = existing_mesh
|
|
|
|
var material: Material = null
|
|
if not material_path.is_empty():
|
|
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
|
|
if material_path_err != null:
|
|
return material_path_err
|
|
if not ResourceLoader.exists(material_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
|
|
var loaded_mat := ResourceLoader.load(material_path)
|
|
if not (loaded_mat is Material):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
|
|
material = loaded_mat
|
|
|
|
var old_draw_passes: int = int(node.draw_passes)
|
|
var new_draw_passes: int = max(old_draw_passes, pass_idx)
|
|
var old_value = existing_mesh # Null if draw_passes < pass_idx
|
|
var old_material: Material = null
|
|
if material != null:
|
|
old_material = node.material_override
|
|
|
|
_undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx])
|
|
# Grow draw_passes first so draw_pass_N property exists before we set it.
|
|
if new_draw_passes != old_draw_passes:
|
|
_undo_redo.add_do_property(node, "draw_passes", new_draw_passes)
|
|
_undo_redo.add_undo_property(node, "draw_passes", old_draw_passes)
|
|
if not mesh_path.is_empty() or mesh_created:
|
|
_undo_redo.add_do_property(node, property_name, mesh)
|
|
_undo_redo.add_undo_property(node, property_name, old_value)
|
|
if mesh_created:
|
|
_undo_redo.add_do_reference(mesh)
|
|
if material != null:
|
|
_undo_redo.add_do_property(node, "material_override", material)
|
|
_undo_redo.add_undo_property(node, "material_override", old_material)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"pass": pass_idx,
|
|
"mesh_path": mesh_path,
|
|
"mesh_class": mesh.get_class() if mesh else "",
|
|
"material_path": material_path,
|
|
"draw_pass_mesh_created": mesh_created,
|
|
"draw_passes_grown": new_draw_passes != old_draw_passes,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary:
|
|
if mesh_path.is_empty() and material_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param")
|
|
|
|
var mesh: Mesh = node.mesh
|
|
var old_mesh: Mesh = mesh
|
|
if not mesh_path.is_empty():
|
|
var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
|
|
if mesh_path_err != null:
|
|
return mesh_path_err
|
|
if not ResourceLoader.exists(mesh_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
|
|
var loaded := ResourceLoader.load(mesh_path)
|
|
if not (loaded is Mesh):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
|
|
mesh = loaded
|
|
|
|
var material: Material = null
|
|
var old_material: Material = node.material_override
|
|
if not material_path.is_empty():
|
|
var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
|
|
if material_path_err != null:
|
|
return material_path_err
|
|
if not ResourceLoader.exists(material_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
|
|
var loaded_mat := ResourceLoader.load(material_path)
|
|
if not (loaded_mat is Material):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
|
|
material = loaded_mat
|
|
|
|
_undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name)
|
|
if not mesh_path.is_empty():
|
|
_undo_redo.add_do_property(node, "mesh", mesh)
|
|
_undo_redo.add_undo_property(node, "mesh", old_mesh)
|
|
if material != null:
|
|
_undo_redo.add_do_property(node, "material_override", material)
|
|
_undo_redo.add_undo_property(node, "material_override", old_material)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"mesh_path": mesh_path,
|
|
"material_path": material_path,
|
|
"draw_pass_mesh_created": false,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary:
|
|
if texture_path.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param")
|
|
var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
|
|
if texture_path_err != null:
|
|
return texture_path_err
|
|
if not ResourceLoader.exists(texture_path):
|
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
|
|
var tex := ResourceLoader.load(texture_path)
|
|
if not (tex is Texture2D):
|
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
|
|
|
|
var old_texture: Texture2D = node.get("texture")
|
|
|
|
_undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name)
|
|
_undo_redo.add_do_property(node, "texture", tex)
|
|
_undo_redo.add_undo_property(node, "texture", old_texture)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"texture_path": texture_path,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_restart
|
|
# ============================================================================
|
|
|
|
func restart_particle(params: Dictionary) -> Dictionary:
|
|
var resolved := _resolve_particle(params)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
if node.has_method("restart"):
|
|
node.restart()
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"undoable": false,
|
|
"reason": "Restart is a runtime operation, not tracked in undo history",
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_get
|
|
# ============================================================================
|
|
|
|
func get_particle(params: Dictionary) -> Dictionary:
|
|
var resolved := _resolve_particle(params)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
|
|
var type_str := ""
|
|
for key in _VALID_TYPES:
|
|
if node.get_class() == _VALID_TYPES[key]:
|
|
type_str = key
|
|
break
|
|
|
|
var main_values: Dictionary = {}
|
|
var node_prop_names := _property_names(node)
|
|
for key in _MAIN_KEYS:
|
|
if node_prop_names.has(key):
|
|
main_values[key] = ParticleValues.serialize(node.get(key))
|
|
|
|
var process_data: Dictionary = {}
|
|
if node is GPUParticles3D or node is GPUParticles2D:
|
|
var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
|
|
if mat != null:
|
|
var process_props: Dictionary = {}
|
|
for prop in mat.get_property_list():
|
|
var usage: int = prop.get("usage", 0)
|
|
if not (usage & PROPERTY_USAGE_EDITOR):
|
|
continue
|
|
var v = mat.get(prop.name)
|
|
if v == null:
|
|
continue
|
|
process_props[prop.name] = ParticleValues.serialize(v)
|
|
process_data = {
|
|
"class": "ParticleProcessMaterial",
|
|
"properties": process_props,
|
|
}
|
|
|
|
var draw_passes: Array[Dictionary] = []
|
|
if node is GPUParticles3D:
|
|
var active_draw_pass_count: int = min(int(node.draw_passes), 4)
|
|
for i in range(1, active_draw_pass_count + 1):
|
|
var prop_name := "draw_pass_%d" % i
|
|
var m: Mesh = node.get(prop_name) as Mesh
|
|
draw_passes.append({
|
|
"pass": i,
|
|
"mesh_class": m.get_class() if m != null else "",
|
|
})
|
|
|
|
var texture_path := ""
|
|
if node is GPUParticles2D or node is CPUParticles2D:
|
|
var t: Texture2D = node.get("texture")
|
|
if t != null:
|
|
texture_path = t.resource_path
|
|
|
|
return {
|
|
"data": {
|
|
"path": node_path,
|
|
"type": type_str,
|
|
"class": node.get_class(),
|
|
"main": main_values,
|
|
"process": process_data,
|
|
"draw_passes": draw_passes,
|
|
"texture_path": texture_path,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# particle_apply_preset
|
|
# ============================================================================
|
|
|
|
func apply_preset(params: Dictionary) -> Dictionary:
|
|
var preset_name: String = params.get("preset", "")
|
|
if preset_name.is_empty():
|
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
|
|
|
|
var overrides: Dictionary = params.get("overrides", {})
|
|
var blueprint = ParticlePresets.build(preset_name, overrides)
|
|
if blueprint == null:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())]
|
|
)
|
|
|
|
var parent_path: String = params.get("parent_path", "")
|
|
var node_name: String = params.get("name", "")
|
|
var type_str: String = params.get("type", "gpu_3d")
|
|
if node_name.is_empty():
|
|
node_name = preset_name.capitalize()
|
|
if not _VALID_TYPES.has(type_str):
|
|
return ErrorCodes.make(
|
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
|
"Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
|
|
)
|
|
|
|
var _scene_check := McpNodeValidator.require_scene_or_error()
|
|
if _scene_check.has("error"):
|
|
return _scene_check
|
|
var scene_root: Node = _scene_check.scene_root
|
|
|
|
var parent: Node = scene_root
|
|
if not parent_path.is_empty():
|
|
parent = McpScenePath.resolve(parent_path, scene_root)
|
|
if parent == null:
|
|
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
|
|
|
|
var node := _instantiate_particle(type_str)
|
|
node.name = node_name
|
|
|
|
var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d"
|
|
var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d"
|
|
|
|
var process_mat: ParticleProcessMaterial = null
|
|
var process_material_created := false
|
|
if is_gpu:
|
|
process_mat = ParticleProcessMaterial.new()
|
|
process_material_created = true
|
|
|
|
var draw_mesh: Mesh = null
|
|
var draw_material: StandardMaterial3D = null
|
|
var draw_pass_mesh_created := false
|
|
var draw_material_created := false
|
|
if type_str == "gpu_3d":
|
|
draw_mesh = QuadMesh.new()
|
|
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
|
|
var draw_config: Dictionary = blueprint.get("draw", {})
|
|
draw_material = ParticleValues.build_draw_material(draw_config)
|
|
(draw_mesh as QuadMesh).material = draw_material
|
|
draw_pass_mesh_created = true
|
|
draw_material_created = true
|
|
|
|
# Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet).
|
|
var main_values: Dictionary = blueprint.get("main", {})
|
|
var process_values: Dictionary = blueprint.get("process", {})
|
|
var applied_main: Array[String] = []
|
|
var applied_process: Array[String] = []
|
|
|
|
for prop in main_values:
|
|
var prop_name := String(prop)
|
|
var prop_type := _object_property_type(node, prop_name)
|
|
if prop_type == TYPE_NIL:
|
|
continue # Silently skip: not all main keys apply to all types.
|
|
var coerce_result := ParticleValues.coerce(prop_name, main_values[prop_name], prop_type)
|
|
if not coerce_result.ok:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
|
|
node.set(prop_name, coerce_result.value)
|
|
applied_main.append(prop_name)
|
|
|
|
# Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node.
|
|
var process_target: Object = process_mat if is_gpu else node
|
|
for prop in process_values:
|
|
var prop_name := String(prop)
|
|
var prop_type := _object_property_type(process_target, prop_name)
|
|
if prop_type == TYPE_NIL:
|
|
continue # Silently skip: preset property doesn't apply to this variant.
|
|
var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type)
|
|
if not coerce_result.ok:
|
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
|
|
process_target.set(prop_name, coerce_result.value)
|
|
applied_process.append(prop_name)
|
|
|
|
_undo_redo.create_action("MCP: Apply preset %s" % preset_name)
|
|
_undo_redo.add_do_method(parent, "add_child", node, true)
|
|
_undo_redo.add_do_method(node, "set_owner", scene_root)
|
|
_undo_redo.add_do_reference(node)
|
|
if process_mat != null:
|
|
_undo_redo.add_do_property(node, "process_material", process_mat)
|
|
_undo_redo.add_do_reference(process_mat)
|
|
if draw_mesh != null:
|
|
_undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
|
|
_undo_redo.add_do_reference(draw_mesh)
|
|
if draw_material != null:
|
|
_undo_redo.add_do_reference(draw_material)
|
|
_undo_redo.add_undo_method(parent, "remove_child", node)
|
|
_undo_redo.commit_action()
|
|
|
|
return {
|
|
"data": {
|
|
"path": McpScenePath.from_node(node, scene_root),
|
|
"parent_path": McpScenePath.from_node(parent, scene_root),
|
|
"name": node_name,
|
|
"preset": preset_name,
|
|
"type": type_str,
|
|
"class": _VALID_TYPES[type_str],
|
|
"applied_main": applied_main,
|
|
"applied_process": applied_process,
|
|
"process_material_created": process_material_created,
|
|
"draw_pass_mesh_created": draw_pass_mesh_created,
|
|
"draw_material_created": draw_material_created,
|
|
"is_3d": is_3d,
|
|
"undoable": true,
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
static func _instantiate_particle(type_str: String) -> Node:
|
|
match type_str:
|
|
"gpu_3d":
|
|
return GPUParticles3D.new()
|
|
"gpu_2d":
|
|
return GPUParticles2D.new()
|
|
"cpu_3d":
|
|
return CPUParticles3D.new()
|
|
"cpu_2d":
|
|
return CPUParticles2D.new()
|
|
return null
|
|
|
|
|
|
func _resolve_particle(params: Dictionary) -> Dictionary:
|
|
var resolved := McpNodeValidator.resolve_or_error(
|
|
params.get("node_path", ""), "node_path",
|
|
)
|
|
if resolved.has("error"):
|
|
return resolved
|
|
var node: Node = resolved.node
|
|
var node_path: String = resolved.path
|
|
var is_particle := node is GPUParticles3D or node is GPUParticles2D \
|
|
or node is CPUParticles3D or node is CPUParticles2D
|
|
if not is_particle:
|
|
return ErrorCodes.make(
|
|
ErrorCodes.WRONG_TYPE,
|
|
"Node %s is not a particle node (got %s)" % [node_path, node.get_class()]
|
|
)
|
|
return {"node": node, "path": node_path}
|
|
|
|
|
|
static func _node_property_type(node: Object, name: String) -> int:
|
|
return _object_property_type(node, name)
|
|
|
|
|
|
static func _object_property_type(obj: Object, name: String) -> int:
|
|
if obj == null:
|
|
return TYPE_NIL
|
|
for prop in obj.get_property_list():
|
|
if prop.name == name:
|
|
return int(prop.get("type", TYPE_NIL))
|
|
return TYPE_NIL
|
|
|
|
|
|
static func _property_names(obj: Object) -> Dictionary:
|
|
var out: Dictionary = {}
|
|
if obj == null:
|
|
return out
|
|
for prop in obj.get_property_list():
|
|
out[prop.name] = true
|
|
return out
|