Added footsteps, new tree, various other tweaks
This commit is contained in:
45
addons/proton_scatter/src/cache/inspector_plugin/cache_panel.gd
vendored
Normal file
45
addons/proton_scatter/src/cache/inspector_plugin/cache_panel.gd
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
@tool
|
||||
extends PanelContainer
|
||||
|
||||
|
||||
const ScatterCache := preload("res://addons/proton_scatter/src/cache/scatter_cache.gd")
|
||||
|
||||
|
||||
@onready var _rebuild_button: Button = %RebuildButton
|
||||
@onready var _restore_button: Button = %RestoreButton
|
||||
@onready var _clear_button: Button = %ClearButton
|
||||
@onready var _enable_for_all_button: Button = %EnableForAllButton
|
||||
|
||||
var _cache: ScatterCache
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_rebuild_button.pressed.connect(_on_rebuild_pressed)
|
||||
_restore_button.pressed.connect(_on_restore_pressed)
|
||||
_clear_button.pressed.connect(_on_clear_pressed)
|
||||
_enable_for_all_button.pressed.connect(_on_enable_for_all_pressed)
|
||||
custom_minimum_size.y = size.y * 1.25
|
||||
|
||||
|
||||
func set_object(cache: ScatterCache) -> void:
|
||||
_cache = cache
|
||||
|
||||
|
||||
func _on_rebuild_pressed() -> void:
|
||||
if is_instance_valid(_cache):
|
||||
_cache.update_cache()
|
||||
|
||||
|
||||
func _on_restore_pressed() -> void:
|
||||
if is_instance_valid(_cache):
|
||||
_cache.restore_cache()
|
||||
|
||||
|
||||
func _on_clear_pressed() -> void:
|
||||
if is_instance_valid(_cache):
|
||||
_cache.clear_cache()
|
||||
|
||||
|
||||
func _on_enable_for_all_pressed() -> void:
|
||||
if is_instance_valid(_cache):
|
||||
_cache.enable_for_all_nodes()
|
||||
49
addons/proton_scatter/src/cache/inspector_plugin/cache_panel.tscn
vendored
Normal file
49
addons/proton_scatter/src/cache/inspector_plugin/cache_panel.tscn
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://dilbceex72g24"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/cache/inspector_plugin/cache_panel.gd" id="1_h1g4a"]
|
||||
[ext_resource type="Texture2D" uid="uid://yqlpvcmb7mfi" path="res://addons/proton_scatter/icons/rebuild.svg" id="2_0ml76"]
|
||||
[ext_resource type="Texture2D" uid="uid://ddjrq1h4mkn6a" path="res://addons/proton_scatter/icons/load.svg" id="3_i6mdl"]
|
||||
[ext_resource type="Texture2D" uid="uid://btb6rqhhi27mx" path="res://addons/proton_scatter/icons/remove.svg" id="4_bfbdy"]
|
||||
|
||||
[node name="CachePanel" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(0, 82.5)
|
||||
offset_right = 161.0
|
||||
offset_bottom = 66.0
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("1_h1g4a")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="RebuildButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
text = "Update Cache"
|
||||
icon = ExtResource("2_0ml76")
|
||||
|
||||
[node name="RestoreButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
text = "Restore Transforms"
|
||||
icon = ExtResource("3_i6mdl")
|
||||
|
||||
[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ClearButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
text = "Clear Cache"
|
||||
icon = ExtResource("4_bfbdy")
|
||||
|
||||
[node name="EnableForAllButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Enable Cache For All"
|
||||
17
addons/proton_scatter/src/cache/inspector_plugin/scatter_cache_plugin.gd
vendored
Normal file
17
addons/proton_scatter/src/cache/inspector_plugin/scatter_cache_plugin.gd
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
@tool
|
||||
extends EditorInspectorPlugin
|
||||
|
||||
|
||||
const CachePanel = preload("./cache_panel.tscn")
|
||||
const ScatterCache = preload("../../cache/scatter_cache.gd")
|
||||
|
||||
|
||||
func _can_handle(object):
|
||||
return is_instance_of(object, ScatterCache)
|
||||
|
||||
|
||||
func _parse_category(object, category: String):
|
||||
if category == "ScatterCache" or category == "scatter_cache.gd":
|
||||
var ui = CachePanel.instantiate()
|
||||
ui.set_object(object)
|
||||
add_custom_control(ui)
|
||||
216
addons/proton_scatter/src/cache/scatter_cache.gd
vendored
Normal file
216
addons/proton_scatter/src/cache/scatter_cache.gd
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# ProtonScatterCacheNode
|
||||
#
|
||||
# Saves the transforms created by ProtonScatter nodes in an external resource
|
||||
# and restore them when loading the scene.
|
||||
#
|
||||
# Use this node when you don't want to wait for scatter nodes to fully rebuild
|
||||
# at start.
|
||||
# You can also enable "Show output in tree" to get the same effect, but the
|
||||
# cache makes it much more VCS friendly, and doesn't clutter your scene tree.
|
||||
|
||||
const DEFAULT_CACHE_FOLDER := "res://addons/proton_scatter/cache/"
|
||||
|
||||
const ProtonScatter := preload("res://addons/proton_scatter/src/scatter.gd")
|
||||
const ProtonScatterTransformList := preload("../common/transform_list.gd")
|
||||
|
||||
|
||||
signal cache_restored
|
||||
|
||||
|
||||
@export_file("*.res", "*.tres") var cache_file := "":
|
||||
set(val):
|
||||
cache_file = val
|
||||
update_configuration_warnings()
|
||||
@export var auto_rebuild_cache_when_saving := true
|
||||
|
||||
@export_group("Debug", "dbg_")
|
||||
@export var dbg_disable_thread := false
|
||||
|
||||
# The resource where transforms are actually stored
|
||||
var _local_cache: ProtonScatterCacheResource
|
||||
var _scene_root: Node
|
||||
var _scatter_nodes: Dictionary #Key: ProtonScatter, Value: cached version
|
||||
var _local_cache_changed := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
|
||||
_scene_root = _get_local_scene_root(self)
|
||||
|
||||
# Check if cache_file is empty, indicating the default case
|
||||
if cache_file.is_empty():
|
||||
if Engine.is_editor_hint():
|
||||
# Ensure the cache folder exists
|
||||
_ensure_cache_folder_exists()
|
||||
else:
|
||||
printerr("ProtonScatter error: You load a ScatterCache node with an empty cache file attribute. Outside of the editor, the addon can't set a default value. Please open the scene in the editor and set a default value.")
|
||||
return
|
||||
|
||||
# Retrieve the scene name to create a unique recognizable name
|
||||
var scene_path: String = _scene_root.get_scene_file_path()
|
||||
var scene_name: String
|
||||
|
||||
# If the scene path is not available, set a random name
|
||||
if scene_path.is_empty():
|
||||
scene_name = str(randi())
|
||||
else:
|
||||
# Use the base name of the scene file and append a hash to avoid collisions
|
||||
scene_name = scene_path.get_file().get_basename()
|
||||
scene_name += "_" + str(scene_path.hash())
|
||||
|
||||
# Set the cache path to the cache folder, incorporating the scene name
|
||||
cache_file = DEFAULT_CACHE_FOLDER.get_basename().path_join(scene_name + "_scatter_cache.res")
|
||||
return
|
||||
|
||||
restore_cache.call_deferred()
|
||||
|
||||
|
||||
func _get_configuration_warnings() -> PackedStringArray:
|
||||
var warnings = PackedStringArray()
|
||||
if cache_file.is_empty():
|
||||
warnings.push_back("No path set for the cache file. Select where to store the cache in the inspector.")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
func _notification(what):
|
||||
if what == NOTIFICATION_EDITOR_PRE_SAVE and auto_rebuild_cache_when_saving:
|
||||
update_cache()
|
||||
|
||||
|
||||
func clear_cache() -> void:
|
||||
_scatter_nodes.clear()
|
||||
_local_cache = null
|
||||
|
||||
|
||||
func update_cache() -> void:
|
||||
if cache_file.is_empty():
|
||||
printerr("Cache file path is empty.")
|
||||
return
|
||||
|
||||
_purge_outdated_nodes()
|
||||
_discover_scatter_nodes(_scene_root)
|
||||
|
||||
if not _local_cache:
|
||||
_local_cache = ProtonScatterCacheResource.new()
|
||||
for s in _scatter_nodes:
|
||||
# Ignore this node if its cache is already up to date
|
||||
var cached_version: int = _scatter_nodes[s]
|
||||
if s.build_version == cached_version:
|
||||
continue
|
||||
|
||||
# If transforms are not available, try to rebuild once.
|
||||
if not s.transforms:
|
||||
s.rebuild.call_deferred()
|
||||
await s.build_completed
|
||||
|
||||
if not s.transforms:
|
||||
continue # Move on to the next if still no results.
|
||||
|
||||
# Store the transforms in the cache.
|
||||
_local_cache.store(_scene_root.get_path_to(s), s.transforms.list)
|
||||
_scatter_nodes[s] = s.build_version
|
||||
_local_cache_changed = true
|
||||
|
||||
# Only save the cache on disk if there's something new to save
|
||||
if not _local_cache_changed:
|
||||
return
|
||||
|
||||
# TODO: Save large files on a thread
|
||||
var err = ResourceSaver.save(_local_cache, cache_file)
|
||||
_local_cache_changed = false
|
||||
|
||||
if err != OK:
|
||||
printerr("ProtonScatter error: Failed to save the cache file. Code: ", err)
|
||||
|
||||
|
||||
func restore_cache() -> void:
|
||||
# Load the cache file if it exists
|
||||
if not ResourceLoader.exists(cache_file):
|
||||
printerr("Could not find cache file ", cache_file)
|
||||
return
|
||||
|
||||
# Cache files are large, load on a separate thread
|
||||
ResourceLoader.load_threaded_request(cache_file)
|
||||
while true:
|
||||
match ResourceLoader.load_threaded_get_status(cache_file):
|
||||
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE:
|
||||
return
|
||||
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_IN_PROGRESS:
|
||||
await get_tree().process_frame
|
||||
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_FAILED:
|
||||
return
|
||||
ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
|
||||
break
|
||||
|
||||
_local_cache = ResourceLoader.load_threaded_get(cache_file)
|
||||
if not _local_cache:
|
||||
printerr("Could not load cache: ", cache_file)
|
||||
return
|
||||
|
||||
_scatter_nodes.clear()
|
||||
_discover_scatter_nodes(_scene_root)
|
||||
|
||||
for s in _scatter_nodes:
|
||||
if s.force_rebuild_on_load:
|
||||
continue # Ignore the cache if the scatter node is about to rebuild anyway.
|
||||
|
||||
# Send the cached transforms to the scatter node.
|
||||
var transforms = ProtonScatterTransformList.new()
|
||||
transforms.list = _local_cache.get_transforms(_scene_root.get_path_to(s))
|
||||
s._perform_sanity_check()
|
||||
s._on_transforms_ready(transforms)
|
||||
s.build_version = 0
|
||||
_scatter_nodes[s] = 0
|
||||
|
||||
cache_restored.emit()
|
||||
|
||||
|
||||
func enable_for_all_nodes() -> void:
|
||||
_purge_outdated_nodes()
|
||||
_discover_scatter_nodes(_scene_root)
|
||||
for s in _scatter_nodes:
|
||||
s.force_rebuild_on_load = false
|
||||
|
||||
|
||||
# If the node comes from an instantiated scene, returns the root of that
|
||||
# instance. Returns the tree root node otherwise.
|
||||
func _get_local_scene_root(node: Node) -> Node:
|
||||
if not node.scene_file_path.is_empty():
|
||||
return node
|
||||
|
||||
var parent: Node = node.get_parent()
|
||||
if not parent:
|
||||
return node
|
||||
|
||||
return _get_local_scene_root(parent)
|
||||
|
||||
|
||||
func _discover_scatter_nodes(node: Node) -> void:
|
||||
if node is ProtonScatter and not _scatter_nodes.has(node):
|
||||
_scatter_nodes[node] = node.build_version
|
||||
|
||||
for c in node.get_children():
|
||||
_discover_scatter_nodes(c)
|
||||
|
||||
|
||||
func _purge_outdated_nodes() -> void:
|
||||
var nodes_to_remove: Array[ProtonScatter] = []
|
||||
for node in _scatter_nodes:
|
||||
if not is_instance_valid(node):
|
||||
nodes_to_remove.push_back(node)
|
||||
_local_cache.erase(_scene_root.get_path_to(node))
|
||||
_local_cache_changed = true
|
||||
|
||||
for node in nodes_to_remove:
|
||||
_scatter_nodes.erase(node)
|
||||
|
||||
|
||||
func _ensure_cache_folder_exists() -> void:
|
||||
if not DirAccess.dir_exists_absolute(DEFAULT_CACHE_FOLDER):
|
||||
DirAccess.make_dir_recursive_absolute(DEFAULT_CACHE_FOLDER)
|
||||
50
addons/proton_scatter/src/common/bounds.gd
Normal file
50
addons/proton_scatter/src/common/bounds.gd
Normal file
@@ -0,0 +1,50 @@
|
||||
@tool
|
||||
extends Resource
|
||||
|
||||
# Used by the Domain class
|
||||
# TODO: This could be replaced by a built-in AABB
|
||||
|
||||
var size: Vector3
|
||||
var center: Vector3
|
||||
var min: Vector3
|
||||
var max: Vector3
|
||||
|
||||
var _points := 0
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
size = Vector3.ZERO
|
||||
center = Vector3.ZERO
|
||||
min = Vector3.ZERO
|
||||
max = Vector3.ZERO
|
||||
_points = 0
|
||||
|
||||
|
||||
func feed(point: Vector3) -> void:
|
||||
if _points == 0:
|
||||
min = point
|
||||
max = point
|
||||
|
||||
min = _minv(min, point)
|
||||
max = _maxv(max, point)
|
||||
_points += 1
|
||||
|
||||
|
||||
# Call this after you've called feed() with all the points in your data set
|
||||
func compute_bounds() -> void:
|
||||
if min == null or max == null:
|
||||
return
|
||||
|
||||
size = max - min
|
||||
center = min + (size / 2.0)
|
||||
|
||||
|
||||
# Returns a vector with the smallest values in each of the 2 input vectors
|
||||
func _minv(v1: Vector3, v2: Vector3) -> Vector3:
|
||||
return Vector3(min(v1.x, v2.x), min(v1.y, v2.y), min(v1.z, v2.z))
|
||||
|
||||
|
||||
# Returns a vector with the highest values in each of the 2 input vectors
|
||||
func _maxv(v1: Vector3, v2: Vector3) -> Vector3:
|
||||
return Vector3(max(v1.x, v2.x), max(v1.y, v2.y), max(v1.z, v2.z))
|
||||
|
||||
27
addons/proton_scatter/src/common/cache_resource.gd
Normal file
27
addons/proton_scatter/src/common/cache_resource.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
@tool
|
||||
class_name ProtonScatterCacheResource
|
||||
extends Resource
|
||||
|
||||
|
||||
@export var data = {}
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
data.clear()
|
||||
|
||||
|
||||
func store(node_path: String, transforms: Array[Transform3D]) -> void:
|
||||
data[node_path] = transforms
|
||||
|
||||
|
||||
func erase(node_path: String) -> void:
|
||||
data.erase(node_path)
|
||||
|
||||
|
||||
func get_transforms(node_path: String) -> Array[Transform3D]:
|
||||
var res: Array[Transform3D]
|
||||
|
||||
if node_path in data:
|
||||
res.assign(data[node_path])
|
||||
|
||||
return res
|
||||
312
addons/proton_scatter/src/common/domain.gd
Normal file
312
addons/proton_scatter/src/common/domain.gd
Normal file
@@ -0,0 +1,312 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# A domain is the complete area where transforms can (and can't) be placed.
|
||||
# A Scatter node has one single domain, a domain has one or more shape nodes.
|
||||
#
|
||||
# It's the combination of every shape defined under a Scatter node, grouped in
|
||||
# a single class that exposes utility functions (check if a point is inside, or
|
||||
# along the surface etc).
|
||||
#
|
||||
# An instance of this class is passed to the modifiers during a rebuild.
|
||||
|
||||
|
||||
const ProtonScatter := preload("../scatter.gd")
|
||||
const ProtonScatterShape := preload("../scatter_shape.gd")
|
||||
const BaseShape := preload("../shapes/base_shape.gd")
|
||||
const Bounds := preload("../common/bounds.gd")
|
||||
|
||||
|
||||
class DomainShapeInfo:
|
||||
var node: Node3D
|
||||
var shape: BaseShape
|
||||
|
||||
func is_point_inside(point: Vector3, local: bool) -> bool:
|
||||
var t: Transform3D
|
||||
if is_instance_valid(node):
|
||||
t = node.get_transform() if local else node.get_global_transform()
|
||||
return shape.is_point_inside(point, t)
|
||||
else:
|
||||
return false
|
||||
|
||||
func get_corners_global() -> Array:
|
||||
return shape.get_corners_global(node.get_global_transform())
|
||||
|
||||
# A polygon made of one outer boundary and one or multiple holes (inner polygons)
|
||||
class ComplexPolygon:
|
||||
var inner: Array[PackedVector2Array] = []
|
||||
var outer: PackedVector2Array
|
||||
|
||||
func add(polygon: PackedVector2Array) -> void:
|
||||
if polygon.is_empty(): return
|
||||
if Geometry2D.is_polygon_clockwise(polygon):
|
||||
inner.push_back(polygon)
|
||||
else:
|
||||
if not outer.is_empty():
|
||||
print_debug("ProtonScatter error: Replacing polygon's existing outer boundary. This should not happen, please report.")
|
||||
outer = polygon
|
||||
|
||||
func add_array(array: Array, reverse := false) -> void:
|
||||
for p in array:
|
||||
if reverse:
|
||||
p.reverse()
|
||||
add(p)
|
||||
|
||||
func get_all() -> Array[PackedVector2Array]:
|
||||
var res = inner.duplicate()
|
||||
res.push_back(outer)
|
||||
return res
|
||||
|
||||
func _to_string() -> String:
|
||||
var res = "o: " + var_to_str(outer.size()) + ", i: ["
|
||||
for i in inner:
|
||||
res += var_to_str(i.size()) + ", "
|
||||
res += "]"
|
||||
return res
|
||||
|
||||
|
||||
var root: ProtonScatter
|
||||
var positive_shapes: Array[DomainShapeInfo]
|
||||
var negative_shapes: Array[DomainShapeInfo]
|
||||
var bounds_global: Bounds = Bounds.new()
|
||||
var bounds_local: Bounds = Bounds.new()
|
||||
var edges: Array[Curve3D] = []
|
||||
|
||||
|
||||
func is_empty() -> bool:
|
||||
return positive_shapes.is_empty()
|
||||
|
||||
|
||||
# If a point is in an exclusion shape, returns false
|
||||
# If a point is in an inclusion shape (but not in an exclusion one), returns true
|
||||
# If a point is in neither, returns false
|
||||
func is_point_inside(point: Vector3, local := true) -> bool:
|
||||
for s in negative_shapes:
|
||||
if s.is_point_inside(point, local):
|
||||
return false
|
||||
|
||||
for s in positive_shapes:
|
||||
if s.is_point_inside(point, local):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# If a point is inside an exclusion shape, returns true
|
||||
# Returns false in every other case
|
||||
func is_point_excluded(point: Vector3, local := true) -> bool:
|
||||
for s in negative_shapes:
|
||||
if s.is_point_inside(point, local):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# Recursively find all ScatterShape nodes under the provided root. In case of
|
||||
# nested Scatter nodes, shapes under these other Scatter nodes will be ignored
|
||||
func discover_shapes(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
positive_shapes.clear()
|
||||
negative_shapes.clear()
|
||||
|
||||
if not is_instance_valid(root):
|
||||
return
|
||||
|
||||
for c in root.get_children():
|
||||
_discover_shapes_recursive(c)
|
||||
compute_bounds()
|
||||
compute_edges()
|
||||
|
||||
|
||||
func compute_bounds() -> void:
|
||||
bounds_global.clear()
|
||||
bounds_local.clear()
|
||||
|
||||
if not is_instance_valid(root):
|
||||
return
|
||||
|
||||
var gt: Transform3D = root.get_global_transform().affine_inverse()
|
||||
|
||||
for info in positive_shapes:
|
||||
for point in info.get_corners_global():
|
||||
bounds_global.feed(point)
|
||||
bounds_local.feed(gt * point)
|
||||
|
||||
bounds_global.compute_bounds()
|
||||
bounds_local.compute_bounds()
|
||||
|
||||
|
||||
func compute_edges() -> void:
|
||||
edges.clear()
|
||||
|
||||
if not is_instance_valid(root):
|
||||
return
|
||||
|
||||
var source_polygons: Array[ComplexPolygon] = []
|
||||
|
||||
## Retrieve all polygons
|
||||
for info in positive_shapes:
|
||||
# Store all closed polygons in a specific array
|
||||
var polygon := ComplexPolygon.new()
|
||||
polygon.add_array(info.shape.get_closed_edges(info.node.transform))
|
||||
|
||||
# Polygons with holes must be merged together first
|
||||
if not polygon.inner.is_empty():
|
||||
source_polygons.push_back(polygon)
|
||||
else:
|
||||
source_polygons.push_front(polygon)
|
||||
|
||||
# Store open edges directly since they are already Curve3D and we
|
||||
# don't apply boolean operations to them.
|
||||
var open_edges = info.shape.get_open_edges(info.node.transform)
|
||||
edges.append_array(open_edges)
|
||||
|
||||
if source_polygons.is_empty():
|
||||
return
|
||||
|
||||
## Merge all closed polygons together
|
||||
var merged_polygons: Array[ComplexPolygon] = []
|
||||
|
||||
while not source_polygons.is_empty():
|
||||
var merged := false
|
||||
var p1: ComplexPolygon = source_polygons.pop_back()
|
||||
var max_steps: int = source_polygons.size()
|
||||
var i = 0
|
||||
|
||||
# Test p1 against every other polygon from source_polygon until a
|
||||
# successful merge. If no merge happened, put it in the final array.
|
||||
while i < max_steps and not merged:
|
||||
i += 1
|
||||
|
||||
# Get the next polygon in the list
|
||||
var p2: ComplexPolygon = source_polygons.pop_back()
|
||||
|
||||
# If the outer boundary of any of the two polygons is completely
|
||||
# enclosed in one of the other polygon's hole, we don't try to
|
||||
# merge them and go the next iteration.
|
||||
var full_overlap = false
|
||||
for ip1 in p1.inner:
|
||||
var res = Geometry2D.clip_polygons(p2.outer, ip1)
|
||||
if res.is_empty():
|
||||
full_overlap = true
|
||||
break
|
||||
|
||||
for ip2 in p2.inner:
|
||||
var res = Geometry2D.clip_polygons(p1.outer, ip2)
|
||||
if res.is_empty():
|
||||
full_overlap = true
|
||||
break
|
||||
|
||||
if full_overlap:
|
||||
source_polygons.push_front(p2)
|
||||
continue
|
||||
|
||||
# Try to merge the two polygons p1 and p2
|
||||
var res = Geometry2D.merge_polygons(p1.outer, p2.outer)
|
||||
var outer_polygons := 0
|
||||
for p in res:
|
||||
if not Geometry2D.is_polygon_clockwise(p):
|
||||
outer_polygons += 1
|
||||
|
||||
# If the merge generated a new polygon, process the holes data from
|
||||
# the two original polygons and store in the new_polygon
|
||||
# P1 and P2 are then discarded and replaced by the new polygon.
|
||||
if outer_polygons == 1:
|
||||
var new_polygon = ComplexPolygon.new()
|
||||
new_polygon.add_array(res)
|
||||
|
||||
# Process the holes data from p1 and p2
|
||||
for ip1 in p1.inner:
|
||||
for ip2 in p2.inner:
|
||||
new_polygon.add_array(Geometry2D.intersect_polygons(ip1, ip2), true)
|
||||
new_polygon.add_array(Geometry2D.clip_polygons(ip2, p1.outer), true)
|
||||
|
||||
new_polygon.add_array(Geometry2D.clip_polygons(ip1, p2.outer), true)
|
||||
|
||||
source_polygons.push_back(new_polygon)
|
||||
merged = true
|
||||
|
||||
# If the polygons don't overlap, return it to the pool to be tested
|
||||
# against other polygons
|
||||
else:
|
||||
source_polygons.push_front(p2)
|
||||
|
||||
# If p1 is not overlapping any other polygon, add it to the final list
|
||||
if not merged:
|
||||
merged_polygons.push_back(p1)
|
||||
|
||||
## For each polygons from the previous step, create a corresponding Curve3D
|
||||
for cp in merged_polygons:
|
||||
for polygon in cp.get_all():
|
||||
if polygon.size() < 2: # Ignore polygons too small to form a loop
|
||||
continue
|
||||
|
||||
var curve := Curve3D.new()
|
||||
for point in polygon:
|
||||
curve.add_point(Vector3(point.x, 0.0, point.y))
|
||||
|
||||
# Close the look if the last vertex is missing (Randomly happens)
|
||||
var first_point := polygon[0]
|
||||
var last_point := polygon[-1]
|
||||
if first_point != last_point:
|
||||
curve.add_point(Vector3(first_point.x, 0.0, first_point.y))
|
||||
|
||||
edges.push_back(curve)
|
||||
|
||||
|
||||
func get_root() -> ProtonScatter:
|
||||
return root
|
||||
|
||||
|
||||
func get_global_transform() -> Transform3D:
|
||||
return root.get_global_transform()
|
||||
|
||||
|
||||
func get_local_transform() -> Transform3D:
|
||||
return root.get_transform()
|
||||
|
||||
|
||||
func get_edges() -> Array[Curve3D]:
|
||||
if edges.is_empty():
|
||||
compute_edges()
|
||||
return edges
|
||||
|
||||
|
||||
func get_copy():
|
||||
var copy = get_script().new()
|
||||
|
||||
copy.root = root
|
||||
copy.bounds_global = bounds_global
|
||||
copy.bounds_local = bounds_local
|
||||
|
||||
for s in positive_shapes:
|
||||
var s_copy = DomainShapeInfo.new()
|
||||
s_copy.node = s.node
|
||||
s_copy.shape = s.shape.get_copy()
|
||||
copy.positive_shapes.push_back(s_copy)
|
||||
|
||||
for s in negative_shapes:
|
||||
var s_copy = DomainShapeInfo.new()
|
||||
s_copy.node = s.node
|
||||
s_copy.shape = s.shape.get_copy()
|
||||
copy.negative_shapes.push_back(s_copy)
|
||||
|
||||
return copy
|
||||
|
||||
|
||||
func _discover_shapes_recursive(node: Node) -> void:
|
||||
if node is ProtonScatter: # Ignore shapes under nested Scatter nodes
|
||||
return
|
||||
|
||||
if node is ProtonScatterShape and node.shape != null:
|
||||
var info := DomainShapeInfo.new()
|
||||
info.node = node
|
||||
info.shape = node.shape
|
||||
|
||||
if node.negative:
|
||||
negative_shapes.push_back(info)
|
||||
else:
|
||||
positive_shapes.push_back(info)
|
||||
|
||||
for c in node.get_children():
|
||||
_discover_shapes_recursive(c)
|
||||
72
addons/proton_scatter/src/common/event_helper.gd
Normal file
72
addons/proton_scatter/src/common/event_helper.gd
Normal file
@@ -0,0 +1,72 @@
|
||||
extends RefCounted
|
||||
|
||||
# Utility class that mimics the Input class behavior
|
||||
#
|
||||
# This only useful when using actions from the Input class isn't possible,
|
||||
# like in _unhandled_input or forward_3d_gui_input for example, where you don't
|
||||
# have a native way to detect if a key was just pressed or released.
|
||||
#
|
||||
# How to use:
|
||||
# Call the feed() method first with the latest event you received, then call
|
||||
# either of the is_key_* function
|
||||
#
|
||||
# If you don't call feed() on the same frame before calling any of these two,
|
||||
# the behavior is undefined.
|
||||
|
||||
|
||||
var _actions := {}
|
||||
|
||||
|
||||
func feed(event: InputEvent) -> void:
|
||||
var key
|
||||
if event is InputEventMouseButton:
|
||||
key = event.button_index
|
||||
elif event is InputEventKey:
|
||||
key = event.keycode
|
||||
else:
|
||||
_cleanup_states()
|
||||
return
|
||||
|
||||
if not key in _actions:
|
||||
_actions[key] = {
|
||||
pressed = event.pressed,
|
||||
just_released = not event.pressed,
|
||||
just_pressed = event.pressed,
|
||||
}
|
||||
return
|
||||
|
||||
var pressed = _actions[key].pressed
|
||||
|
||||
if pressed and not event.pressed:
|
||||
_actions[key].just_released = true
|
||||
_actions[key].just_pressed = false
|
||||
|
||||
if not pressed and event.pressed:
|
||||
_actions[key].just_pressed = true
|
||||
_actions[key].just_released = false
|
||||
|
||||
if pressed and event.pressed:
|
||||
_actions[key].just_pressed = false
|
||||
_actions[key].just_released = false
|
||||
|
||||
_actions[key].pressed = event.pressed
|
||||
|
||||
|
||||
func _cleanup_states() -> void:
|
||||
for key in _actions:
|
||||
_actions[key].just_released = false
|
||||
_actions[key].just_pressed = false
|
||||
|
||||
|
||||
func is_key_just_pressed(key) -> bool:
|
||||
if key in _actions:
|
||||
return _actions[key].just_pressed
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func is_key_just_released(key) -> bool:
|
||||
if key in _actions:
|
||||
return _actions[key].just_released
|
||||
|
||||
return false
|
||||
103
addons/proton_scatter/src/common/physics_helper.gd
Normal file
103
addons/proton_scatter/src/common/physics_helper.gd
Normal file
@@ -0,0 +1,103 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# Runs jobs during the physics step.
|
||||
# Only supports raycast for now, but can easilly be adapted to handle
|
||||
# the other types of queries.
|
||||
|
||||
signal job_completed
|
||||
|
||||
|
||||
const MAX_PHYSICS_QUERIES_SETTING := "addons/proton_scatter/max_physics_queries_per_frame"
|
||||
|
||||
|
||||
var _is_ready := false
|
||||
var _job_in_progress := false
|
||||
var _max_queries_per_frame := 400
|
||||
var _main_thread_id: int
|
||||
var _queries: Array
|
||||
var _results: Array[Dictionary]
|
||||
var _space_state: PhysicsDirectSpaceState3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_physics_process(false)
|
||||
_main_thread_id = OS.get_thread_caller_id()
|
||||
_is_ready = true
|
||||
|
||||
|
||||
func _exit_tree():
|
||||
if _job_in_progress:
|
||||
_job_in_progress = false
|
||||
job_completed.emit()
|
||||
|
||||
|
||||
func execute(queries: Array) -> Array[Dictionary]:
|
||||
if not _is_ready:
|
||||
printerr("ProtonScatter error: Calling execute on a PhysicsHelper before it's ready, this should not happen.")
|
||||
return []
|
||||
|
||||
# Don't execute physics queries, if the node is not inside the tree.
|
||||
# This avoids infinite loops, because the _physics_process will never be executed.
|
||||
# This happens when the Scatter node is removed, while it perform a rebuild with a Thread.
|
||||
if not is_inside_tree():
|
||||
printerr("ProtonScatter error: Calling execute on a PhysicsHelper while the node is not inside the tree.")
|
||||
return []
|
||||
|
||||
# Clear previous job if any
|
||||
_queries.clear()
|
||||
|
||||
if _job_in_progress:
|
||||
await _until(get_tree().physics_frame, func(): return _job_in_progress)
|
||||
|
||||
_results.clear()
|
||||
_queries = queries
|
||||
_max_queries_per_frame = ProjectSettings.get_setting(MAX_PHYSICS_QUERIES_SETTING, 500)
|
||||
_job_in_progress = true
|
||||
set_physics_process.bind(true).call_deferred()
|
||||
|
||||
await _until(job_completed, func(): return _job_in_progress, true)
|
||||
|
||||
return _results.duplicate()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if _queries.is_empty():
|
||||
return
|
||||
|
||||
if not _space_state:
|
||||
_space_state = get_tree().get_root().get_world_3d().get_direct_space_state()
|
||||
|
||||
var steps = min(_max_queries_per_frame, _queries.size())
|
||||
for i in steps:
|
||||
var q = _queries.pop_back()
|
||||
var hit := _space_state.intersect_ray(q) # TODO: Add support for other operations
|
||||
_results.push_back(hit)
|
||||
|
||||
if _queries.is_empty():
|
||||
set_physics_process(false)
|
||||
_results.reverse()
|
||||
_job_in_progress = false
|
||||
job_completed.emit()
|
||||
|
||||
|
||||
func _in_main_thread() -> bool:
|
||||
return OS.get_thread_caller_id() == _main_thread_id
|
||||
|
||||
|
||||
func _until(s: Signal, callable: Callable, physics := false) -> void:
|
||||
if _in_main_thread():
|
||||
await s
|
||||
return
|
||||
|
||||
# Called from a sub thread
|
||||
var delay: int = 0
|
||||
if physics:
|
||||
delay = round(get_physics_process_delta_time() * 100.0)
|
||||
else:
|
||||
delay = round(get_process_delta_time() * 100.0)
|
||||
|
||||
while callable.call():
|
||||
OS.delay_msec(delay)
|
||||
if not is_inside_tree():
|
||||
return
|
||||
490
addons/proton_scatter/src/common/scatter_util.gd
Normal file
490
addons/proton_scatter/src/common/scatter_util.gd
Normal file
@@ -0,0 +1,490 @@
|
||||
extends Node
|
||||
|
||||
# To prevent the other core scripts from becoming too large, some of their
|
||||
# utility functions are written here (only the functions that don't disturb
|
||||
# reading the core code, mostly data validation and other verbose checks).
|
||||
|
||||
|
||||
const ProtonScatter := preload("../scatter.gd")
|
||||
const ProtonScatterItem := preload("../scatter_item.gd")
|
||||
const ModifierStack := preload("../stack/modifier_stack.gd")
|
||||
|
||||
### SCATTER UTILITY FUNCTIONS ###
|
||||
|
||||
|
||||
# Make sure the output node exists. This is the parent node to
|
||||
# everything generated by the scatter mesh
|
||||
static func ensure_output_root_exists(s: ProtonScatter) -> void:
|
||||
# Check if the node exists in the tree
|
||||
if not s.output_root:
|
||||
s.output_root = s.get_node_or_null("ScatterOutput")
|
||||
|
||||
# If the node is valid, end here
|
||||
if is_instance_valid(s.output_root) and s.has_node(NodePath(s.output_root.name)):
|
||||
enforce_output_root_owner(s)
|
||||
return
|
||||
|
||||
# Some conditions are not met, cleanup and recreate the root
|
||||
if s.output_root:
|
||||
if s.has_node(NodePath(s.output_root.name)):
|
||||
s.remove_node(s.output_root.name)
|
||||
s.output_root.queue_free()
|
||||
s.output_root = null
|
||||
|
||||
s.output_root = Marker3D.new()
|
||||
s.output_root.name = "ScatterOutput"
|
||||
s.add_child(s.output_root, true)
|
||||
|
||||
enforce_output_root_owner(s)
|
||||
|
||||
|
||||
static func enforce_output_root_owner(s: ProtonScatter) -> void:
|
||||
if is_instance_valid(s.output_root) and s.is_inside_tree():
|
||||
if s.show_output_in_tree:
|
||||
set_owner_recursive(s.output_root, s.get_tree().get_edited_scene_root())
|
||||
else:
|
||||
set_owner_recursive(s.output_root, null)
|
||||
|
||||
# TMP: Workaround to force the scene tree to update and take in account
|
||||
# the owner changes. Otherwise it doesn't show until much later.
|
||||
s.output_root.update_configuration_warnings()
|
||||
|
||||
|
||||
# Item root is a Node3D placed as a child of the ScatterOutput node.
|
||||
# Each ScatterItem has a corresponding output node, serving as a parent for
|
||||
# the Multimeshes or duplicates generated by the Scatter node.
|
||||
static func get_or_create_item_root(item: ProtonScatterItem) -> Node3D:
|
||||
var s: ProtonScatter = item.get_parent()
|
||||
ensure_output_root_exists(s)
|
||||
var item_root: Node3D = s.output_root.get_node_or_null(NodePath(item.name))
|
||||
|
||||
if not item_root:
|
||||
item_root = Node3D.new()
|
||||
item_root.name = item.name
|
||||
s.output_root.add_child(item_root, true)
|
||||
|
||||
if Engine.is_editor_hint():
|
||||
item_root.owner = item.get_tree().get_edited_scene_root()
|
||||
|
||||
return item_root
|
||||
|
||||
|
||||
static func get_or_create_multimesh(item: ProtonScatterItem, count: int) -> MultiMeshInstance3D:
|
||||
var item_root := get_or_create_item_root(item)
|
||||
var mmi: MultiMeshInstance3D = item_root.get_node_or_null("MultiMeshInstance3D")
|
||||
|
||||
if not mmi:
|
||||
mmi = MultiMeshInstance3D.new()
|
||||
mmi.set_name("MultiMeshInstance3D")
|
||||
item_root.add_child(mmi, true)
|
||||
|
||||
mmi.set_owner(item_root.owner)
|
||||
if not mmi.multimesh:
|
||||
mmi.multimesh = MultiMesh.new()
|
||||
|
||||
var mesh_instance: MeshInstance3D = get_merged_meshes_from(item)
|
||||
if not mesh_instance:
|
||||
return
|
||||
|
||||
mmi.position = Vector3.ZERO
|
||||
mmi.material_override = get_final_material(item, mesh_instance)
|
||||
mmi.set_cast_shadows_setting(item.override_cast_shadow)
|
||||
|
||||
mmi.multimesh.instance_count = 0 # Set this to zero or you can't change the other values
|
||||
mmi.multimesh.mesh = mesh_instance.mesh
|
||||
mmi.multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
|
||||
mmi.visibility_range_begin = item.visibility_range_begin
|
||||
mmi.visibility_range_begin_margin = item.visibility_range_begin_margin
|
||||
mmi.visibility_range_end = item.visibility_range_end
|
||||
mmi.visibility_range_end_margin = item.visibility_range_end_margin
|
||||
mmi.visibility_range_fade_mode = item.visibility_range_fade_mode
|
||||
mmi.layers = item.visibility_layers
|
||||
|
||||
mmi.multimesh.instance_count = count
|
||||
|
||||
mesh_instance.queue_free()
|
||||
|
||||
return mmi
|
||||
|
||||
|
||||
static func get_or_create_multimesh_chunk(item: ProtonScatterItem,
|
||||
mesh_instance: MeshInstance3D,
|
||||
index: Vector3i,
|
||||
count: int)\
|
||||
-> MultiMeshInstance3D:
|
||||
var item_root := get_or_create_item_root(item)
|
||||
var chunk_name = "MultiMeshInstance3D" + "_%s_%s_%s"%[index.x, index.y, index.z]
|
||||
var mmi: MultiMeshInstance3D = item_root.get_node_or_null(chunk_name)
|
||||
if not mesh_instance:
|
||||
return
|
||||
|
||||
if not mmi:
|
||||
mmi = MultiMeshInstance3D.new()
|
||||
mmi.set_name(chunk_name)
|
||||
# if set_name is used after add_child it is crazy slow
|
||||
# This doesn't make much sense but it is definitely the case.
|
||||
# About a 100x slowdown was observed in this case
|
||||
item_root.add_child.bind(mmi, true).call_deferred()
|
||||
|
||||
if not mmi.multimesh:
|
||||
mmi.multimesh = MultiMesh.new()
|
||||
|
||||
mmi.position = Vector3.ZERO
|
||||
mmi.material_override = get_final_material(item, mesh_instance)
|
||||
mmi.set_cast_shadows_setting(item.override_cast_shadow)
|
||||
|
||||
mmi.multimesh.instance_count = 0 # Set this to zero or you can't change the other values
|
||||
mmi.multimesh.mesh = mesh_instance.mesh
|
||||
mmi.multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
|
||||
mmi.visibility_range_begin = item.visibility_range_begin
|
||||
mmi.visibility_range_begin_margin = item.visibility_range_begin_margin
|
||||
mmi.visibility_range_end = item.visibility_range_end
|
||||
mmi.visibility_range_end_margin = item.visibility_range_end_margin
|
||||
mmi.visibility_range_fade_mode = item.visibility_range_fade_mode
|
||||
mmi.layers = item.visibility_layers
|
||||
|
||||
mmi.multimesh.instance_count = count
|
||||
|
||||
return mmi
|
||||
|
||||
|
||||
static func get_or_create_particles(item: ProtonScatterItem) -> GPUParticles3D:
|
||||
var item_root := get_or_create_item_root(item)
|
||||
var particles: GPUParticles3D = item_root.get_node_or_null("GPUParticles3D")
|
||||
|
||||
if not particles:
|
||||
particles = GPUParticles3D.new()
|
||||
particles.set_name("GPUParticles3D")
|
||||
item_root.add_child(particles)
|
||||
|
||||
particles.set_owner(item_root.owner)
|
||||
|
||||
var mesh_instance: MeshInstance3D = get_merged_meshes_from(item)
|
||||
if not mesh_instance:
|
||||
return
|
||||
|
||||
particles.material_override = get_final_material(item, mesh_instance)
|
||||
particles.set_draw_pass_mesh(0, mesh_instance.mesh)
|
||||
particles.position = Vector3.ZERO
|
||||
particles.local_coords = true
|
||||
particles.layers = item.visibility_layers
|
||||
|
||||
# Use the user provided material if it exists.
|
||||
var process_material: Material = item.override_process_material
|
||||
|
||||
# Or load the default one if there's nothing.
|
||||
if not process_material:
|
||||
process_material = ShaderMaterial.new()
|
||||
process_material.shader = preload("../particles/static.gdshader")
|
||||
|
||||
if process_material is ShaderMaterial:
|
||||
process_material.set_shader_parameter("global_transform", item_root.get_global_transform())
|
||||
|
||||
particles.set_process_material(process_material)
|
||||
|
||||
# TMP: Workaround to get infinite life time.
|
||||
# Should be fine, but extensive testing is required.
|
||||
# I can't get particles to restart when using emit_particle() from a script, so it's either
|
||||
# that, or encoding the transform array in a texture an read that data from the particle
|
||||
# shader, which is significantly harder.
|
||||
particles.lifetime = 1.79769e308
|
||||
|
||||
# Kill previous particles or new ones will not spawn.
|
||||
particles.restart()
|
||||
|
||||
return particles
|
||||
|
||||
|
||||
# Called from child nodes who affect the rebuild process (like ScatterShape)
|
||||
# Usually, it would be the Scatter node responsibility to listen to changes from
|
||||
# the children nodes, but keeping track of the children is annoying (they can
|
||||
# be moved around from a Scatter node to another, or put under a wrong node, or
|
||||
# other edge cases).
|
||||
# So instead, when a child change, it notifies the parent Scatter node through
|
||||
# this method.
|
||||
static func request_parent_to_rebuild(node: Node, deferred := true) -> void:
|
||||
var parent = node.get_parent()
|
||||
if not parent or not parent.is_inside_tree():
|
||||
return
|
||||
|
||||
if parent and parent is ProtonScatter:
|
||||
if not parent.is_ready:
|
||||
return
|
||||
|
||||
if deferred:
|
||||
parent.rebuild.call_deferred(true)
|
||||
else:
|
||||
parent.rebuild(true)
|
||||
|
||||
|
||||
### MESH UTILITY ###
|
||||
|
||||
# Recursively search for all MeshInstances3D in the node's children and
|
||||
# returns them all in an array. If node is a MeshInstance, it will also be
|
||||
# added to the array
|
||||
static func get_all_mesh_instances_from(node: Node) -> Array[MeshInstance3D]:
|
||||
var res: Array[MeshInstance3D] = []
|
||||
|
||||
if node is MeshInstance3D:
|
||||
res.push_back(node)
|
||||
|
||||
for c in node.get_children():
|
||||
res.append_array(get_all_mesh_instances_from(c))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
static func get_final_material(item: ProtonScatterItem, mi: MeshInstance3D) -> Material:
|
||||
if item.override_material:
|
||||
return item.override_material
|
||||
|
||||
if mi.material_override:
|
||||
return mi.material_override
|
||||
|
||||
if mi.get_surface_override_material(0):
|
||||
return mi.get_surface_override_material(0)
|
||||
|
||||
return null
|
||||
|
||||
|
||||
# Merge all the MeshInstances from the local node tree into a single MeshInstance.
|
||||
# /!\ This is a best effort algorithm and will not work in some specific cases. /!\
|
||||
#
|
||||
# Mesh resources can have a maximum of 8 surfaces:
|
||||
# + If less than 8 different surfaces are found across all the MeshInstances,
|
||||
# this returns a single instance with all the surfaces.
|
||||
#
|
||||
# + If more than 8 surfaces are found, but some shares the same material,
|
||||
# these surfaces will be merged together if there's less than 8 unique materials.
|
||||
#
|
||||
# + If there's more than 8 unique materials, everything will be merged into
|
||||
# a single surface. Material and custom data will NOT be preserved on the new mesh.
|
||||
#
|
||||
static func get_merged_meshes_from(item: ProtonScatterItem) -> MeshInstance3D:
|
||||
if not item:
|
||||
return null
|
||||
|
||||
var source: Node = item.get_item()
|
||||
if not is_instance_valid(source):
|
||||
return null
|
||||
|
||||
source.transform = Transform3D()
|
||||
|
||||
# Get all the mesh instances
|
||||
var mesh_instances: Array[MeshInstance3D] = get_all_mesh_instances_from(source)
|
||||
source.queue_free()
|
||||
|
||||
if mesh_instances.is_empty():
|
||||
return null
|
||||
|
||||
# If there's only one mesh instance we can reuse it directly if the materials allow it.
|
||||
if mesh_instances.size() == 1:
|
||||
# Duplicate the meshinstance, not the mesh resource
|
||||
var mi: MeshInstance3D = mesh_instances[0].duplicate()
|
||||
|
||||
# MI uses a material override, all surface materials will be ignored
|
||||
if mi.material_override:
|
||||
return mi
|
||||
|
||||
var surface_overrides_count := 0
|
||||
for i in mi.get_surface_override_material_count():
|
||||
if mi.get_surface_override_material(i):
|
||||
surface_overrides_count += 1
|
||||
|
||||
# If there's one material override or less, no duplicate mesh is required.
|
||||
if surface_overrides_count <= 1:
|
||||
return mi
|
||||
|
||||
|
||||
# Helper lambdas
|
||||
var get_material_for_surface = func (mi: MeshInstance3D, idx: int) -> Material:
|
||||
if mi.get_material_override():
|
||||
return mi.get_material_override()
|
||||
|
||||
if mi.get_surface_override_material(idx):
|
||||
return mi.get_surface_override_material(idx)
|
||||
|
||||
if mi.mesh is PrimitiveMesh:
|
||||
return mi.mesh.get_material()
|
||||
|
||||
return mi.mesh.surface_get_material(idx)
|
||||
|
||||
# Count how many surfaces / materials there are in the source instances
|
||||
var total_surfaces := 0
|
||||
var surfaces_map := {}
|
||||
# Key: Material
|
||||
# data: Array[Dictionary]
|
||||
# "surface": surface index
|
||||
# "mesh_instance": parent mesh instance
|
||||
|
||||
for mi in mesh_instances:
|
||||
if not mi.mesh:
|
||||
continue # Should not happen
|
||||
|
||||
# Update the total surface count
|
||||
var surface_count = mi.mesh.get_surface_count()
|
||||
total_surfaces += surface_count
|
||||
|
||||
# Store surfaces in the material indexed dictionary
|
||||
for surface_index in surface_count:
|
||||
var material: Material = get_material_for_surface.call(mi, surface_index)
|
||||
if not material in surfaces_map:
|
||||
surfaces_map[material] = []
|
||||
|
||||
surfaces_map[material].push_back({
|
||||
"surface": surface_index,
|
||||
"mesh_instance": mi,
|
||||
})
|
||||
|
||||
# ------
|
||||
# Less than 8 surfaces, merge in a single MeshInstance
|
||||
# ------
|
||||
if total_surfaces <= 8:
|
||||
var mesh := ImporterMesh.new()
|
||||
|
||||
for mi in mesh_instances:
|
||||
var inverse_transform := mi.transform.affine_inverse()
|
||||
|
||||
for surface_index in mi.mesh.get_surface_count():
|
||||
# Retrieve surface data
|
||||
var primitive_type = Mesh.PRIMITIVE_TRIANGLES
|
||||
var format = 0
|
||||
var arrays := mi.mesh.surface_get_arrays(surface_index)
|
||||
if mi.mesh is ArrayMesh:
|
||||
primitive_type = mi.mesh.surface_get_primitive_type(surface_index)
|
||||
format = mi.mesh.surface_get_format(surface_index) # Preserve custom data format
|
||||
|
||||
# Update vertex position based on MeshInstance transform
|
||||
var vertex_count = arrays[ArrayMesh.ARRAY_VERTEX].size()
|
||||
var vertex: Vector3
|
||||
for index in vertex_count:
|
||||
vertex = arrays[ArrayMesh.ARRAY_VERTEX][index] * inverse_transform
|
||||
arrays[ArrayMesh.ARRAY_VERTEX][index] = vertex
|
||||
|
||||
# Get the material if any
|
||||
var material: Material = get_material_for_surface.call(mi, surface_index)
|
||||
|
||||
# Store updated surface data in the new mesh
|
||||
mesh.add_surface(primitive_type, arrays, [], {}, material, "", format)
|
||||
|
||||
if item.lod_generate:
|
||||
mesh.generate_lods(item.lod_merge_angle, item.lod_split_angle, [])
|
||||
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh.get_mesh()
|
||||
return instance
|
||||
|
||||
# ------
|
||||
# Too many surfaces and materials, merge everything in a single one.
|
||||
# ------
|
||||
var total_unique_materials := surfaces_map.size()
|
||||
|
||||
if total_unique_materials > 8:
|
||||
var surface_tool := SurfaceTool.new()
|
||||
surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
|
||||
for mi in mesh_instances:
|
||||
var mesh : Mesh = mi.mesh
|
||||
for surface_i in mesh.get_surface_count():
|
||||
surface_tool.append_from(mesh, surface_i, mi.transform)
|
||||
|
||||
var mesh := ImporterMesh.new()
|
||||
mesh.add_surface(surface_tool.get_primitive_type(), surface_tool.commit_to_arrays())
|
||||
|
||||
if item.lod_generate:
|
||||
mesh.generate_lods(item.lod_merge_angle, item.lod_split_angle, [])
|
||||
|
||||
var instance = MeshInstance3D.new()
|
||||
instance.mesh = mesh.get_mesh()
|
||||
return instance
|
||||
|
||||
# ------
|
||||
# Merge surfaces grouped by their materials
|
||||
# ------
|
||||
var mesh := ImporterMesh.new()
|
||||
|
||||
for material in surfaces_map.keys():
|
||||
var surface_tool := SurfaceTool.new()
|
||||
surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
|
||||
var surfaces: Array = surfaces_map[material]
|
||||
for data in surfaces:
|
||||
var idx: int = data["surface"]
|
||||
var mi: MeshInstance3D = data["mesh_instance"]
|
||||
|
||||
surface_tool.append_from(mi.mesh, idx, mi.transform)
|
||||
|
||||
mesh.add_surface(
|
||||
surface_tool.get_primitive_type(),
|
||||
surface_tool.commit_to_arrays(),
|
||||
[], {},
|
||||
material)
|
||||
|
||||
if item.lod_generate:
|
||||
mesh.generate_lods(item.lod_merge_angle, item.lod_split_angle, [])
|
||||
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.mesh = mesh.get_mesh()
|
||||
return instance
|
||||
|
||||
|
||||
static func get_all_static_bodies_from(node: Node) -> Array[StaticBody3D]:
|
||||
var res: Array[StaticBody3D] = []
|
||||
|
||||
if node is StaticBody3D:
|
||||
res.push_back(node)
|
||||
|
||||
for c in node.get_children():
|
||||
res.append_array(get_all_static_bodies_from(c))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# Grab every static bodies from the source item and merge them in a single
|
||||
# one with multiple collision shapes.
|
||||
static func get_collision_data(item: ProtonScatterItem) -> StaticBody3D:
|
||||
var static_body := StaticBody3D.new()
|
||||
var source: Node3D = item.get_item()
|
||||
if not is_instance_valid(source):
|
||||
return static_body
|
||||
|
||||
source.transform = Transform3D()
|
||||
|
||||
for body in get_all_static_bodies_from(source):
|
||||
for child in body.get_children():
|
||||
if child is CollisionShape3D:
|
||||
# Don't use reparent() here or the child transform gets reset.
|
||||
body.remove_child(child)
|
||||
child.owner = null
|
||||
static_body.add_child(child)
|
||||
|
||||
source.queue_free()
|
||||
return static_body
|
||||
|
||||
|
||||
static func set_owner_recursive(node: Node, new_owner) -> void:
|
||||
node.set_owner(new_owner)
|
||||
|
||||
if not node.get_scene_file_path().is_empty():
|
||||
return # Node is an instantiated scene, don't change its children owner.
|
||||
|
||||
for c in node.get_children():
|
||||
set_owner_recursive(c, new_owner)
|
||||
|
||||
|
||||
static func get_aabb_from_transforms(transforms : Array) -> AABB:
|
||||
if transforms.size() < 1:
|
||||
return AABB(Vector3.ZERO, Vector3.ZERO)
|
||||
var aabb = AABB(transforms[0].origin, Vector3.ZERO)
|
||||
for t in transforms:
|
||||
aabb = aabb.expand(t.origin)
|
||||
return aabb
|
||||
|
||||
|
||||
static func set_visibility_layers(node: Node, layers: int) -> void:
|
||||
if node is VisualInstance3D:
|
||||
node.layers = layers
|
||||
for child in node.get_children():
|
||||
set_visibility_layers(child, layers)
|
||||
66
addons/proton_scatter/src/common/transform_list.gd
Normal file
66
addons/proton_scatter/src/common/transform_list.gd
Normal file
@@ -0,0 +1,66 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
|
||||
var list: Array[Transform3D] = []
|
||||
var max_count := -1
|
||||
|
||||
|
||||
func add(count: int) -> void:
|
||||
for i in count:
|
||||
var t := Transform3D()
|
||||
list.push_back(t)
|
||||
|
||||
|
||||
func append(array: Array[Transform3D]) -> void:
|
||||
list.append_array(array)
|
||||
|
||||
|
||||
func remove(count: int) -> void:
|
||||
count = int(max(count, 0)) # Prevent using a negative number
|
||||
var new_size = max(list.size() - count, 0)
|
||||
list.resize(new_size)
|
||||
|
||||
|
||||
func resize(count: int) -> void:
|
||||
if max_count >= 0:
|
||||
count = int(min(count, max_count))
|
||||
|
||||
var current_count = list.size()
|
||||
if count > current_count:
|
||||
add(count - current_count)
|
||||
else:
|
||||
remove(current_count - count)
|
||||
|
||||
|
||||
# TODO: Faster algorithm probably exists for this, research an alternatives
|
||||
# if this ever becomes a performance bottleneck.
|
||||
func shuffle(random_seed := 0) -> void:
|
||||
var n = list.size()
|
||||
if n < 2:
|
||||
return
|
||||
|
||||
var rng = RandomNumberGenerator.new()
|
||||
rng.set_seed(random_seed)
|
||||
|
||||
var i = n - 1
|
||||
var j
|
||||
var tmp
|
||||
while i >= 1:
|
||||
j = rng.randi() % (i + 1)
|
||||
tmp = list[j]
|
||||
list[j] = list[i]
|
||||
list[i] = tmp
|
||||
i -= 1
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
list = []
|
||||
|
||||
|
||||
func is_empty() -> bool:
|
||||
return list.is_empty()
|
||||
|
||||
|
||||
func size() -> int:
|
||||
return list.size()
|
||||
29
addons/proton_scatter/src/common/util.gd
Normal file
29
addons/proton_scatter/src/common/util.gd
Normal file
@@ -0,0 +1,29 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
|
||||
static func get_position_and_normal_at(curve: Curve3D, offset: float) -> Array:
|
||||
if not curve:
|
||||
return []
|
||||
|
||||
var pos: Vector3 = curve.sample_baked(offset)
|
||||
var normal := Vector3.ZERO
|
||||
|
||||
var pos1
|
||||
if offset + curve.get_bake_interval() < curve.get_baked_length():
|
||||
pos1 = curve.sample_baked(offset + curve.get_bake_interval())
|
||||
normal = (pos1 - pos)
|
||||
else:
|
||||
pos1 = curve.sample_baked(offset - curve.get_bake_interval())
|
||||
normal = (pos - pos1)
|
||||
|
||||
return [pos, normal]
|
||||
|
||||
|
||||
static func remove_line_breaks(text: String) -> String:
|
||||
# Remove tabs
|
||||
text = text.replace("\t", "")
|
||||
# Remove line breaks
|
||||
text = text.replace("\n", " ")
|
||||
# Remove occasional double space caused by the line above
|
||||
return text.replace(" ", " ")
|
||||
284
addons/proton_scatter/src/documentation/documentation.gd
Normal file
284
addons/proton_scatter/src/documentation/documentation.gd
Normal file
@@ -0,0 +1,284 @@
|
||||
@tool
|
||||
extends PopupPanel
|
||||
|
||||
|
||||
# Formats and displays the DocumentationData provided by other parts of the addon
|
||||
# TODO: Adjust title font size based on the editor font size / scaling
|
||||
|
||||
|
||||
const DocumentationInfo = preload("./documentation_info.gd")
|
||||
const SpecialPages = preload("./pages/special_pages.gd")
|
||||
|
||||
var _pages := {}
|
||||
var _items := {}
|
||||
var _categories_roots := {}
|
||||
|
||||
var _modifiers_root: TreeItem
|
||||
|
||||
var _edited_text: String
|
||||
var _accent_color := Color.CORNFLOWER_BLUE
|
||||
var _editor_scale := 1.0
|
||||
var _header_size := 20
|
||||
var _sub_header_size := 16
|
||||
|
||||
var _populated := false
|
||||
|
||||
|
||||
@onready var tree: Tree = $HSplitContainer/Tree
|
||||
@onready var label: RichTextLabel = $HSplitContainer/RichTextLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
tree.create_item() # Create tree root
|
||||
tree.hide_root = true
|
||||
tree.item_selected.connect(_on_item_selected)
|
||||
|
||||
add_page(SpecialPages.get_scatter_documentation(), tree.create_item())
|
||||
add_page(SpecialPages.get_item_documentation(), tree.create_item())
|
||||
add_page(SpecialPages.get_shape_documentation(), tree.create_item())
|
||||
add_page(SpecialPages.get_cache_documentation(), tree.create_item())
|
||||
|
||||
_modifiers_root = tree.create_item()
|
||||
add_page(SpecialPages.get_modifiers_documentation(), _modifiers_root)
|
||||
|
||||
_populate()
|
||||
|
||||
|
||||
# Fed from the StackPanel scene, before the ready function
|
||||
func set_editor_plugin(editor_plugin: EditorPlugin) -> void:
|
||||
if not editor_plugin:
|
||||
return
|
||||
|
||||
var editor_interface := editor_plugin.get_editor_interface()
|
||||
var editor_settings := editor_interface.get_editor_settings()
|
||||
|
||||
_accent_color = editor_settings.get("interface/theme/accent_color")
|
||||
_editor_scale = editor_interface.get_editor_scale()
|
||||
|
||||
|
||||
func show_page(page_name: String) -> void:
|
||||
if not page_name in _items:
|
||||
return
|
||||
|
||||
var item: TreeItem = _items[page_name]
|
||||
item.select(0)
|
||||
popup_centered(Vector2i(900, 600))
|
||||
|
||||
|
||||
# Generate a formatted string from the DocumentationInfo input.
|
||||
# This string will be stored and later displayed in the RichTextLabel so we
|
||||
# we don't have to regenerate it everytime we look at another page.
|
||||
func add_page(info: DocumentationInfo, item: TreeItem = null) -> void:
|
||||
if not item:
|
||||
var root: TreeItem = _get_or_create_tree_root(info.get_category())
|
||||
item = tree.create_item(root)
|
||||
|
||||
item.set_text(0, info.get_title())
|
||||
|
||||
_begin_formatting()
|
||||
|
||||
# Page title
|
||||
_format_title(info.get_title())
|
||||
|
||||
# Paragraphs
|
||||
for p in info.get_paragraphs():
|
||||
_format_paragraph(p)
|
||||
|
||||
# Parameters
|
||||
if not info.get_parameters().is_empty():
|
||||
_format_subtitle("Parameters")
|
||||
|
||||
for p in info.get_parameters():
|
||||
_format_parameter(p)
|
||||
|
||||
# Warnings
|
||||
if not info.get_warnings().is_empty():
|
||||
_format_subtitle("Warnings")
|
||||
|
||||
for w in info.get_warnings():
|
||||
_format_warning(w)
|
||||
|
||||
_pages[item] = _get_formatted_text()
|
||||
_items[info.get_title()] = item
|
||||
|
||||
|
||||
func _populate():
|
||||
if _populated: # Already generated the documentation pages
|
||||
return
|
||||
|
||||
var path = _get_root_folder() + "/src/modifiers/"
|
||||
var result := {}
|
||||
_discover_modifiers_recursive(path, result)
|
||||
|
||||
var names := result.keys()
|
||||
names.sort()
|
||||
|
||||
for n in names:
|
||||
var info = result[n]
|
||||
add_page(info)
|
||||
|
||||
_populated = true
|
||||
|
||||
|
||||
func _discover_modifiers_recursive(path, result) -> void:
|
||||
var dir = DirAccess.open(path)
|
||||
dir.list_dir_begin()
|
||||
var path_root = dir.get_current_dir() + "/"
|
||||
|
||||
while true:
|
||||
var file = dir.get_next()
|
||||
if file == "":
|
||||
break
|
||||
if file == "base_modifier.gd":
|
||||
continue
|
||||
if dir.current_is_dir():
|
||||
_discover_modifiers_recursive(path_root + file, result)
|
||||
continue
|
||||
if not file.ends_with(".gd") and not file.ends_with(".gdc"):
|
||||
continue
|
||||
|
||||
var full_path = path_root + file
|
||||
var script = load(full_path)
|
||||
if not script or not script.can_instantiate():
|
||||
print("Error: Failed to load script ", file)
|
||||
continue
|
||||
|
||||
var modifier = script.new()
|
||||
|
||||
var info: DocumentationInfo = modifier.documentation
|
||||
info.set_title(modifier.display_name)
|
||||
info.set_category(modifier.category)
|
||||
if modifier.use_edge_data:
|
||||
info.add_warning(
|
||||
"This modifier uses edge data (represented by the blue lines
|
||||
on the Scatter node). These edges are usually locked to the
|
||||
local XZ plane, (except for the Path shape when they are
|
||||
NOT closed). If you can't see these lines, make sure to have at
|
||||
least one Shape crossing the ProtonScatter local XZ plane.",
|
||||
1)
|
||||
|
||||
if modifier.deprecated:
|
||||
info.add_warning(
|
||||
"This modifier has been deprecated. It won't receive any updates
|
||||
and will be deleted in a future update.",
|
||||
2)
|
||||
|
||||
result[modifier.display_name] = info
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _get_root_folder() -> String:
|
||||
var script: Script = get_script()
|
||||
var path: String = script.get_path().get_base_dir()
|
||||
var folders = path.right(-6) # Remove the res://
|
||||
var tokens = folders.split('/')
|
||||
return "res://" + tokens[0] + "/" + tokens[1]
|
||||
|
||||
|
||||
func _get_or_create_tree_root(root_name: String) -> TreeItem:
|
||||
if root_name in _categories_roots:
|
||||
return _categories_roots[root_name]
|
||||
|
||||
var root = tree.create_item(_modifiers_root)
|
||||
root.set_text(0, root_name)
|
||||
root.set_selectable(0, false)
|
||||
_categories_roots[root_name] = root
|
||||
return root
|
||||
|
||||
|
||||
func _begin_formatting() -> void:
|
||||
_edited_text = ""
|
||||
|
||||
|
||||
func _get_formatted_text() -> String:
|
||||
return _edited_text
|
||||
|
||||
|
||||
func _format_title(text: String) -> void:
|
||||
_edited_text += "[font_size=" + var_to_str(_header_size * _editor_scale) + "]"
|
||||
_edited_text += "[color=" + _accent_color.to_html() + "]"
|
||||
_edited_text += "[center][b]"
|
||||
_edited_text += text
|
||||
_edited_text += "[/b][/center]"
|
||||
_edited_text += "[/color]"
|
||||
_edited_text += "[/font_size]"
|
||||
_format_line_break(2)
|
||||
|
||||
|
||||
func _format_subtitle(text: String) -> void:
|
||||
_edited_text += "[font_size=" + var_to_str(_header_size * _editor_scale) + "]"
|
||||
_edited_text += "[color=" + _accent_color.to_html() + "]"
|
||||
_edited_text += "[b]" + text + "[/b]"
|
||||
_edited_text += "[/color]"
|
||||
_edited_text += "[/font_size]"
|
||||
_format_line_break(2)
|
||||
|
||||
|
||||
func _format_line_break(count := 1) -> void:
|
||||
for i in count:
|
||||
_edited_text += "\n"
|
||||
|
||||
|
||||
func _format_paragraph(text: String) -> void:
|
||||
_edited_text += "[p]" + text + "[/p]"
|
||||
_format_line_break(2)
|
||||
|
||||
|
||||
func _format_parameter(p) -> void:
|
||||
var root_folder = _get_root_folder()
|
||||
|
||||
_edited_text += "[indent]"
|
||||
|
||||
if not p.type.is_empty():
|
||||
var file_name = p.type.to_lower() + ".svg"
|
||||
_edited_text += "[img]" + root_folder + "/icons/types/" + file_name + "[/img] "
|
||||
|
||||
_edited_text += "[b]" + p.name + "[/b] "
|
||||
|
||||
match p.cost:
|
||||
1:
|
||||
_edited_text += "[img]" + root_folder + "/icons/arrow_log.svg[/img]"
|
||||
2:
|
||||
_edited_text += "[img]" + root_folder + "/icons/arrow_linear.svg[/img]"
|
||||
3:
|
||||
_edited_text += "[img]" + root_folder + "/icons/arrow_exp.svg[/img]"
|
||||
|
||||
_format_line_break(2)
|
||||
_edited_text += "[indent]" + p.description + "[/indent]"
|
||||
_format_line_break(2)
|
||||
|
||||
for warning in p.warnings:
|
||||
if not warning.text.is_empty():
|
||||
_format_warning(warning)
|
||||
|
||||
_edited_text += "[/indent]"
|
||||
|
||||
|
||||
func _format_warning(w, indent := true) -> void:
|
||||
if indent:
|
||||
_edited_text += "[indent]"
|
||||
|
||||
var color := "Darkgray"
|
||||
match w.importance:
|
||||
1:
|
||||
color = "yellow"
|
||||
2:
|
||||
color = "red"
|
||||
|
||||
_edited_text += "[color=" + color + "][i]" + w.text + "[/i][/color]\n"
|
||||
|
||||
if indent:
|
||||
_edited_text += "[/indent]"
|
||||
|
||||
_format_line_break(1)
|
||||
|
||||
|
||||
func _on_item_selected() -> void:
|
||||
var selected: TreeItem = tree.get_selected()
|
||||
|
||||
if _pages.has(selected):
|
||||
var text: String = _pages[selected]
|
||||
label.set_text(text)
|
||||
else:
|
||||
label.set_text("[center] Under construction [/center]")
|
||||
29
addons/proton_scatter/src/documentation/documentation.tscn
Normal file
29
addons/proton_scatter/src/documentation/documentation.tscn
Normal file
@@ -0,0 +1,29 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cfg8iqtuion8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/documentation/documentation.gd" id="1_5c4lw"]
|
||||
[ext_resource type="PackedScene" uid="uid://cojoo2c73fpsb" path="res://addons/proton_scatter/src/documentation/panel.tscn" id="2_vpfxu"]
|
||||
|
||||
[node name="Documentation" type="PopupPanel"]
|
||||
title = "ProtonScatter documentation"
|
||||
exclusive = true
|
||||
unresizable = false
|
||||
borderless = false
|
||||
script = ExtResource("1_5c4lw")
|
||||
|
||||
[node name="HSplitContainer" parent="." instance=ExtResource("2_vpfxu")]
|
||||
offset_left = 4.0
|
||||
offset_top = 4.0
|
||||
offset_right = -1824.0
|
||||
offset_bottom = -984.0
|
||||
|
||||
[node name="Tree" parent="HSplitContainer" index="0"]
|
||||
offset_right = 80.0
|
||||
offset_bottom = 92.0
|
||||
hide_root = true
|
||||
|
||||
[node name="RichTextLabel" parent="HSplitContainer" index="1"]
|
||||
offset_left = 92.0
|
||||
offset_right = 92.0
|
||||
offset_bottom = 92.0
|
||||
|
||||
[editable path="HSplitContainer"]
|
||||
113
addons/proton_scatter/src/documentation/documentation_info.gd
Normal file
113
addons/proton_scatter/src/documentation/documentation_info.gd
Normal file
@@ -0,0 +1,113 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# Stores raw documentation data.
|
||||
|
||||
# The data is provided by any class that needs an entry in the documentation
|
||||
# panel. This was initially designed for all the modifiers, but might be expanded
|
||||
# to other parts of the addon as well.
|
||||
|
||||
# Formatting is handled by the main Documentation class.
|
||||
|
||||
const Util := preload("../common/util.gd")
|
||||
|
||||
|
||||
class Warning:
|
||||
var text: String
|
||||
var importance: int
|
||||
|
||||
class Parameter:
|
||||
var name: String
|
||||
var cost: int
|
||||
var type: String
|
||||
var description: String
|
||||
var warnings: Array[Warning] = []
|
||||
|
||||
func set_name(text: String) -> Parameter:
|
||||
name = Util.remove_line_breaks(text)
|
||||
return self
|
||||
|
||||
func set_description(text: String) -> Parameter:
|
||||
description = Util.remove_line_breaks(text)
|
||||
return self
|
||||
|
||||
func set_cost(val: int) -> Parameter:
|
||||
cost = val
|
||||
return self
|
||||
|
||||
func set_type(val: String) -> Parameter:
|
||||
type = Util.remove_line_breaks(val)
|
||||
return self
|
||||
|
||||
func add_warning(warning: String, warning_importance := -1) -> Parameter:
|
||||
var w = Warning.new()
|
||||
w.text = Util.remove_line_breaks(warning)
|
||||
w.importance = warning_importance
|
||||
warnings.push_back(w)
|
||||
return self
|
||||
|
||||
|
||||
var _category: String
|
||||
var _page_title: String
|
||||
var _paragraphs: Array[String] = []
|
||||
var _warnings: Array[Warning] = []
|
||||
var _parameters: Array[Parameter] = []
|
||||
|
||||
|
||||
func set_category(text: String) -> void:
|
||||
_category = text
|
||||
|
||||
|
||||
func set_title(text: String) -> void:
|
||||
_page_title = text
|
||||
|
||||
|
||||
func add_paragraph(text: String) -> void:
|
||||
_paragraphs.push_back(Util.remove_line_breaks(text))
|
||||
|
||||
|
||||
# Warning importance:
|
||||
# 0: Default (Grey)
|
||||
# 1: Mid (Yellow)
|
||||
# 2: Critical (Red)
|
||||
func add_warning(text: String, importance: int = 0) -> void:
|
||||
var w = Warning.new()
|
||||
w.text = Util.remove_line_breaks(text)
|
||||
w.importance = importance
|
||||
|
||||
_warnings.push_back(w)
|
||||
|
||||
|
||||
# Add documentation for a user exposed parameter.
|
||||
# Cost:
|
||||
# 0: None
|
||||
# 1: Log
|
||||
# 2: Linear
|
||||
# 3: Exponential
|
||||
func add_parameter(name := "") -> Parameter:
|
||||
var p = Parameter.new()
|
||||
p.name = name
|
||||
p.cost = 0
|
||||
_parameters.push_back(p)
|
||||
return p
|
||||
|
||||
|
||||
func get_title() -> String:
|
||||
return _page_title
|
||||
|
||||
|
||||
func get_category() -> String:
|
||||
return _category
|
||||
|
||||
|
||||
func get_paragraphs() -> Array[String]:
|
||||
return _paragraphs
|
||||
|
||||
|
||||
func get_warnings() -> Array[Warning]:
|
||||
return _warnings
|
||||
|
||||
|
||||
func get_parameters() -> Array[Parameter]:
|
||||
return _parameters
|
||||
150
addons/proton_scatter/src/documentation/pages/special_pages.gd
Normal file
150
addons/proton_scatter/src/documentation/pages/special_pages.gd
Normal file
@@ -0,0 +1,150 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const DocumentationInfo = preload("../documentation_info.gd")
|
||||
|
||||
|
||||
static func get_scatter_documentation() -> DocumentationInfo:
|
||||
var info := DocumentationInfo.new()
|
||||
|
||||
info.set_title("ProtonScatter")
|
||||
info.add_paragraph(
|
||||
"ProtonScatter is a content positioning add-on. It is suited to place
|
||||
a large amount of objects in a procedural way.")
|
||||
info.add_paragraph(
|
||||
"This add-on is [color=red][b]IN BETA[/b][/color] which means breaking
|
||||
changes may happen. It is not recommended to use in production yet."
|
||||
)
|
||||
info.add_paragraph(
|
||||
"First, define [i]what[/i] you want to place using [b]ScatterItems[/b]
|
||||
nodes.")
|
||||
info.add_paragraph(
|
||||
"Then, define [i]where[/i] to place them using [b]ScatterShapes[/b]
|
||||
nodes.")
|
||||
info.add_paragraph(
|
||||
"Finaly, define [i]how[/i] the content should be placed using the
|
||||
[b]Modifier stack[/b] that's on the [b]ProtonScatter[/b] node.")
|
||||
info.add_paragraph(
|
||||
"Each of these components have their dedicated documenation page, but
|
||||
first, you should check out the example scenes in the demo folder.")
|
||||
|
||||
var p := info.add_parameter("General / Global seed")
|
||||
p.set_type("int")
|
||||
p.set_description(
|
||||
"The random seed to use on this node. Modifiers using random components
|
||||
can access this value and use it accordingly. You can also specify
|
||||
a custom seed for specific modifiers as well.")
|
||||
|
||||
p = info.add_parameter("General / Show output in tree")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"Show the generated items in the editor scene tree. By default this
|
||||
option is disabled as it creates quite a bit of clutter when instancing
|
||||
is disabled. It also increases the scene file size significantly.")
|
||||
|
||||
p = info.add_parameter("Performance / Use instancing")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"When enabled, ProtonScatter will use MultiMeshInstance3D nodes
|
||||
instead of duplicating the source nodes. This allows the GPU to render
|
||||
thousands of meshes in a single draw call.")
|
||||
p.add_warning("Collisions and attached scripts are ignored when this
|
||||
option is enabled.", 1)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
static func get_item_documentation() -> DocumentationInfo:
|
||||
var info := DocumentationInfo.new()
|
||||
|
||||
info.set_title("ScatterItems")
|
||||
|
||||
info.add_paragraph("TODO: Write this page")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
static func get_shape_documentation() -> DocumentationInfo:
|
||||
var info := DocumentationInfo.new()
|
||||
|
||||
info.set_title("ScatterShapes")
|
||||
|
||||
info.add_paragraph("TODO: Write this page")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
static func get_cache_documentation() -> DocumentationInfo:
|
||||
var info := DocumentationInfo.new()
|
||||
|
||||
info.set_title("ScatterCache")
|
||||
|
||||
info.add_paragraph(
|
||||
"By default, Scatter nodes will recalculate their output on load,
|
||||
which can be slow in really complex scenes. The cache allows you to
|
||||
store these results in a file on your disk, and load these instead.")
|
||||
info.add_paragraph(
|
||||
"This can significantly speed up loading times, while also being VCS
|
||||
friendly since the transforms are stored in their own files, rather
|
||||
than your scenes files.")
|
||||
info.add_paragraph("[b]How to use:[/b]")
|
||||
info.add_paragraph(
|
||||
"[p]+ Disable the [code]Force rebuild on load[code] on every Scatter item you want to cache.[/p]
|
||||
[p]+ Add a ScatterCache node anywhere in your scene.[/p]
|
||||
[p]+ Press the 'Rebuild' button to scan for other ProtonScatter nodes
|
||||
and store their results in the cache.[/p]")
|
||||
info.add_paragraph("[i]A single cache per scene is enough.[/i]")
|
||||
|
||||
var p := info.add_parameter("Cache File")
|
||||
p.set_cost(0)
|
||||
p.set_description("Path to the cache file. By default they are store in the
|
||||
add-on folder. Their name has a random component to avoid naming collisions
|
||||
with scenes sharing the same file name. You are free to place this file
|
||||
anywhere, using any name you would like.")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
static func get_modifiers_documentation() -> DocumentationInfo:
|
||||
var info := DocumentationInfo.new()
|
||||
|
||||
info.set_title("Modifiers")
|
||||
info.add_paragraph(
|
||||
"A modifier takes in a Transform3D list, create, modify or delete
|
||||
transforms, then pass it down to the next modifier. Remember that
|
||||
[b] modifiers are processed from top to bottom [/b]. A modifier
|
||||
down the stack will recieve a list processed by the modifiers above.")
|
||||
info.add_paragraph(
|
||||
"The initial transform list is empty, so it's necessary to start the
|
||||
stack with a [b] Create [/b] modifier.")
|
||||
info.add_paragraph(
|
||||
"When clicking the [b] Expand button [/b] (the little arrow on the left)
|
||||
you get access to this modifier's parameters. This is where you can
|
||||
adjust its behavior according to your needs.")
|
||||
info.add_paragraph(
|
||||
"Three common options might be found on these modifiers. (They may
|
||||
not appear if they are irrelevant). They are defined as follow:")
|
||||
|
||||
var p := info.add_parameter("Use local seed")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"The dice icon on the left allows you to force a specific seed for the
|
||||
modifier. If this option is not used then the Global seed from the
|
||||
ProtonScatter node will be used instead.")
|
||||
|
||||
p = info.add_parameter("Restrict height")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"When applicable, the modifier will remain within the local XZ plane
|
||||
instead of using the full volume described by the ScatterShape nodes.")
|
||||
|
||||
p = info.add_parameter("Reference frame")
|
||||
p.set_type("int")
|
||||
p.set_description(
|
||||
"[p]+ [b]Global[/b]: Modifier operates in Global space. [/p]
|
||||
[p]+ [b]Local[/b]: Modifier operates in local space, relative to the ProtonScatter node.[/p]
|
||||
[p]+ [b]Individual[/b]: Modifier operates on local space, relative to each
|
||||
individual transforms.[/p]"
|
||||
)
|
||||
|
||||
return info
|
||||
22
addons/proton_scatter/src/documentation/panel.tscn
Normal file
22
addons/proton_scatter/src/documentation/panel.tscn
Normal file
@@ -0,0 +1,22 @@
|
||||
[gd_scene format=3 uid="uid://cojoo2c73fpsb"]
|
||||
|
||||
[node name="HSplitContainer" type="HSplitContainer"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
split_offset = 250
|
||||
|
||||
[node name="Tree" type="Tree" parent="."]
|
||||
layout_mode = 2
|
||||
offset_right = 250.0
|
||||
offset_bottom = 648.0
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="."]
|
||||
layout_mode = 2
|
||||
offset_left = 262.0
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 648.0
|
||||
bbcode_enabled = true
|
||||
text = "[center] [b] [i] Documentation page [/i] [/b] [/center]"
|
||||
182
addons/proton_scatter/src/modifiers/array.gd
Normal file
182
addons/proton_scatter/src/modifiers/array.gd
Normal file
@@ -0,0 +1,182 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
# Takes existing objects and duplicates them recursively with given transforms
|
||||
|
||||
|
||||
@export var amount := 1
|
||||
@export var min_amount := -1
|
||||
@export var local_offset := false
|
||||
@export var offset := Vector3.ZERO
|
||||
@export var local_rotation := false
|
||||
@export var rotation := Vector3.ZERO
|
||||
@export var individual_rotation_pivots := true
|
||||
@export var rotation_pivot := Vector3.ZERO
|
||||
@export var local_scale := true
|
||||
@export var scale := Vector3.ONE
|
||||
@export var randomize_indices := true
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Array"
|
||||
category = "Create"
|
||||
can_override_seed = true
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = false
|
||||
local_reference_frame_available = false
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Recursively creates copies of the existing transforms, with each copy
|
||||
being offset from the previous one in any of a number of possible ways.")
|
||||
|
||||
var p := documentation.add_parameter("Amount")
|
||||
p.set_type("int")
|
||||
p.set_cost(2)
|
||||
p.set_description(
|
||||
"The iteration count. If set to 1, each existing transforms are copied
|
||||
once.")
|
||||
p.add_warning("If set to 0, no copies are created.")
|
||||
|
||||
p = documentation.add_parameter("Minimum amount")
|
||||
p.set_type("int")
|
||||
p.set_description(
|
||||
"Creates a random amount of copies for each transforms, between this
|
||||
value and the amount value.")
|
||||
p.add_warning("Ignored if set to a negative value.")
|
||||
|
||||
p = documentation.add_parameter("Offset")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"Adds a constant offset between each copies and the previous one.")
|
||||
|
||||
p = documentation.add_parameter("Local offset")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"If enabled, offset is relative to the previous copy orientation.
|
||||
Otherwise, the offset is in global space.")
|
||||
|
||||
p = documentation.add_parameter("Rotation")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"The rotation offset (on each axes) to add on each copy.")
|
||||
|
||||
p = documentation.add_parameter("Local rotation")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"If enabled, the rotation is applied in local space relative to each
|
||||
individual transforms. Otherwise, the rotation is applied in global
|
||||
space.")
|
||||
|
||||
p = documentation.add_parameter("Rotation Pivot")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"The point around which each copies are rotated. By default, each
|
||||
transforms are rotated around their individual centers.")
|
||||
|
||||
p = documentation.add_parameter("Individual Rotation Pivots")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"If enabled, each copies will use their own pivot relative to the
|
||||
previous copy. Otherwise, a single pivot point (defined in global space)
|
||||
will be used for the rotation of [b]all[/b] the copies.")
|
||||
|
||||
p = documentation.add_parameter("Scale")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"Scales the copies relative to the transforms they are from.")
|
||||
|
||||
p = documentation.add_parameter("Local Scale")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"If enabled, scaling is applied in local space relative to each
|
||||
individual transforms. Otherwise, global axes are used, resulting
|
||||
in skewed transforms in most cases.")
|
||||
|
||||
p = documentation.add_parameter("Randomize Indices")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"Randomize the transform list order. This is only useful to break up the
|
||||
repetitive patterns if you're using multiple ScatterItem nodes.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, random_seed: int) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(random_seed)
|
||||
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
var rotation_rad := Vector3.ZERO
|
||||
|
||||
rotation_rad.x = deg_to_rad(rotation.x)
|
||||
rotation_rad.y = deg_to_rad(rotation.y)
|
||||
rotation_rad.z = deg_to_rad(rotation.z)
|
||||
|
||||
var axis_x := Vector3.RIGHT
|
||||
var axis_y := Vector3.UP
|
||||
var axis_z := Vector3.FORWARD
|
||||
|
||||
for t in transforms.size():
|
||||
new_transforms.push_back(transforms.list[t])
|
||||
|
||||
var steps = amount
|
||||
if min_amount >= 0:
|
||||
steps = _rng.randi_range(min_amount, amount)
|
||||
|
||||
for a in steps:
|
||||
a += 1
|
||||
|
||||
# use original object's transform as base transform
|
||||
var transform : Transform3D = transforms.list[t]
|
||||
var basis := transform.basis
|
||||
|
||||
# first move to rotation point defined in rotation offset
|
||||
var rotation_pivot_offset = rotation_pivot
|
||||
if individual_rotation_pivots:
|
||||
rotation_pivot_offset = transform * rotation_pivot
|
||||
|
||||
transform.origin -= rotation_pivot_offset
|
||||
|
||||
# then rotate
|
||||
if local_rotation:
|
||||
axis_x = basis.x.normalized()
|
||||
axis_y = basis.y.normalized()
|
||||
axis_z = basis.z.normalized()
|
||||
|
||||
transform = transform.rotated(axis_x, rotation_rad.x * a)
|
||||
transform = transform.rotated(axis_y, rotation_rad.y * a)
|
||||
transform = transform.rotated(axis_z, rotation_rad.z * a)
|
||||
|
||||
# scale
|
||||
# If the scale is different than 1, each transform gets bigger or
|
||||
# smaller for each iteration.
|
||||
var s = scale
|
||||
s.x = pow(s.x, a)
|
||||
s.y = pow(s.y, a)
|
||||
s.z = pow(s.z, a)
|
||||
|
||||
if local_scale:
|
||||
transform.basis.x *= s.x
|
||||
transform.basis.y *= s.y
|
||||
transform.basis.z *= s.z
|
||||
else:
|
||||
transform.basis = transform.basis.scaled(s)
|
||||
|
||||
# apply changes back to the transform and undo the rotation pivot offset
|
||||
transform.origin += rotation_pivot_offset
|
||||
|
||||
# offset
|
||||
if local_offset:
|
||||
transform.origin += offset * a
|
||||
else:
|
||||
transform.origin += (basis * offset) * a
|
||||
|
||||
# store the final result if the position is valid
|
||||
if not domain.is_point_excluded(transform.origin):
|
||||
new_transforms.push_back(transform)
|
||||
|
||||
transforms.list = new_transforms
|
||||
|
||||
if randomize_indices:
|
||||
transforms.shuffle(random_seed)
|
||||
126
addons/proton_scatter/src/modifiers/base_modifier.gd
Normal file
126
addons/proton_scatter/src/modifiers/base_modifier.gd
Normal file
@@ -0,0 +1,126 @@
|
||||
@tool
|
||||
class_name ScatterBaseModifier
|
||||
extends Resource
|
||||
|
||||
# Modifiers place transforms. They create, edit or remove transforms in a list,
|
||||
# before the next Modifier in the stack does the same.
|
||||
# All Modifiers must inherit from this class.
|
||||
# Transforms in the provided transforms list must be in global space.
|
||||
|
||||
|
||||
signal warning_changed
|
||||
signal modifier_changed
|
||||
|
||||
const TransformList = preload("../common/transform_list.gd")
|
||||
const Domain = preload("../common/domain.gd")
|
||||
const DocumentationInfo = preload("../documentation/documentation_info.gd")
|
||||
|
||||
@export var enabled := true
|
||||
@export var override_global_seed := false
|
||||
@export var custom_seed := 0
|
||||
@export var restrict_height := false # Tells the modifier whether to constrain transforms to the local XY plane or not
|
||||
@export var reference_frame := 0
|
||||
|
||||
var display_name: String = "Base Modifier Name"
|
||||
var category: String = "None"
|
||||
var documentation := DocumentationInfo.new()
|
||||
var warning: String = ""
|
||||
var warning_ignore_no_transforms := false
|
||||
var warning_ignore_no_shape := true
|
||||
var expanded := false
|
||||
var can_override_seed := false
|
||||
var can_restrict_height := true
|
||||
var global_reference_frame_available := true
|
||||
var local_reference_frame_available := false
|
||||
var individual_instances_reference_frame_available := false
|
||||
var use_edge_data := false
|
||||
var deprecated := false
|
||||
var deprecation_message: String
|
||||
var interrupt_update: bool = false
|
||||
|
||||
|
||||
func get_warning() -> String:
|
||||
return warning
|
||||
|
||||
|
||||
func process_transforms(transforms: TransformList, domain: Domain, global_seed: int) -> void:
|
||||
if not domain.get_root().is_inside_tree():
|
||||
return
|
||||
|
||||
if Engine.is_editor_hint():
|
||||
_clear_warning()
|
||||
|
||||
if deprecated:
|
||||
warning += "This modifier is deprecated.\n"
|
||||
warning += deprecation_message + "\n"
|
||||
|
||||
if not enabled:
|
||||
warning_changed.emit()
|
||||
return
|
||||
|
||||
if domain.is_empty() and not warning_ignore_no_shape:
|
||||
warning += """The Scatter node does not have a shape.
|
||||
Add at least one ScatterShape node as a child.\n"""
|
||||
|
||||
if transforms.is_empty() and not warning_ignore_no_transforms:
|
||||
warning += """There's no transforms to act on.
|
||||
Make sure you have a Create modifier before this one.\n
|
||||
"""
|
||||
|
||||
var random_seed: int = global_seed
|
||||
if can_override_seed and override_global_seed:
|
||||
random_seed = custom_seed
|
||||
interrupt_update = false
|
||||
|
||||
@warning_ignore("redundant_await") # Not redundant as child classes could use the await keyword here.
|
||||
await _process_transforms(transforms, domain, random_seed)
|
||||
|
||||
warning_changed.emit()
|
||||
|
||||
|
||||
func get_copy():
|
||||
var script: Script = get_script()
|
||||
var copy = script.new()
|
||||
for p in get_property_list():
|
||||
var value = get(p.name)
|
||||
copy.set(p.name, value)
|
||||
return copy
|
||||
|
||||
|
||||
## Notify the modifier it should stop updating as soon as it can.
|
||||
func interrupt() -> void:
|
||||
interrupt_update = true
|
||||
|
||||
|
||||
func is_using_global_space() -> bool:
|
||||
return reference_frame == 0
|
||||
|
||||
|
||||
func is_using_local_space() -> bool:
|
||||
return reference_frame == 1
|
||||
|
||||
|
||||
func is_using_individual_instances_space() -> bool:
|
||||
return reference_frame == 2
|
||||
|
||||
|
||||
func use_global_space_by_default() -> void:
|
||||
reference_frame = 0
|
||||
|
||||
|
||||
func use_local_space_by_default() -> void:
|
||||
reference_frame = 1
|
||||
|
||||
|
||||
func use_individual_instances_space_by_default() -> void:
|
||||
reference_frame = 2
|
||||
|
||||
|
||||
func _clear_warning() -> void:
|
||||
warning = ""
|
||||
warning_changed.emit()
|
||||
|
||||
|
||||
# Override in inherited class
|
||||
func _process_transforms(_transforms: TransformList, _domain: Domain, _seed: int) -> void:
|
||||
pass
|
||||
131
addons/proton_scatter/src/modifiers/clusterize.gd
Normal file
131
addons/proton_scatter/src/modifiers/clusterize.gd
Normal file
@@ -0,0 +1,131 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export_file("Texture") var mask: String
|
||||
@export var mask_rotation := 0.0
|
||||
@export var mask_offset := Vector2.ZERO
|
||||
@export var mask_scale := Vector2.ONE
|
||||
@export var pixel_to_unit_ratio := 64.0
|
||||
@export_range(0.0, 1.0) var remove_below = 0.1
|
||||
@export_range(0.0, 1.0) var remove_above = 1.0
|
||||
@export var scale_transforms := true
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Clusterize"
|
||||
category = "Edit"
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = false # TODO, enable this and handle this case
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Clump transforms together based on a mask.
|
||||
Sampling the mask returns values between 0 and 1. The transforms are
|
||||
scaled against these values which means, bright areas don't affect their
|
||||
scale while dark area scales them down. Transforms are then removed
|
||||
below a threshold, leaving clumps behind.")
|
||||
|
||||
var p := documentation.add_parameter("Mask")
|
||||
p.set_type("Texture")
|
||||
p.set_description("The texture used as a mask.")
|
||||
p.add_warning(
|
||||
"The amount of texture fetch depends on the amount of transforms
|
||||
generated in the previous modifiers (4 reads for each transform).
|
||||
In theory, the texture size shouldn't affect performances in a
|
||||
noticeable way.")
|
||||
|
||||
p = documentation.add_parameter("Mask scale")
|
||||
p.set_type("Vector2")
|
||||
p.set_description(
|
||||
"Depending on the mask resolution, the perceived scale will change.
|
||||
Use this parameter to increase or decrease the area covered by the mask.")
|
||||
|
||||
p = documentation.add_parameter("Mask offset")
|
||||
p.set_type("Vector2")
|
||||
p.set_description("Moves the mask XZ position in 3D space")
|
||||
|
||||
p = documentation.add_parameter("Mask rotation")
|
||||
p.set_type("Float")
|
||||
p.set_description("Rotates the mask around the Y axis. (Angle in degrees)")
|
||||
|
||||
p = documentation.add_parameter("Remove below")
|
||||
p.set_type("Float")
|
||||
p.set_description("Threshold below which the transforms are removed.")
|
||||
|
||||
p = documentation.add_parameter("Remove above")
|
||||
p.set_type("Float")
|
||||
p.set_description("Threshold above which the transforms are removed.")
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
if not ResourceLoader.exists(mask):
|
||||
warning += "The specified file " + mask + " could not be loaded."
|
||||
return
|
||||
|
||||
var texture: Texture = load(mask)
|
||||
|
||||
if not texture is Texture:
|
||||
warning += "The specified file is not a valid texture."
|
||||
return
|
||||
|
||||
var image: Image
|
||||
|
||||
# Wait for a frame or risk the whole editor to freeze because of get_image()
|
||||
# TODO: Check if more safe guards are required here.
|
||||
await domain.get_root().get_tree().process_frame
|
||||
|
||||
if texture is Texture2D:
|
||||
image = texture.get_image()
|
||||
|
||||
elif texture is Texture3D:
|
||||
image = texture.get_data()[0] # TMP, this should depends on the transforms Y coordinates
|
||||
|
||||
elif texture is TextureLayered:
|
||||
image = texture.get_layer_data(0) # TMP
|
||||
|
||||
image.decompress()
|
||||
|
||||
var width := image.get_width()
|
||||
var height := image.get_height()
|
||||
var i := 0
|
||||
var angle := deg_to_rad(mask_rotation)
|
||||
|
||||
while i < transforms.list.size():
|
||||
var t: Transform3D = transforms.list[i]
|
||||
var origin := t.origin.rotated(Vector3.UP, angle)
|
||||
|
||||
var x := origin.x * (pixel_to_unit_ratio / mask_scale.x) + mask_offset.x
|
||||
x = fposmod(x, width - 1)
|
||||
var y := origin.z * (pixel_to_unit_ratio / mask_scale.y) + mask_offset.y
|
||||
y = fposmod(y, height - 1)
|
||||
|
||||
var level := _get_pixel(image, x, y)
|
||||
if level < remove_below:
|
||||
transforms.list.remove_at(i)
|
||||
continue
|
||||
|
||||
if level > remove_above:
|
||||
transforms.list.remove_at(i)
|
||||
continue
|
||||
|
||||
if scale_transforms:
|
||||
t.basis = t.basis.scaled(Vector3(level, level, level))
|
||||
|
||||
transforms.list[i] = t
|
||||
i += 1
|
||||
|
||||
|
||||
# x and y don't always match an exact pixel, so we sample the neighboring
|
||||
# pixels as well and return a weighted value based on the input coords.
|
||||
func _get_pixel(image: Image, x: float, y: float) -> float:
|
||||
var ix = int(x)
|
||||
var iy = int(y)
|
||||
x -= ix
|
||||
y -= iy
|
||||
|
||||
var nw = image.get_pixel(ix, iy).v
|
||||
var ne = image.get_pixel(ix + 1, iy).v
|
||||
var sw = image.get_pixel(ix, iy + 1).v
|
||||
var se = image.get_pixel(ix + 1, iy + 1).v
|
||||
|
||||
return nw * (1 - x) * (1 - y) + ne * x * (1 - y) + sw * (1 - x) * y + se * x * y
|
||||
@@ -0,0 +1,43 @@
|
||||
#[compute]
|
||||
#version 450
|
||||
|
||||
// Invocations in the (x, y, z) dimension
|
||||
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
|
||||
|
||||
// A binding to the input buffer we create in our script
|
||||
layout(set = 0, binding = 0, std430) readonly buffer BufferIn {
|
||||
vec4 data[];
|
||||
}
|
||||
buffer_in;
|
||||
|
||||
// A binding to the output buffer we create in our script
|
||||
layout(set = 0, binding = 1, std430) restrict buffer BufferOut {
|
||||
vec4 data[];
|
||||
}
|
||||
buffer_out;
|
||||
|
||||
// The code we want to execute in each invocation
|
||||
void main() {
|
||||
int last_element_index = buffer_in.data.length();
|
||||
// Unique index for each element
|
||||
uint workgroupSize = gl_WorkGroupSize.x * gl_WorkGroupSize.y * gl_WorkGroupSize.z;
|
||||
uint index = gl_WorkGroupID.x * workgroupSize + gl_LocalInvocationIndex;
|
||||
|
||||
vec3 infvec = vec3(1, 1, 1) * 999999; // vector approaching "infinity"
|
||||
vec3 closest = infvec; // initialize closest to infinity
|
||||
vec3 origin = buffer_in.data[index].xyz;
|
||||
|
||||
for(int i = 0; i <= last_element_index; i++){
|
||||
vec3 newvec = buffer_in.data[i].xyz;
|
||||
|
||||
if (i == index) continue; // ignore self
|
||||
|
||||
float olddist = length(closest - origin);
|
||||
float newdist = length(newvec - origin);
|
||||
if (newdist < olddist)
|
||||
{
|
||||
closest = newvec;
|
||||
}
|
||||
}
|
||||
buffer_out.data[index] = vec4(origin - closest, 0);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[remap]
|
||||
|
||||
importer="glsl"
|
||||
type="RDShaderFile"
|
||||
uid="uid://cpg67dxgr360g"
|
||||
path="res://.godot/imported/compute_relax.glsl-b06f9e60cda7719b78bde9673f2501b7.res"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/proton_scatter/src/modifiers/compute_shaders/compute_relax.glsl"
|
||||
dest_files=["res://.godot/imported/compute_relax.glsl-b06f9e60cda7719b78bde9673f2501b7.res"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var item_length := 2.0
|
||||
@export var ignore_slopes := false
|
||||
|
||||
var _current_offset = 0.0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Along Edge (Continuous)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
use_edge_data = true
|
||||
global_reference_frame_available = false
|
||||
local_reference_frame_available = false
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
var p
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Create new transforms along the edges of the Scatter shapes. These
|
||||
transforms are placed so they touch each other but don't overlap, even
|
||||
if the curve has sharp turns.")
|
||||
|
||||
documentation.add_paragraph(
|
||||
"This is useful to place props suchs as fences, walls or anything that
|
||||
needs to look organized without leaving gaps.")
|
||||
|
||||
documentation.add_warning(
|
||||
"The transforms are placed starting from the begining of each curves.
|
||||
If the curve is closed, there will be a gap at the end if the total
|
||||
curve length isn't a multiple of the item length.")
|
||||
|
||||
p = documentation.add_parameter("Item length")
|
||||
p.set_type("float")
|
||||
p.set_description("How long is the item being placed")
|
||||
p.set_cost(2)
|
||||
p.add_warning(
|
||||
"The smaller this value, the more transforms will be created.
|
||||
Setting a slightly different length than the actual model length
|
||||
allow for gaps between each transforms.")
|
||||
|
||||
p = documentation.add_parameter("Ignore slopes")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"If enabled, all the curves will be projected to the local XZ plane
|
||||
before creating the new transforms.")
|
||||
|
||||
|
||||
# TODO: Use dichotomic search instead of fixed step length?
|
||||
func _process_transforms(transforms, domain, seed) -> void:
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
var curves: Array[Curve3D] = domain.get_edges()
|
||||
|
||||
for curve in curves:
|
||||
if not ignore_slopes:
|
||||
curve = curve.duplicate()
|
||||
else:
|
||||
curve = get_projected_curve(curve, domain.get_global_transform())
|
||||
|
||||
var length_squared = pow(item_length, 2)
|
||||
var offset_max = curve.get_baked_length()
|
||||
var offset = 0.0
|
||||
var step = item_length / 20.0
|
||||
|
||||
while offset < offset_max:
|
||||
var start := curve.sample_baked(offset)
|
||||
var end: Vector3
|
||||
var dist: float
|
||||
offset += item_length * 0.9 # Saves a few iterations, the target
|
||||
# point will never be closer than the item length, only further
|
||||
|
||||
while offset < offset_max:
|
||||
offset += step
|
||||
end = curve.sample_baked(offset)
|
||||
dist = start.distance_squared_to(end)
|
||||
|
||||
if dist >= length_squared:
|
||||
var t = Transform3D()
|
||||
t.origin = start + ((end - start) / 2.0)
|
||||
new_transforms.push_back(t.looking_at(end, Vector3.UP))
|
||||
break
|
||||
|
||||
transforms.append(new_transforms)
|
||||
transforms.shuffle(seed)
|
||||
|
||||
|
||||
func get_projected_curve(curve: Curve3D, t: Transform3D) -> Curve3D:
|
||||
var points = curve.tessellate()
|
||||
var new_curve = Curve3D.new()
|
||||
for p in points:
|
||||
p.y = t.origin.y
|
||||
new_curve.add_point(p)
|
||||
|
||||
return new_curve
|
||||
@@ -0,0 +1,79 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
const Util := preload("../common/util.gd")
|
||||
|
||||
|
||||
# TODO :
|
||||
# + change alignement parameters to something more usable and intuitive
|
||||
# + Use the curve up vector, default to local Y+ when not available
|
||||
@export var spacing := 1.0
|
||||
@export var offset := 0.0
|
||||
@export var align_to_path := false
|
||||
@export var align_up_axis := Vector3.UP
|
||||
|
||||
var _min_spacing := 0.05
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Along Edge (Even)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = false
|
||||
use_edge_data = true
|
||||
|
||||
var p
|
||||
documentation.add_paragraph(
|
||||
"Evenly create transforms along the edges of the ScatterShapes")
|
||||
|
||||
p = documentation.add_parameter("Spacing")
|
||||
p.set_type("float")
|
||||
p.set_description("How much space between the transforms origin")
|
||||
p.set_cost(3)
|
||||
p.add_warning("The smaller the value, the denser the resulting transforms list.", 1)
|
||||
p.add_warning(
|
||||
"A value of 0 would result in infinite transforms, so it's capped
|
||||
to 0.05 at least.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, seed) -> void:
|
||||
spacing = max(_min_spacing, spacing)
|
||||
|
||||
var gt_inverse: Transform3D = domain.get_global_transform().affine_inverse()
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
var curves: Array[Curve3D] = domain.get_edges()
|
||||
|
||||
for curve in curves:
|
||||
var length: float = curve.get_baked_length()
|
||||
var count := int(round(length / spacing))
|
||||
var stepped_length: float = count * spacing
|
||||
|
||||
for i in count:
|
||||
var curve_offset = i * spacing + abs(offset)
|
||||
|
||||
while curve_offset > stepped_length: # Loop back to the curve start if offset is too large
|
||||
curve_offset -= stepped_length
|
||||
|
||||
var data : Array = Util.get_position_and_normal_at(curve, curve_offset)
|
||||
var pos: Vector3 = data[0]
|
||||
var normal: Vector3 = data[1]
|
||||
|
||||
if domain.is_point_excluded(pos):
|
||||
continue
|
||||
|
||||
var t := Transform3D()
|
||||
t.origin = pos
|
||||
if align_to_path:
|
||||
t = t.looking_at(normal + pos, align_up_axis)
|
||||
elif is_using_global_space():
|
||||
t.basis = gt_inverse.basis
|
||||
|
||||
new_transforms.push_back(t)
|
||||
|
||||
transforms.append(new_transforms)
|
||||
transforms.shuffle(seed)
|
||||
@@ -0,0 +1,69 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var instance_count := 10
|
||||
@export var align_to_path := false
|
||||
@export var align_up_axis := Vector3.UP
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Along Edge (Random)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
use_edge_data = true
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, random_seed) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(random_seed)
|
||||
|
||||
var gt_inverse: Transform3D = domain.get_global_transform().affine_inverse()
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
var curves: Array[Curve3D] = domain.get_edges()
|
||||
var total_curve_length := 0.0
|
||||
|
||||
for curve in curves:
|
||||
var length: float = curve.get_baked_length()
|
||||
total_curve_length += length
|
||||
|
||||
for curve in curves:
|
||||
var length: float = curve.get_baked_length()
|
||||
var local_instance_count: int = round((length / total_curve_length) * instance_count)
|
||||
|
||||
for i in local_instance_count:
|
||||
var data = get_pos_and_normal(curve, _rng.randf() * length)
|
||||
var pos: Vector3 = data[0]
|
||||
var normal: Vector3 = data[1]
|
||||
var t := Transform3D()
|
||||
|
||||
t.origin = pos
|
||||
if align_to_path:
|
||||
t = t.looking_at(normal + pos, align_up_axis)
|
||||
elif is_using_global_space():
|
||||
t.basis = gt_inverse.basis
|
||||
|
||||
new_transforms.push_back(t)
|
||||
|
||||
transforms.append(new_transforms)
|
||||
|
||||
|
||||
func get_pos_and_normal(curve: Curve3D, offset : float) -> Array:
|
||||
var pos: Vector3 = curve.sample_baked(offset)
|
||||
var normal := Vector3.ZERO
|
||||
|
||||
var pos1
|
||||
if offset + curve.get_bake_interval() < curve.get_baked_length():
|
||||
pos1 = curve.sample_baked(offset + curve.get_bake_interval())
|
||||
normal = (pos1 - pos)
|
||||
else:
|
||||
pos1 = curve.sample_baked(offset - curve.get_bake_interval())
|
||||
normal = (pos - pos1)
|
||||
|
||||
return [pos, normal]
|
||||
97
addons/proton_scatter/src/modifiers/create_inside_grid.gd
Normal file
97
addons/proton_scatter/src/modifiers/create_inside_grid.gd
Normal file
@@ -0,0 +1,97 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var spacing := Vector3(2.0, 2.0, 2.0)
|
||||
|
||||
var _min_spacing := 0.05
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Inside (Grid)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
can_restrict_height = true
|
||||
restrict_height = true
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Place transforms along the edges of the ScatterShapes")
|
||||
|
||||
documentation.add_paragraph(
|
||||
"When [b]Local Space[/b] is enabled, the grid is aligned with the
|
||||
Scatter root node. Otherwise, the grid is aligned with the global
|
||||
axes."
|
||||
)
|
||||
|
||||
var p = documentation.add_parameter("Spacing")
|
||||
p.set_type("vector3")
|
||||
p.set_description(
|
||||
"Defines the grid size along the 3 axes. A spacing of 1 means 1 unit
|
||||
of space between each transform on this axis.")
|
||||
p.set_cost(3)
|
||||
p.add_warning(
|
||||
"The smaller the value, the denser the resulting transforms list.
|
||||
Use with care as the performance impact will go up quickly.", 1)
|
||||
p.add_warning(
|
||||
"A value of 0 would result in infinite transforms, so it's capped to 0.05
|
||||
at least.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, seed) -> void:
|
||||
spacing.x = max(_min_spacing, spacing.x)
|
||||
spacing.y = max(_min_spacing, spacing.y)
|
||||
spacing.z = max(_min_spacing, spacing.z)
|
||||
|
||||
var gt: Transform3D = domain.get_local_transform()
|
||||
var center: Vector3 = domain.bounds_local.center
|
||||
var size: Vector3 = domain.bounds_local.size
|
||||
|
||||
var half_size := size * 0.5
|
||||
var start_corner := center - half_size
|
||||
var baseline: float = 0.0
|
||||
|
||||
var width := int(ceil(size.x / spacing.x))
|
||||
var height := int(ceil(size.y / spacing.y))
|
||||
var length := int(ceil(size.z / spacing.z))
|
||||
|
||||
if restrict_height:
|
||||
height = 1
|
||||
baseline = domain.bounds_local.max.y
|
||||
else:
|
||||
height = max(1, height) # Make sure height never gets below 1 or else nothing happens
|
||||
|
||||
var max_count: int = width * length * height
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
new_transforms.resize(max_count)
|
||||
|
||||
var t: Transform3D
|
||||
var pos: Vector3
|
||||
var t_index := 0
|
||||
|
||||
for i in width * length:
|
||||
for j in height:
|
||||
t = Transform3D()
|
||||
pos = Vector3.ZERO
|
||||
pos.x = (i % width) * spacing.x
|
||||
pos.y = (j * spacing.y) + baseline
|
||||
pos.z = (i / width) * spacing.z
|
||||
pos += start_corner
|
||||
|
||||
if is_using_global_space():
|
||||
t.basis = gt.affine_inverse().basis
|
||||
pos = t * pos
|
||||
|
||||
if domain.is_point_inside(pos):
|
||||
t.origin = pos
|
||||
new_transforms[t_index] = t
|
||||
t_index += 1
|
||||
|
||||
if t_index != new_transforms.size():
|
||||
new_transforms.resize(t_index)
|
||||
|
||||
transforms.append(new_transforms)
|
||||
transforms.shuffle(seed)
|
||||
230
addons/proton_scatter/src/modifiers/create_inside_poisson.gd
Normal file
230
addons/proton_scatter/src/modifiers/create_inside_poisson.gd
Normal file
@@ -0,0 +1,230 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
# Poisson disc sampling based on Sebastian Lague implementation, modified to
|
||||
# support both 2D and 3D space.
|
||||
# Reference: https://www.youtube.com/watch?v=7WcmyxyFO7o
|
||||
|
||||
# TODO: This doesn't work if the valid space isn't one solid space
|
||||
# (fails to fill the full domain if it's made of discrete, separate shapes)
|
||||
|
||||
|
||||
const Bounds := preload("../common/bounds.gd")
|
||||
|
||||
@export var radius := 1.0
|
||||
@export var samples_before_rejection := 15
|
||||
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
var _squared_radius: float
|
||||
var _domain
|
||||
var _bounds: Bounds
|
||||
|
||||
var _gt: Transform3D
|
||||
var _points: Array[Transform3D] # Stores the generated points
|
||||
var _grid: Array[int] = [] # Flattened array
|
||||
var _grid_size := Vector3i.ZERO
|
||||
var _cell_size: float
|
||||
var _cell_x: int
|
||||
var _cell_y: int
|
||||
var _cell_z: int
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Inside (Poisson)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
can_restrict_height = true
|
||||
can_override_seed = true
|
||||
restrict_height = true
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = false
|
||||
use_local_space_by_default()
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Place transforms without overlaps. Transforms are assumed to have a
|
||||
spherical shape.")
|
||||
|
||||
var p := documentation.add_parameter("Radius")
|
||||
p.set_type("float")
|
||||
p.set_description("Transform size.")
|
||||
p.add_warning(
|
||||
"The larger the radius, the harder it will be to place the transform,
|
||||
resulting in a faster early exit.
|
||||
On the other hand, smaller radius means more room for more points,
|
||||
meaning more transforms to generate so it will take longer to complete.")
|
||||
|
||||
p = documentation.add_parameter("Samples before rejection")
|
||||
p.set_type("int")
|
||||
p.set_description(
|
||||
"The algorithm tries a point at random until it finds a valid one. This
|
||||
parameter controls how many attempts before moving to the next
|
||||
iteration. Lower values are faster but gives poor coverage. Higher
|
||||
values generates better coverage but are slower.")
|
||||
p.set_cost(2)
|
||||
|
||||
documentation.add_warning(
|
||||
"This modifier uses a poisson disk sampling algorithm which can be
|
||||
quite slow.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, seed) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(seed)
|
||||
_domain = domain
|
||||
_bounds = _domain.bounds_local
|
||||
_gt = domain.get_global_transform()
|
||||
_points = []
|
||||
_init_grid()
|
||||
|
||||
# Stores the possible starting points from where we run the sampling.
|
||||
# This array will progressively be emptied as the algorithm progresses.
|
||||
var spawn_points: Array[Transform3D]
|
||||
spawn_points.push_back(_get_starting_point())
|
||||
|
||||
# Sampler main loop
|
||||
while not spawn_points.is_empty():
|
||||
|
||||
# Pick a starting point at random from the existing list
|
||||
var spawn_index: int = _rng.randi_range(0, spawn_points.size() - 1)
|
||||
var spawn_center := spawn_points[spawn_index]
|
||||
|
||||
var tries := 0
|
||||
var candidate_accepted := false
|
||||
|
||||
while tries < samples_before_rejection:
|
||||
tries += 1
|
||||
|
||||
# Generate a random point in space, outside the radius of the spawn point
|
||||
var dir: Vector3 = _generate_random_vector()
|
||||
var candidate: Vector3 = spawn_center.origin + dir * _rng.randf_range(radius, radius * 2.0)
|
||||
|
||||
if _is_valid(candidate):
|
||||
candidate_accepted = true
|
||||
|
||||
# Add new points to the lists
|
||||
var t = Transform3D()
|
||||
t.origin = candidate
|
||||
|
||||
if is_using_global_space():
|
||||
t.basis = _gt.affine_inverse().basis
|
||||
|
||||
_points.push_back(t)
|
||||
spawn_points.push_back(t)
|
||||
|
||||
var index: int
|
||||
if restrict_height:
|
||||
index = _cell_x + _cell_z * _grid_size.z
|
||||
else:
|
||||
index = _cell_x + (_grid_size.y * _cell_y) + (_grid_size.x * _grid_size.y * _cell_z)
|
||||
|
||||
if index < _grid.size():
|
||||
_grid[index] = _points.size() - 1
|
||||
|
||||
break
|
||||
|
||||
# Failed to find a point after too many tries. The space around this
|
||||
# spawn point is probably full, discard it.
|
||||
if not candidate_accepted:
|
||||
spawn_points.remove_at(spawn_index)
|
||||
|
||||
transforms.append(_points)
|
||||
transforms.shuffle(seed)
|
||||
|
||||
|
||||
func _init_grid() -> void:
|
||||
_squared_radius = radius * radius
|
||||
_cell_size = radius / sqrt(2)
|
||||
_grid_size.x = ceil(_bounds.size.x / _cell_size)
|
||||
_grid_size.y = ceil(_bounds.size.y / _cell_size)
|
||||
_grid_size.z = ceil(_bounds.size.z / _cell_size)
|
||||
|
||||
_grid_size = _grid_size.clamp(Vector3.ONE, _grid_size)
|
||||
|
||||
_grid = []
|
||||
if restrict_height:
|
||||
_grid.resize(_grid_size.x * _grid_size.z)
|
||||
else:
|
||||
_grid.resize(_grid_size.x * _grid_size.y * _grid_size.z)
|
||||
|
||||
|
||||
# Starting point must be inside the domain, or we run the risk to never generate
|
||||
# any valid point later on
|
||||
# TODO: Domain may have islands, so we should use multiple starting points
|
||||
func _get_starting_point() -> Transform3D:
|
||||
var point: Vector3 = _bounds.center
|
||||
|
||||
var tries := 0
|
||||
while not _domain.is_point_inside(point) or tries > 200:
|
||||
tries += 1
|
||||
point.x = _rng.randf_range(_bounds.min.x, _bounds.max.x)
|
||||
point.y = _rng.randf_range(_bounds.min.y, _bounds.max.y)
|
||||
point.z = _rng.randf_range(_bounds.min.z, _bounds.max.z)
|
||||
|
||||
if restrict_height:
|
||||
point.y = _bounds.center.y
|
||||
|
||||
var starting_point := Transform3D()
|
||||
starting_point.origin = point
|
||||
return starting_point
|
||||
|
||||
|
||||
func _is_valid(candidate: Vector3) -> bool:
|
||||
if not _domain.is_point_inside(candidate):
|
||||
return false
|
||||
|
||||
# compute candidate current cell
|
||||
var t_candidate = candidate - _bounds.min
|
||||
_cell_x = floor(t_candidate.x / _cell_size)
|
||||
_cell_y = floor(t_candidate.y / _cell_size)
|
||||
_cell_z = floor(t_candidate.z / _cell_size)
|
||||
|
||||
# Search the surrounding cells for other points
|
||||
var search_start_x: int = max(0, _cell_x - 2)
|
||||
var search_end_x: int = min(_cell_x + 2, _grid_size.x - 1)
|
||||
var search_start_y: int = max(0, _cell_y - 2)
|
||||
var search_end_y: int = min(_cell_y + 2, _grid_size.y - 1)
|
||||
var search_start_z: int = max(0, _cell_z - 2)
|
||||
var search_end_z: int = min(_cell_z + 2, _grid_size.z - 1)
|
||||
|
||||
if restrict_height:
|
||||
for x in range(search_start_x, search_end_x + 1):
|
||||
for z in range(search_start_z, search_end_z + 1):
|
||||
var point_index = _grid[x + z * _grid_size.z]
|
||||
if _is_point_too_close(candidate, point_index):
|
||||
return false
|
||||
else:
|
||||
for x in range(search_start_x, search_end_x + 1):
|
||||
for y in range(search_start_y, search_end_y + 1):
|
||||
for z in range(search_start_z, search_end_z + 1):
|
||||
var point_index = _grid[x + (_grid_size.y * y) + (_grid_size.x * _grid_size.y * z)]
|
||||
if _is_point_too_close(candidate, point_index):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func _is_point_too_close(candidate: Vector3, point_index: int) -> bool:
|
||||
if point_index >= _points.size():
|
||||
return false
|
||||
|
||||
var other_point := _points[point_index]
|
||||
var squared_dist: float = candidate.distance_squared_to(other_point.origin)
|
||||
return squared_dist < _squared_radius
|
||||
|
||||
|
||||
func _generate_random_vector():
|
||||
var angle = _rng.randf_range(0.0, TAU)
|
||||
if restrict_height:
|
||||
return Vector3(sin(angle), 0.0, cos(angle))
|
||||
|
||||
var costheta = _rng.randf_range(-1.0, 1.0)
|
||||
var theta = acos(costheta)
|
||||
var vector := Vector3.ZERO
|
||||
vector.x = sin(theta) * cos(angle)
|
||||
vector.y = sin(theta) * sin(angle)
|
||||
vector.z = cos(theta)
|
||||
return vector
|
||||
84
addons/proton_scatter/src/modifiers/create_inside_random.gd
Normal file
84
addons/proton_scatter/src/modifiers/create_inside_random.gd
Normal file
@@ -0,0 +1,84 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var amount := 10
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Create Inside (Random)"
|
||||
category = "Create"
|
||||
warning_ignore_no_transforms = true
|
||||
warning_ignore_no_shape = false
|
||||
can_override_seed = true
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
use_local_space_by_default()
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Randomly place new transforms inside the area defined by
|
||||
the ScatterShape nodes.")
|
||||
|
||||
var p := documentation.add_parameter("Amount")
|
||||
p.set_type("int")
|
||||
p.set_description("How many transforms will be created.")
|
||||
p.set_cost(2)
|
||||
|
||||
documentation.add_warning(
|
||||
"In some cases, the amount of transforms created by this modifier
|
||||
might be lower than the requested amount (but never higher). This may
|
||||
happen if the provided ScatterShape has a huge bounding box but a tiny
|
||||
valid space, like a curved and narrow path.")
|
||||
|
||||
|
||||
# TODO:
|
||||
# + Multithreading
|
||||
# + Spatial partionning to discard areas outside the domain earlier
|
||||
func _process_transforms(transforms, domain, random_seed) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(random_seed)
|
||||
|
||||
var gt: Transform3D = domain.get_global_transform()
|
||||
var center: Vector3 = domain.bounds_local.center
|
||||
var half_size: Vector3 = domain.bounds_local.size / 2.0
|
||||
var height: float = domain.bounds_local.center.y
|
||||
|
||||
# Generate a random point in the bounding box. Store if it's inside the
|
||||
# domain, or discard if invalid. Repeat until enough valid points are found.
|
||||
var t: Transform3D
|
||||
var pos: Vector3
|
||||
var new_transforms: Array[Transform3D] = []
|
||||
var max_retries = amount * 10 # TODO: expose this parameter?
|
||||
var tries := 0
|
||||
|
||||
while new_transforms.size() != amount:
|
||||
t = Transform3D()
|
||||
pos = _random_vec3() * half_size + center
|
||||
|
||||
if restrict_height:
|
||||
pos.y = height
|
||||
|
||||
if is_using_global_space():
|
||||
t.basis = gt.affine_inverse().basis
|
||||
|
||||
if domain.is_point_inside(pos):
|
||||
t.origin = pos
|
||||
new_transforms.push_back(t)
|
||||
continue
|
||||
|
||||
# Prevents an infinite loop
|
||||
tries += 1
|
||||
if tries > max_retries:
|
||||
break
|
||||
|
||||
transforms.append(new_transforms)
|
||||
|
||||
|
||||
func _random_vec3() -> Vector3:
|
||||
var vec3 = Vector3.ZERO
|
||||
vec3.x = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.y = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.z = _rng.randf_range(-1.0, 1.0)
|
||||
return vec3
|
||||
39
addons/proton_scatter/src/modifiers/look_at.gd
Normal file
39
addons/proton_scatter/src/modifiers/look_at.gd
Normal file
@@ -0,0 +1,39 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var target := Vector3.ZERO
|
||||
@export var up := Vector3.UP
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Look At"
|
||||
category = "Edit"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_local_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Rotates every transform such that the forward axis (-Z) points towards the target position.")
|
||||
|
||||
documentation.add_parameter("Target").set_type("Vector3").set_description(
|
||||
"Target position (X, Y, Z)")
|
||||
documentation.add_parameter("Up").set_type("Vector3").set_description(
|
||||
"Up axes (X, Y, Z)")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed : int) -> void:
|
||||
var st: Transform3D = domain.get_global_transform()
|
||||
|
||||
for i in transforms.size():
|
||||
var transform: Transform3D = transforms.list[i]
|
||||
var local_target := target
|
||||
|
||||
if is_using_global_space():
|
||||
local_target = st.affine_inverse().basis * local_target
|
||||
|
||||
elif is_using_individual_instances_space():
|
||||
local_target = transform.basis * local_target
|
||||
|
||||
transforms.list[i] = transform.looking_at(local_target, up)
|
||||
63
addons/proton_scatter/src/modifiers/offset_position.gd
Normal file
63
addons/proton_scatter/src/modifiers/offset_position.gd
Normal file
@@ -0,0 +1,63 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export_enum("Offset:0", "Multiply:1", "Override:2") var operation: int
|
||||
@export var position := Vector3.ZERO
|
||||
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Edit Position"
|
||||
category = "Offset"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Moves every transform the same way.")
|
||||
|
||||
var p := documentation.add_parameter("Position")
|
||||
p.set_type("vector3")
|
||||
p.set_description("How far each transforms are moved.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
var s_gt: Transform3D = domain.get_global_transform()
|
||||
var s_gt_inverse: Transform3D = s_gt.affine_inverse()
|
||||
var t: Transform3D
|
||||
|
||||
for i in transforms.list.size():
|
||||
t = transforms.list[i]
|
||||
|
||||
var value: Vector3
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
value = t.basis * position
|
||||
elif is_using_global_space():
|
||||
value = s_gt_inverse.basis * position
|
||||
else:
|
||||
value = position
|
||||
|
||||
match operation:
|
||||
0:
|
||||
t.origin += value
|
||||
1:
|
||||
if is_using_local_space():
|
||||
t.origin *= value
|
||||
|
||||
if is_using_global_space():
|
||||
var global_pos = s_gt * t.origin
|
||||
global_pos -= s_gt.origin
|
||||
global_pos *= position
|
||||
global_pos += s_gt.origin
|
||||
|
||||
t.origin = s_gt_inverse * global_pos
|
||||
|
||||
elif is_using_individual_instances_space():
|
||||
pass # Multiply does nothing on this reference frame.
|
||||
2:
|
||||
t.origin = value
|
||||
|
||||
transforms.list[i] = t
|
||||
100
addons/proton_scatter/src/modifiers/offset_rotation.gd
Normal file
100
addons/proton_scatter/src/modifiers/offset_rotation.gd
Normal file
@@ -0,0 +1,100 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export_enum("Offset:0", "Multiply:1", "Override:2") var operation: int
|
||||
@export var rotation := Vector3.ZERO
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Edit Rotation"
|
||||
category = "Offset"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Rotates every transform.")
|
||||
|
||||
documentation.add_parameter("Rotation").set_type("Vector3").set_description(
|
||||
"Rotation angle (in degrees) along each axes (X, Y, Z)")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed : int) -> void:
|
||||
var rotation_rad := Vector3.ZERO
|
||||
rotation_rad.x = deg_to_rad(rotation.x)
|
||||
rotation_rad.y = deg_to_rad(rotation.y)
|
||||
rotation_rad.z = deg_to_rad(rotation.z)
|
||||
|
||||
var s_gt: Transform3D = domain.get_global_transform()
|
||||
var s_lt: Transform3D = domain.get_local_transform()
|
||||
var s_gt_inverse := s_gt.affine_inverse()
|
||||
var t: Transform3D
|
||||
var basis: Basis
|
||||
var axis_x: Vector3
|
||||
var axis_y: Vector3
|
||||
var axis_z: Vector3
|
||||
var final_rotation: Vector3
|
||||
|
||||
if is_using_local_space():
|
||||
axis_x = Vector3.RIGHT
|
||||
axis_y = Vector3.UP
|
||||
axis_z = Vector3.FORWARD
|
||||
|
||||
elif is_using_global_space():
|
||||
axis_x = (s_gt_inverse.basis * Vector3.RIGHT).normalized()
|
||||
axis_y = (s_gt_inverse.basis * Vector3.UP).normalized()
|
||||
axis_z = (s_gt_inverse.basis * Vector3.FORWARD).normalized()
|
||||
|
||||
for i in transforms.size():
|
||||
t = transforms.list[i]
|
||||
basis = t.basis
|
||||
|
||||
match operation:
|
||||
0: # Offset
|
||||
final_rotation = rotation_rad
|
||||
|
||||
1: # Multiply
|
||||
# TMP: Local and global space calculations are probably wrong
|
||||
var current_rotation: Vector3
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
current_rotation = basis.get_euler()
|
||||
|
||||
elif is_using_local_space():
|
||||
var local_t := t * s_lt
|
||||
current_rotation = local_t.basis.get_euler()
|
||||
|
||||
else:
|
||||
var global_t := t * s_gt
|
||||
current_rotation = global_t.basis.get_euler()
|
||||
|
||||
final_rotation = (current_rotation * rotation) - current_rotation
|
||||
|
||||
2: # Override
|
||||
# Creates a new basis with the original scale only
|
||||
# Applies new rotation on top
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
basis = Basis().from_scale(t.basis.get_scale())
|
||||
|
||||
elif is_using_local_space():
|
||||
basis = (s_gt_inverse * s_gt).basis
|
||||
|
||||
else:
|
||||
var tmp_t = Transform3D(Basis.from_scale(t.basis.get_scale()), Vector3.ZERO)
|
||||
basis = (s_gt_inverse * tmp_t).basis
|
||||
|
||||
final_rotation = rotation_rad
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
axis_x = basis.x.normalized()
|
||||
axis_y = basis.y.normalized()
|
||||
axis_z = basis.z.normalized()
|
||||
|
||||
basis = basis.rotated(axis_y, final_rotation.y)
|
||||
basis = basis.rotated(axis_x, final_rotation.x)
|
||||
basis = basis.rotated(axis_z, final_rotation.z)
|
||||
|
||||
transforms.list[i].basis = basis
|
||||
94
addons/proton_scatter/src/modifiers/offset_scale.gd
Normal file
94
addons/proton_scatter/src/modifiers/offset_scale.gd
Normal file
@@ -0,0 +1,94 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export_enum("Offset:0", "Multiply:1", "Override:2") var operation: int = 1
|
||||
@export var scale := Vector3(1, 1, 1)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Edit Scale"
|
||||
category = "Offset"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Scales every transform.")
|
||||
|
||||
var p := documentation.add_parameter("Scale")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("How much to scale the transform along each axes (X, Y, Z)")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
var s_gt: Transform3D = domain.get_global_transform()
|
||||
var s_lt: Transform3D = domain.get_local_transform()
|
||||
var s_gt_inverse := s_gt.affine_inverse()
|
||||
var s_lt_inverse := s_lt.affine_inverse()
|
||||
var basis: Basis
|
||||
var t: Transform3D
|
||||
var tmp_t: Transform3D
|
||||
|
||||
for i in transforms.size():
|
||||
t = transforms.list[i]
|
||||
basis = t.basis
|
||||
|
||||
match operation:
|
||||
0: # Offset
|
||||
if is_using_individual_instances_space():
|
||||
var current_scale := basis.get_scale()
|
||||
var s = (current_scale + scale) / current_scale
|
||||
basis = t.scaled_local(s).basis
|
||||
|
||||
elif is_using_global_space():
|
||||
# Convert to global space, scale, convert back to local space
|
||||
tmp_t = s_gt * t
|
||||
var current_scale: Vector3 = tmp_t.basis.get_scale()
|
||||
tmp_t.basis = tmp_t.basis.scaled((current_scale + scale) / current_scale)
|
||||
basis = (s_gt_inverse * tmp_t).basis
|
||||
|
||||
else:
|
||||
var current_scale: Vector3 = basis.get_scale()
|
||||
basis = basis.scaled((current_scale + scale) / current_scale)
|
||||
|
||||
1: # Multiply
|
||||
if is_using_individual_instances_space():
|
||||
basis = t.scaled_local(scale).basis
|
||||
|
||||
elif is_using_global_space():
|
||||
# Convert to global space, scale, convert back to local space
|
||||
tmp_t = s_gt * t
|
||||
tmp_t = tmp_t.scaled(scale)
|
||||
basis = (s_gt_inverse * tmp_t).basis
|
||||
|
||||
else:
|
||||
basis = basis.scaled(scale)
|
||||
|
||||
2: # Override
|
||||
if is_using_individual_instances_space():
|
||||
var t_scale: Vector3 = basis.get_scale()
|
||||
t_scale.x = (1.0 / t_scale.x) * scale.x
|
||||
t_scale.y = (1.0 / t_scale.y) * scale.y
|
||||
t_scale.z = (1.0 / t_scale.z) * scale.z
|
||||
basis = t.scaled_local(t_scale).basis
|
||||
|
||||
elif is_using_global_space():
|
||||
# Convert to global space, scale, convert back to local space
|
||||
tmp_t = t * s_gt
|
||||
var t_scale: Vector3 = tmp_t.basis.get_scale()
|
||||
t_scale.x = (1.0 / t_scale.x) * scale.x
|
||||
t_scale.y = (1.0 / t_scale.y) * scale.y
|
||||
t_scale.z = (1.0 / t_scale.z) * scale.z
|
||||
tmp_t.basis = tmp_t.basis.scaled(t_scale)
|
||||
basis = (s_gt_inverse * tmp_t).basis
|
||||
|
||||
else:
|
||||
var t_scale: Vector3 = basis.get_scale()
|
||||
t_scale.x = (1.0 / t_scale.x) * scale.x
|
||||
t_scale.y = (1.0 / t_scale.y) * scale.y
|
||||
t_scale.z = (1.0 / t_scale.z) * scale.z
|
||||
basis = basis.scaled(t_scale)
|
||||
|
||||
transforms.list[i].basis = basis
|
||||
83
addons/proton_scatter/src/modifiers/offset_transform.gd
Normal file
83
addons/proton_scatter/src/modifiers/offset_transform.gd
Normal file
@@ -0,0 +1,83 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var position := Vector3.ZERO
|
||||
@export var rotation := Vector3(0.0, 0.0, 0.0)
|
||||
@export var scale := Vector3.ONE
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Edit Transform"
|
||||
category = "Offset"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_local_space_by_default()
|
||||
deprecated = true
|
||||
deprecation_message = "Use a combination of 'Edit Position', 'Edit Rotation' and 'Edit Scale' instead."
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Offsets position, rotation and scale in a single modifier. Every
|
||||
transforms generated before will see the same transformation applied.")
|
||||
|
||||
var p := documentation.add_parameter("Position")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("How far each transforms are moved.")
|
||||
|
||||
p = documentation.add_parameter("Rotation")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("Rotation angle (in degrees) along each axes (X, Y, Z)")
|
||||
|
||||
p = documentation.add_parameter("Scale")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("How much to scale the transform along each axes (X, Y, Z)")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
var t: Transform3D
|
||||
var local_t: Transform3D
|
||||
var basis: Basis
|
||||
var axis_x := Vector3.RIGHT
|
||||
var axis_y := Vector3.UP
|
||||
var axis_z := Vector3.DOWN
|
||||
var final_scale := scale
|
||||
var final_position := position
|
||||
var st: Transform3D = domain.get_global_transform()
|
||||
|
||||
if is_using_local_space():
|
||||
axis_x = st.basis.x
|
||||
axis_y = st.basis.y
|
||||
axis_z = st.basis.z
|
||||
final_scale = scale.rotated(Vector3.RIGHT, st.basis.get_euler().x)
|
||||
final_position = st.basis * position
|
||||
|
||||
for i in transforms.size():
|
||||
t = transforms.list[i]
|
||||
basis = t.basis
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
axis_x = basis.x
|
||||
axis_y = basis.y
|
||||
axis_z = basis.z
|
||||
basis.x *= scale.x
|
||||
basis.y *= scale.y
|
||||
basis.z *= scale.z
|
||||
final_position = t.basis * position
|
||||
|
||||
elif is_using_local_space():
|
||||
local_t = t * st
|
||||
local_t.basis = local_t.basis.scaled(final_scale)
|
||||
basis = (st * local_t).basis
|
||||
|
||||
else:
|
||||
basis = basis.scaled(final_scale)
|
||||
|
||||
basis = basis.rotated(axis_x, deg_to_rad(rotation.x))
|
||||
basis = basis.rotated(axis_y, deg_to_rad(rotation.y))
|
||||
basis = basis.rotated(axis_z, deg_to_rad(rotation.z))
|
||||
t.basis = basis
|
||||
t.origin += final_position
|
||||
|
||||
transforms.list[i] = t
|
||||
216
addons/proton_scatter/src/modifiers/project_on_geometry.gd
Normal file
216
addons/proton_scatter/src/modifiers/project_on_geometry.gd
Normal file
@@ -0,0 +1,216 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
signal projection_completed
|
||||
|
||||
|
||||
const ProtonScatterPhysicsHelper := preload("res://addons/proton_scatter/src/common/physics_helper.gd")
|
||||
|
||||
|
||||
@export var ray_direction := Vector3.DOWN
|
||||
@export var ray_length := 10.0
|
||||
@export var ray_offset := 1.0
|
||||
@export var remove_points_on_miss := true
|
||||
@export var align_with_collision_normal := false
|
||||
@export_range(0.0, 90.0) var max_slope = 90.0
|
||||
@export_flags_3d_physics var collision_mask = 1
|
||||
@export_flags_3d_physics var exclude_mask = 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Project On Colliders"
|
||||
category = "Edit"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_global_space_by_default()
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Moves each transforms along the ray direction until they hit a collider.
|
||||
This is useful to avoid floating objects on uneven terrain for example.")
|
||||
|
||||
documentation.add_warning(
|
||||
"This modifier only works when physics bodies are around. It will ignore
|
||||
simple MeshInstances nodes.")
|
||||
|
||||
var p := documentation.add_parameter("Ray direction")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"In which direction we look for a collider. This default to the DOWN
|
||||
direction by default (look at the ground).")
|
||||
p.add_warning(
|
||||
"This is relative to the transform is local space is enabled, or aligned
|
||||
with the global axis if local space is disabled.")
|
||||
|
||||
p = documentation.add_parameter("Ray length")
|
||||
p.set_type("float")
|
||||
p.set_description("How far we look for other physics objects.")
|
||||
p.set_cost(2)
|
||||
|
||||
p = documentation.add_parameter("Ray offset")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"Moves back the raycast origin point along the ray direction. This is
|
||||
useful if the initial transform is slightly below the ground, which would
|
||||
make the raycast miss the collider (since it would start inside).")
|
||||
|
||||
p = documentation.add_parameter("Remove points on miss")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"When enabled, if the raycast didn't collide with anything, or collided
|
||||
with a surface above the max slope setting, the transform is removed
|
||||
from the list.
|
||||
This is useful to avoid floating objects that are too far from the rest
|
||||
of the scene's geometry.")
|
||||
|
||||
p = documentation.add_parameter("Align with collision normal")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"Rotate the transform to align it with the collision normal in case
|
||||
the ray cast hit a collider.")
|
||||
|
||||
p = documentation.add_parameter("Max slope")
|
||||
p.set_type("float")
|
||||
p.set_description(
|
||||
"Angle (in degrees) after which the hit is considered invalid.
|
||||
When a ray cast hit, the normal of the ray is compared against the
|
||||
normal of the hit. If you set the slope to 0°, the ray and the hit
|
||||
normal would have to be perfectly aligned to be valid. On the other
|
||||
hand, setting the maximum slope to 90° treats every collisions as
|
||||
valid regardless of their normals.")
|
||||
|
||||
p = documentation.add_parameter("Mask")
|
||||
p.set_description(
|
||||
"Only collide with colliders on these layers. Disabled layers will
|
||||
be ignored. It's useful to ignore players or npcs that might be on the
|
||||
scene when you're editing it.")
|
||||
|
||||
p = documentation.add_parameter("Exclude Mask")
|
||||
p.set_description(
|
||||
"Tests if the snapping would collide with the selected layers.
|
||||
If it collides, the point will be excluded from the list.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
if transforms.is_empty():
|
||||
return
|
||||
|
||||
# Create all the physics ray queries
|
||||
var gt: Transform3D = domain.get_global_transform()
|
||||
var gt_inverse := gt.affine_inverse()
|
||||
var queries: Array[PhysicsRayQueryParameters3D] = []
|
||||
var exclude_queries: Array[PhysicsRayQueryParameters3D] = []
|
||||
|
||||
for t in transforms.list:
|
||||
var start = gt * t.origin
|
||||
var end = start
|
||||
var dir = ray_direction.normalized()
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
dir = t.basis * dir
|
||||
|
||||
elif is_using_local_space():
|
||||
dir = gt.basis * dir
|
||||
|
||||
start -= ray_offset * dir
|
||||
end += ray_length * dir
|
||||
|
||||
var ray_query := PhysicsRayQueryParameters3D.new()
|
||||
ray_query.from = start
|
||||
ray_query.to = end
|
||||
ray_query.collision_mask = collision_mask
|
||||
|
||||
queries.push_back(ray_query)
|
||||
|
||||
var exclude_query := PhysicsRayQueryParameters3D.new()
|
||||
exclude_query.from = start
|
||||
exclude_query.to = end
|
||||
exclude_query.collision_mask = exclude_mask
|
||||
exclude_queries.push_back(exclude_query)
|
||||
|
||||
# Run the queries in the physics helper since we can't access the PhysicsServer
|
||||
# from outside the _physics_process while also being in a separate thread.
|
||||
var physics_helper: ProtonScatterPhysicsHelper = domain.get_root().get_physics_helper()
|
||||
|
||||
var ray_hits := await physics_helper.execute(queries)
|
||||
|
||||
if ray_hits.is_empty():
|
||||
return
|
||||
|
||||
# Create queries from the hit points
|
||||
var index := -1
|
||||
for ray_hit in ray_hits:
|
||||
index += 1
|
||||
var hit : Dictionary = ray_hit
|
||||
if hit.is_empty():
|
||||
exclude_queries[index].collision_mask = 0 # this point is empty anyway, we dont care
|
||||
continue
|
||||
exclude_queries[index].to = hit.position # only cast up to hit point for correct ordering
|
||||
|
||||
var exclude_hits : Array[Dictionary] = []
|
||||
if exclude_mask != 0: # Only cast the rays if it makes any sense
|
||||
exclude_hits = await physics_helper.execute(exclude_queries)
|
||||
|
||||
# Apply the results
|
||||
index = 0
|
||||
var d: float
|
||||
var t: Transform3D
|
||||
var remapped_max_slope = remap(max_slope, 0.0, 90.0, 0.0, 1.0)
|
||||
var is_point_valid := false
|
||||
exclude_hits.reverse() # makes it possible to use pop_back which is much faster
|
||||
var new_transforms_array : Array[Transform3D] = []
|
||||
|
||||
for hit in ray_hits:
|
||||
is_point_valid = true
|
||||
|
||||
if hit.is_empty():
|
||||
is_point_valid = false
|
||||
else:
|
||||
d = abs(Vector3.UP.dot(hit.normal))
|
||||
is_point_valid = d >= (1.0 - remapped_max_slope)
|
||||
|
||||
var exclude_hit = exclude_hits.pop_back()
|
||||
if exclude_hit != null:
|
||||
if not exclude_hit.is_empty():
|
||||
is_point_valid = false
|
||||
|
||||
t = transforms.list[index]
|
||||
if is_point_valid:
|
||||
if align_with_collision_normal:
|
||||
t = _align_with(t, gt_inverse.basis * hit.normal)
|
||||
|
||||
t.origin = gt_inverse * hit.position
|
||||
new_transforms_array.push_back(t)
|
||||
elif not remove_points_on_miss:
|
||||
new_transforms_array.push_back(t)
|
||||
|
||||
index += 1
|
||||
|
||||
# All done, store the transforms in the original array
|
||||
transforms.list.clear()
|
||||
transforms.list.append_array(new_transforms_array) # this avoids memory leak
|
||||
|
||||
if transforms.is_empty():
|
||||
warning += """Every points have been removed. Possible reasons include: \n
|
||||
+ No collider is close enough to the shapes.
|
||||
+ Ray length is too short.
|
||||
+ Ray direction is incorrect.
|
||||
+ Collision mask is not set properly.
|
||||
+ Max slope is too low.
|
||||
"""
|
||||
|
||||
|
||||
func _align_with(t: Transform3D, normal: Vector3) -> Transform3D:
|
||||
var n1 = t.basis.y.normalized()
|
||||
var n2 = normal.normalized()
|
||||
|
||||
var cosa = n1.dot(n2)
|
||||
var alpha = acos(cosa)
|
||||
var axis = n1.cross(n2)
|
||||
|
||||
if axis == Vector3.ZERO:
|
||||
return t
|
||||
|
||||
return t.rotated(axis.normalized(), alpha)
|
||||
75
addons/proton_scatter/src/modifiers/proxy.gd
Normal file
75
addons/proton_scatter/src/modifiers/proxy.gd
Normal file
@@ -0,0 +1,75 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
const ProtonScatter := preload("../scatter.gd")
|
||||
const ModifierStack := preload("../stack/modifier_stack.gd")
|
||||
|
||||
|
||||
@export_node_path var scatter_node: NodePath
|
||||
@export var auto_rebuild := true:
|
||||
set(val):
|
||||
auto_rebuild = val
|
||||
if not is_instance_valid(_source_node) or not _source_node is ProtonScatter:
|
||||
return
|
||||
|
||||
if auto_rebuild: # Connect signal if not already connected
|
||||
if not _source_node.build_completed.is_connected(_on_source_changed):
|
||||
_source_node.build_completed.connect(_on_source_changed)
|
||||
|
||||
# Auto rebuild disabled, disconnect signal if connected
|
||||
elif _source_node.build_completed.is_connected(_on_source_changed):
|
||||
_source_node.build_completed.disconnect(_on_source_changed)
|
||||
|
||||
var _source_node: ProtonScatter:
|
||||
set(val):
|
||||
# Disconnect signals from previous scatter node if any
|
||||
if is_instance_valid(_source_node) and _source_node is ProtonScatter:
|
||||
if _source_node.build_completed.is_connected(_on_source_changed):
|
||||
_source_node.build_completed.disconnect(_on_source_changed)
|
||||
|
||||
# Replace reference and retrigger the auto_rebuild setter
|
||||
_source_node = val
|
||||
auto_rebuild = auto_rebuild
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Proxy"
|
||||
category = "Misc"
|
||||
can_restrict_height = false
|
||||
can_override_seed = false
|
||||
global_reference_frame_available = false
|
||||
local_reference_frame_available = false
|
||||
individual_instances_reference_frame_available = false
|
||||
warning_ignore_no_transforms = true
|
||||
|
||||
documentation.add_paragraph("Copy a modifier stack from another ProtonScatter node in the scene.")
|
||||
documentation.add_paragraph(
|
||||
"Useful when you need multiple Scatter nodes sharing the same rules, without having to
|
||||
replicate their modifiers and settings in each."
|
||||
)
|
||||
documentation.add_paragraph(
|
||||
"Unlike presets which are full independent copies, this method is more similar to a linked
|
||||
copy. Changes on the original modifier stack will be accounted for in here."
|
||||
)
|
||||
|
||||
var p = documentation.add_parameter("Scatter node")
|
||||
p.set_type("NodePath")
|
||||
p.set_description("The Scatter node to use as a reference.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
_source_node = domain.get_root().get_node_or_null(scatter_node)
|
||||
|
||||
if not _source_node or not _source_node is ProtonScatter:
|
||||
warning += "You need to select a valid ProtonScatter node."
|
||||
return
|
||||
|
||||
if _source_node.modifier_stack:
|
||||
var stack: ModifierStack = _source_node.modifier_stack.get_copy()
|
||||
var results = await stack.start_update(domain.get_root(), domain)
|
||||
transforms.append(results.list)
|
||||
|
||||
|
||||
func _on_source_changed() -> void:
|
||||
modifier_changed.emit()
|
||||
88
addons/proton_scatter/src/modifiers/randomize_rotation.gd
Normal file
88
addons/proton_scatter/src/modifiers/randomize_rotation.gd
Normal file
@@ -0,0 +1,88 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var rotation := Vector3(360.0, 360.0, 360.0)
|
||||
@export var snap_angle := Vector3.ZERO
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Randomize Rotation"
|
||||
category = "Edit"
|
||||
can_override_seed = true
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Randomly rotate every transforms individually.")
|
||||
|
||||
var p := documentation.add_parameter("Rotation")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("Rotation angle (in degrees) along each axes (X, Y, Z)")
|
||||
|
||||
p = documentation.add_parameter("Snap angle")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"When set to any value above 0, the rotation will be done by increments
|
||||
of the snap angle.")
|
||||
p.add_warning(
|
||||
"Example: When Snap Angle is set to 90, the possible random rotation
|
||||
offsets around an axis will be among [0, 90, 180, 360].")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, random_seed) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(random_seed)
|
||||
|
||||
var t: Transform3D
|
||||
var b: Basis
|
||||
|
||||
var gt: Transform3D = domain.get_global_transform()
|
||||
var gb: Basis = gt.basis
|
||||
var axis_x: Vector3 = Vector3.RIGHT
|
||||
var axis_y: Vector3 = Vector3.UP
|
||||
var axis_z: Vector3 = Vector3.FORWARD
|
||||
|
||||
if is_using_local_space():
|
||||
axis_x = (Vector3.RIGHT * gb).normalized()
|
||||
axis_y = (Vector3.UP * gb).normalized()
|
||||
axis_z = (Vector3.FORWARD * gb).normalized()
|
||||
|
||||
for i in transforms.list.size():
|
||||
t = transforms.list[i]
|
||||
b = t.basis
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
axis_x = t.basis.x.normalized()
|
||||
axis_y = t.basis.y.normalized()
|
||||
axis_z = t.basis.z.normalized()
|
||||
|
||||
b = b.rotated(axis_x, _random_angle(rotation.x, snap_angle.x))
|
||||
b = b.rotated(axis_y, _random_angle(rotation.y, snap_angle.y))
|
||||
b = b.rotated(axis_z, _random_angle(rotation.z, snap_angle.z))
|
||||
|
||||
t.basis = b
|
||||
transforms.list[i] = t
|
||||
|
||||
|
||||
func _random_vec3() -> Vector3:
|
||||
var vec3 = Vector3.ZERO
|
||||
vec3.x = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.y = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.z = _rng.randf_range(-1.0, 1.0)
|
||||
return vec3
|
||||
|
||||
|
||||
func _random_angle(rot: float, snap: float) -> float:
|
||||
return deg_to_rad(snapped(_rng.randf_range(-1.0, 1.0) * rot, snap))
|
||||
|
||||
|
||||
func _clamp_vector(vec3, vmin, vmax) -> Vector3:
|
||||
vec3.x = clamp(vec3.x, vmin.x, vmax.x)
|
||||
vec3.y = clamp(vec3.y, vmin.y, vmax.y)
|
||||
vec3.z = clamp(vec3.z, vmin.z, vmax.z)
|
||||
return vec3
|
||||
106
addons/proton_scatter/src/modifiers/randomize_transforms.gd
Normal file
106
addons/proton_scatter/src/modifiers/randomize_transforms.gd
Normal file
@@ -0,0 +1,106 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var position := Vector3.ZERO
|
||||
@export var rotation := Vector3.ZERO
|
||||
@export var scale := Vector3.ZERO
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Randomize Transforms"
|
||||
category = "Edit"
|
||||
can_override_seed = true
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, random_seed) -> void:
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(random_seed)
|
||||
|
||||
var t: Transform3D
|
||||
var global_t: Transform3D
|
||||
var basis: Basis
|
||||
var random_scale: Vector3
|
||||
var random_position: Vector3
|
||||
var s_gt: Transform3D = domain.get_global_transform()
|
||||
var s_gt_inverse := s_gt.affine_inverse()
|
||||
|
||||
# Global rotation axis
|
||||
var axis_x := Vector3.RIGHT
|
||||
var axis_y := Vector3.UP
|
||||
var axis_z := Vector3.DOWN
|
||||
|
||||
if is_using_global_space():
|
||||
axis_x = (s_gt_inverse.basis * Vector3.RIGHT).normalized()
|
||||
axis_y = (s_gt_inverse.basis * Vector3.UP).normalized()
|
||||
axis_z = (s_gt_inverse.basis * Vector3.FORWARD).normalized()
|
||||
|
||||
for i in transforms.size():
|
||||
t = transforms.list[i]
|
||||
basis = t.basis
|
||||
|
||||
# Apply rotation
|
||||
if is_using_individual_instances_space():
|
||||
axis_x = basis.x.normalized()
|
||||
axis_y = basis.y.normalized()
|
||||
axis_z = basis.z.normalized()
|
||||
|
||||
basis = basis.rotated(axis_x, deg_to_rad(_random_float() * rotation.x))
|
||||
basis = basis.rotated(axis_y, deg_to_rad(_random_float() * rotation.y))
|
||||
basis = basis.rotated(axis_z, deg_to_rad(_random_float() * rotation.z))
|
||||
|
||||
# Apply scale
|
||||
random_scale = Vector3.ONE + (_rng.randf() * scale)
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
basis.x *= random_scale.x
|
||||
basis.y *= random_scale.y
|
||||
basis.z *= random_scale.z
|
||||
|
||||
elif is_using_global_space():
|
||||
global_t = s_gt * Transform3D(basis, Vector3.ZERO)
|
||||
global_t = global_t.scaled(random_scale)
|
||||
basis = (s_gt_inverse * global_t).basis
|
||||
|
||||
else:
|
||||
basis = basis.scaled(random_scale)
|
||||
|
||||
# Apply position
|
||||
random_position = _random_vec3() * position
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
random_position = t.basis * random_position
|
||||
|
||||
elif is_using_global_space():
|
||||
random_position = s_gt_inverse.basis * random_position
|
||||
|
||||
t.origin += random_position
|
||||
t.basis = basis
|
||||
|
||||
transforms.list[i] = t
|
||||
|
||||
|
||||
func _random_vec3() -> Vector3:
|
||||
var vec3 = Vector3.ZERO
|
||||
vec3.x = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.y = _rng.randf_range(-1.0, 1.0)
|
||||
vec3.z = _rng.randf_range(-1.0, 1.0)
|
||||
return vec3
|
||||
|
||||
|
||||
func _random_float() -> float:
|
||||
return _rng.randf_range(-1.0, 1.0)
|
||||
|
||||
|
||||
func _clamp_vector(vec3, vmin, vmax) -> Vector3:
|
||||
vec3.x = clamp(vec3.x, vmin.x, vmax.x)
|
||||
vec3.y = clamp(vec3.y, vmin.y, vmax.y)
|
||||
vec3.z = clamp(vec3.z, vmin.z, vmax.z)
|
||||
return vec3
|
||||
191
addons/proton_scatter/src/modifiers/relax.gd
Normal file
191
addons/proton_scatter/src/modifiers/relax.gd
Normal file
@@ -0,0 +1,191 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
static var shader_file: RDShaderFile
|
||||
|
||||
|
||||
@export var iterations : int = 3
|
||||
@export var offset_step : float = 0.01
|
||||
@export var consecutive_step_multiplier : float = 0.5
|
||||
@export var use_computeshader : bool = true
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Relax Position"
|
||||
category = "Edit"
|
||||
global_reference_frame_available = false
|
||||
local_reference_frame_available = false
|
||||
individual_instances_reference_frame_available = false
|
||||
can_restrict_height = true
|
||||
restrict_height = true
|
||||
|
||||
documentation.add_warning(
|
||||
"This modifier is has an O(n²) complexity and will be slow with
|
||||
large amounts of points, unless your device supports compute shaders.",
|
||||
1)
|
||||
|
||||
var p := documentation.add_parameter("iterations")
|
||||
p.set_type("int")
|
||||
p.set_cost(2)
|
||||
p.set_description(
|
||||
"How many times the relax algorithm will run. Increasing this value will
|
||||
generally improves the result, at the cost of execution speed."
|
||||
)
|
||||
|
||||
p = documentation.add_parameter("Offset step")
|
||||
p.set_type("float")
|
||||
p.set_cost(0)
|
||||
p.set_description("How far the transform will be pushed away each iteration.")
|
||||
|
||||
p = documentation.add_parameter("Consecutive step multiplier")
|
||||
p.set_type("float")
|
||||
p.set_cost(0)
|
||||
p.set_description(
|
||||
"On each iteration, multiply the offset step by this value. This value
|
||||
is usually set between 0 and 1, to make the effect less pronounced on
|
||||
successive iterations.")
|
||||
|
||||
p = documentation.add_parameter("Use compute shader")
|
||||
p.set_cost(0)
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"Run the calculations on the GPU instead of the CPU. This provides
|
||||
a significant speed boost and should be enabled when possible.")
|
||||
p.add_warning(
|
||||
"This parameter can't be enabled when using the OpenGL backend or running
|
||||
in headless mode.", 2)
|
||||
|
||||
|
||||
func _process_transforms(transforms, _domain, _seed) -> void:
|
||||
var offset := offset_step
|
||||
if transforms.size() < 2:
|
||||
return
|
||||
|
||||
# Disable the use of compute shader, if we cannot create a RenderingDevice
|
||||
if use_computeshader:
|
||||
var rd := RenderingServer.create_local_rendering_device()
|
||||
if rd == null:
|
||||
use_computeshader = false
|
||||
else:
|
||||
rd.free()
|
||||
rd = null
|
||||
|
||||
if use_computeshader:
|
||||
for iteration in iterations:
|
||||
if interrupt_update:
|
||||
return
|
||||
var movedir: PackedVector3Array = compute_closest(transforms)
|
||||
for i in transforms.size():
|
||||
var dir = movedir[i]
|
||||
if restrict_height:
|
||||
dir.y = 0.0
|
||||
# move away from closest point
|
||||
transforms.list[i].origin += dir.normalized() * offset
|
||||
|
||||
offset *= consecutive_step_multiplier
|
||||
|
||||
else:
|
||||
# calculate the relax transforms on the cpu
|
||||
for iteration in iterations:
|
||||
for i in transforms.size():
|
||||
if interrupt_update:
|
||||
return
|
||||
var min_vector = Vector3.ONE * 99999.0
|
||||
var threshold := 99999.0
|
||||
var distance := 0.0
|
||||
var diff: Vector3
|
||||
|
||||
# Find the closest point
|
||||
for j in transforms.size():
|
||||
if i == j:
|
||||
continue
|
||||
|
||||
diff = transforms.list[i].origin - transforms.list[j].origin
|
||||
distance = diff.length_squared()
|
||||
|
||||
if distance < threshold:
|
||||
min_vector = diff
|
||||
threshold = distance
|
||||
|
||||
if restrict_height:
|
||||
min_vector.y = 0.0
|
||||
|
||||
# move away from closest point
|
||||
transforms.list[i].origin += min_vector.normalized() * offset
|
||||
|
||||
offset *= consecutive_step_multiplier
|
||||
|
||||
|
||||
# compute the closest points to each other using a compute shader
|
||||
# return a vector for each point that points away from the closest neighbour
|
||||
func compute_closest(transforms) -> PackedVector3Array:
|
||||
var padded_num_vecs = ceil(float(transforms.size()) / 64.0) * 64
|
||||
var padded_num_floats = padded_num_vecs * 4
|
||||
var rd := RenderingServer.create_local_rendering_device()
|
||||
var shader_spirv: RDShaderSPIRV = get_shader_file().get_spirv()
|
||||
var shader := rd.shader_create_from_spirv(shader_spirv)
|
||||
# Prepare our data. We use vec4 floats in the shader, so we need 32 bit.
|
||||
var input := PackedFloat32Array()
|
||||
for i in transforms.size():
|
||||
input.append(transforms.list[i].origin.x)
|
||||
input.append(transforms.list[i].origin.y)
|
||||
input.append(transforms.list[i].origin.z)
|
||||
input.append(0) # needed to use vec4, necessary for byte alignment in the shader code
|
||||
# buffer size, number of vectors sent to the gpu
|
||||
input.resize(padded_num_floats) # indexing in the compute shader requires padding
|
||||
var input_bytes := input.to_byte_array()
|
||||
var output_bytes := input_bytes.duplicate()
|
||||
# Create a storage buffer that can hold our float values.
|
||||
var buffer_in := rd.storage_buffer_create(input_bytes.size(), input_bytes)
|
||||
var buffer_out := rd.storage_buffer_create(output_bytes.size(), output_bytes)
|
||||
|
||||
# Create a uniform to assign the buffer to the rendering device
|
||||
var uniform_in := RDUniform.new()
|
||||
uniform_in.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
|
||||
uniform_in.binding = 0 # this needs to match the "binding" in our shader file
|
||||
uniform_in.add_id(buffer_in)
|
||||
# Create a uniform to assign the buffer to the rendering device
|
||||
var uniform_out := RDUniform.new()
|
||||
uniform_out.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
|
||||
uniform_out.binding = 1 # this needs to match the "binding" in our shader file
|
||||
uniform_out.add_id(buffer_out)
|
||||
# the last parameter (the 0) needs to match the "set" in our shader file
|
||||
var uniform_set := rd.uniform_set_create([uniform_in, uniform_out], shader, 0)
|
||||
|
||||
# Create a compute pipeline
|
||||
var pipeline := rd.compute_pipeline_create(shader)
|
||||
var compute_list := rd.compute_list_begin()
|
||||
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
|
||||
rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
|
||||
# each workgroup computes 64 vectors
|
||||
# print("Dispatching workgroups: ", padded_num_vecs/64)
|
||||
rd.compute_list_dispatch(compute_list, padded_num_vecs/64, 1, 1)
|
||||
rd.compute_list_end()
|
||||
# Submit to GPU and wait for sync
|
||||
rd.submit()
|
||||
rd.sync()
|
||||
# Read back the data from the buffer
|
||||
var result_bytes := rd.buffer_get_data(buffer_out)
|
||||
var result := result_bytes.to_float32_array()
|
||||
var retval = PackedVector3Array()
|
||||
for i in transforms.size():
|
||||
retval.append(Vector3(result[i*4], result[i*4+1], result[i*4+2]))
|
||||
|
||||
# Free the allocated objects.
|
||||
# All resources must be freed after use to avoid memory leaks.
|
||||
if rd != null:
|
||||
rd.free_rid(pipeline)
|
||||
rd.free_rid(uniform_set)
|
||||
rd.free_rid(shader)
|
||||
rd.free_rid(buffer_in)
|
||||
rd.free_rid(buffer_out)
|
||||
rd.free()
|
||||
rd = null
|
||||
return retval
|
||||
|
||||
func get_shader_file() -> RDShaderFile:
|
||||
if shader_file == null:
|
||||
shader_file = load(get_script().resource_path.get_base_dir() + "/compute_shaders/compute_relax.glsl")
|
||||
|
||||
return shader_file
|
||||
44
addons/proton_scatter/src/modifiers/remove_outside_shapes.gd
Normal file
44
addons/proton_scatter/src/modifiers/remove_outside_shapes.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
|
||||
@export var negative_shapes_only := false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Remove Outside"
|
||||
category = "Remove"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = false
|
||||
local_reference_frame_available = false
|
||||
individual_instances_reference_frame_available = false
|
||||
|
||||
documentation.add_paragraph(
|
||||
"Remove all transforms falling outside a ScatterShape node, or inside
|
||||
a shape set to 'Negative' mode.")
|
||||
|
||||
var p := documentation.add_parameter("Negative Shapes Only")
|
||||
p.set_type("bool")
|
||||
p.set_description(
|
||||
"Only remove transforms falling inside the negative shapes (shown
|
||||
in red). Transforms outside any shapes will still remain.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, seed) -> void:
|
||||
var i := 0
|
||||
var point: Vector3
|
||||
var to_remove := false
|
||||
|
||||
while i < transforms.size():
|
||||
point = transforms.list[i].origin
|
||||
|
||||
if negative_shapes_only:
|
||||
to_remove = domain.is_point_excluded(point)
|
||||
else:
|
||||
to_remove = not domain.is_point_inside(point)
|
||||
|
||||
if to_remove:
|
||||
transforms.list.remove_at(i)
|
||||
continue
|
||||
|
||||
i += 1
|
||||
55
addons/proton_scatter/src/modifiers/single_item.gd
Normal file
55
addons/proton_scatter/src/modifiers/single_item.gd
Normal file
@@ -0,0 +1,55 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
# Adds a single object with the given transform
|
||||
|
||||
@export var offset := Vector3.ZERO
|
||||
@export var rotation := Vector3.ZERO
|
||||
@export var scale := Vector3.ONE
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Add Single Item"
|
||||
category = "Create"
|
||||
warning_ignore_no_shape = true
|
||||
warning_ignore_no_transforms = true
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = false
|
||||
use_local_space_by_default()
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
var gt: Transform3D = domain.get_global_transform()
|
||||
var gt_inverse: Transform3D = gt.affine_inverse()
|
||||
|
||||
var t_origin := offset
|
||||
var basis := Basis()
|
||||
var x_axis = Vector3.RIGHT
|
||||
var y_axis = Vector3.UP
|
||||
var z_axis = Vector3.FORWARD
|
||||
|
||||
if is_using_global_space():
|
||||
t_origin = gt_inverse.basis * t_origin
|
||||
x_axis = gt_inverse.basis * x_axis
|
||||
y_axis = gt_inverse.basis * y_axis
|
||||
z_axis = gt_inverse.basis * z_axis
|
||||
basis = gt_inverse.basis
|
||||
|
||||
basis = basis.rotated(x_axis, deg_to_rad(rotation.x))
|
||||
basis = basis.rotated(y_axis, deg_to_rad(rotation.y))
|
||||
basis = basis.rotated(z_axis, deg_to_rad(rotation.z))
|
||||
|
||||
var transform := Transform3D(basis, Vector3.ZERO)
|
||||
|
||||
if is_using_global_space():
|
||||
var global_t: Transform3D = gt * transform
|
||||
global_t.basis = global_t.basis.scaled(scale)
|
||||
transform = gt_inverse * global_t
|
||||
else:
|
||||
transform = transform.scaled_local(scale)
|
||||
|
||||
transform.origin = t_origin
|
||||
|
||||
transforms.list.push_back(transform)
|
||||
100
addons/proton_scatter/src/modifiers/snap_transforms.gd
Normal file
100
addons/proton_scatter/src/modifiers/snap_transforms.gd
Normal file
@@ -0,0 +1,100 @@
|
||||
@tool
|
||||
extends "base_modifier.gd"
|
||||
|
||||
# TODO: This modifier has the same shortcomings as offset_rotation, but in every reference frame.
|
||||
|
||||
|
||||
@export var position_step := Vector3.ZERO
|
||||
@export var rotation_step := Vector3.ZERO
|
||||
@export var scale_step := Vector3.ZERO
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
display_name = "Snap Transforms"
|
||||
category = "Edit"
|
||||
can_restrict_height = false
|
||||
global_reference_frame_available = true
|
||||
local_reference_frame_available = true
|
||||
individual_instances_reference_frame_available = true
|
||||
use_individual_instances_space_by_default()
|
||||
|
||||
documentation.add_paragraph("Snap the individual transforms components.")
|
||||
documentation.add_paragraph("Values of 0 do not affect the transforms.")
|
||||
|
||||
var p := documentation.add_parameter("Position")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("Defines the grid size used to snap the transform position.")
|
||||
|
||||
p = documentation.add_parameter("Rotation")
|
||||
p.set_type("Vector3")
|
||||
p.set_description(
|
||||
"When set to any value above 0, the rotation will be set to the nearest
|
||||
multiple of that angle.")
|
||||
p.add_warning(
|
||||
"Example: If rotation is set to (0, 90.0, 0), the rotation around the Y
|
||||
axis will be snapped to the closed value among [0, 90, 180, 360].")
|
||||
|
||||
p = documentation.add_parameter("Scale")
|
||||
p.set_type("Vector3")
|
||||
p.set_description("Snap the current scale to the nearest multiple.")
|
||||
|
||||
|
||||
func _process_transforms(transforms, domain, _seed) -> void:
|
||||
var s_gt: Transform3D = domain.get_global_transform()
|
||||
var s_lt: Transform3D = domain.get_local_transform()
|
||||
var s_gt_inverse := s_gt.affine_inverse()
|
||||
|
||||
var axis_x := Vector3.RIGHT
|
||||
var axis_y := Vector3.UP
|
||||
var axis_z := Vector3.FORWARD
|
||||
|
||||
if is_using_global_space():
|
||||
axis_x = (s_gt_inverse.basis * Vector3.RIGHT).normalized()
|
||||
axis_y = (s_gt_inverse.basis * Vector3.UP).normalized()
|
||||
axis_z = (s_gt_inverse.basis * Vector3.FORWARD).normalized()
|
||||
|
||||
var rotation_step_rad := Vector3.ZERO
|
||||
rotation_step_rad.x = deg_to_rad(rotation_step.x)
|
||||
rotation_step_rad.y = deg_to_rad(rotation_step.y)
|
||||
rotation_step_rad.z = deg_to_rad(rotation_step.z)
|
||||
|
||||
for i in transforms.size():
|
||||
var t: Transform3D = transforms.list[i]
|
||||
var b := Basis()
|
||||
var current_rotation: Vector3
|
||||
|
||||
if is_using_individual_instances_space():
|
||||
axis_x = t.basis.x.normalized()
|
||||
axis_y = t.basis.y.normalized()
|
||||
axis_z = t.basis.z.normalized()
|
||||
|
||||
current_rotation = t.basis.get_euler()
|
||||
t.origin = snapped(t.origin, position_step)
|
||||
|
||||
elif is_using_local_space():
|
||||
var local_t := s_lt * t
|
||||
current_rotation = local_t.basis.get_euler()
|
||||
t.origin = snapped(t.origin, position_step)
|
||||
|
||||
else:
|
||||
b = (s_gt_inverse * Transform3D()).basis
|
||||
var global_t := s_gt * t
|
||||
current_rotation = global_t.basis.get_euler()
|
||||
t.origin = s_gt_inverse * snapped(global_t.origin, position_step)
|
||||
|
||||
b = b.rotated(axis_x, snapped(current_rotation.x, rotation_step_rad.x))
|
||||
b = b.rotated(axis_y, snapped(current_rotation.y, rotation_step_rad.y))
|
||||
b = b.rotated(axis_z, snapped(current_rotation.z, rotation_step_rad.z))
|
||||
|
||||
# Snap scale
|
||||
var current_scale := t.basis.get_scale()
|
||||
var snapped_scale: Vector3 = snapped(current_scale, scale_step)
|
||||
t.basis = b
|
||||
transforms.list[i] = t.scaled_local(snapped_scale)
|
||||
|
||||
|
||||
func _clamp_vector(vec3, vmin, vmax) -> Vector3:
|
||||
vec3.x = clamp(vec3.x, vmin.x, vmax.x)
|
||||
vec3.y = clamp(vec3.y, vmin.y, vmax.y)
|
||||
vec3.z = clamp(vec3.z, vmin.z, vmax.z)
|
||||
return vec3
|
||||
@@ -0,0 +1,37 @@
|
||||
shader_type particles;
|
||||
|
||||
|
||||
uniform mat4 global_transform;
|
||||
|
||||
|
||||
float rand_from_seed(in uint seed) {
|
||||
int k;
|
||||
int s = int(seed);
|
||||
if (s == 0)
|
||||
s = 305420679;
|
||||
k = s / 127773;
|
||||
s = 16807 * (s - k * 127773) - 2836 * k;
|
||||
if (s < 0)
|
||||
s += 2147483647;
|
||||
seed = uint(s);
|
||||
return float(seed % uint(65536)) / 65535.0;
|
||||
}
|
||||
|
||||
uint hash(uint x) {
|
||||
x = ((x >> uint(16)) ^ x) * uint(73244475);
|
||||
x = ((x >> uint(16)) ^ x) * uint(73244475);
|
||||
x = (x >> uint(16)) ^ x;
|
||||
return x;
|
||||
}
|
||||
|
||||
void start() {
|
||||
CUSTOM.x = 0.0;
|
||||
}
|
||||
|
||||
void process() {
|
||||
uint seed = hash(uint(INDEX) + uint(1) + RANDOM_SEED);
|
||||
float random = rand_from_seed(seed);
|
||||
float offset = cos(TIME) * random * 0.05;
|
||||
|
||||
TRANSFORM[3].y += offset;
|
||||
}
|
||||
15
addons/proton_scatter/src/particles/static.gdshader
Normal file
15
addons/proton_scatter/src/particles/static.gdshader
Normal file
@@ -0,0 +1,15 @@
|
||||
shader_type particles;
|
||||
render_mode keep_data;
|
||||
|
||||
|
||||
uniform mat4 global_transform;
|
||||
|
||||
|
||||
void start() {
|
||||
|
||||
}
|
||||
|
||||
void process() {
|
||||
|
||||
}
|
||||
|
||||
28
addons/proton_scatter/src/presets/preset_entry.gd
Normal file
28
addons/proton_scatter/src/presets/preset_entry.gd
Normal file
@@ -0,0 +1,28 @@
|
||||
@tool
|
||||
extends MarginContainer
|
||||
|
||||
signal load_full
|
||||
signal load_stack_only
|
||||
signal delete
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
$%LoadStackOnly.pressed.connect(func (): load_stack_only.emit())
|
||||
$%LoadFullPreset.pressed.connect(func (): load_full.emit())
|
||||
$%DeleteButton.pressed.connect(func (): delete.emit())
|
||||
|
||||
|
||||
func set_preset_name(preset_name: String) -> void:
|
||||
preset_name = preset_name.trim_suffix(".tres")
|
||||
$%Label.set_text(preset_name.capitalize())
|
||||
|
||||
|
||||
func show_save_controls() -> void:
|
||||
$%SaveButtons.visible = true
|
||||
$%LoadButtons.visible = false
|
||||
|
||||
|
||||
func show_load_controls() -> void:
|
||||
$%SaveButtons.visible = false
|
||||
$%LoadButtons.visible = true
|
||||
|
||||
92
addons/proton_scatter/src/presets/preset_entry.tscn
Normal file
92
addons/proton_scatter/src/presets/preset_entry.tscn
Normal file
@@ -0,0 +1,92 @@
|
||||
[gd_scene load_steps=6 format=3 uid="uid://bosqtuvhckh3g"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://ddjrq1h4mkn6a" path="res://addons/proton_scatter/icons/load.svg" id="1_0auay"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/presets/preset_entry.gd" id="1_bqha3"]
|
||||
[ext_resource type="Texture2D" uid="uid://btb6rqhhi27mx" path="res://addons/proton_scatter/icons/remove.svg" id="2_p04k2"]
|
||||
|
||||
[sub_resource type="SystemFont" id="SystemFont_kgkwq"]
|
||||
|
||||
[sub_resource type="LabelSettings" id="LabelSettings_poli7"]
|
||||
font = SubResource("SystemFont_kgkwq")
|
||||
|
||||
[node name="PresetEntry" type="MarginContainer"]
|
||||
custom_minimum_size = Vector2(450, 0)
|
||||
anchors_preset = 14
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_top = -45.0
|
||||
offset_bottom = 45.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("1_bqha3")
|
||||
|
||||
[node name="Panel" type="Panel" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Preset name"
|
||||
label_settings = SubResource("LabelSettings_poli7")
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="LoadButtons" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="LoadStackOnly" type="Button" parent="MarginContainer/HBoxContainer/LoadButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Load modifier stack"
|
||||
icon = ExtResource("1_0auay")
|
||||
alignment = 0
|
||||
|
||||
[node name="LoadFullPreset" type="Button" parent="MarginContainer/HBoxContainer/LoadButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Load full preset"
|
||||
icon = ExtResource("1_0auay")
|
||||
alignment = 0
|
||||
|
||||
[node name="SaveButtons" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_stretch_ratio = 2.0
|
||||
alignment = 1
|
||||
|
||||
[node name="OverrideButton" type="Button" parent="MarginContainer/HBoxContainer/SaveButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Override preset"
|
||||
icon = ExtResource("1_0auay")
|
||||
alignment = 0
|
||||
|
||||
[node name="VSeparator2" type="VSeparator" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="DeleteButton" type="Button" parent="MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/icon_normal_color = Color(0.917647, 0.0784314, 0, 1)
|
||||
icon = ExtResource("2_p04k2")
|
||||
198
addons/proton_scatter/src/presets/presets.gd
Normal file
198
addons/proton_scatter/src/presets/presets.gd
Normal file
@@ -0,0 +1,198 @@
|
||||
@tool
|
||||
extends Popup
|
||||
|
||||
|
||||
const PRESETS_PATH = "res://addons/proton_scatter/presets"
|
||||
const PresetEntry := preload("./preset_entry.tscn")
|
||||
const ProtonScatterUtil := preload('../common/scatter_util.gd')
|
||||
const ProtonScatterItem := preload('../scatter_item.gd')
|
||||
const ProtonScatterShape := preload('../scatter_shape.gd')
|
||||
|
||||
var _scatter_node
|
||||
var _ideal_popup_size: Vector2i
|
||||
var _editor_file_system: EditorFileSystem
|
||||
var _preset_path_to_delete: String
|
||||
var _preset_control_to_delete: Control
|
||||
|
||||
@onready var _new_preset_button: Button = %NewPresetButton
|
||||
@onready var _new_preset_dialog: ConfirmationDialog = %NewPresetDialog
|
||||
@onready var _delete_preset_dialog: ConfirmationDialog = %DeleteDialog
|
||||
@onready var _delete_warning_label: Label = %DeleteLabel
|
||||
@onready var _presets_root: Control = %PresetsRoot
|
||||
|
||||
|
||||
func _ready():
|
||||
_new_preset_button.pressed.connect(_show_preset_dialog)
|
||||
_new_preset_dialog.confirmed.connect(_on_new_preset_name_confirmed)
|
||||
_delete_preset_dialog.confirmed.connect(_on_delete_preset_confirmed)
|
||||
_new_preset_dialog.hide()
|
||||
_delete_preset_dialog.hide()
|
||||
hide()
|
||||
|
||||
|
||||
func save_preset(scatter_node: Node3D) -> void:
|
||||
if not scatter_node:
|
||||
return
|
||||
|
||||
_populate()
|
||||
_scatter_node = scatter_node
|
||||
_new_preset_button.visible = true
|
||||
|
||||
for c in _presets_root.get_children():
|
||||
c.show_save_controls()
|
||||
|
||||
popup_centered(_ideal_popup_size)
|
||||
|
||||
|
||||
func load_preset(scatter_node: Node3D) -> void:
|
||||
if not scatter_node:
|
||||
return
|
||||
|
||||
_populate()
|
||||
_scatter_node = scatter_node
|
||||
_new_preset_button.visible = false
|
||||
|
||||
for c in _presets_root.get_children():
|
||||
c.show_load_controls()
|
||||
|
||||
popup_centered(_ideal_popup_size)
|
||||
|
||||
|
||||
func load_default(scatter_node: Node3D) -> void:
|
||||
_scatter_node = scatter_node
|
||||
_on_load_full_preset(PRESETS_PATH.path_join("scatter_default.tscn"))
|
||||
|
||||
|
||||
func set_editor_plugin(editor_plugin: EditorPlugin) -> void:
|
||||
if not editor_plugin:
|
||||
return
|
||||
|
||||
_editor_file_system = editor_plugin.get_editor_interface().get_resource_filesystem()
|
||||
|
||||
|
||||
func _clear():
|
||||
for c in _presets_root.get_children():
|
||||
c.queue_free()
|
||||
|
||||
|
||||
func _populate() -> void:
|
||||
_clear()
|
||||
var dir = DirAccess.open(PRESETS_PATH)
|
||||
if not dir:
|
||||
print_debug("ProtonScatter error: Could not open folder ", PRESETS_PATH)
|
||||
return
|
||||
|
||||
dir.include_hidden = false
|
||||
dir.include_navigational = false
|
||||
dir.list_dir_begin()
|
||||
|
||||
while true:
|
||||
var file := dir.get_next()
|
||||
if file == "":
|
||||
break
|
||||
|
||||
if dir.current_is_dir():
|
||||
continue
|
||||
|
||||
if not file.ends_with(".tscn") and not file.ends_with(".scn"):
|
||||
continue
|
||||
|
||||
# Preset found, create an entry
|
||||
var full_path = PRESETS_PATH.path_join(file)
|
||||
var entry := PresetEntry.instantiate()
|
||||
entry.set_preset_name(file.get_basename())
|
||||
entry.load_full.connect(_on_load_full_preset.bind(full_path))
|
||||
entry.load_stack_only.connect(_on_load_stack_only.bind(full_path))
|
||||
entry.delete.connect(_on_delete_button_pressed.bind(full_path, entry))
|
||||
|
||||
_presets_root.add_child(entry)
|
||||
|
||||
dir.list_dir_end()
|
||||
var full_height = _presets_root.get_child_count() * 120
|
||||
_ideal_popup_size = Vector2i(450, clamp(full_height, 120, 500))
|
||||
|
||||
|
||||
func _show_preset_dialog() -> void:
|
||||
$%NewPresetName.set_text("")
|
||||
_new_preset_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_new_preset_name_confirmed() -> void:
|
||||
var file_name: String = $%NewPresetName.text.to_lower().strip_edges() + ".tscn"
|
||||
var full_path := PRESETS_PATH.path_join(file_name)
|
||||
_on_save_preset(full_path)
|
||||
hide()
|
||||
|
||||
|
||||
func _on_save_preset(path) -> void:
|
||||
var preset = _scatter_node.duplicate(7)
|
||||
preset.clear_output()
|
||||
ProtonScatterUtil.set_owner_recursive(preset, preset)
|
||||
preset.global_transform.origin = Vector3.ZERO
|
||||
|
||||
var packed_scene = PackedScene.new()
|
||||
if packed_scene.pack(preset) != OK:
|
||||
print_debug("ProtonScatter error: Failed to save preset")
|
||||
return
|
||||
|
||||
var err = ResourceSaver.save(packed_scene, path)
|
||||
if err:
|
||||
print_debug("ProtonScatter error: Failed to save preset. Code: ", err)
|
||||
|
||||
|
||||
func _on_load_full_preset(path: String) -> void:
|
||||
var preset_scene: PackedScene = load(path)
|
||||
if not preset_scene:
|
||||
print("Could not find preset ", path)
|
||||
return
|
||||
|
||||
var preset = preset_scene.instantiate()
|
||||
|
||||
if preset:
|
||||
_scatter_node.modifier_stack = preset.modifier_stack.get_copy()
|
||||
preset.global_transform = _scatter_node.get_global_transform()
|
||||
|
||||
for c in _scatter_node.get_children():
|
||||
if c is ProtonScatterItem or c is ProtonScatterShape:
|
||||
_scatter_node.remove_child(c)
|
||||
c.owner = null
|
||||
c.queue_free()
|
||||
|
||||
for c in preset.get_children():
|
||||
if c is Marker3D or c.name == "ScatterOutput":
|
||||
continue
|
||||
preset.remove_child(c)
|
||||
c.owner = null
|
||||
_scatter_node.add_child(c, true)
|
||||
|
||||
ProtonScatterUtil.set_owner_recursive(_scatter_node, get_tree().get_edited_scene_root())
|
||||
preset.queue_free()
|
||||
|
||||
_scatter_node.rebuild.call_deferred()
|
||||
hide()
|
||||
|
||||
|
||||
func _on_load_stack_only(path: String) -> void:
|
||||
var preset = load(path).instantiate()
|
||||
if preset:
|
||||
_scatter_node.modifier_stack = preset.modifier_stack.get_copy()
|
||||
_scatter_node.rebuild.call_deferred()
|
||||
preset.queue_free()
|
||||
|
||||
hide()
|
||||
|
||||
|
||||
func _on_delete_button_pressed(path: String, entry: Control) -> void:
|
||||
_preset_path_to_delete = path
|
||||
_preset_control_to_delete = entry
|
||||
_delete_warning_label.text = "Are you sure you want to delete the preset [" \
|
||||
+ path.get_file().get_basename().capitalize() + "] ?\n\n" \
|
||||
+ "This operation cannot be undone."
|
||||
_delete_preset_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_delete_preset_confirmed() -> void:
|
||||
DirAccess.remove_absolute(_preset_path_to_delete)
|
||||
_presets_root.remove_child(_preset_control_to_delete)
|
||||
_preset_control_to_delete.queue_free()
|
||||
_editor_file_system.scan() # Refresh the filesystem view
|
||||
90
addons/proton_scatter/src/presets/presets.tscn
Normal file
90
addons/proton_scatter/src/presets/presets.tscn
Normal file
@@ -0,0 +1,90 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://bcsosdvstytoq"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/presets/presets.gd" id="1_ualle"]
|
||||
[ext_resource type="Texture2D" uid="uid://cun73k8jdmr4e" path="res://addons/proton_scatter/icons/add.svg" id="2_j26xt"]
|
||||
[ext_resource type="PackedScene" uid="uid://bosqtuvhckh3g" path="res://addons/proton_scatter/src/presets/preset_entry.tscn" id="2_orram"]
|
||||
|
||||
[node name="Presets" type="PopupPanel"]
|
||||
title = "Manage presets"
|
||||
initial_position = 2
|
||||
size = Vector2i(490, 200)
|
||||
unresizable = false
|
||||
borderless = false
|
||||
extend_to_title = true
|
||||
min_size = Vector2i(400, 150)
|
||||
script = ExtResource("1_ualle")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
offset_left = 4.0
|
||||
offset_top = 4.0
|
||||
offset_right = 486.0
|
||||
offset_bottom = 196.0
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="PresetsRoot" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
alignment = 1
|
||||
|
||||
[node name="PresetEntry" parent="MarginContainer/VBoxContainer/ScrollContainer/PresetsRoot" instance=ExtResource("2_orram")]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="NewPresetButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(200, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
text = "Create new preset"
|
||||
icon = ExtResource("2_j26xt")
|
||||
|
||||
[node name="NewPresetDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Create new preset"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="NewPresetDialog"]
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = 192.0
|
||||
offset_bottom = 51.0
|
||||
|
||||
[node name="NewPresetName" type="LineEdit" parent="NewPresetDialog/MarginContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
placeholder_text = "New preset name"
|
||||
|
||||
[node name="DeleteDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Delete preset"
|
||||
size = Vector2i(250, 184)
|
||||
ok_button_text = "Delete"
|
||||
dialog_autowrap = true
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="DeleteDialog"]
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = 242.0
|
||||
offset_bottom = 395.0
|
||||
|
||||
[node name="DeleteLabel" type="Label" parent="DeleteDialog/MarginContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(250, 0)
|
||||
layout_mode = 2
|
||||
text = "Are you sure you want to delete preset [Preset]?
|
||||
|
||||
This operation cannot be undone."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
734
addons/proton_scatter/src/scatter.gd
Normal file
734
addons/proton_scatter/src/scatter.gd
Normal file
@@ -0,0 +1,734 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
|
||||
signal shape_changed
|
||||
signal thread_completed
|
||||
signal build_completed
|
||||
|
||||
|
||||
# Includes
|
||||
const ProtonScatterDomain := preload("./common/domain.gd")
|
||||
const ProtonScatterItem := preload("./scatter_item.gd")
|
||||
const ProtonScatterModifierStack := preload("./stack/modifier_stack.gd")
|
||||
const ProtonScatterPhysicsHelper := preload("./common/physics_helper.gd")
|
||||
const ProtonScatterShape := preload("./scatter_shape.gd")
|
||||
const ProtonScatterTransformList := preload("./common/transform_list.gd")
|
||||
const ProtonScatterUtil := preload('./common/scatter_util.gd')
|
||||
|
||||
|
||||
@export_category("ProtonScatter")
|
||||
|
||||
@export_group("General")
|
||||
@export var enabled := true:
|
||||
set(val):
|
||||
enabled = val
|
||||
if is_ready:
|
||||
rebuild()
|
||||
@export var global_seed := 0:
|
||||
set(val):
|
||||
global_seed = val
|
||||
rebuild()
|
||||
@export var show_output_in_tree := false:
|
||||
set(val):
|
||||
show_output_in_tree = val
|
||||
if output_root:
|
||||
ProtonScatterUtil.enforce_output_root_owner(self)
|
||||
|
||||
@export_group("Performance")
|
||||
@export_enum("Use Instancing:0",
|
||||
"Create Copies:1",
|
||||
"Use Particles:2")\
|
||||
var render_mode := 0:
|
||||
set(val):
|
||||
render_mode = val
|
||||
notify_property_list_changed()
|
||||
if is_ready:
|
||||
full_rebuild.call_deferred()
|
||||
|
||||
var use_chunks : bool = true:
|
||||
set(val):
|
||||
use_chunks = val
|
||||
notify_property_list_changed()
|
||||
if is_ready:
|
||||
full_rebuild.call_deferred()
|
||||
|
||||
var chunk_dimensions := Vector3.ONE * 15.0:
|
||||
set(val):
|
||||
chunk_dimensions.x = max(val.x, 1.0)
|
||||
chunk_dimensions.y = max(val.y, 1.0)
|
||||
chunk_dimensions.z = max(val.z, 1.0)
|
||||
if is_ready:
|
||||
rebuild.call_deferred()
|
||||
|
||||
@export var keep_static_colliders := false
|
||||
@export var force_rebuild_on_load := true
|
||||
@export var enable_updates_in_game := false
|
||||
|
||||
@export_group("Dependency")
|
||||
@export var scatter_parent: NodePath:
|
||||
set(val):
|
||||
if not is_inside_tree():
|
||||
scatter_parent = val
|
||||
return
|
||||
|
||||
scatter_parent = NodePath()
|
||||
if is_instance_valid(_dependency_parent):
|
||||
_dependency_parent.build_completed.disconnect(rebuild)
|
||||
_dependency_parent = null
|
||||
|
||||
var node = get_node_or_null(val)
|
||||
if not node:
|
||||
return
|
||||
|
||||
var type = node.get_script()
|
||||
var scatter_type = get_script()
|
||||
if type != scatter_type:
|
||||
push_warning("ProtonScatter warning: Please select a ProtonScatter node as a parent dependency.")
|
||||
return
|
||||
|
||||
# TODO: Check for cyclic dependency
|
||||
|
||||
scatter_parent = val
|
||||
_dependency_parent = node
|
||||
_dependency_parent.build_completed.connect(rebuild, CONNECT_DEFERRED)
|
||||
|
||||
|
||||
@export_group("Debug", "dbg_")
|
||||
@export var dbg_disable_thread := false
|
||||
|
||||
var undo_redo # EditorUndoRedoManager - Can't type this, class not available outside the editor
|
||||
var modifier_stack: ProtonScatterModifierStack:
|
||||
set(val):
|
||||
if modifier_stack:
|
||||
if modifier_stack.value_changed.is_connected(rebuild):
|
||||
modifier_stack.value_changed.disconnect(rebuild)
|
||||
if modifier_stack.stack_changed.is_connected(rebuild):
|
||||
modifier_stack.stack_changed.disconnect(rebuild)
|
||||
if modifier_stack.transforms_ready.is_connected(_on_transforms_ready):
|
||||
modifier_stack.transforms_ready.disconnect(_on_transforms_ready)
|
||||
|
||||
modifier_stack = val.get_copy() # Enfore uniqueness
|
||||
modifier_stack.value_changed.connect(rebuild, CONNECT_DEFERRED)
|
||||
modifier_stack.stack_changed.connect(rebuild, CONNECT_DEFERRED)
|
||||
modifier_stack.transforms_ready.connect(_on_transforms_ready, CONNECT_DEFERRED)
|
||||
|
||||
var domain: ProtonScatterDomain:
|
||||
set(_val):
|
||||
domain = ProtonScatterDomain.new() # Enforce uniqueness
|
||||
|
||||
var items: Array = []
|
||||
var total_item_proportion: int
|
||||
var output_root: Marker3D
|
||||
var transforms: ProtonScatterTransformList
|
||||
var editor_plugin # Holds a reference to the EditorPlugin. Used by other parts.
|
||||
var is_ready := false
|
||||
var build_version := 0
|
||||
|
||||
# Internal variables
|
||||
var _thread: Thread
|
||||
var _rebuild_queued := false
|
||||
var _dependency_parent
|
||||
var _physics_helper: ProtonScatterPhysicsHelper
|
||||
var _body_rid: RID
|
||||
var _collision_shapes: Array[RID]
|
||||
var _ignore_transform_notification = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint() or enable_updates_in_game:
|
||||
set_notify_transform(true)
|
||||
child_exiting_tree.connect(_on_child_exiting_tree)
|
||||
|
||||
_perform_sanity_check()
|
||||
_discover_items()
|
||||
update_configuration_warnings.call_deferred()
|
||||
is_ready = true
|
||||
|
||||
if force_rebuild_on_load and not is_instance_valid(_dependency_parent):
|
||||
full_rebuild.call_deferred()
|
||||
|
||||
|
||||
func _exit_tree():
|
||||
if is_thread_running():
|
||||
modifier_stack.stop_update()
|
||||
_thread.wait_to_finish()
|
||||
_thread = null
|
||||
|
||||
_clear_collision_data()
|
||||
|
||||
|
||||
func _get_property_list() -> Array:
|
||||
var list := []
|
||||
list.push_back({
|
||||
name = "modifier_stack",
|
||||
type = TYPE_OBJECT,
|
||||
hint_string = "ScatterModifierStack",
|
||||
})
|
||||
|
||||
var chunk_usage := PROPERTY_USAGE_NO_EDITOR
|
||||
var dimensions_usage := PROPERTY_USAGE_NO_EDITOR
|
||||
if render_mode == 0 or render_mode == 2:
|
||||
chunk_usage = PROPERTY_USAGE_DEFAULT
|
||||
if use_chunks:
|
||||
dimensions_usage = PROPERTY_USAGE_DEFAULT
|
||||
|
||||
list.push_back({
|
||||
name = "Performance/use_chunks",
|
||||
type = TYPE_BOOL,
|
||||
usage = chunk_usage
|
||||
})
|
||||
|
||||
list.push_back({
|
||||
name = "Performance/chunk_dimensions",
|
||||
type = TYPE_VECTOR3,
|
||||
usage = dimensions_usage
|
||||
})
|
||||
return list
|
||||
|
||||
|
||||
func _get_configuration_warnings() -> PackedStringArray:
|
||||
var warnings := PackedStringArray()
|
||||
|
||||
if items.is_empty():
|
||||
warnings.push_back("At least one ScatterItem node is required.")
|
||||
|
||||
if modifier_stack and not modifier_stack.does_not_require_shapes():
|
||||
if domain and domain.is_empty():
|
||||
warnings.push_back("At least one ScatterShape node is required.")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
func _notification(what):
|
||||
if not is_ready:
|
||||
return
|
||||
match what:
|
||||
NOTIFICATION_TRANSFORM_CHANGED:
|
||||
if _ignore_transform_notification:
|
||||
_ignore_transform_notification = false
|
||||
return
|
||||
_perform_sanity_check()
|
||||
domain.compute_bounds()
|
||||
rebuild.call_deferred()
|
||||
NOTIFICATION_ENTER_WORLD:
|
||||
_ignore_transform_notification = true
|
||||
|
||||
|
||||
func _set(property, value):
|
||||
if not Engine.is_editor_hint():
|
||||
return false
|
||||
|
||||
# Workaround to detect when the node was duplicated from the editor.
|
||||
if property == "transform":
|
||||
_on_node_duplicated.call_deferred()
|
||||
|
||||
elif property == "Performance/use_chunks":
|
||||
use_chunks = value
|
||||
|
||||
elif property == "Performance/chunk_dimensions":
|
||||
chunk_dimensions = value
|
||||
|
||||
# Backward compatibility.
|
||||
# Convert the value of previous property "use_instancing" into the proper render_mode.
|
||||
elif property == "use_instancing":
|
||||
render_mode = 0 if value else 1
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _get(property):
|
||||
if property == "Performance/use_chunks":
|
||||
return use_chunks
|
||||
|
||||
elif property == "Performance/chunk_dimensions":
|
||||
return chunk_dimensions
|
||||
|
||||
|
||||
func is_thread_running() -> bool:
|
||||
return _thread != null and _thread.is_started()
|
||||
|
||||
|
||||
# Used by some modifiers to retrieve a physics helper node
|
||||
func get_physics_helper() -> ProtonScatterPhysicsHelper:
|
||||
return _physics_helper
|
||||
|
||||
|
||||
# Deletes what the Scatter node generated.
|
||||
func clear_output() -> void:
|
||||
if not output_root:
|
||||
output_root = get_node_or_null("ScatterOutput")
|
||||
|
||||
if output_root:
|
||||
remove_child(output_root)
|
||||
output_root.queue_free()
|
||||
output_root = null
|
||||
|
||||
ProtonScatterUtil.ensure_output_root_exists(self)
|
||||
_clear_collision_data()
|
||||
|
||||
|
||||
func _clear_collision_data() -> void:
|
||||
if _body_rid.is_valid():
|
||||
PhysicsServer3D.free_rid(_body_rid)
|
||||
_body_rid = RID()
|
||||
|
||||
for rid in _collision_shapes:
|
||||
if rid.is_valid():
|
||||
PhysicsServer3D.free_rid(rid)
|
||||
|
||||
_collision_shapes.clear()
|
||||
|
||||
|
||||
# Wrapper around the _rebuild function. Clears previous output and force
|
||||
# a clean rebuild.
|
||||
func full_rebuild():
|
||||
update_gizmos()
|
||||
|
||||
if not is_inside_tree():
|
||||
return
|
||||
|
||||
_rebuild_queued = false
|
||||
|
||||
if is_thread_running():
|
||||
await _thread.wait_to_finish()
|
||||
_thread = null
|
||||
|
||||
clear_output()
|
||||
_rebuild(true)
|
||||
|
||||
|
||||
# A wrapper around the _rebuild function. Ensure it's not called more than once
|
||||
# per frame. (Happens when the Scatter node is moved, which triggers the
|
||||
# TRANSFORM_CHANGED notification in every children, which in turn notify the
|
||||
# parent Scatter node back about the changes).
|
||||
func rebuild(force_discover := false) -> void:
|
||||
update_gizmos()
|
||||
|
||||
if not is_inside_tree() or not is_ready:
|
||||
return
|
||||
|
||||
if is_thread_running():
|
||||
_rebuild_queued = true
|
||||
return
|
||||
|
||||
force_discover = true # TMP while we fix the other issues
|
||||
_rebuild(force_discover)
|
||||
|
||||
|
||||
# Re compute the desired output.
|
||||
# This is the main function, scattering the objects in the scene.
|
||||
# Scattered objects are stored under a Marker3D node called "ScatterOutput"
|
||||
# DON'T call this function directly outside of the 'rebuild()' function above.
|
||||
func _rebuild(force_discover) -> void:
|
||||
if not enabled:
|
||||
_clear_collision_data()
|
||||
clear_output()
|
||||
build_completed.emit()
|
||||
return
|
||||
|
||||
_perform_sanity_check()
|
||||
|
||||
if force_discover:
|
||||
_discover_items()
|
||||
domain.discover_shapes(self)
|
||||
|
||||
if items.is_empty() or (domain.is_empty() and not modifier_stack.does_not_require_shapes()):
|
||||
clear_output()
|
||||
push_warning("ProtonScatter warning: No items or shapes, abort")
|
||||
return
|
||||
|
||||
if render_mode == 1:
|
||||
clear_output() # TMP, prevents raycasts in modifier to self intersect with previous output
|
||||
|
||||
if keep_static_colliders:
|
||||
_clear_collision_data()
|
||||
|
||||
if dbg_disable_thread:
|
||||
modifier_stack.start_update(self, domain)
|
||||
return
|
||||
|
||||
if is_thread_running():
|
||||
await _thread.wait_to_finish()
|
||||
|
||||
_thread = Thread.new()
|
||||
_thread.start(_rebuild_threaded, Thread.PRIORITY_NORMAL)
|
||||
|
||||
|
||||
func _rebuild_threaded() -> void:
|
||||
# Disable thread safety, but only after 4.1 beta 3
|
||||
if _thread.has_method("set_thread_safety_checks_enabled"):
|
||||
# Calls static method on instance, otherwise it crashes in 4.0.x
|
||||
@warning_ignore("static_called_on_instance")
|
||||
_thread.set_thread_safety_checks_enabled(false)
|
||||
|
||||
modifier_stack.start_update(self, domain.get_copy())
|
||||
|
||||
|
||||
func _discover_items() -> void:
|
||||
items.clear()
|
||||
total_item_proportion = 0
|
||||
|
||||
for c in get_children():
|
||||
if is_instance_of(c, ProtonScatterItem):
|
||||
items.push_back(c)
|
||||
total_item_proportion += c.proportion
|
||||
|
||||
update_configuration_warnings()
|
||||
|
||||
|
||||
# Creates one MultimeshInstance3D for each ScatterItem node.
|
||||
func _update_multimeshes() -> void:
|
||||
if items.is_empty():
|
||||
_discover_items()
|
||||
|
||||
var offset := 0
|
||||
var transforms_count: int = transforms.size()
|
||||
|
||||
for item in items:
|
||||
var count = int(round(float(item.proportion) / total_item_proportion * transforms_count))
|
||||
var mmi = ProtonScatterUtil.get_or_create_multimesh(item, count)
|
||||
if not mmi:
|
||||
continue
|
||||
var static_body := ProtonScatterUtil.get_collision_data(item)
|
||||
|
||||
var t: Transform3D
|
||||
for i in count:
|
||||
# Extra check because of how 'count' is calculated
|
||||
if (offset + i) >= transforms_count:
|
||||
mmi.multimesh.instance_count = i - 1
|
||||
continue
|
||||
|
||||
t = item.process_transform(transforms.list[offset + i])
|
||||
mmi.multimesh.set_instance_transform(i, t)
|
||||
_create_collision(static_body, t)
|
||||
|
||||
static_body.queue_free()
|
||||
offset += count
|
||||
|
||||
|
||||
func _update_split_multimeshes() -> void:
|
||||
var size = domain.bounds_local.size
|
||||
|
||||
var splits := Vector3i.ONE
|
||||
splits.x = max(1, ceil(size.x / chunk_dimensions.x))
|
||||
splits.y = max(1, ceil(size.y / chunk_dimensions.y))
|
||||
splits.z = max(1, ceil(size.z / chunk_dimensions.z))
|
||||
|
||||
if items.is_empty():
|
||||
_discover_items()
|
||||
|
||||
var offset := 0 # this many transforms have been used up
|
||||
var transforms_count: int = transforms.size()
|
||||
clear_output()
|
||||
|
||||
for item in items:
|
||||
var root: Node3D = ProtonScatterUtil.get_or_create_item_root(item)
|
||||
if not is_instance_valid(root):
|
||||
continue
|
||||
|
||||
# use count number of transforms for this item
|
||||
var count = int(round(float(item.proportion) / total_item_proportion * transforms_count))
|
||||
|
||||
# create 3d array with dimensions of split_size to store the chunks' transforms
|
||||
var transform_chunks : Array = []
|
||||
for xi in splits.x:
|
||||
transform_chunks.append([])
|
||||
for yi in splits.y:
|
||||
transform_chunks[xi].append([])
|
||||
for zi in splits.z:
|
||||
transform_chunks[xi][yi].append([])
|
||||
|
||||
var t_list = transforms.list.slice(offset)
|
||||
var aabb = ProtonScatterUtil.get_aabb_from_transforms(t_list)
|
||||
aabb = aabb.grow(0.1) # avoid degenerate cases
|
||||
var static_body := ProtonScatterUtil.get_collision_data(item)
|
||||
|
||||
for i in count:
|
||||
if (offset + i) >= transforms_count:
|
||||
continue
|
||||
# both aabb and t are in mmi's local coordinates
|
||||
var t = item.process_transform(transforms.list[offset + i])
|
||||
var p_rel = (t.origin - aabb.position) / aabb.size
|
||||
# Chunk index
|
||||
var ci = (p_rel * Vector3(splits)).floor()
|
||||
# Store the transform to the appropriate array
|
||||
transform_chunks[ci.x][ci.y][ci.z].append(t)
|
||||
_create_collision(static_body, t)
|
||||
|
||||
static_body.queue_free()
|
||||
|
||||
# Cache the mesh instance to be used for the chunks
|
||||
var mesh_instance: MeshInstance3D = ProtonScatterUtil.get_merged_meshes_from(item)
|
||||
# The relevant transforms are now ordered in chunks
|
||||
for xi in splits.x:
|
||||
for yi in splits.y:
|
||||
for zi in splits.z:
|
||||
var chunk_elements = transform_chunks[xi][yi][zi].size()
|
||||
if chunk_elements == 0:
|
||||
continue
|
||||
var mmi = ProtonScatterUtil.get_or_create_multimesh_chunk(
|
||||
item,
|
||||
mesh_instance,
|
||||
Vector3i(xi, yi, zi),
|
||||
chunk_elements)
|
||||
if not mmi:
|
||||
continue
|
||||
|
||||
# Use the eventual aabb as origin
|
||||
# The multimeshinstance needs to be centered where the transforms are
|
||||
# This matters because otherwise the visibility range fading is messed up
|
||||
var center = ProtonScatterUtil.get_aabb_from_transforms(transform_chunks[xi][yi][zi]).get_center()
|
||||
mmi.transform.origin = center
|
||||
|
||||
var t: Transform3D
|
||||
for i in chunk_elements:
|
||||
t = transform_chunks[xi][yi][zi][i]
|
||||
t.origin -= center
|
||||
mmi.multimesh.set_instance_transform(i, t)
|
||||
mesh_instance.queue_free()
|
||||
offset += count
|
||||
|
||||
|
||||
func _update_duplicates() -> void:
|
||||
var offset := 0
|
||||
var transforms_count: int = transforms.size()
|
||||
|
||||
for item in items:
|
||||
var count := int(round(float(item.proportion) / total_item_proportion * transforms_count))
|
||||
var root: Node3D = ProtonScatterUtil.get_or_create_item_root(item)
|
||||
var child_count := root.get_child_count()
|
||||
|
||||
for i in count:
|
||||
if (offset + i) >= transforms_count:
|
||||
return
|
||||
|
||||
var instance
|
||||
if i < child_count: # Grab an instance from the pool if there's one available
|
||||
instance = root.get_child(i)
|
||||
else:
|
||||
instance = _create_instance(item, root)
|
||||
|
||||
if not instance:
|
||||
break
|
||||
|
||||
var t: Transform3D = item.process_transform(transforms.list[offset + i])
|
||||
instance.transform = t
|
||||
ProtonScatterUtil.set_visibility_layers(instance, item.visibility_layers)
|
||||
|
||||
# Delete the unused instances left in the pool if any
|
||||
if count < child_count:
|
||||
for i in (child_count - count):
|
||||
root.get_child(-1).queue_free()
|
||||
|
||||
offset += count
|
||||
|
||||
|
||||
func _update_particles_system() -> void:
|
||||
var offset := 0
|
||||
var transforms_count: int = transforms.size()
|
||||
|
||||
for item in items:
|
||||
var count := int(round(float(item.proportion) / total_item_proportion * transforms_count))
|
||||
var particles = ProtonScatterUtil.get_or_create_particles(item)
|
||||
if not particles:
|
||||
continue
|
||||
|
||||
particles.visibility_aabb = AABB(domain.bounds_local.min, domain.bounds_local.size)
|
||||
particles.amount = count
|
||||
|
||||
var static_body := ProtonScatterUtil.get_collision_data(item)
|
||||
var t: Transform3D
|
||||
|
||||
for i in count:
|
||||
if (offset + i) >= transforms_count:
|
||||
particles.amount = i - 1
|
||||
return
|
||||
|
||||
t = item.process_transform(transforms.list[offset + i])
|
||||
particles.emit_particle(
|
||||
t,
|
||||
Vector3.ZERO,
|
||||
Color.WHITE,
|
||||
Color.BLACK,
|
||||
GPUParticles3D.EMIT_FLAG_POSITION | GPUParticles3D.EMIT_FLAG_ROTATION_SCALE)
|
||||
_create_collision(static_body, t)
|
||||
|
||||
offset += count
|
||||
|
||||
|
||||
# Creates collision data with the Physics server directly.
|
||||
# This does not create new nodes in the scene tree. This also means you can't
|
||||
# see these colliders, even when enabling "Debug > Visible collision shapes".
|
||||
func _create_collision(body: StaticBody3D, t: Transform3D) -> void:
|
||||
if not keep_static_colliders or render_mode == 1:
|
||||
return
|
||||
|
||||
# Create a static body
|
||||
if not _body_rid.is_valid():
|
||||
_body_rid = PhysicsServer3D.body_create()
|
||||
PhysicsServer3D.body_set_mode(_body_rid, PhysicsServer3D.BODY_MODE_STATIC)
|
||||
PhysicsServer3D.body_set_state(_body_rid, PhysicsServer3D.BODY_STATE_TRANSFORM, global_transform)
|
||||
PhysicsServer3D.body_set_space(_body_rid, get_world_3d().space)
|
||||
|
||||
for c in body.get_children():
|
||||
if c is CollisionShape3D:
|
||||
var shape_rid: RID
|
||||
var data: Variant
|
||||
|
||||
if c.shape is SphereShape3D:
|
||||
shape_rid = PhysicsServer3D.sphere_shape_create()
|
||||
data = c.shape.radius
|
||||
|
||||
elif c.shape is BoxShape3D:
|
||||
shape_rid = PhysicsServer3D.box_shape_create()
|
||||
data = c.shape.size / 2.0
|
||||
|
||||
elif c.shape is CapsuleShape3D:
|
||||
shape_rid = PhysicsServer3D.capsule_shape_create()
|
||||
data = {
|
||||
"radius": c.shape.radius,
|
||||
"height": c.shape.height,
|
||||
}
|
||||
|
||||
elif c.shape is CylinderShape3D:
|
||||
shape_rid = PhysicsServer3D.cylinder_shape_create()
|
||||
data = {
|
||||
"radius": c.shape.radius,
|
||||
"height": c.shape.height,
|
||||
}
|
||||
|
||||
elif c.shape is ConcavePolygonShape3D:
|
||||
shape_rid = PhysicsServer3D.concave_polygon_shape_create()
|
||||
data = {
|
||||
"faces": c.shape.get_faces(),
|
||||
"backface_collision": c.shape.backface_collision,
|
||||
}
|
||||
|
||||
elif c.shape is ConvexPolygonShape3D:
|
||||
shape_rid = PhysicsServer3D.convex_polygon_shape_create()
|
||||
data = c.shape.points
|
||||
|
||||
elif c.shape is HeightMapShape3D:
|
||||
shape_rid = PhysicsServer3D.heightmap_shape_create()
|
||||
var min_height := 9999999.0
|
||||
var max_height := -9999999.0
|
||||
for v in c.shape.map_data:
|
||||
min_height = v if v < min_height else min_height
|
||||
max_height = v if v > max_height else max_height
|
||||
data = {
|
||||
"width": c.shape.map_width,
|
||||
"depth": c.shape.map_depth,
|
||||
"heights": c.shape.map_data,
|
||||
"min_height": min_height,
|
||||
"max_height": max_height,
|
||||
}
|
||||
|
||||
elif c.shape is SeparationRayShape3D:
|
||||
shape_rid = PhysicsServer3D.separation_ray_shape_create()
|
||||
data = {
|
||||
"length": c.shape.length,
|
||||
"slide_on_slope": c.shape.slide_on_slope,
|
||||
}
|
||||
|
||||
else:
|
||||
print_debug("Scatter - Unsupported collision shape: ", c.shape)
|
||||
continue
|
||||
|
||||
PhysicsServer3D.shape_set_data(shape_rid, data)
|
||||
PhysicsServer3D.body_add_shape(_body_rid, shape_rid, t * c.transform)
|
||||
_collision_shapes.push_back(shape_rid)
|
||||
|
||||
|
||||
func _create_instance(item: ProtonScatterItem, root: Node3D):
|
||||
if not item:
|
||||
return null
|
||||
|
||||
var instance = item.get_item()
|
||||
if not instance:
|
||||
return null
|
||||
|
||||
instance.visible = true
|
||||
root.add_child.bind(instance, true).call_deferred()
|
||||
|
||||
if show_output_in_tree:
|
||||
# We have to use a lambda here because ProtonScatterUtil isn't an
|
||||
# actual class_name, it's a const, which makes it impossible to reference
|
||||
# the callable, (but we can still call it)
|
||||
var defer_ownership := func(i, o):
|
||||
ProtonScatterUtil.set_owner_recursive(i, o)
|
||||
defer_ownership.bind(instance, get_tree().get_edited_scene_root()).call_deferred()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
# Enforce the Scatter node has its required variables set.
|
||||
func _perform_sanity_check() -> void:
|
||||
if not modifier_stack:
|
||||
modifier_stack = ProtonScatterModifierStack.new()
|
||||
modifier_stack.just_created = true
|
||||
|
||||
if not domain:
|
||||
domain = ProtonScatterDomain.new()
|
||||
|
||||
domain.discover_shapes(self)
|
||||
|
||||
if not is_instance_valid(_physics_helper):
|
||||
_physics_helper = ProtonScatterPhysicsHelper.new()
|
||||
_physics_helper.name = "PhysicsHelper"
|
||||
add_child(_physics_helper, true, INTERNAL_MODE_BACK)
|
||||
|
||||
# Retrigger the parent setter, in case the parent node no longer exists or changed type.
|
||||
scatter_parent = scatter_parent
|
||||
|
||||
|
||||
# Remove output coming from the source node to avoid linked multimeshes or
|
||||
# other unwanted side effects
|
||||
func _on_node_duplicated() -> void:
|
||||
clear_output()
|
||||
|
||||
|
||||
func _on_child_exiting_tree(node: Node) -> void:
|
||||
if node is ProtonScatterShape or node is ProtonScatterItem:
|
||||
rebuild.bind(true).call_deferred()
|
||||
|
||||
|
||||
# Called when the modifier stack is done generating the full transform list
|
||||
func _on_transforms_ready(new_transforms: ProtonScatterTransformList) -> void:
|
||||
if is_thread_running():
|
||||
await _thread.wait_to_finish()
|
||||
_thread = null
|
||||
|
||||
_clear_collision_data()
|
||||
|
||||
if _rebuild_queued:
|
||||
_rebuild_queued = false
|
||||
rebuild.call_deferred()
|
||||
return
|
||||
|
||||
transforms = new_transforms
|
||||
|
||||
if not transforms or transforms.is_empty():
|
||||
clear_output()
|
||||
update_gizmos()
|
||||
return
|
||||
|
||||
match render_mode:
|
||||
0:
|
||||
if use_chunks:
|
||||
_update_split_multimeshes()
|
||||
else:
|
||||
_update_multimeshes()
|
||||
1:
|
||||
_update_duplicates()
|
||||
2:
|
||||
_update_particles_system()
|
||||
|
||||
update_gizmos()
|
||||
build_version += 1
|
||||
|
||||
if is_inside_tree():
|
||||
await get_tree().process_frame
|
||||
|
||||
build_completed.emit()
|
||||
82
addons/proton_scatter/src/scatter_gizmo_plugin.gd
Normal file
82
addons/proton_scatter/src/scatter_gizmo_plugin.gd
Normal file
@@ -0,0 +1,82 @@
|
||||
@tool
|
||||
extends EditorNode3DGizmoPlugin
|
||||
|
||||
|
||||
# Gizmo plugin for the ProtonScatter nodes.
|
||||
#
|
||||
# Displays a loading animation when the node is rebuilding its output
|
||||
# Also displays the domain edges if one of its modifiers is using this data.
|
||||
|
||||
|
||||
const ProtonScatter := preload("./scatter.gd")
|
||||
const LoadingAnimation := preload("../icons/loading/m_loading.tres")
|
||||
|
||||
var _loading_mesh: Mesh
|
||||
var _editor_plugin: EditorPlugin
|
||||
|
||||
|
||||
func _init():
|
||||
# TODO: Replace hardcoded colors by a setting fetch
|
||||
create_custom_material("line", Color(0.2, 0.4, 0.8))
|
||||
add_material("loading", LoadingAnimation)
|
||||
|
||||
_loading_mesh = QuadMesh.new()
|
||||
_loading_mesh.set_size(Vector2.ONE * 0.15)
|
||||
|
||||
|
||||
func _get_gizmo_name() -> String:
|
||||
return "ProtonScatter"
|
||||
|
||||
|
||||
func _has_gizmo(node) -> bool:
|
||||
return node is ProtonScatter
|
||||
|
||||
|
||||
func _redraw(gizmo: EditorNode3DGizmo):
|
||||
gizmo.clear()
|
||||
var node = gizmo.get_node_3d()
|
||||
|
||||
if not node.modifier_stack:
|
||||
return
|
||||
|
||||
if node.is_thread_running():
|
||||
gizmo.add_mesh(_loading_mesh, get_material("loading"))
|
||||
|
||||
if node.modifier_stack.is_using_edge_data() and _is_selected(node):
|
||||
var curves: Array[Curve3D] = node.domain.get_edges()
|
||||
|
||||
for curve in curves:
|
||||
var lines := PackedVector3Array()
|
||||
var points: PackedVector3Array = curve.tessellate(4, 8)
|
||||
var lines_count := points.size() - 1
|
||||
|
||||
for i in lines_count:
|
||||
lines.append(points[i])
|
||||
lines.append(points[i + 1])
|
||||
|
||||
gizmo.add_lines(lines, get_material("line"))
|
||||
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
|
||||
# WORKAROUND
|
||||
# Creates a standard material displayed on top of everything.
|
||||
# Only exists because 'create_material() on_top' parameter doesn't seem to work.
|
||||
func create_custom_material(name, color := Color.WHITE):
|
||||
var material := StandardMaterial3D.new()
|
||||
material.set_blend_mode(StandardMaterial3D.BLEND_MODE_ADD)
|
||||
material.set_shading_mode(StandardMaterial3D.SHADING_MODE_UNSHADED)
|
||||
material.set_flag(StandardMaterial3D.FLAG_DISABLE_DEPTH_TEST, true)
|
||||
material.set_albedo(color)
|
||||
material.render_priority = 100
|
||||
|
||||
add_material(name, material)
|
||||
|
||||
|
||||
func _is_selected(node: Node) -> bool:
|
||||
if ProjectSettings.get_setting(_editor_plugin.GIZMO_SETTING):
|
||||
return true
|
||||
|
||||
return node in _editor_plugin.get_custom_selection()
|
||||
171
addons/proton_scatter/src/scatter_item.gd
Normal file
171
addons/proton_scatter/src/scatter_item.gd
Normal file
@@ -0,0 +1,171 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
|
||||
const ScatterUtil := preload('./common/scatter_util.gd')
|
||||
|
||||
|
||||
@export_category("ScatterItem")
|
||||
@export var proportion := 100:
|
||||
set(val):
|
||||
proportion = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export_enum("From current scene:0", "From disk:1") var source = 1:
|
||||
set(val):
|
||||
source = val
|
||||
property_list_changed.emit()
|
||||
|
||||
@export_group("Source options", "source_")
|
||||
@export var source_scale_multiplier := 1.0:
|
||||
set(val):
|
||||
source_scale_multiplier = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export var source_ignore_position := true:
|
||||
set(val):
|
||||
source_ignore_position = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export var source_ignore_rotation := true:
|
||||
set(val):
|
||||
source_ignore_rotation = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export var source_ignore_scale := true:
|
||||
set(val):
|
||||
source_ignore_scale = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export_group("Override options", "override_")
|
||||
@export var override_material: Material:
|
||||
set(val):
|
||||
override_material = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export var override_process_material: Material:
|
||||
set(val):
|
||||
override_process_material = val
|
||||
ScatterUtil.request_parent_to_rebuild(self) # TODO - No need for a full rebuild here
|
||||
|
||||
@export var override_cast_shadow: GeometryInstance3D.ShadowCastingSetting = GeometryInstance3D.SHADOW_CASTING_SETTING_ON:
|
||||
set(val):
|
||||
override_cast_shadow = val
|
||||
ScatterUtil.request_parent_to_rebuild(self) # TODO - Only change the multimesh flag instead
|
||||
|
||||
@export_group("Visibility", "visibility")
|
||||
@export_flags_3d_render var visibility_layers: int = 1
|
||||
@export var visibility_range_begin : float = 0
|
||||
@export var visibility_range_begin_margin : float = 0
|
||||
@export var visibility_range_end : float = 0
|
||||
@export var visibility_range_end_margin : float = 0
|
||||
#TODO what is a nicer way to expose this?
|
||||
@export_enum("Disabled:0", "Self:1") var visibility_range_fade_mode = 0
|
||||
|
||||
@export_group("Level Of Detail", "lod_")
|
||||
@export var lod_generate := true:
|
||||
set(val):
|
||||
lod_generate = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
@export_range(0.0, 180.0) var lod_merge_angle := 25.0:
|
||||
set(val):
|
||||
lod_merge_angle = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
@export_range(0.0, 180.0) var lod_split_angle := 60.0:
|
||||
set(val):
|
||||
lod_split_angle = val
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
var path: String:
|
||||
set(val):
|
||||
path = val
|
||||
source_data_ready = false
|
||||
_target_scene = load(path) if source != 0 else null
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
var source_position: Vector3
|
||||
var source_rotation: Vector3
|
||||
var source_scale: Vector3
|
||||
var source_data_ready := false
|
||||
|
||||
var _target_scene: PackedScene
|
||||
|
||||
|
||||
func _get_property_list() -> Array:
|
||||
var list := []
|
||||
|
||||
if source == 0:
|
||||
list.push_back({
|
||||
name = "path",
|
||||
type = TYPE_NODE_PATH,
|
||||
})
|
||||
else:
|
||||
list.push_back({
|
||||
name = "path",
|
||||
type = TYPE_STRING,
|
||||
hint = PROPERTY_HINT_FILE,
|
||||
})
|
||||
|
||||
return list
|
||||
|
||||
|
||||
func get_item() -> Node3D:
|
||||
if path.is_empty():
|
||||
return null
|
||||
|
||||
var node: Node3D
|
||||
|
||||
if source == 0 and has_node(path):
|
||||
node = get_node(path).duplicate() # Never expose the original node
|
||||
elif source == 1:
|
||||
node = _target_scene.instantiate()
|
||||
|
||||
if node:
|
||||
_save_source_data(node)
|
||||
return node
|
||||
|
||||
return null
|
||||
|
||||
|
||||
# Takes a transform in input, scale it based on the local scale multiplier
|
||||
# If the source transform is not ignored, also copy the source position, rotation and scale.
|
||||
# Returns the processed transform
|
||||
func process_transform(t: Transform3D) -> Transform3D:
|
||||
if not source_data_ready:
|
||||
_update_source_data()
|
||||
|
||||
var origin = t.origin
|
||||
t.origin = Vector3.ZERO
|
||||
|
||||
t = t.scaled(Vector3.ONE * source_scale_multiplier)
|
||||
|
||||
if not source_ignore_scale:
|
||||
t = t.scaled(source_scale)
|
||||
|
||||
if not source_ignore_rotation:
|
||||
t = t.rotated(t.basis.x.normalized(), source_rotation.x)
|
||||
t = t.rotated(t.basis.y.normalized(), source_rotation.y)
|
||||
t = t.rotated(t.basis.z.normalized(), source_rotation.z)
|
||||
|
||||
t.origin = origin
|
||||
|
||||
if not source_ignore_position:
|
||||
t.origin += source_position
|
||||
|
||||
return t
|
||||
|
||||
|
||||
func _save_source_data(node: Node3D) -> void:
|
||||
if not node:
|
||||
return
|
||||
|
||||
source_position = node.position
|
||||
source_rotation = node.rotation
|
||||
source_scale = node.scale
|
||||
source_data_ready = true
|
||||
|
||||
|
||||
func _update_source_data() -> void:
|
||||
var node = get_item()
|
||||
if node:
|
||||
node.queue_free()
|
||||
64
addons/proton_scatter/src/scatter_shape.gd
Normal file
64
addons/proton_scatter/src/scatter_shape.gd
Normal file
@@ -0,0 +1,64 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
|
||||
const ScatterUtil := preload('./common/scatter_util.gd')
|
||||
|
||||
|
||||
@export_category("ScatterShape")
|
||||
@export var negative = false:
|
||||
set(val):
|
||||
negative = val
|
||||
update_gizmos()
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
@export var shape: ProtonScatterBaseShape:
|
||||
set(val):
|
||||
# Disconnect the previous shape if any
|
||||
if shape and shape.changed.is_connected(_on_shape_changed):
|
||||
shape.changed.disconnect(_on_shape_changed)
|
||||
|
||||
shape = val
|
||||
if shape:
|
||||
shape.changed.connect(_on_shape_changed)
|
||||
|
||||
update_gizmos()
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
var _ignore_transform_notification = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_notify_transform(true)
|
||||
|
||||
|
||||
func _notification(what):
|
||||
match what:
|
||||
NOTIFICATION_TRANSFORM_CHANGED:
|
||||
if _ignore_transform_notification:
|
||||
_ignore_transform_notification = false
|
||||
return
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
NOTIFICATION_ENTER_WORLD:
|
||||
_ignore_transform_notification = true
|
||||
|
||||
|
||||
func _set(property, _value):
|
||||
if not Engine.is_editor_hint():
|
||||
return false
|
||||
|
||||
# Workaround to detect when the node was duplicated from the editor.
|
||||
if property == "transform":
|
||||
_on_node_duplicated.call_deferred()
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _on_shape_changed() -> void:
|
||||
update_gizmos()
|
||||
ScatterUtil.request_parent_to_rebuild(self)
|
||||
|
||||
|
||||
func _on_node_duplicated() -> void:
|
||||
shape = shape.get_copy() # Enfore uniqueness on duplicate, could be an option
|
||||
36
addons/proton_scatter/src/shapes/base_shape.gd
Normal file
36
addons/proton_scatter/src/shapes/base_shape.gd
Normal file
@@ -0,0 +1,36 @@
|
||||
@tool
|
||||
class_name ProtonScatterBaseShape
|
||||
extends Resource
|
||||
|
||||
|
||||
func is_point_inside_global(_point_global: Vector3, _global_transform: Transform3D) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func is_point_inside_local(_point_local: Vector3) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# Returns an array of Vector3. This should contain enough points to compute
|
||||
# a bounding box for the given shape.
|
||||
func get_corners_global(_shape_global_transform: Transform3D) -> Array[Vector3]:
|
||||
return []
|
||||
|
||||
|
||||
# Returns the closed contour of the shape (closed, inner and outer if
|
||||
# applicable) as a 2D polygon, in local space relative to the scatter node.
|
||||
func get_closed_edges(_shape_t: Transform3D) -> Array[PackedVector2Array]:
|
||||
return []
|
||||
|
||||
|
||||
# Returns the open edges (in the case of a regular path, not closed)
|
||||
# in local space relative to the scatter node.
|
||||
func get_open_edges(_shape_t: Transform3D) -> Array[Curve3D]:
|
||||
return []
|
||||
|
||||
|
||||
# Returns a copy of this shape.
|
||||
# TODO: check later when Godot4 enters beta if we can get rid of this and use
|
||||
# the built-in duplicate() method properly.
|
||||
func get_copy() -> Resource:
|
||||
return null
|
||||
95
addons/proton_scatter/src/shapes/box_shape.gd
Normal file
95
addons/proton_scatter/src/shapes/box_shape.gd
Normal file
@@ -0,0 +1,95 @@
|
||||
@tool
|
||||
class_name ProtonScatterBoxShape
|
||||
extends ProtonScatterBaseShape
|
||||
|
||||
|
||||
@export var size := Vector3.ONE:
|
||||
set(val):
|
||||
size = val
|
||||
_half_size = size * 0.5
|
||||
emit_changed()
|
||||
|
||||
var _half_size := Vector3.ONE
|
||||
|
||||
|
||||
func get_copy():
|
||||
var copy = get_script().new()
|
||||
copy.size = size
|
||||
return copy
|
||||
|
||||
|
||||
func is_point_inside(point: Vector3, global_transform: Transform3D) -> bool:
|
||||
var local_point = global_transform.affine_inverse() * point
|
||||
return AABB(-_half_size, size).has_point(local_point)
|
||||
|
||||
|
||||
func get_corners_global(gt: Transform3D) -> Array:
|
||||
var res := []
|
||||
var corners := [
|
||||
Vector3(-1, -1, -1),
|
||||
Vector3(-1, -1, 1),
|
||||
Vector3(1, -1, 1),
|
||||
Vector3(1, -1, -1),
|
||||
Vector3(-1, 1, -1),
|
||||
Vector3(-1, 1, 1),
|
||||
Vector3(1, 1, 1),
|
||||
Vector3(1, 1, -1),
|
||||
]
|
||||
|
||||
for c in corners:
|
||||
c *= size * 0.5
|
||||
res.push_back(gt * c)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# Intersection between and box and a plane results in a polygon between 3 and 6
|
||||
# vertices.
|
||||
# Compute the intersection of each of the 12 edges to the plane, then recompute
|
||||
# the polygon from the positions found.
|
||||
func get_closed_edges(shape_t: Transform3D) -> Array[PackedVector2Array]:
|
||||
var polygon := PackedVector2Array()
|
||||
|
||||
var plane := Plane(Vector3.UP, 0.0)
|
||||
|
||||
var box_edges := [
|
||||
# Bottom square
|
||||
[Vector3(-1, -1, -1), Vector3(-1, -1, 1)],
|
||||
[Vector3(-1, -1, 1), Vector3(1, -1, 1)],
|
||||
[Vector3(1, -1, 1), Vector3(1, -1, -1)],
|
||||
[Vector3(1, -1, -1), Vector3(-1, -1, -1)],
|
||||
|
||||
# Top square
|
||||
[Vector3(-1, 1, -1), Vector3(-1, 1, 1)],
|
||||
[Vector3(-1, 1, 1), Vector3(1, 1, 1)],
|
||||
[Vector3(1, 1, 1), Vector3(1, 1, -1)],
|
||||
[Vector3(1, 1, -1), Vector3(-1, 1, -1)],
|
||||
|
||||
# Vertical lines
|
||||
[Vector3(-1, -1, -1), Vector3(-1, 1, -1)],
|
||||
[Vector3(-1, -1, 1), Vector3(-1, 1, 1)],
|
||||
[Vector3(1, -1, 1), Vector3(1, 1, 1)],
|
||||
[Vector3(1, -1, -1), Vector3(1, 1, -1)],
|
||||
]
|
||||
|
||||
var intersection_points := PackedVector3Array()
|
||||
var point
|
||||
var shape_t_inverse := shape_t.affine_inverse()
|
||||
|
||||
for edge in box_edges:
|
||||
var p1 = (edge[0] * _half_size) * shape_t_inverse
|
||||
var p2 = (edge[1] * _half_size) * shape_t_inverse
|
||||
point = plane.intersects_segment(p1, p2)
|
||||
if point:
|
||||
intersection_points.push_back(point)
|
||||
|
||||
if intersection_points.size() < 3:
|
||||
return []
|
||||
|
||||
var points_unordered := PackedVector2Array()
|
||||
for p in intersection_points:
|
||||
points_unordered.push_back(Vector2(p.x, p.z))
|
||||
|
||||
polygon = Geometry2D.convex_hull(points_unordered)
|
||||
|
||||
return [polygon]
|
||||
135
addons/proton_scatter/src/shapes/gizmos_plugin/box_gizmo.gd
Normal file
135
addons/proton_scatter/src/shapes/gizmos_plugin/box_gizmo.gd
Normal file
@@ -0,0 +1,135 @@
|
||||
@tool
|
||||
extends "gizmo_handler.gd"
|
||||
|
||||
# 3D Gizmo for the Box shape.
|
||||
|
||||
|
||||
func get_handle_name(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> String:
|
||||
return "Box Size"
|
||||
|
||||
|
||||
func get_handle_value(gizmo: EditorNode3DGizmo, handle_id: int, _secondary: bool) -> Variant:
|
||||
return gizmo.get_node_3d().shape.size
|
||||
|
||||
|
||||
func set_handle(gizmo: EditorNode3DGizmo, handle_id: int, _secondary: bool, camera: Camera3D, screen_pos: Vector2) -> void:
|
||||
if handle_id < 0 or handle_id > 2:
|
||||
return
|
||||
|
||||
var axis := Vector3.ZERO
|
||||
axis[handle_id] = 1.0 # handle 0:x, 1:y, 2:z
|
||||
|
||||
var shape_node = gizmo.get_node_3d()
|
||||
var gt := shape_node.get_global_transform()
|
||||
var gt_inverse := gt.affine_inverse()
|
||||
|
||||
var origin := gt.origin
|
||||
var drag_axis := (axis * 4096) * gt_inverse
|
||||
var ray_from = camera.project_ray_origin(screen_pos)
|
||||
var ray_to = ray_from + camera.project_ray_normal(screen_pos) * 4096
|
||||
|
||||
var points = Geometry3D.get_closest_points_between_segments(origin, drag_axis, ray_from, ray_to)
|
||||
|
||||
var size = shape_node.shape.size
|
||||
size -= axis * size
|
||||
var dist = origin.distance_to(points[0]) * 2.0
|
||||
size += axis * dist
|
||||
|
||||
shape_node.shape.size = size
|
||||
|
||||
|
||||
func commit_handle(gizmo: EditorNode3DGizmo, handle_id: int, _secondary: bool, restore: Variant, cancel: bool) -> void:
|
||||
var shape: ProtonScatterBoxShape = gizmo.get_node_3d().shape
|
||||
if cancel:
|
||||
shape.size = restore
|
||||
return
|
||||
|
||||
_undo_redo.create_action("Set ScatterShape size")
|
||||
_undo_redo.add_undo_method(self, "_set_size", shape, restore)
|
||||
_undo_redo.add_do_method(self, "_set_size", shape, shape.size)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
|
||||
func redraw(plugin: EditorNode3DGizmoPlugin, gizmo: EditorNode3DGizmo):
|
||||
gizmo.clear()
|
||||
var scatter_shape = gizmo.get_node_3d()
|
||||
var shape: ProtonScatterBoxShape = scatter_shape.shape
|
||||
|
||||
### Draw the Box lines
|
||||
var lines = PackedVector3Array()
|
||||
var lines_material := plugin.get_material("primary_top", gizmo)
|
||||
var half_size = shape.size * 0.5
|
||||
|
||||
var corners := [
|
||||
[ # Bottom square
|
||||
Vector3(-1, -1, -1),
|
||||
Vector3(-1, -1, 1),
|
||||
Vector3(1, -1, 1),
|
||||
Vector3(1, -1, -1),
|
||||
Vector3(-1, -1, -1),
|
||||
],
|
||||
[ # Top square
|
||||
Vector3(-1, 1, -1),
|
||||
Vector3(-1, 1, 1),
|
||||
Vector3(1, 1, 1),
|
||||
Vector3(1, 1, -1),
|
||||
Vector3(-1, 1, -1),
|
||||
],
|
||||
[ # Vertical lines
|
||||
Vector3(-1, -1, -1),
|
||||
Vector3(-1, 1, -1),
|
||||
],
|
||||
[
|
||||
Vector3(-1, -1, 1),
|
||||
Vector3(-1, 1, 1),
|
||||
],
|
||||
[
|
||||
Vector3(1, -1, 1),
|
||||
Vector3(1, 1, 1),
|
||||
],
|
||||
[
|
||||
Vector3(1, -1, -1),
|
||||
Vector3(1, 1, -1),
|
||||
]
|
||||
]
|
||||
|
||||
var block_count = corners.size()
|
||||
if not is_selected(gizmo):
|
||||
block_count = 1
|
||||
|
||||
for i in block_count:
|
||||
var block = corners[i]
|
||||
for j in block.size() - 1:
|
||||
lines.push_back(block[j] * half_size)
|
||||
lines.push_back(block[j + 1] * half_size)
|
||||
|
||||
gizmo.add_lines(lines, lines_material)
|
||||
gizmo.add_collision_segments(lines)
|
||||
|
||||
### Fills the box inside
|
||||
var mesh = BoxMesh.new()
|
||||
mesh.size = shape.size
|
||||
|
||||
var mesh_material: StandardMaterial3D
|
||||
if scatter_shape.negative:
|
||||
mesh_material = plugin.get_material("exclusive", gizmo)
|
||||
else:
|
||||
mesh_material = plugin.get_material("inclusive", gizmo)
|
||||
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
### Draw the handles, one for each axis
|
||||
var handles := PackedVector3Array()
|
||||
var handles_ids := PackedInt32Array()
|
||||
var handles_material := plugin.get_material("default_handle", gizmo)
|
||||
|
||||
handles.push_back(Vector3.RIGHT * shape.size.x * 0.5)
|
||||
handles.push_back(Vector3.UP * shape.size.y * 0.5)
|
||||
handles.push_back(Vector3.BACK * shape.size.z * 0.5)
|
||||
|
||||
gizmo.add_handles(handles, handles_material, handles_ids)
|
||||
|
||||
|
||||
func _set_size(box: ProtonScatterBoxShape, size: Vector3) -> void:
|
||||
if box:
|
||||
box.size = size
|
||||
@@ -0,0 +1,3 @@
|
||||
[gd_resource type="ButtonGroup" format=3 uid="uid://1xy55037k3k5"]
|
||||
|
||||
[resource]
|
||||
@@ -0,0 +1,55 @@
|
||||
[gd_scene format=3 uid="uid://qb8j7oasuqbc"]
|
||||
|
||||
[node name="AdvancedOptionsPanel" type="MarginContainer"]
|
||||
offset_right = 221.0
|
||||
offset_bottom = 136.0
|
||||
grow_horizontal = 2
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
metadata/_edit_use_custom_anchors = true
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
offset_right = 221.0
|
||||
offset_bottom = 136.0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="HBoxContainer"]
|
||||
offset_right = 217.0
|
||||
offset_bottom = 136.0
|
||||
|
||||
[node name="MirrorLength" type="CheckButton" parent="HBoxContainer/VBoxContainer"]
|
||||
offset_right = 217.0
|
||||
offset_bottom = 31.0
|
||||
focus_mode = 0
|
||||
text = "Mirror handles length"
|
||||
|
||||
[node name="MirrorAngle" type="CheckButton" parent="HBoxContainer/VBoxContainer"]
|
||||
offset_top = 35.0
|
||||
offset_right = 217.0
|
||||
offset_bottom = 66.0
|
||||
focus_mode = 0
|
||||
text = "Mirror handles angle"
|
||||
|
||||
[node name="LockToPlane" type="CheckButton" parent="HBoxContainer/VBoxContainer"]
|
||||
offset_top = 70.0
|
||||
offset_right = 217.0
|
||||
offset_bottom = 101.0
|
||||
focus_mode = 0
|
||||
text = "Lock to plane"
|
||||
|
||||
[node name="MirrorAngle3" type="CheckButton" parent="HBoxContainer/VBoxContainer"]
|
||||
offset_top = 105.0
|
||||
offset_right = 217.0
|
||||
offset_bottom = 136.0
|
||||
focus_mode = 0
|
||||
text = "Snap to colliders"
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="HBoxContainer"]
|
||||
visible = false
|
||||
offset_left = 221.0
|
||||
offset_right = 225.0
|
||||
offset_bottom = 136.0
|
||||
|
||||
[node name="VBoxContainer2" type="VBoxContainer" parent="HBoxContainer"]
|
||||
offset_left = 221.0
|
||||
offset_right = 221.0
|
||||
offset_bottom = 136.0
|
||||
@@ -0,0 +1,96 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
|
||||
const ScatterShape = preload("../../../scatter_shape.gd")
|
||||
const PathShape = preload("../../path_shape.gd")
|
||||
|
||||
var shape_node: ScatterShape
|
||||
|
||||
@onready var _options_button: Button = $%Options
|
||||
@onready var _options_panel: Popup = $%OptionsPanel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_options_button.toggled.connect(_on_options_button_toggled)
|
||||
_options_panel.popup_hide.connect(_on_options_panel_hide)
|
||||
$%SnapToColliders.toggled.connect(_on_snap_to_colliders_toggled)
|
||||
$%ClosedPath.toggled.connect(_on_closed_path_toggled)
|
||||
$%MirrorAngle.toggled.connect(_on_mirror_angle_toggled)
|
||||
|
||||
for button in [$%LockToPlane, $%SnapToColliders, $%ClosedPath]:
|
||||
button.pressed.connect(_on_button_pressed)
|
||||
|
||||
|
||||
# Called by the editor plugin when the node selection changes.
|
||||
# Hides the panel when the selected node is not a path shape.
|
||||
func selection_changed(selected: Array) -> void:
|
||||
if selected.is_empty():
|
||||
visible = false
|
||||
shape_node = null
|
||||
return
|
||||
|
||||
var node = selected[0]
|
||||
visible = node is ScatterShape and node.shape is PathShape
|
||||
if visible:
|
||||
shape_node = node
|
||||
$%ClosedPath.button_pressed = node.shape.closed
|
||||
|
||||
|
||||
func is_select_mode_enabled() -> bool:
|
||||
return $%Select.button_pressed
|
||||
|
||||
|
||||
func is_create_mode_enabled() -> bool:
|
||||
return $%Create.button_pressed
|
||||
|
||||
|
||||
func is_delete_mode_enabled() -> bool:
|
||||
return $%Delete.button_pressed
|
||||
|
||||
|
||||
func is_lock_to_plane_enabled() -> bool:
|
||||
return $%LockToPlane.button_pressed and not is_snap_to_colliders_enabled()
|
||||
|
||||
|
||||
func is_snap_to_colliders_enabled() -> bool:
|
||||
return $%SnapToColliders.button_pressed
|
||||
|
||||
|
||||
func is_mirror_length_enabled() -> bool:
|
||||
return $%MirrorLength.button_pressed
|
||||
|
||||
|
||||
func is_mirror_angle_enabled() -> bool:
|
||||
return $%MirrorAngle.button_pressed
|
||||
|
||||
|
||||
func _on_options_button_toggled(enabled: bool) -> void:
|
||||
if enabled:
|
||||
var popup_position := Vector2i(get_global_transform().origin)
|
||||
popup_position.y += size.y + 12
|
||||
_options_panel.popup(Rect2i(popup_position, Vector2i.ZERO))
|
||||
else:
|
||||
_options_panel.hide()
|
||||
|
||||
|
||||
func _on_options_panel_hide() -> void:
|
||||
_options_button.button_pressed = false
|
||||
|
||||
|
||||
func _on_mirror_angle_toggled(enabled: bool) -> void:
|
||||
$%MirrorLength.disabled = not enabled
|
||||
|
||||
|
||||
func _on_snap_to_colliders_toggled(enabled: bool) -> void:
|
||||
$%LockToPlane.disabled = enabled
|
||||
|
||||
|
||||
func _on_closed_path_toggled(enabled: bool) -> void:
|
||||
if shape_node and shape_node.shape is PathShape:
|
||||
shape_node.shape.closed = enabled
|
||||
|
||||
|
||||
func _on_button_pressed() -> void:
|
||||
if shape_node:
|
||||
shape_node.update_gizmos()
|
||||
@@ -0,0 +1,124 @@
|
||||
[gd_scene load_steps=7 format=3 uid="uid://vijpujrvtyin"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/shapes/gizmos_plugin/components/path_panel.gd" id="1_o7kkg"]
|
||||
[ext_resource type="Texture2D" uid="uid://c1t5x34pc4vs5" path="res://addons/proton_scatter/icons/curve_select.svg" id="2_d7o1n"]
|
||||
[ext_resource type="ButtonGroup" uid="uid://1xy55037k3k5" path="res://addons/proton_scatter/src/shapes/gizmos_plugin/components/curve_mode_button_group.tres" id="2_sl6yo"]
|
||||
[ext_resource type="Texture2D" uid="uid://cmykha5ja17vj" path="res://addons/proton_scatter/icons/curve_create.svg" id="3_l70sn"]
|
||||
[ext_resource type="Texture2D" uid="uid://cligdljx1ad5e" path="res://addons/proton_scatter/icons/curve_delete.svg" id="4_b5yum"]
|
||||
[ext_resource type="Texture2D" uid="uid://n66mufjib4ds" path="res://addons/proton_scatter/icons/menu.svg" id="6_xiaj2"]
|
||||
|
||||
[node name="PathPanel" type="MarginContainer"]
|
||||
offset_right = 108.0
|
||||
offset_bottom = 24.0
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
script = ExtResource("1_o7kkg")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Select" type="Button" parent="HBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
toggle_mode = true
|
||||
button_pressed = true
|
||||
button_group = ExtResource("2_sl6yo")
|
||||
icon = ExtResource("2_d7o1n")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
|
||||
[node name="Create" type="Button" parent="HBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
toggle_mode = true
|
||||
button_group = ExtResource("2_sl6yo")
|
||||
icon = ExtResource("3_l70sn")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
|
||||
[node name="Delete" type="Button" parent="HBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
toggle_mode = true
|
||||
button_group = ExtResource("2_sl6yo")
|
||||
icon = ExtResource("4_b5yum")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
|
||||
[node name="Options" type="Button" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
toggle_mode = true
|
||||
action_mode = 0
|
||||
icon = ExtResource("6_xiaj2")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
|
||||
[node name="OptionsPanel" type="PopupPanel" parent="."]
|
||||
unique_name_in_owner = true
|
||||
size = Vector2i(229, 179)
|
||||
|
||||
[node name="AdvancedOptionsPanel" type="MarginContainer" parent="OptionsPanel"]
|
||||
offset_left = 4.0
|
||||
offset_top = 4.0
|
||||
offset_right = 225.0
|
||||
offset_bottom = 175.0
|
||||
grow_horizontal = 2
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
metadata/_edit_use_custom_anchors = true
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="OptionsPanel/AdvancedOptionsPanel"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MirrorAngle" type="CheckButton" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
button_pressed = true
|
||||
text = "Mirror handles angle"
|
||||
|
||||
[node name="MirrorLength" type="CheckButton" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
button_pressed = true
|
||||
text = "Mirror handles length"
|
||||
|
||||
[node name="ClosedPath" type="CheckButton" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Closed path"
|
||||
|
||||
[node name="LockToPlane" type="CheckButton" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
button_pressed = true
|
||||
text = "Lock to plane"
|
||||
|
||||
[node name="SnapToColliders" type="CheckButton" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Snap to colliders"
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
|
||||
[node name="VBoxContainer2" type="VBoxContainer" parent="OptionsPanel/AdvancedOptionsPanel/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
@@ -0,0 +1,50 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Abstract class.
|
||||
|
||||
|
||||
var _undo_redo: EditorUndoRedoManager
|
||||
var _plugin: EditorPlugin
|
||||
|
||||
|
||||
func set_undo_redo(ur: EditorUndoRedoManager) -> void:
|
||||
_undo_redo = ur
|
||||
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_plugin = plugin
|
||||
|
||||
|
||||
func get_handle_name(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func get_handle_value(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> Variant:
|
||||
return null
|
||||
|
||||
|
||||
func set_handle(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool, _camera: Camera3D, _screen_pos: Vector2) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func commit_handle(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool, _restore: Variant, _cancel: bool) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func redraw(_gizmo_plugin: EditorNode3DGizmoPlugin, _gizmo: EditorNode3DGizmo):
|
||||
pass
|
||||
|
||||
|
||||
func forward_3d_gui_input(_viewport_camera: Camera3D, _event: InputEvent) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func is_selected(gizmo: EditorNode3DGizmo) -> bool:
|
||||
if not _plugin:
|
||||
return true
|
||||
|
||||
var current_node = gizmo.get_node_3d()
|
||||
var selected_nodes := _plugin.get_editor_interface().get_selection().get_selected_nodes()
|
||||
|
||||
return current_node in selected_nodes
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m13.6 2.4v11.2h-11.2v-11.2z" fill="#ffffff" stroke="#000000" stroke-linecap="square" stroke-width="1.6"/></svg>
|
||||
|
After Width: | Height: | Size: 204 B |
@@ -0,0 +1,38 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dmjp2vpqp4qjy"
|
||||
path.s3tc="res://.godot/imported/main_handle.svg-e76638c615070e68035d2b711214a1fc.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/proton_scatter/src/shapes/gizmos_plugin/icons/main_handle.svg"
|
||||
dest_files=["res://.godot/imported/main_handle.svg-e76638c615070e68035d2b711214a1fc.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m14.868629 8.0000002-6.8686288 6.8686288-6.8686293-6.8686288 6.8686293-6.8686293z" fill="#ffffff" stroke="#000000" stroke-linecap="square" stroke-width="1.6"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -0,0 +1,38 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://kygbxbbnqkdh"
|
||||
path.s3tc="res://.godot/imported/secondary_handle.svg-d46e6e295afbc9a7509025fe11144dfd.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/proton_scatter/src/shapes/gizmos_plugin/icons/secondary_handle.svg"
|
||||
dest_files=["res://.godot/imported/secondary_handle.svg-d46e6e295afbc9a7509025fe11144dfd.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
356
addons/proton_scatter/src/shapes/gizmos_plugin/path_gizmo.gd
Normal file
356
addons/proton_scatter/src/shapes/gizmos_plugin/path_gizmo.gd
Normal file
@@ -0,0 +1,356 @@
|
||||
@tool
|
||||
extends "gizmo_handler.gd"
|
||||
|
||||
|
||||
const ProtonScatter := preload("res://addons/proton_scatter/src/scatter.gd")
|
||||
const ProtonScatterShape := preload("res://addons/proton_scatter/src/scatter_shape.gd")
|
||||
const ProtonScatterEventHelper := preload("res://addons/proton_scatter/src/common/event_helper.gd")
|
||||
const PathPanel := preload("./components/path_panel.gd")
|
||||
|
||||
var _gizmo_panel: PathPanel
|
||||
var _event_helper: ProtonScatterEventHelper
|
||||
|
||||
|
||||
func get_handle_name(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> String:
|
||||
return "Path point"
|
||||
|
||||
|
||||
func get_handle_value(gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> Variant:
|
||||
var shape: ProtonScatterPathShape = gizmo.get_node_3d().shape
|
||||
return shape.get_copy()
|
||||
|
||||
|
||||
func set_handle(gizmo: EditorNode3DGizmo, handle_id: int, secondary: bool, camera: Camera3D, screen_pos: Vector2) -> void:
|
||||
if not _gizmo_panel.is_select_mode_enabled():
|
||||
return
|
||||
|
||||
var shape_node: ProtonScatterShape = gizmo.get_node_3d()
|
||||
var curve: Curve3D = shape_node.shape.curve
|
||||
var point_count: int = curve.get_point_count()
|
||||
var curve_index := handle_id
|
||||
var previous_handle_position: Vector3
|
||||
|
||||
if not secondary:
|
||||
previous_handle_position = curve.get_point_position(curve_index)
|
||||
else:
|
||||
curve_index = int(handle_id / 2)
|
||||
previous_handle_position = curve.get_point_position(curve_index)
|
||||
if handle_id % 2 == 0:
|
||||
previous_handle_position += curve.get_point_in(curve_index)
|
||||
else:
|
||||
previous_handle_position += curve.get_point_out(curve_index)
|
||||
|
||||
var click_world_position := _intersect_with(shape_node, camera, screen_pos, previous_handle_position)
|
||||
var point_local_position: Vector3 = shape_node.get_global_transform().affine_inverse() * click_world_position
|
||||
|
||||
if not secondary:
|
||||
# Main curve point moved
|
||||
curve.set_point_position(handle_id, point_local_position)
|
||||
else:
|
||||
# In out handle moved
|
||||
var mirror_angle := _gizmo_panel.is_mirror_angle_enabled()
|
||||
var mirror_length := _gizmo_panel.is_mirror_length_enabled()
|
||||
|
||||
var point_origin = curve.get_point_position(curve_index)
|
||||
var in_out_position = point_local_position - point_origin
|
||||
var mirror_position = -in_out_position
|
||||
|
||||
if handle_id % 2 == 0:
|
||||
curve.set_point_in(curve_index, in_out_position)
|
||||
if mirror_angle:
|
||||
if not mirror_length:
|
||||
mirror_position = curve.get_point_out(curve_index).length() * -in_out_position.normalized()
|
||||
curve.set_point_out(curve_index, mirror_position)
|
||||
else:
|
||||
curve.set_point_out(curve_index, in_out_position)
|
||||
if mirror_angle:
|
||||
if not mirror_length:
|
||||
mirror_position = curve.get_point_in(curve_index).length() * -in_out_position.normalized()
|
||||
curve.set_point_in(curve_index, mirror_position)
|
||||
|
||||
shape_node.update_gizmos()
|
||||
|
||||
|
||||
func commit_handle(gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool, restore: Variant, cancel: bool) -> void:
|
||||
var shape_node: ProtonScatterShape = gizmo.get_node_3d()
|
||||
|
||||
if cancel:
|
||||
_edit_path(shape_node, restore)
|
||||
else:
|
||||
_undo_redo.create_action("Edit ScatterShape Path")
|
||||
_undo_redo.add_undo_method(self, "_edit_path", shape_node, restore)
|
||||
_undo_redo.add_do_method(self, "_edit_path", shape_node, shape_node.shape.get_copy())
|
||||
_undo_redo.commit_action()
|
||||
|
||||
shape_node.update_gizmos()
|
||||
|
||||
|
||||
func redraw(plugin: EditorNode3DGizmoPlugin, gizmo: EditorNode3DGizmo):
|
||||
gizmo.clear()
|
||||
|
||||
# Force the path panel to appear when the scatter shape type is changed
|
||||
# from the inspector.
|
||||
if is_selected(gizmo):
|
||||
_gizmo_panel.selection_changed([gizmo.get_node_3d()])
|
||||
|
||||
var shape_node: ProtonScatterShape = gizmo.get_node_3d()
|
||||
var shape: ProtonScatterPathShape = shape_node.shape
|
||||
|
||||
if not shape:
|
||||
return
|
||||
|
||||
var curve: Curve3D = shape.curve
|
||||
if not curve or curve.get_point_count() == 0:
|
||||
return
|
||||
|
||||
# ------ Common stuff ------
|
||||
var points := curve.tessellate(4, 8)
|
||||
var points_2d := PackedVector2Array()
|
||||
for p in points:
|
||||
points_2d.push_back(Vector2(p.x, p.z))
|
||||
|
||||
var line_material: StandardMaterial3D = plugin.get_material("primary_top", gizmo)
|
||||
var mesh_material: StandardMaterial3D = plugin.get_material("inclusive", gizmo)
|
||||
if shape_node.negative:
|
||||
mesh_material = plugin.get_material("exclusive", gizmo)
|
||||
|
||||
# ------ Main line along the path curve ------
|
||||
var lines := PackedVector3Array()
|
||||
var lines_count := points.size() - 1
|
||||
|
||||
for i in lines_count:
|
||||
lines.append(points[i])
|
||||
lines.append(points[i + 1])
|
||||
|
||||
gizmo.add_lines(lines, line_material)
|
||||
gizmo.add_collision_segments(lines)
|
||||
|
||||
# ------ Draw handles ------
|
||||
var main_handles := PackedVector3Array()
|
||||
var in_out_handles := PackedVector3Array()
|
||||
var handle_lines := PackedVector3Array()
|
||||
var ids := PackedInt32Array() # Stays empty on purpose
|
||||
|
||||
for i in curve.get_point_count():
|
||||
var point_pos = curve.get_point_position(i)
|
||||
var point_in = curve.get_point_in(i) + point_pos
|
||||
var point_out = curve.get_point_out(i) + point_pos
|
||||
|
||||
handle_lines.push_back(point_pos)
|
||||
handle_lines.push_back(point_in)
|
||||
handle_lines.push_back(point_pos)
|
||||
handle_lines.push_back(point_out)
|
||||
|
||||
in_out_handles.push_back(point_in)
|
||||
in_out_handles.push_back(point_out)
|
||||
main_handles.push_back(point_pos)
|
||||
|
||||
gizmo.add_handles(main_handles, plugin.get_material("primary_handle", gizmo), ids)
|
||||
gizmo.add_handles(in_out_handles, plugin.get_material("secondary_handle", gizmo), ids, false, true)
|
||||
|
||||
if is_selected(gizmo):
|
||||
gizmo.add_lines(handle_lines, plugin.get_material("secondary_top", gizmo))
|
||||
|
||||
# -------- Visual when lock to plane is enabled --------
|
||||
if _gizmo_panel.is_lock_to_plane_enabled() and is_selected(gizmo):
|
||||
var bounds = shape.get_bounds()
|
||||
var aabb = AABB(bounds.min, bounds.size).grow(shape.thickness / 2.0)
|
||||
|
||||
var width: float = aabb.size.x
|
||||
var length: float = aabb.size.z
|
||||
var plane_center: Vector3 = bounds.center
|
||||
plane_center.y = 0.0
|
||||
|
||||
var plane_mesh := PlaneMesh.new()
|
||||
plane_mesh.set_size(Vector2(width, length))
|
||||
plane_mesh.set_center_offset(plane_center)
|
||||
|
||||
gizmo.add_mesh(plane_mesh, plugin.get_material("tertiary", gizmo))
|
||||
|
||||
var plane_lines := PackedVector3Array()
|
||||
var corners = [
|
||||
Vector3(-width, 0, -length),
|
||||
Vector3(-width, 0, length),
|
||||
Vector3(width, 0, length),
|
||||
Vector3(width, 0, -length),
|
||||
Vector3(-width, 0, -length),
|
||||
]
|
||||
for i in corners.size() - 1:
|
||||
plane_lines.push_back(corners[i] * 0.5 + plane_center)
|
||||
plane_lines.push_back(corners[i + 1] * 0.5 + plane_center)
|
||||
|
||||
gizmo.add_lines(plane_lines, plugin.get_material("secondary_top", gizmo))
|
||||
|
||||
# ----- Mesh representing the inside part of the path -----
|
||||
if shape.closed:
|
||||
var indices = Geometry2D.triangulate_polygon(points_2d)
|
||||
if indices.is_empty():
|
||||
indices = Geometry2D.triangulate_delaunay(points_2d)
|
||||
|
||||
var st = SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
for index in indices:
|
||||
var p = points_2d[index]
|
||||
st.add_vertex(Vector3(p.x, 0.0, p.y))
|
||||
|
||||
var mesh = st.commit()
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
# ------ Mesh representing path thickness ------
|
||||
if shape.thickness > 0 and points.size() > 1:
|
||||
|
||||
# ____ TODO ____ : check if this whole section could be replaced by
|
||||
# Geometry2D.expand_polyline, or an extruded capsule along the path
|
||||
|
||||
## Main path mesh
|
||||
var st = SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP)
|
||||
|
||||
for i in points.size() - 1:
|
||||
var p1: Vector3 = points[i]
|
||||
var p2: Vector3 = points[i + 1]
|
||||
|
||||
var normal = (p2 - p1).cross(Vector3.UP).normalized()
|
||||
var offset = normal * shape.thickness * 0.5
|
||||
|
||||
st.add_vertex(p1 - offset)
|
||||
st.add_vertex(p1 + offset)
|
||||
|
||||
## Add the last missing two triangles from the loop above
|
||||
var p1: Vector3 = points[-1]
|
||||
var p2: Vector3 = points[-2]
|
||||
var normal = (p1 - p2).cross(Vector3.UP).normalized()
|
||||
var offset = normal * shape.thickness * 0.5
|
||||
|
||||
st.add_vertex(p1 - offset)
|
||||
st.add_vertex(p1 + offset)
|
||||
|
||||
var mesh := st.commit()
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
## Rounded cap (start)
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
var center = points[0]
|
||||
var next = points[1]
|
||||
normal = (center - next).cross(Vector3.UP).normalized()
|
||||
|
||||
for i in 12:
|
||||
st.add_vertex(center)
|
||||
st.add_vertex(center + normal * shape.thickness * 0.5)
|
||||
normal = normal.rotated(Vector3.UP, PI / 12)
|
||||
st.add_vertex(center + normal * shape.thickness * 0.5)
|
||||
|
||||
mesh = st.commit()
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
## Rounded cap (end)
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
center = points[-1]
|
||||
next = points[-2]
|
||||
normal = (next - center).cross(Vector3.UP).normalized()
|
||||
|
||||
for i in 12:
|
||||
st.add_vertex(center)
|
||||
st.add_vertex(center + normal * shape.thickness * 0.5)
|
||||
normal = normal.rotated(Vector3.UP, -PI / 12)
|
||||
st.add_vertex(center + normal * shape.thickness * 0.5)
|
||||
|
||||
mesh = st.commit()
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
|
||||
func forward_3d_gui_input(viewport_camera: Camera3D, event: InputEvent) -> bool:
|
||||
if not _event_helper:
|
||||
_event_helper = ProtonScatterEventHelper.new()
|
||||
|
||||
_event_helper.feed(event)
|
||||
|
||||
if not event is InputEventMouseButton:
|
||||
return false
|
||||
|
||||
if not _event_helper.is_key_just_pressed(MOUSE_BUTTON_LEFT): # Can't use just_released here
|
||||
return false
|
||||
|
||||
var shape_node: ProtonScatterShape = _gizmo_panel.shape_node
|
||||
if not shape_node:
|
||||
return false
|
||||
|
||||
if not shape_node.shape or not shape_node.shape is ProtonScatterPathShape:
|
||||
return false
|
||||
|
||||
var shape: ProtonScatterPathShape = shape_node.shape
|
||||
|
||||
# In select mode, the set_handle and commit_handle functions take over.
|
||||
if _gizmo_panel.is_select_mode_enabled():
|
||||
return false
|
||||
|
||||
var click_world_position := _intersect_with(shape_node, viewport_camera, event.position)
|
||||
var point_local_position: Vector3 = shape_node.get_global_transform().affine_inverse() * click_world_position
|
||||
|
||||
if _gizmo_panel.is_create_mode_enabled():
|
||||
shape.create_point(point_local_position) # TODO: add undo redo
|
||||
shape_node.update_gizmos()
|
||||
return true
|
||||
|
||||
elif _gizmo_panel.is_delete_mode_enabled():
|
||||
var index = shape.get_closest_to(point_local_position)
|
||||
if index != -1:
|
||||
shape.remove_point(index) # TODO: add undo redo
|
||||
shape_node.update_gizmos()
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func set_gizmo_panel(panel: PathPanel) -> void:
|
||||
_gizmo_panel = panel
|
||||
|
||||
|
||||
func _edit_path(shape_node: ProtonScatterShape, restore: ProtonScatterPathShape) -> void:
|
||||
shape_node.shape.curve = restore.curve.duplicate()
|
||||
shape_node.shape.thickness = restore.thickness
|
||||
shape_node.update_gizmos()
|
||||
|
||||
|
||||
func _intersect_with(path: ProtonScatterShape, camera: Camera3D, screen_point: Vector2, handle_position_local = null) -> Vector3:
|
||||
# Get the ray data
|
||||
var from = camera.project_ray_origin(screen_point)
|
||||
var dir = camera.project_ray_normal(screen_point)
|
||||
var gt = path.get_global_transform()
|
||||
|
||||
# Snap to collider enabled
|
||||
if _gizmo_panel.is_snap_to_colliders_enabled():
|
||||
var space_state: PhysicsDirectSpaceState3D = path.get_world_3d().get_direct_space_state()
|
||||
var parameters := PhysicsRayQueryParameters3D.new()
|
||||
parameters.from = from
|
||||
parameters.to = from + (dir * 2048)
|
||||
var hit := space_state.intersect_ray(parameters)
|
||||
if not hit.is_empty():
|
||||
return hit.position
|
||||
|
||||
# Lock to plane enabled
|
||||
if _gizmo_panel.is_lock_to_plane_enabled():
|
||||
var t = Transform3D(gt)
|
||||
var a = t.basis.x
|
||||
var b = t.basis.z
|
||||
var c = a + b
|
||||
var o = t.origin
|
||||
var plane = Plane(a + o, b + o, c + o)
|
||||
var result = plane.intersects_ray(from, dir)
|
||||
if result != null:
|
||||
return result
|
||||
|
||||
# Default case (similar to the built in Path3D node)
|
||||
var origin: Vector3
|
||||
if handle_position_local:
|
||||
origin = gt * handle_position_local
|
||||
else:
|
||||
origin = path.get_global_transform().origin
|
||||
|
||||
var plane = Plane(dir, origin)
|
||||
var res = plane.intersects_ray(from, dir)
|
||||
if res != null:
|
||||
return res
|
||||
|
||||
return origin
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
@tool
|
||||
extends EditorNode3DGizmoPlugin
|
||||
|
||||
|
||||
# Actual logic split in the handler class to avoid cluttering this script as
|
||||
# we add extra shapes.
|
||||
#
|
||||
# Although we could make an actual gizmo per shape type and add the extra type
|
||||
# check in the 'has_gizmo' function, it causes more issues to the editor
|
||||
# than it's worth (2 fewer files), so it's done like this instead.
|
||||
|
||||
|
||||
const ScatterShape = preload("../../scatter_shape.gd")
|
||||
const GizmoHandler = preload("./gizmo_handler.gd")
|
||||
|
||||
|
||||
var _editor_plugin: EditorPlugin
|
||||
var _handlers: Dictionary
|
||||
|
||||
|
||||
func _init():
|
||||
var handle_icon = preload("./icons/main_handle.svg")
|
||||
var secondary_handle_icon = preload("./icons/secondary_handle.svg")
|
||||
|
||||
# TODO: Replace hardcoded colors by a setting fetch
|
||||
create_material("primary", Color(1, 0.4, 0))
|
||||
create_material("secondary", Color(0.4, 0.7, 1.0))
|
||||
create_material("tertiary", Color(Color.STEEL_BLUE, 0.2))
|
||||
create_custom_material("primary_top", Color(1, 0.4, 0))
|
||||
create_custom_material("secondary_top", Color(0.4, 0.7, 1.0))
|
||||
create_custom_material("tertiary_top", Color(Color.STEEL_BLUE, 0.1))
|
||||
|
||||
create_material("inclusive", Color(0.9, 0.7, 0.2, 0.15))
|
||||
create_material("exclusive", Color(0.9, 0.1, 0.2, 0.15))
|
||||
|
||||
create_handle_material("default_handle")
|
||||
create_handle_material("primary_handle", false, handle_icon)
|
||||
create_handle_material("secondary_handle", false, secondary_handle_icon)
|
||||
|
||||
_handlers[ProtonScatterSphereShape] = preload("./sphere_gizmo.gd").new()
|
||||
_handlers[ProtonScatterPathShape] = preload("./path_gizmo.gd").new()
|
||||
_handlers[ProtonScatterBoxShape] = preload("./box_gizmo.gd").new()
|
||||
|
||||
|
||||
func _get_gizmo_name() -> String:
|
||||
return "ScatterShape"
|
||||
|
||||
|
||||
func _has_gizmo(node) -> bool:
|
||||
return node is ScatterShape
|
||||
|
||||
|
||||
func _get_handle_name(gizmo: EditorNode3DGizmo, handle_id: int, secondary: bool) -> String:
|
||||
return _get_handler(gizmo).get_handle_name(gizmo, handle_id, secondary)
|
||||
|
||||
|
||||
func _get_handle_value(gizmo: EditorNode3DGizmo, handle_id: int, secondary: bool) -> Variant:
|
||||
return _get_handler(gizmo).get_handle_value(gizmo, handle_id, secondary)
|
||||
|
||||
|
||||
func _set_handle(gizmo: EditorNode3DGizmo, handle_id: int, secondary: bool, camera: Camera3D, screen_pos: Vector2) -> void:
|
||||
_get_handler(gizmo).set_handle(gizmo, handle_id, secondary, camera, screen_pos)
|
||||
|
||||
|
||||
func _commit_handle(gizmo: EditorNode3DGizmo, handle_id: int, secondary: bool, restore: Variant, cancel: bool) -> void:
|
||||
_get_handler(gizmo).commit_handle(gizmo, handle_id, secondary, restore, cancel)
|
||||
|
||||
|
||||
func _redraw(gizmo: EditorNode3DGizmo):
|
||||
if _is_node_selected(gizmo):
|
||||
_get_handler(gizmo).redraw(self, gizmo)
|
||||
else:
|
||||
gizmo.clear()
|
||||
|
||||
|
||||
func forward_3d_gui_input(viewport_camera: Camera3D, event: InputEvent) -> int:
|
||||
for handler in _handlers.values():
|
||||
if handler.forward_3d_gui_input(viewport_camera, event):
|
||||
return EditorPlugin.AFTER_GUI_INPUT_STOP
|
||||
|
||||
return EditorPlugin.AFTER_GUI_INPUT_PASS
|
||||
|
||||
|
||||
func set_undo_redo(ur: EditorUndoRedoManager) -> void:
|
||||
for handler_type in _handlers:
|
||||
_handlers[handler_type].set_undo_redo(ur)
|
||||
|
||||
|
||||
func set_path_gizmo_panel(panel: Control) -> void:
|
||||
if ProtonScatterPathShape in _handlers:
|
||||
_handlers[ProtonScatterPathShape].set_gizmo_panel(panel)
|
||||
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
for handler_type in _handlers:
|
||||
_handlers[handler_type].set_editor_plugin(plugin)
|
||||
|
||||
|
||||
# Creates a standard material displayed on top of everything.
|
||||
# Only exists because 'create_material() on_top' parameter doesn't seem to work.
|
||||
func create_custom_material(name: String, color := Color.WHITE):
|
||||
var material := StandardMaterial3D.new()
|
||||
material.set_blend_mode(StandardMaterial3D.BLEND_MODE_ADD)
|
||||
material.set_shading_mode(StandardMaterial3D.SHADING_MODE_UNSHADED)
|
||||
material.set_flag(StandardMaterial3D.FLAG_DISABLE_DEPTH_TEST, true)
|
||||
material.set_albedo(color)
|
||||
material.render_priority = 100
|
||||
|
||||
add_material(name, material)
|
||||
|
||||
|
||||
func _get_handler(gizmo: EditorNode3DGizmo) -> GizmoHandler:
|
||||
var null_handler = GizmoHandler.new() # Only so we don't have to check existence later
|
||||
|
||||
var shape_node = gizmo.get_node_3d()
|
||||
if not shape_node or not shape_node is ScatterShape:
|
||||
return null_handler
|
||||
|
||||
var shape_resource = shape_node.shape
|
||||
if not shape_resource:
|
||||
return null_handler
|
||||
|
||||
var shape_type = shape_resource.get_script()
|
||||
if not shape_type in _handlers:
|
||||
return null_handler
|
||||
|
||||
return _handlers[shape_type]
|
||||
|
||||
|
||||
func _is_node_selected(gizmo: EditorNode3DGizmo) -> bool:
|
||||
if ProjectSettings.get_setting(_editor_plugin.GIZMO_SETTING):
|
||||
return true
|
||||
|
||||
var selected_nodes: Array[Node] = _editor_plugin.get_custom_selection()
|
||||
return gizmo.get_node_3d() in selected_nodes
|
||||
@@ -0,0 +1,97 @@
|
||||
@tool
|
||||
extends "gizmo_handler.gd"
|
||||
|
||||
# 3D Gizmo for the Sphere shape. Draws three circle on each axis to represent
|
||||
# a sphere, displays one handle on the size to control the radius.
|
||||
#
|
||||
# (handle_id is ignored in every function since there's a single handle)
|
||||
|
||||
const SphereShape = preload("../sphere_shape.gd")
|
||||
|
||||
|
||||
func get_handle_name(_gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> String:
|
||||
return "Radius"
|
||||
|
||||
|
||||
func get_handle_value(gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool) -> Variant:
|
||||
return gizmo.get_node_3d().shape.radius
|
||||
|
||||
|
||||
func set_handle(gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool, camera: Camera3D, screen_pos: Vector2) -> void:
|
||||
var shape_node = gizmo.get_node_3d()
|
||||
var gt := shape_node.get_global_transform()
|
||||
var gt_inverse := gt.affine_inverse()
|
||||
var origin := gt.origin
|
||||
|
||||
var ray_from = camera.project_ray_origin(screen_pos)
|
||||
var ray_to = ray_from + camera.project_ray_normal(screen_pos) * 4096
|
||||
var points = Geometry3D.get_closest_points_between_segments(origin, (Vector3.LEFT * 4096) * gt_inverse, ray_from, ray_to)
|
||||
shape_node.shape.radius = origin.distance_to(points[0])
|
||||
|
||||
|
||||
func commit_handle(gizmo: EditorNode3DGizmo, _handle_id: int, _secondary: bool, restore: Variant, cancel: bool) -> void:
|
||||
var shape: SphereShape = gizmo.get_node_3d().shape
|
||||
if cancel:
|
||||
shape.radius = restore
|
||||
return
|
||||
|
||||
_undo_redo.create_action("Set ScatterShape Radius")
|
||||
_undo_redo.add_undo_method(self, "_set_radius", shape, restore)
|
||||
_undo_redo.add_do_method(self, "_set_radius", shape, shape.radius)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
|
||||
func redraw(plugin: EditorNode3DGizmoPlugin, gizmo: EditorNode3DGizmo):
|
||||
gizmo.clear()
|
||||
|
||||
var scatter_shape = gizmo.get_node_3d()
|
||||
var shape: SphereShape = scatter_shape.shape
|
||||
|
||||
### Draw the 3 circles on each axis to represent the sphere
|
||||
var lines = PackedVector3Array()
|
||||
var lines_material := plugin.get_material("primary_top", gizmo)
|
||||
var steps = 32 # TODO: Update based on sphere radius maybe ?
|
||||
var step_angle = 2 * PI / steps
|
||||
var radius = shape.radius
|
||||
|
||||
for i in steps:
|
||||
lines.append(Vector3(cos(i * step_angle), 0.0, sin(i * step_angle)) * radius)
|
||||
lines.append(Vector3(cos((i + 1) * step_angle), 0.0, sin((i + 1) * step_angle)) * radius)
|
||||
|
||||
if is_selected(gizmo):
|
||||
for i in steps:
|
||||
lines.append(Vector3(cos(i * step_angle), sin(i * step_angle), 0.0) * radius)
|
||||
lines.append(Vector3(cos((i + 1) * step_angle), sin((i + 1) * step_angle), 0.0) * radius)
|
||||
|
||||
for i in steps:
|
||||
lines.append(Vector3(0.0, cos(i * step_angle), sin(i * step_angle)) * radius)
|
||||
lines.append(Vector3(0.0, cos((i + 1) * step_angle), sin((i + 1) * step_angle)) * radius)
|
||||
|
||||
gizmo.add_lines(lines, lines_material)
|
||||
gizmo.add_collision_segments(lines)
|
||||
|
||||
### Draw the handle
|
||||
var handles := PackedVector3Array()
|
||||
var handles_ids := PackedInt32Array()
|
||||
var handles_material := plugin.get_material("default_handle", gizmo)
|
||||
|
||||
var handle_position: Vector3 = Vector3.LEFT * radius
|
||||
handles.push_back(handle_position)
|
||||
|
||||
gizmo.add_handles(handles, handles_material, handles_ids)
|
||||
|
||||
### Fills the sphere inside
|
||||
var mesh = SphereMesh.new()
|
||||
mesh.height = shape.radius * 2.0
|
||||
mesh.radius = shape.radius
|
||||
var mesh_material: StandardMaterial3D
|
||||
if scatter_shape.negative:
|
||||
mesh_material = plugin.get_material("exclusive", gizmo)
|
||||
else:
|
||||
mesh_material = plugin.get_material("inclusive", gizmo)
|
||||
gizmo.add_mesh(mesh, mesh_material)
|
||||
|
||||
|
||||
func _set_radius(sphere: SphereShape, radius: float) -> void:
|
||||
if sphere:
|
||||
sphere.radius = radius
|
||||
249
addons/proton_scatter/src/shapes/path_shape.gd
Normal file
249
addons/proton_scatter/src/shapes/path_shape.gd
Normal file
@@ -0,0 +1,249 @@
|
||||
@tool
|
||||
class_name ProtonScatterPathShape
|
||||
extends ProtonScatterBaseShape
|
||||
|
||||
|
||||
const Bounds := preload("../common/bounds.gd")
|
||||
|
||||
|
||||
@export var closed := true:
|
||||
set(val):
|
||||
closed = val
|
||||
emit_changed()
|
||||
|
||||
@export var thickness := 0.0:
|
||||
set(val):
|
||||
thickness = max(0, val) # Width cannot be negative
|
||||
_half_thickness_squared = pow(thickness * 0.5, 2)
|
||||
emit_changed()
|
||||
|
||||
@export var curve: Curve3D:
|
||||
set(val):
|
||||
# Disconnect previous signal
|
||||
if curve and curve.changed.is_connected(_on_curve_changed):
|
||||
curve.changed.disconnect(_on_curve_changed)
|
||||
|
||||
curve = val
|
||||
curve.changed.connect(_on_curve_changed)
|
||||
emit_changed()
|
||||
|
||||
|
||||
var _polygon: PolygonPathFinder
|
||||
var _half_thickness_squared: float
|
||||
var _bounds: Bounds
|
||||
|
||||
|
||||
func is_point_inside(point: Vector3, global_transform: Transform3D) -> bool:
|
||||
if not _polygon:
|
||||
_update_polygon_from_curve()
|
||||
|
||||
if not _polygon:
|
||||
return false
|
||||
|
||||
point = global_transform.affine_inverse() * point
|
||||
|
||||
if thickness > 0:
|
||||
var closest_point_on_curve: Vector3 = curve.get_closest_point(point)
|
||||
var dist2 = closest_point_on_curve.distance_squared_to(point)
|
||||
if dist2 < _half_thickness_squared:
|
||||
return true
|
||||
|
||||
if closed:
|
||||
return _polygon.is_point_inside(Vector2(point.x, point.z))
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func get_corners_global(gt: Transform3D) -> Array:
|
||||
var res := []
|
||||
|
||||
if not curve:
|
||||
return res
|
||||
|
||||
var half_thickness = thickness * 0.5
|
||||
var corners = [
|
||||
Vector3(-1, -1, -1),
|
||||
Vector3(1, -1, -1),
|
||||
Vector3(1, -1, 1),
|
||||
Vector3(-1, -1, 1),
|
||||
Vector3(-1, 1, -1),
|
||||
Vector3(1, 1, -1),
|
||||
Vector3(1, 1, 1),
|
||||
Vector3(-1, 1, 1),
|
||||
]
|
||||
|
||||
var points = curve.tessellate(3, 10)
|
||||
for p in points:
|
||||
res.push_back(gt * p)
|
||||
|
||||
if thickness > 0:
|
||||
for offset in corners:
|
||||
res.push_back(gt * (p + offset * half_thickness))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
func get_bounds() -> Bounds:
|
||||
if not _bounds:
|
||||
_update_polygon_from_curve()
|
||||
return _bounds
|
||||
|
||||
|
||||
func get_copy():
|
||||
var copy = get_script().new()
|
||||
|
||||
copy.thickness = thickness
|
||||
copy.closed = closed
|
||||
if curve:
|
||||
copy.curve = curve.duplicate()
|
||||
|
||||
return copy
|
||||
|
||||
|
||||
func copy_from(source) -> void:
|
||||
thickness = source.thickness
|
||||
if source.curve:
|
||||
curve = source.curve.duplicate() # TODO, update signals
|
||||
|
||||
|
||||
# TODO: create points in the middle of the path
|
||||
func create_point(position: Vector3) -> void:
|
||||
if not curve:
|
||||
curve = Curve3D.new()
|
||||
|
||||
curve.add_point(position)
|
||||
|
||||
|
||||
func remove_point(index):
|
||||
if index > curve.get_point_count() - 1:
|
||||
return
|
||||
curve.remove_point(index)
|
||||
|
||||
|
||||
func get_closest_to(position):
|
||||
if curve.get_point_count() == 0:
|
||||
return -1
|
||||
|
||||
var closest = -1
|
||||
var dist_squared = -1
|
||||
|
||||
for i in curve.get_point_count():
|
||||
var point_pos: Vector3 = curve.get_point_position(i)
|
||||
var point_dist: float = point_pos.distance_squared_to(position)
|
||||
|
||||
if (closest == -1) or (dist_squared > point_dist):
|
||||
closest = i
|
||||
dist_squared = point_dist
|
||||
|
||||
var threshold = 16 # Ignore if the closest point is farther than this
|
||||
if dist_squared >= threshold:
|
||||
return -1
|
||||
|
||||
return closest
|
||||
|
||||
|
||||
func get_closed_edges(shape_t: Transform3D) -> Array[PackedVector2Array]:
|
||||
if not closed and thickness <= 0:
|
||||
return []
|
||||
|
||||
if not curve:
|
||||
return []
|
||||
|
||||
var edges: Array[PackedVector2Array] = []
|
||||
var polyline := PackedVector2Array()
|
||||
var shape_t_inverse := shape_t.affine_inverse()
|
||||
var points := curve.tessellate(5, 5) # TODO: find optimal values
|
||||
|
||||
for p in points:
|
||||
p *= shape_t_inverse # Apply the shape node transform
|
||||
polyline.push_back(Vector2(p.x, p.z))
|
||||
|
||||
if closed:
|
||||
# Ensure the polygon is closed
|
||||
var first_point: Vector3 = points[0]
|
||||
var last_point: Vector3 = points[-1]
|
||||
|
||||
if first_point != last_point:
|
||||
first_point *= shape_t_inverse
|
||||
polyline.push_back(Vector2(first_point.x, first_point.z))
|
||||
|
||||
# Prevents the polyline to be considered as a hole later.
|
||||
if Geometry2D.is_polygon_clockwise(polyline):
|
||||
polyline.reverse()
|
||||
|
||||
# Expand the polyline to get the outer edge of the path.
|
||||
if thickness > 0:
|
||||
# WORKAROUND. We cant specify the round end caps resolution, but it's tied to the polyline
|
||||
# size. So we scale everything up before calling offset_polyline(), then scale the result
|
||||
# down so we get rounder caps.
|
||||
var scale = 5.0 * thickness
|
||||
var delta = (thickness / 2.0) * scale
|
||||
|
||||
var t2 = Transform2D().scaled(Vector2.ONE * scale)
|
||||
var result := Geometry2D.offset_polyline(polyline * t2, delta, Geometry2D.JOIN_ROUND, Geometry2D.END_ROUND)
|
||||
|
||||
t2 = Transform2D().scaled(Vector2.ONE * (1.0 / scale))
|
||||
for polygon in result:
|
||||
edges.push_back(polygon * t2)
|
||||
|
||||
if closed and thickness == 0.0:
|
||||
edges.push_back(polyline)
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
func get_open_edges(shape_t: Transform3D) -> Array[Curve3D]:
|
||||
if not curve or closed or thickness > 0:
|
||||
return []
|
||||
|
||||
var res := Curve3D.new()
|
||||
var shape_t_inverse := shape_t.affine_inverse()
|
||||
|
||||
for i in curve.get_point_count():
|
||||
var pos = curve.get_point_position(i)
|
||||
var pos_t = pos * shape_t_inverse
|
||||
var p_in = (curve.get_point_in(i) + pos) * shape_t_inverse - pos_t
|
||||
var p_out = (curve.get_point_out(i) + pos) * shape_t_inverse - pos_t
|
||||
res.add_point(pos_t, p_in, p_out)
|
||||
|
||||
return [res]
|
||||
|
||||
|
||||
func _update_polygon_from_curve() -> void:
|
||||
var connections = PackedInt32Array()
|
||||
var polygon_points = PackedVector2Array()
|
||||
|
||||
if not _bounds:
|
||||
_bounds = Bounds.new()
|
||||
|
||||
_bounds.clear()
|
||||
_polygon = PolygonPathFinder.new()
|
||||
|
||||
if not curve:
|
||||
curve = Curve3D.new()
|
||||
|
||||
if curve.get_point_count() == 0:
|
||||
return
|
||||
|
||||
var baked_points = curve.tessellate(4, 6)
|
||||
var steps := baked_points.size()
|
||||
|
||||
for i in baked_points.size():
|
||||
var point = baked_points[i]
|
||||
var projected_point = Vector2(point.x, point.z)
|
||||
_bounds.feed(point)
|
||||
|
||||
polygon_points.push_back(projected_point)
|
||||
connections.append(i)
|
||||
if i == steps - 1:
|
||||
connections.append(0)
|
||||
else:
|
||||
connections.append(i + 1)
|
||||
|
||||
_bounds.compute_bounds()
|
||||
_polygon.setup(polygon_points, connections)
|
||||
|
||||
|
||||
func _on_curve_changed() -> void:
|
||||
_update_polygon_from_curve()
|
||||
emit_changed()
|
||||
71
addons/proton_scatter/src/shapes/sphere_shape.gd
Normal file
71
addons/proton_scatter/src/shapes/sphere_shape.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
@tool
|
||||
class_name ProtonScatterSphereShape
|
||||
extends ProtonScatterBaseShape
|
||||
|
||||
|
||||
@export var radius := 1.0:
|
||||
set(val):
|
||||
radius = val
|
||||
_radius_squared = val * val
|
||||
emit_changed()
|
||||
|
||||
var _radius_squared := 0.0
|
||||
|
||||
|
||||
func get_copy():
|
||||
var copy = ProtonScatterSphereShape.new()
|
||||
copy.radius = radius
|
||||
return copy
|
||||
|
||||
|
||||
func is_point_inside(point: Vector3, global_transform: Transform3D) -> bool:
|
||||
var shape_center = global_transform * Vector3.ZERO
|
||||
return shape_center.distance_squared_to(point) < _radius_squared
|
||||
|
||||
|
||||
func get_corners_global(gt: Transform3D) -> Array:
|
||||
var res := []
|
||||
|
||||
var corners := [
|
||||
Vector3(-1, -1, -1),
|
||||
Vector3(-1, -1, 1),
|
||||
Vector3(1, -1, 1),
|
||||
Vector3(1, -1, -1),
|
||||
Vector3(-1, 1, -1),
|
||||
Vector3(-1, 1, 1),
|
||||
Vector3(1, 1, 1),
|
||||
Vector3(1, 1, -1),
|
||||
]
|
||||
|
||||
for c in corners:
|
||||
c *= radius
|
||||
res.push_back(gt * c)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
|
||||
# Returns the circle matching the intersection between the scatter node XZ plane
|
||||
# and the sphere. Returns an empty array if there's no intersection.
|
||||
func get_closed_edges(shape_t: Transform3D) -> Array[PackedVector2Array]:
|
||||
var edge := PackedVector2Array()
|
||||
var plane := Plane(Vector3.UP, 0.0)
|
||||
|
||||
var sphere_center := shape_t.origin
|
||||
var dist2plane = plane.distance_to(sphere_center)
|
||||
var radius_at_ground_level := sqrt(pow(radius, 2) - pow(dist2plane, 2))
|
||||
|
||||
# No intersection with plane
|
||||
if radius_at_ground_level <= 0.0 or radius_at_ground_level > radius:
|
||||
return []
|
||||
|
||||
var origin := Vector2(sphere_center.x, sphere_center.z)
|
||||
var steps: int = max(16, int(radius_at_ground_level * 12))
|
||||
var angle: float = TAU / steps
|
||||
|
||||
for i in steps + 1:
|
||||
var theta = angle * i
|
||||
var point := origin + Vector2(cos(theta), sin(theta)) * radius_at_ground_level
|
||||
edge.push_back(point)
|
||||
|
||||
return [edge]
|
||||
@@ -0,0 +1,15 @@
|
||||
@tool
|
||||
extends EditorProperty
|
||||
|
||||
|
||||
var _ui: Control
|
||||
|
||||
|
||||
func _init():
|
||||
_ui = preload("./ui/stack_panel.tscn").instantiate()
|
||||
add_child(_ui)
|
||||
set_bottom_editor(_ui)
|
||||
|
||||
|
||||
func set_node(object) -> void:
|
||||
_ui.set_node(object)
|
||||
@@ -0,0 +1,19 @@
|
||||
@tool
|
||||
extends EditorInspectorPlugin
|
||||
|
||||
|
||||
const Editor = preload("./editor_property.gd")
|
||||
const Scatter = preload("../../scatter.gd")
|
||||
|
||||
|
||||
func _can_handle(object):
|
||||
return is_instance_of(object, Scatter)
|
||||
|
||||
|
||||
func _parse_property(object, type, name, hint_type, hint_string, usage_flags, wide):
|
||||
if name == "modifier_stack":
|
||||
var editor_property = Editor.new()
|
||||
editor_property.set_node(object)
|
||||
add_property_editor("modifier_stack", editor_property)
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,19 @@
|
||||
@tool
|
||||
extends Button
|
||||
|
||||
|
||||
@onready var _popup: PopupPanel = $ModifiersPopup
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_popup.popup_hide.connect(_on_popup_closed)
|
||||
|
||||
|
||||
func _toggled(button_pressed):
|
||||
if button_pressed:
|
||||
_popup.position = global_position + Vector2(0.0, size.y)
|
||||
_popup.popup()
|
||||
|
||||
|
||||
func _on_popup_closed() -> void:
|
||||
button_pressed = false
|
||||
@@ -0,0 +1,59 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
|
||||
signal value_changed
|
||||
|
||||
|
||||
var _scatter
|
||||
var _previous
|
||||
var _locked := false
|
||||
|
||||
|
||||
func set_scatter(scatter_node) -> void:
|
||||
_scatter = scatter_node
|
||||
|
||||
|
||||
func set_parameter_name(_text: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_hint_string(_hint: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_value(val) -> void:
|
||||
_locked = true
|
||||
_set_value(val)
|
||||
_previous = get_value()
|
||||
_locked = false
|
||||
|
||||
|
||||
func get_value():
|
||||
pass
|
||||
|
||||
|
||||
func get_editor_theme() -> Theme:
|
||||
if not _scatter:
|
||||
return ThemeDB.get_default_theme()
|
||||
|
||||
var editor_interface: Variant
|
||||
|
||||
if Engine.get_version_info().minor >= 2:
|
||||
editor_interface = EditorInterface
|
||||
return editor_interface.get_editor_theme()
|
||||
else:
|
||||
editor_interface = _scatter.editor_plugin.get_editor_interface()
|
||||
return editor_interface.get_base_control().get_theme()
|
||||
|
||||
|
||||
func _set_value(_val):
|
||||
pass
|
||||
|
||||
|
||||
func _on_value_changed(_val) -> void:
|
||||
if not _locked:
|
||||
var value = get_value()
|
||||
if value != _previous:
|
||||
value_changed.emit(value, _previous)
|
||||
_previous = value
|
||||
@@ -0,0 +1,51 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://cf4lrr5tnlwnw"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lylt6"]
|
||||
content_margin_left = 0.0
|
||||
content_margin_top = 0.0
|
||||
content_margin_right = 0.0
|
||||
content_margin_bottom = 0.0
|
||||
bg_color = Color(1, 1, 1, 0.54902)
|
||||
corner_radius_top_left = 2
|
||||
corner_radius_top_right = 2
|
||||
corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8hejw"]
|
||||
content_margin_left = 0.0
|
||||
content_margin_top = 0.0
|
||||
content_margin_right = 0.0
|
||||
content_margin_bottom = 0.0
|
||||
bg_color = Color(1, 1, 1, 0.784314)
|
||||
corner_radius_top_left = 2
|
||||
corner_radius_top_right = 2
|
||||
corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dmtgy"]
|
||||
content_margin_left = 0.0
|
||||
content_margin_top = 0.0
|
||||
content_margin_right = 0.0
|
||||
content_margin_bottom = 0.0
|
||||
bg_color = Color(1, 1, 1, 1)
|
||||
corner_radius_top_left = 2
|
||||
corner_radius_top_right = 2
|
||||
corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 6
|
||||
|
||||
[node name="Button" type="Button"]
|
||||
custom_minimum_size = Vector2(20, 20)
|
||||
size_flags_horizontal = 3
|
||||
focus_mode = 0
|
||||
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
||||
theme_override_colors/font_pressed_color = Color(0, 0, 0, 1)
|
||||
theme_override_colors/font_hover_color = Color(0, 0, 0, 1)
|
||||
theme_override_font_sizes/font_size = 12
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_lylt6")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_8hejw")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_dmtgy")
|
||||
toggle_mode = true
|
||||
text = "00"
|
||||
@@ -0,0 +1,347 @@
|
||||
# warning-ignore-all:return_value_discarded
|
||||
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
|
||||
signal curve_updated
|
||||
|
||||
|
||||
@export var grid_color := Color(1, 1, 1, 0.2)
|
||||
@export var grid_color_sub := Color(1, 1, 1, 0.1)
|
||||
@export var curve_color := Color(1, 1, 1, 0.9)
|
||||
@export var point_color := Color.WHITE
|
||||
@export var selected_point_color := Color.ORANGE
|
||||
@export var point_radius := 4.0
|
||||
@export var text_color := Color(0.9, 0.9, 0.9)
|
||||
@export var columns := 4
|
||||
@export var rows := 2
|
||||
@export var dynamic_row_count := true
|
||||
|
||||
var curve: Curve
|
||||
var gt: Transform2D
|
||||
|
||||
var _hover_point := -1:
|
||||
set(val):
|
||||
if val != _hover_point:
|
||||
_hover_point = val
|
||||
queue_redraw()
|
||||
|
||||
var _selected_point := -1:
|
||||
set(val):
|
||||
if val != _selected_point:
|
||||
_selected_point = val
|
||||
queue_redraw()
|
||||
|
||||
var _selected_tangent := -1:
|
||||
set(val):
|
||||
if val != _selected_tangent:
|
||||
_selected_tangent = val
|
||||
queue_redraw()
|
||||
|
||||
var _dragging := false
|
||||
var _hover_radius := 50.0 # Squared
|
||||
var _tangents_length := 30.0
|
||||
var _font: Font
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
#rect_min_size.y *= EditorUtil.get_editor_scale()
|
||||
var plugin := EditorPlugin.new()
|
||||
var editor_theme := plugin.get_editor_interface().get_base_control().get_theme()
|
||||
if editor_theme:
|
||||
_font = editor_theme.get_font("Main", "EditorFonts")
|
||||
else:
|
||||
_font = ThemeDB.fallback_font
|
||||
plugin.queue_free()
|
||||
|
||||
queue_redraw()
|
||||
connect("resized", _on_resized)
|
||||
|
||||
|
||||
func set_curve(c: Curve) -> void:
|
||||
curve = c
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_curve() -> Curve:
|
||||
return curve
|
||||
|
||||
|
||||
func _gui_input(event) -> void:
|
||||
if event is InputEventKey:
|
||||
if _selected_point != -1 and event.scancode == KEY_DELETE:
|
||||
remove_point(_selected_point)
|
||||
|
||||
elif event is InputEventMouseButton:
|
||||
if event.double_click:
|
||||
add_point(_to_curve_space(event.position))
|
||||
|
||||
elif event.pressed and event.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
var i = get_point_at(event.position)
|
||||
if i != -1:
|
||||
remove_point(i)
|
||||
|
||||
elif event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
set_selected_tangent(get_tangent_at(event.position))
|
||||
|
||||
if _selected_tangent == -1:
|
||||
set_selected_point(get_point_at(event.position))
|
||||
if _selected_point != -1:
|
||||
_dragging = true
|
||||
|
||||
elif _dragging and not event.pressed:
|
||||
_dragging = false
|
||||
emit_signal("curve_updated")
|
||||
|
||||
elif event is InputEventMouseMotion:
|
||||
if _dragging:
|
||||
var curve_amplitude: float = curve.get_max_value() - curve.get_min_value()
|
||||
|
||||
# Snap to "round" coordinates when holding Ctrl.
|
||||
# Be more precise when holding Shift as well.
|
||||
var snap_threshold: float
|
||||
if event.ctrl_pressed:
|
||||
snap_threshold = 0.025 if event.shift else 0.1
|
||||
else:
|
||||
snap_threshold = 0.0
|
||||
|
||||
if _selected_tangent == -1: # Drag point
|
||||
var point_pos: Vector2 = _to_curve_space(event.position).snapped(Vector2(snap_threshold, snap_threshold * curve_amplitude))
|
||||
|
||||
# The index may change if the point is dragged across another one
|
||||
var i: int = curve.set_point_offset(_selected_point, point_pos.x)
|
||||
set_hover(i)
|
||||
set_selected_point(i)
|
||||
|
||||
# This is to prevent the user from losing a point out of view.
|
||||
if point_pos.y < curve.get_min_value():
|
||||
point_pos.y = curve.get_min_value()
|
||||
elif point_pos.y > curve.get_max_value():
|
||||
point_pos.y = curve.get_max_value()
|
||||
|
||||
curve.set_point_value(_selected_point, point_pos.y)
|
||||
|
||||
else: # Drag tangent
|
||||
var point_pos: Vector2 = curve.get_point_position(_selected_point)
|
||||
var control_pos: Vector2 = _to_curve_space(event.position).snapped(Vector2(snap_threshold, snap_threshold * curve_amplitude))
|
||||
|
||||
var dir: Vector2 = (control_pos - point_pos).normalized()
|
||||
|
||||
var tangent: float
|
||||
if not is_zero_approx(dir.x):
|
||||
tangent = dir.y / dir.x
|
||||
else:
|
||||
tangent = 1 if dir.y >= 0 else -1
|
||||
tangent *= 9999
|
||||
|
||||
var link: bool = not Input.is_key_pressed(KEY_SHIFT)
|
||||
|
||||
if _selected_tangent == 0:
|
||||
curve.set_point_left_tangent(_selected_point, tangent)
|
||||
|
||||
# Note: if a tangent is set to linear, it shouldn't be linked to the other
|
||||
if link and _selected_point != (curve.get_point_count() - 1) and curve.get_point_right_mode(_selected_point) != Curve.TANGENT_LINEAR:
|
||||
curve.set_point_right_tangent(_selected_point, tangent)
|
||||
|
||||
else:
|
||||
curve.set_point_right_tangent(_selected_point, tangent)
|
||||
|
||||
if link and _selected_point != 0 and curve.get_point_left_mode(_selected_point) != Curve.TANGENT_LINEAR:
|
||||
curve.set_point_left_tangent(_selected_point, tangent)
|
||||
queue_redraw()
|
||||
else:
|
||||
set_hover(get_point_at(event.position))
|
||||
|
||||
|
||||
func add_point(pos: Vector2) -> void:
|
||||
if not curve:
|
||||
return
|
||||
|
||||
pos.y = clamp(pos.y, 0.0, 1.0)
|
||||
curve.add_point(pos)
|
||||
queue_redraw()
|
||||
emit_signal("curve_updated")
|
||||
|
||||
|
||||
func remove_point(idx: int) -> void:
|
||||
if not curve:
|
||||
return
|
||||
|
||||
if idx == _selected_point:
|
||||
set_selected_point(-1)
|
||||
|
||||
if idx == _hover_point:
|
||||
set_hover(-1)
|
||||
|
||||
curve.remove_point(idx)
|
||||
queue_redraw()
|
||||
emit_signal("curve_updated")
|
||||
|
||||
|
||||
func get_point_at(pos: Vector2) -> int:
|
||||
if not curve:
|
||||
return -1
|
||||
|
||||
for i in curve.get_point_count():
|
||||
var p := _to_view_space(curve.get_point_position(i))
|
||||
if p.distance_squared_to(pos) <= _hover_radius:
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
func get_tangent_at(pos: Vector2) -> int:
|
||||
if not curve or _selected_point < 0:
|
||||
return -1
|
||||
|
||||
if _selected_point != 0:
|
||||
var control_pos: Vector2 = _get_tangent_view_pos(_selected_point, 0)
|
||||
if control_pos.distance_squared_to(pos) < _hover_radius:
|
||||
return 0
|
||||
|
||||
if _selected_point != curve.get_point_count() - 1:
|
||||
var control_pos = _get_tangent_view_pos(_selected_point, 1)
|
||||
if control_pos.distance_squared_to(pos) < _hover_radius:
|
||||
return 1
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not curve:
|
||||
return
|
||||
|
||||
var text_height = _font.get_height()
|
||||
var min_outer := Vector2(0, size.y)
|
||||
var max_outer := Vector2(size.x, 0)
|
||||
var min_inner := Vector2(text_height, size.y - text_height)
|
||||
var max_inner := Vector2(size.x - text_height, text_height)
|
||||
|
||||
var width: float = max_inner.x - min_inner.x
|
||||
var height: float = max_inner.y - min_inner.y
|
||||
|
||||
var curve_min: float = curve.get_min_value()
|
||||
var curve_max: float = curve.get_max_value()
|
||||
|
||||
|
||||
# Main area
|
||||
draw_line(Vector2(0, max_inner.y), Vector2(max_outer.x, max_inner.y), grid_color)
|
||||
draw_line(Vector2(0, min_inner.y), Vector2(max_outer.x, min_inner.y), grid_color)
|
||||
draw_line(Vector2(min_inner.x, max_outer.y), Vector2(min_inner.x, min_outer.y), grid_color)
|
||||
draw_line(Vector2(max_inner.x, max_outer.y), Vector2(max_inner.x, min_outer.y), grid_color)
|
||||
|
||||
# Grid and scale
|
||||
## Vertical lines
|
||||
var x_offset = 1.0 / columns
|
||||
var margin = 4
|
||||
|
||||
for i in columns + 1:
|
||||
var x = width * (i * x_offset) + min_inner.x
|
||||
draw_line(Vector2(x, max_outer.y), Vector2(x, min_outer.y), grid_color_sub)
|
||||
draw_string(_font, Vector2(x + margin, min_outer.y - margin), str(snapped(i * x_offset, 0.01)), 0, -1, -1, text_color)
|
||||
|
||||
## Horizontal lines
|
||||
var y_offset = 1.0 / rows
|
||||
|
||||
for i in rows + 1:
|
||||
var y = height * (i * y_offset) + min_inner.y
|
||||
draw_line(Vector2(min_outer.x, y), Vector2(max_outer.x, y), grid_color_sub)
|
||||
var y_value = i * ((curve_max - curve_min) / rows) + curve_min
|
||||
draw_string(_font, Vector2(min_inner.x + margin, y - margin), str(snapped(y_value, 0.01)), 0, -1, -1, text_color)
|
||||
|
||||
# Plot curve
|
||||
var steps = 100
|
||||
var offset = 1.0 / steps
|
||||
x_offset = width / steps
|
||||
|
||||
var a: float
|
||||
var a_y: float
|
||||
var b: float
|
||||
var b_y: float
|
||||
|
||||
a = curve.sample_baked(0.0)
|
||||
a_y = remap(a, curve_min, curve_max, min_inner.y, max_inner.y)
|
||||
|
||||
for i in steps - 1:
|
||||
b = curve.sample_baked((i + 1) * offset)
|
||||
b_y = remap(b, curve_min, curve_max, min_inner.y, max_inner.y)
|
||||
draw_line(Vector2(min_inner.x + x_offset * i, a_y), Vector2(min_inner.x + x_offset * (i + 1), b_y), curve_color)
|
||||
a_y = b_y
|
||||
|
||||
# Draw points
|
||||
for i in curve.get_point_count():
|
||||
var pos: Vector2 = _to_view_space(curve.get_point_position(i))
|
||||
if _selected_point == i:
|
||||
draw_circle(pos, point_radius, selected_point_color)
|
||||
else:
|
||||
draw_circle(pos, point_radius, point_color);
|
||||
|
||||
if _hover_point == i:
|
||||
draw_arc(pos, point_radius + 4.0, 0.0, 2 * PI, 12, point_color, 1.0, true)
|
||||
|
||||
# Draw tangents
|
||||
if _selected_point >= 0:
|
||||
var i: int = _selected_point
|
||||
var pos: Vector2 = _to_view_space(curve.get_point_position(i))
|
||||
|
||||
if i != 0:
|
||||
var control_pos: Vector2 = _get_tangent_view_pos(i, 0)
|
||||
draw_line(pos, control_pos, selected_point_color)
|
||||
draw_rect(Rect2(control_pos, Vector2(1, 1)).grow(2), selected_point_color)
|
||||
|
||||
if i != curve.get_point_count() - 1:
|
||||
var control_pos: Vector2 = _get_tangent_view_pos(i, 1)
|
||||
draw_line(pos, control_pos, selected_point_color)
|
||||
draw_rect(Rect2(control_pos, Vector2(1, 1)).grow(2), selected_point_color)
|
||||
|
||||
|
||||
func _to_view_space(pos: Vector2) -> Vector2:
|
||||
var h = _font.get_height()
|
||||
pos.x = remap(pos.x, 0.0, 1.0, h, size.x - h)
|
||||
pos.y = remap(pos.y, curve.get_min_value(), curve.get_max_value(), size.y - h, h)
|
||||
return pos
|
||||
|
||||
|
||||
func _to_curve_space(pos: Vector2) -> Vector2:
|
||||
var h = _font.get_height()
|
||||
pos.x = remap(pos.x, h, size.x - h, 0.0, 1.0)
|
||||
pos.y = remap(pos.y, size.y - h, h, curve.get_min_value(), curve.get_max_value())
|
||||
return pos
|
||||
|
||||
|
||||
func _get_tangent_view_pos(i: int, tangent: int) -> Vector2:
|
||||
var dir: Vector2
|
||||
|
||||
if tangent == 0:
|
||||
dir = -Vector2(1.0, curve.get_point_left_tangent(i))
|
||||
else:
|
||||
dir = Vector2(1.0, curve.get_point_right_tangent(i))
|
||||
|
||||
var point_pos = _to_view_space(curve.get_point_position(i))
|
||||
var control_pos = _to_view_space(curve.get_point_position(i) + dir)
|
||||
|
||||
return point_pos + _tangents_length * (control_pos - point_pos).normalized()
|
||||
|
||||
|
||||
func set_hover(val: int) -> void:
|
||||
if val != _hover_point:
|
||||
_hover_point = val
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_selected_point(val: int) -> void:
|
||||
if val != _selected_point:
|
||||
_selected_point = val
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_selected_tangent(val: int) -> void:
|
||||
if val != _selected_tangent:
|
||||
_selected_tangent = val
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_resized() -> void:
|
||||
if dynamic_row_count:
|
||||
rows = (int(size.y / custom_minimum_size.y) + 1) * 2
|
||||
@@ -0,0 +1,23 @@
|
||||
@tool
|
||||
extends "../base_parameter.gd"
|
||||
|
||||
|
||||
var _button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_button = get_node("Button")
|
||||
_button.toggled.connect(_on_value_changed)
|
||||
|
||||
|
||||
func enable(enabled: bool) -> void:
|
||||
_button.disabled = not enabled
|
||||
_button.flat = not enabled
|
||||
|
||||
|
||||
func get_value() -> bool:
|
||||
return _button.button_pressed
|
||||
|
||||
|
||||
func _set_value(val: bool) -> void:
|
||||
_button.button_pressed = val
|
||||
@@ -0,0 +1,19 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://w6ycb4oveqhd"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/header/parameter_button.gd" id="1_f6puy"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/toggle_button.gd" id="2_167vc"]
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer"]
|
||||
offset_right = 40.0
|
||||
offset_bottom = 40.0
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
script = ExtResource( "1_f6puy" )
|
||||
|
||||
[node name="Button" type="Button" parent="."]
|
||||
offset_right = 40.0
|
||||
offset_bottom = 40.0
|
||||
focus_mode = 0
|
||||
toggle_mode = true
|
||||
icon_alignment = 1
|
||||
script = ExtResource( "2_167vc" )
|
||||
@@ -0,0 +1,17 @@
|
||||
@tool
|
||||
extends "../base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _spinbox = $SpinBox
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_spinbox.value_changed.connect(_on_value_changed)
|
||||
|
||||
|
||||
func get_value() -> int:
|
||||
return int(_spinbox.get_value())
|
||||
|
||||
|
||||
func _set_value(val: int) -> void:
|
||||
_spinbox.set_value(val)
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c36gqn03pvlnr"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/header/parameter_spinbox.gd" id="1_f0oq6"]
|
||||
|
||||
[node name="ParameterSpinbox" type="MarginContainer"]
|
||||
offset_right = 83.0625
|
||||
offset_bottom = 31.0
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
script = ExtResource( "1_f0oq6" )
|
||||
|
||||
[node name="SpinBox" type="SpinBox" parent="."]
|
||||
offset_right = 83.0
|
||||
offset_bottom = 31.0
|
||||
min_value = -100.0
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
@@ -0,0 +1,131 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _grid_1: Control = $%GridContainer1
|
||||
@onready var _grid_2: Control = $%GridContainer2
|
||||
@onready var _grid_3: Control = $%GridContainer3
|
||||
@onready var _grid_4: Control = $%GridContainer4
|
||||
@onready var _menu_button: MenuButton = $%MenuButton
|
||||
|
||||
var _buttons: Array[Button]
|
||||
var _popup: PopupMenu
|
||||
var _layer_count := 32
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_buttons = []
|
||||
var grids = [_grid_1, _grid_2, _grid_3, _grid_4]
|
||||
|
||||
for g in grids:
|
||||
for c in g.get_children():
|
||||
if c is Button:
|
||||
var layer_number = c.text.to_int()
|
||||
if layer_number > _layer_count:
|
||||
c.visible = false
|
||||
continue
|
||||
_buttons.push_front(c)
|
||||
c.focus_mode = Control.FOCUS_NONE
|
||||
c.pressed.connect(_on_button_pressed)
|
||||
|
||||
_popup = _menu_button.get_popup()
|
||||
_popup.clear()
|
||||
|
||||
var layer_name := ""
|
||||
for i in _layer_count:
|
||||
if i != 0 and i % 4 == 0:
|
||||
_popup.add_separator("", 100 + i)
|
||||
|
||||
layer_name = ProjectSettings.get_setting("layer_names/3d_physics/layer_" + str(i + 1))
|
||||
if layer_name.is_empty():
|
||||
layer_name = "Layer " + str(i + 1)
|
||||
_popup.add_check_item(layer_name, _layer_count - 1 - i)
|
||||
|
||||
_sync_popup_state()
|
||||
_popup.id_pressed.connect(_on_id_pressed)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func _set_value(val: int) -> void:
|
||||
var binary_string: String = _dec2bin(val)
|
||||
var length = binary_string.length()
|
||||
|
||||
if length < _layer_count:
|
||||
binary_string = binary_string.pad_zeros(_layer_count)
|
||||
elif length > _layer_count:
|
||||
binary_string = binary_string.substr(length - _layer_count, length)
|
||||
|
||||
for i in _layer_count:
|
||||
_buttons[i].button_pressed = binary_string[i] == "1"
|
||||
|
||||
_sync_popup_state()
|
||||
|
||||
|
||||
func get_value() -> int:
|
||||
var binary_string = ""
|
||||
for b in _buttons:
|
||||
binary_string += "1" if b.button_pressed else "0"
|
||||
|
||||
var val = _bin2dec(binary_string)
|
||||
return val
|
||||
|
||||
|
||||
func _dec2bin(value: int) -> String:
|
||||
if value == 0:
|
||||
return "0"
|
||||
|
||||
var binary_string = ""
|
||||
while value != 0:
|
||||
var m = value % 2
|
||||
binary_string = str(m) + binary_string
|
||||
# warning-ignore:integer_division
|
||||
value = value / 2
|
||||
|
||||
return binary_string
|
||||
|
||||
|
||||
func _bin2dec(binary_string: String) -> int:
|
||||
var decimal_value = 0
|
||||
var count = binary_string.length() - 1
|
||||
|
||||
for i in binary_string.length():
|
||||
decimal_value += pow(2, count) * binary_string[i].to_int()
|
||||
count -= 1
|
||||
|
||||
return decimal_value
|
||||
|
||||
|
||||
func _sync_popup_state() -> void:
|
||||
if not _popup:
|
||||
return
|
||||
|
||||
for i in _layer_count:
|
||||
var idx = _popup.get_item_index(i)
|
||||
_popup.set_item_checked(idx, _buttons[i].button_pressed)
|
||||
|
||||
|
||||
func _on_button_pressed() -> void:
|
||||
_on_value_changed(null)
|
||||
_sync_popup_state()
|
||||
|
||||
|
||||
func _on_id_pressed(id: int) -> void:
|
||||
var idx = _popup.get_item_index(id)
|
||||
var checked = not _popup.is_item_checked(idx)
|
||||
_buttons[id].button_pressed = checked
|
||||
_popup.set_item_checked(idx, checked)
|
||||
_on_button_pressed()
|
||||
|
||||
|
||||
func _on_enable_all_pressed() -> void:
|
||||
_set_value(4294967295)
|
||||
_on_value_changed(null)
|
||||
|
||||
|
||||
func _on_clear_pressed() -> void:
|
||||
_set_value(0)
|
||||
_on_value_changed(null)
|
||||
@@ -0,0 +1,335 @@
|
||||
[gd_scene load_steps=6 format=3 uid="uid://chondv2lhs4pl"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_bitmask.gd" id="1"]
|
||||
[ext_resource type="PackedScene" uid="uid://cf4lrr5tnlwnw" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/bitmask_button.tscn" id="2"]
|
||||
[ext_resource type="Texture2D" uid="uid://n66mufjib4ds" path="res://addons/proton_scatter/icons/menu.svg" id="3"]
|
||||
[ext_resource type="Texture2D" uid="uid://bosx22dy64f11" path="res://addons/proton_scatter/icons/clear.svg" id="4"]
|
||||
[ext_resource type="Texture2D" uid="uid://uytbptu3a34s" path="res://addons/proton_scatter/icons/select_all.svg" id="4_h30jm"]
|
||||
|
||||
[node name="parameter_bitmask" type="VBoxContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 178.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
layout_mode = 2
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
alignment = 2
|
||||
|
||||
[node name="MenuButton" type="MenuButton" parent="MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
icon = ExtResource("3")
|
||||
item_count = 39
|
||||
popup/item_0/text = "Layer 1"
|
||||
popup/item_0/checkable = 1
|
||||
popup/item_0/id = 31
|
||||
popup/item_1/text = "Layer 2"
|
||||
popup/item_1/checkable = 1
|
||||
popup/item_1/id = 30
|
||||
popup/item_2/text = "Layer 3"
|
||||
popup/item_2/checkable = 1
|
||||
popup/item_2/id = 29
|
||||
popup/item_3/text = "Layer 4"
|
||||
popup/item_3/checkable = 1
|
||||
popup/item_3/id = 28
|
||||
popup/item_4/text = ""
|
||||
popup/item_4/id = 104
|
||||
popup/item_4/separator = true
|
||||
popup/item_5/text = "Layer 5"
|
||||
popup/item_5/checkable = 1
|
||||
popup/item_5/id = 27
|
||||
popup/item_6/text = "Layer 6"
|
||||
popup/item_6/checkable = 1
|
||||
popup/item_6/id = 26
|
||||
popup/item_7/text = "Layer 7"
|
||||
popup/item_7/checkable = 1
|
||||
popup/item_7/id = 25
|
||||
popup/item_8/text = "Layer 8"
|
||||
popup/item_8/checkable = 1
|
||||
popup/item_8/id = 24
|
||||
popup/item_9/text = ""
|
||||
popup/item_9/id = 108
|
||||
popup/item_9/separator = true
|
||||
popup/item_10/text = "Layer 9"
|
||||
popup/item_10/checkable = 1
|
||||
popup/item_10/id = 23
|
||||
popup/item_11/text = "Layer 10"
|
||||
popup/item_11/checkable = 1
|
||||
popup/item_11/id = 22
|
||||
popup/item_12/text = "Layer 11"
|
||||
popup/item_12/checkable = 1
|
||||
popup/item_12/id = 21
|
||||
popup/item_13/text = "Layer 12"
|
||||
popup/item_13/checkable = 1
|
||||
popup/item_13/id = 20
|
||||
popup/item_14/text = ""
|
||||
popup/item_14/id = 112
|
||||
popup/item_14/separator = true
|
||||
popup/item_15/text = "Layer 13"
|
||||
popup/item_15/checkable = 1
|
||||
popup/item_15/id = 19
|
||||
popup/item_16/text = "Layer 14"
|
||||
popup/item_16/checkable = 1
|
||||
popup/item_16/id = 18
|
||||
popup/item_17/text = "Layer 15"
|
||||
popup/item_17/checkable = 1
|
||||
popup/item_17/id = 17
|
||||
popup/item_18/text = "Layer 16"
|
||||
popup/item_18/checkable = 1
|
||||
popup/item_18/id = 16
|
||||
popup/item_19/text = ""
|
||||
popup/item_19/id = 116
|
||||
popup/item_19/separator = true
|
||||
popup/item_20/text = "Layer 17"
|
||||
popup/item_20/checkable = 1
|
||||
popup/item_20/id = 15
|
||||
popup/item_21/text = "Layer 18"
|
||||
popup/item_21/checkable = 1
|
||||
popup/item_21/id = 14
|
||||
popup/item_22/text = "Layer 19"
|
||||
popup/item_22/checkable = 1
|
||||
popup/item_22/id = 13
|
||||
popup/item_23/text = "Layer 20"
|
||||
popup/item_23/checkable = 1
|
||||
popup/item_23/id = 12
|
||||
popup/item_24/text = ""
|
||||
popup/item_24/id = 120
|
||||
popup/item_24/separator = true
|
||||
popup/item_25/text = "Layer 21"
|
||||
popup/item_25/checkable = 1
|
||||
popup/item_25/id = 11
|
||||
popup/item_26/text = "Layer 22"
|
||||
popup/item_26/checkable = 1
|
||||
popup/item_26/id = 10
|
||||
popup/item_27/text = "Layer 23"
|
||||
popup/item_27/checkable = 1
|
||||
popup/item_27/id = 9
|
||||
popup/item_28/text = "Layer 24"
|
||||
popup/item_28/checkable = 1
|
||||
popup/item_28/id = 8
|
||||
popup/item_29/text = ""
|
||||
popup/item_29/id = 124
|
||||
popup/item_29/separator = true
|
||||
popup/item_30/text = "Layer 25"
|
||||
popup/item_30/checkable = 1
|
||||
popup/item_30/id = 7
|
||||
popup/item_31/text = "Layer 26"
|
||||
popup/item_31/checkable = 1
|
||||
popup/item_31/id = 6
|
||||
popup/item_32/text = "Layer 27"
|
||||
popup/item_32/checkable = 1
|
||||
popup/item_32/id = 5
|
||||
popup/item_33/text = "Layer 28"
|
||||
popup/item_33/checkable = 1
|
||||
popup/item_33/id = 4
|
||||
popup/item_34/text = ""
|
||||
popup/item_34/id = 128
|
||||
popup/item_34/separator = true
|
||||
popup/item_35/text = "Layer 29"
|
||||
popup/item_35/checkable = 1
|
||||
popup/item_35/id = 3
|
||||
popup/item_36/text = "Layer 30"
|
||||
popup/item_36/checkable = 1
|
||||
popup/item_36/id = 2
|
||||
popup/item_37/text = "Layer 31"
|
||||
popup/item_37/checkable = 1
|
||||
popup/item_37/id = 1
|
||||
popup/item_38/text = "Layer 32"
|
||||
popup/item_38/checkable = 1
|
||||
popup/item_38/id = 0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer1" type="GridContainer" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
columns = 4
|
||||
|
||||
[node name="Button1" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
|
||||
[node name="Button2" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "2"
|
||||
|
||||
[node name="Button3" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "3"
|
||||
|
||||
[node name="Button4" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "4"
|
||||
|
||||
[node name="Button5" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "5"
|
||||
|
||||
[node name="Button6" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "6"
|
||||
|
||||
[node name="Button7" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "7"
|
||||
|
||||
[node name="Button8" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer1" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "8"
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer2" type="GridContainer" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
columns = 4
|
||||
|
||||
[node name="Button9" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "9"
|
||||
|
||||
[node name="Button10" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "10"
|
||||
|
||||
[node name="Button11" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "11"
|
||||
|
||||
[node name="Button12" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "12"
|
||||
|
||||
[node name="Button13" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "13"
|
||||
|
||||
[node name="Button14" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "14"
|
||||
|
||||
[node name="Button15" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "15"
|
||||
|
||||
[node name="Button16" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer/GridContainer2" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "16"
|
||||
|
||||
[node name="HSeparator" type="HSeparator" parent="MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer2" type="HBoxContainer" parent="MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer3" type="GridContainer" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
columns = 4
|
||||
|
||||
[node name="Button17" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "17"
|
||||
|
||||
[node name="Button18" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "18"
|
||||
|
||||
[node name="Button19" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "19"
|
||||
|
||||
[node name="Button20" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "20"
|
||||
|
||||
[node name="Button21" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "21"
|
||||
|
||||
[node name="Button22" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "22"
|
||||
|
||||
[node name="Button23" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "23"
|
||||
|
||||
[node name="Button24" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer3" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "24"
|
||||
|
||||
[node name="VSeparator2" type="VSeparator" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer4" type="GridContainer" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
columns = 4
|
||||
|
||||
[node name="Button25" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "25"
|
||||
|
||||
[node name="Button26" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "26"
|
||||
|
||||
[node name="Button27" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "27"
|
||||
|
||||
[node name="Button28" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "28"
|
||||
|
||||
[node name="Button29" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "29"
|
||||
|
||||
[node name="Button30" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "30"
|
||||
|
||||
[node name="Button31" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "31"
|
||||
|
||||
[node name="Button32" parent="MarginContainer/HBoxContainer/VBoxContainer/HBoxContainer2/GridContainer4" instance=ExtResource("2")]
|
||||
layout_mode = 2
|
||||
text = "32"
|
||||
|
||||
[node name="VBoxContainer2" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="EnableAll" type="Button" parent="MarginContainer/HBoxContainer/VBoxContainer2"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
focus_mode = 0
|
||||
icon = ExtResource("4_h30jm")
|
||||
flat = true
|
||||
expand_icon = true
|
||||
|
||||
[node name="ClearButton" type="Button" parent="MarginContainer/HBoxContainer/VBoxContainer2"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
focus_mode = 0
|
||||
icon = ExtResource("4")
|
||||
flat = true
|
||||
|
||||
[connection signal="pressed" from="MarginContainer/HBoxContainer/VBoxContainer2/EnableAll" to="." method="_on_enable_all_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/HBoxContainer/VBoxContainer2/ClearButton" to="." method="_on_clear_pressed"]
|
||||
@@ -0,0 +1,23 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _check_box: CheckBox = $CheckBox
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# warning-ignore:return_value_discarded
|
||||
_check_box.connect("toggled", _on_value_changed)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func get_value() -> bool:
|
||||
return _check_box.button_pressed
|
||||
|
||||
|
||||
func _set_value(val: bool) -> void:
|
||||
_check_box.button_pressed = val
|
||||
@@ -0,0 +1,21 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://10wqs13p5i3d"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_bool.gd" id="1"]
|
||||
|
||||
[node name="ParameterScalar" type="HBoxContainer"]
|
||||
anchor_right = 1.0
|
||||
script = ExtResource( "1" )
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
offset_top = 2.0
|
||||
offset_right = 996.0
|
||||
offset_bottom = 28.0
|
||||
size_flags_horizontal = 3
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="CheckBox" type="CheckBox" parent="."]
|
||||
offset_left = 1000.0
|
||||
offset_right = 1024.0
|
||||
offset_bottom = 31.0
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
@@ -0,0 +1,25 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
const Util = preload("../../../../../common/util.gd")
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _panel: Control = $MarginContainer/CurvePanel
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func get_value() -> Curve:
|
||||
return _panel.get_curve()
|
||||
|
||||
|
||||
func _set_value(val: Curve) -> void:
|
||||
_panel.set_curve(val)
|
||||
|
||||
|
||||
func _on_curve_updated() -> void:
|
||||
_on_value_changed(get_value())
|
||||
@@ -0,0 +1,26 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://dqjwibwhdmgsb"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_curve.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/curve_panel.gd" id="2"]
|
||||
|
||||
[node name="ParameterCurve" type="VBoxContainer"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
layout_mode = 2
|
||||
text = "Curve name"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CurvePanel" type="PanelContainer" parent="MarginContainer"]
|
||||
custom_minimum_size = Vector2(0, 100)
|
||||
layout_mode = 2
|
||||
script = ExtResource("2")
|
||||
selected_point_color = Color(0.878431, 0.47451, 0, 1)
|
||||
rows = 4
|
||||
|
||||
[connection signal="curve_updated" from="MarginContainer/CurvePanel" to="." method="_on_curve_updated"]
|
||||
@@ -0,0 +1,54 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $%Label
|
||||
@onready var _select_button: Button = $%FileButton
|
||||
@onready var _dialog: FileDialog = $%FileDialog
|
||||
@onready var _texture: Button = $%TextureButton
|
||||
@onready var _preview_root: Control = $%PreviewRoot
|
||||
|
||||
var _path := ""
|
||||
var _is_texture := false
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func set_hint_string(hint: String) -> void:
|
||||
_is_texture = hint == "Texture"
|
||||
_set_value(get_value())
|
||||
|
||||
|
||||
func _set_value(val: String) -> void:
|
||||
_path = val
|
||||
_select_button.text = val.get_file()
|
||||
_preview_root.visible = false
|
||||
|
||||
if val.is_empty():
|
||||
_select_button.text = "Select a file"
|
||||
|
||||
if _is_texture:
|
||||
var texture = load(get_value())
|
||||
if texture is Texture:
|
||||
_texture.icon = texture
|
||||
_preview_root.visible = true
|
||||
|
||||
|
||||
func get_value() -> String:
|
||||
return _path
|
||||
|
||||
|
||||
func _on_clear_button_pressed() -> void:
|
||||
_set_value("")
|
||||
_on_value_changed("")
|
||||
|
||||
|
||||
func _on_select_button_pressed() -> void:
|
||||
_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_file_selected(file: String) -> void:
|
||||
_set_value(file)
|
||||
_on_value_changed(file)
|
||||
@@ -0,0 +1,67 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cvgj4rdc0mxxq"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://bosx22dy64f11" path="res://addons/proton_scatter/icons/clear.svg" id="1"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_file.gd" id="2"]
|
||||
|
||||
[node name="ParameterFile" type="VBoxContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 31.0
|
||||
size_flags_vertical = 0
|
||||
theme_override_constants/separation = 0
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="FileButton" type="Button" parent="HBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Select a file"
|
||||
|
||||
[node name="ClearButton" type="Button" parent="HBoxContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
icon = ExtResource("1")
|
||||
|
||||
[node name="PreviewRoot" type="HBoxContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Control" type="Control" parent="PreviewRoot"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="TextureButton" type="Button" parent="PreviewRoot"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 100)
|
||||
layout_mode = 2
|
||||
flat = true
|
||||
expand_icon = true
|
||||
|
||||
[node name="Control" type="Control" parent="."]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FileDialog" type="FileDialog" parent="Control"]
|
||||
unique_name_in_owner = true
|
||||
title = "Open a File"
|
||||
size = Vector2i(400, 600)
|
||||
ok_button_text = "Open"
|
||||
file_mode = 0
|
||||
filters = PackedStringArray("*.bmp", "*.dds", "*.exr", "*.hdr", "*.jpg", "*.jpeg", "*.png", "*.tga", "*.svg", "*.svgz", "*.webp")
|
||||
|
||||
[connection signal="pressed" from="HBoxContainer/HBoxContainer/FileButton" to="." method="_on_select_button_pressed"]
|
||||
[connection signal="pressed" from="HBoxContainer/HBoxContainer/ClearButton" to="." method="_on_clear_button_pressed"]
|
||||
[connection signal="pressed" from="PreviewRoot/TextureButton" to="." method="_on_select_button_pressed"]
|
||||
[connection signal="file_selected" from="Control/FileDialog" to="." method="_on_file_selected"]
|
||||
@@ -0,0 +1,88 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $%Label
|
||||
@onready var _select_button: Button = $%SelectButton
|
||||
@onready var _popup: ConfirmationDialog = $%ConfirmationDialog
|
||||
@onready var _tree: Tree = $%Tree
|
||||
|
||||
var _full_path: NodePath
|
||||
var _root: Node
|
||||
var _selected: Node
|
||||
|
||||
|
||||
func set_root(root) -> void:
|
||||
_root = root
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func _set_value(val) -> void:
|
||||
if val == null:
|
||||
return
|
||||
|
||||
_full_path = val
|
||||
|
||||
if val.is_empty():
|
||||
return
|
||||
|
||||
_select_button.text = val.get_name(val.get_name_count() - 1)
|
||||
|
||||
if _root and _root.has_node(val):
|
||||
_selected = _root.get_node(val)
|
||||
|
||||
if val.is_empty():
|
||||
_select_button.text = "Select a node"
|
||||
|
||||
|
||||
func get_value() -> NodePath:
|
||||
#if _root and _selected:
|
||||
# _full_path = String(_root.get_path_to(_selected))
|
||||
return _full_path
|
||||
|
||||
|
||||
func _populate_tree() -> void:
|
||||
_tree.clear()
|
||||
var scene_root: Node = get_tree().get_edited_scene_root()
|
||||
var editor_theme: Theme = get_editor_theme()
|
||||
_create_items_recursive(scene_root, null, editor_theme)
|
||||
|
||||
|
||||
func _create_items_recursive(node: Node, parent: TreeItem, editor_theme: Theme) -> void:
|
||||
if parent and not node.owner:
|
||||
return # Hidden node.
|
||||
|
||||
var node_item = _tree.create_item(parent)
|
||||
node_item.set_text(0, node.get_name())
|
||||
node_item.set_meta("node", node)
|
||||
|
||||
var node_icon: Texture2D
|
||||
var node_class := node.get_class()
|
||||
if is_instance_valid(editor_theme):
|
||||
if editor_theme.has_icon(node_class, "EditorIcons"):
|
||||
node_icon = editor_theme.get_icon(node_class, "EditorIcons")
|
||||
else:
|
||||
node_icon = editor_theme.get_icon("Node", "EditorIcons")
|
||||
node_item.set_icon(0, node_icon)
|
||||
|
||||
for child in node.get_children():
|
||||
_create_items_recursive(child, node_item, editor_theme)
|
||||
|
||||
|
||||
func _on_select_button_pressed() -> void:
|
||||
_populate_tree()
|
||||
_popup.popup_centered(Vector2i(400, 600))
|
||||
|
||||
|
||||
func _on_clear_button_pressed() -> void:
|
||||
_select_button.text = "Select a node"
|
||||
_full_path = NodePath()
|
||||
|
||||
|
||||
func _on_node_selected():
|
||||
var node = _tree.get_selected().get_meta("node")
|
||||
_set_value(_root.get_path_to(node))
|
||||
_on_value_changed(get_value())
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://bku7i3ct7ftui"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://bosx22dy64f11" path="res://addons/proton_scatter/icons/clear.svg" id="1"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_node_selector.gd" id="2"]
|
||||
|
||||
[node name="NodeSelector" type="MarginContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 31.0
|
||||
|
||||
[node name="Label" type="Label" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
offset_top = 2.0
|
||||
offset_right = 560.0
|
||||
offset_bottom = 28.0
|
||||
size_flags_horizontal = 3
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="SelectButton" type="Button" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
offset_left = 564.0
|
||||
offset_right = 1124.0
|
||||
offset_bottom = 31.0
|
||||
size_flags_horizontal = 3
|
||||
text = "Select Node"
|
||||
flat = true
|
||||
|
||||
[node name="ClearButton" type="Button" parent="HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
offset_left = 1128.0
|
||||
offset_right = 1152.0
|
||||
offset_bottom = 31.0
|
||||
icon = ExtResource("1")
|
||||
|
||||
[node name="ConfirmationDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
size = Vector2i(400, 500)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="ConfirmationDialog"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = -960.0
|
||||
offset_bottom = -597.0
|
||||
|
||||
[node name="Tree" type="Tree" parent="ConfirmationDialog/ScrollContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
offset_right = 184.0
|
||||
offset_bottom = 43.0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[connection signal="pressed" from="HBoxContainer/SelectButton" to="." method="_on_select_button_pressed"]
|
||||
[connection signal="pressed" from="HBoxContainer/ClearButton" to="." method="_on_clear_button_pressed"]
|
||||
[connection signal="confirmed" from="ConfirmationDialog" to="." method="_on_node_selected"]
|
||||
@@ -0,0 +1,119 @@
|
||||
# warning-ignore-all:return_value_discarded
|
||||
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
var _is_int := false
|
||||
var _is_enum := false
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _spinbox: SpinBox = $%SpinBox
|
||||
@onready var _option: OptionButton = $%OptionButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_spinbox.value_changed.connect(_on_value_changed)
|
||||
_option.item_selected.connect(_on_value_changed)
|
||||
mark_as_int(_is_int)
|
||||
|
||||
|
||||
func mark_as_int(val: bool) -> void:
|
||||
_is_int = val
|
||||
if _is_int and _spinbox:
|
||||
_spinbox.step = 1
|
||||
|
||||
|
||||
func mark_as_enum(val: bool) -> void:
|
||||
_is_enum = val
|
||||
|
||||
|
||||
func toggle_option_item(idx: int, value := false) -> void:
|
||||
_option.set_item_disabled(idx, not value)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func set_hint_string(hint: String) -> void:
|
||||
# No hint provided, ignore.
|
||||
if hint.is_empty():
|
||||
return
|
||||
|
||||
if hint == "float":
|
||||
_spinbox.step = 0.01
|
||||
return
|
||||
|
||||
if hint == "int":
|
||||
_spinbox.step = 1
|
||||
return
|
||||
|
||||
# One integer provided
|
||||
if hint.is_valid_int():
|
||||
_set_range(0, hint.to_int())
|
||||
return
|
||||
|
||||
# Multiple items provided, check their types
|
||||
var tokens = hint.split(",")
|
||||
var all_int = true
|
||||
var all_float = true
|
||||
|
||||
for t in tokens:
|
||||
if not t.is_valid_int():
|
||||
all_int = false
|
||||
if not t.is_valid_float():
|
||||
all_float = false
|
||||
|
||||
# All items are integer
|
||||
if all_int and tokens.size() >= 2:
|
||||
_set_range(tokens[0].to_int(), tokens[1].to_int())
|
||||
return
|
||||
|
||||
# All items are float
|
||||
if all_float:
|
||||
if tokens.size() >= 2:
|
||||
_set_range(tokens[0].to_float(), tokens[1].to_float())
|
||||
if tokens.size() >= 3:
|
||||
_spinbox.step = tokens[2].to_float()
|
||||
return
|
||||
|
||||
# All items are strings, make it a dropdown
|
||||
_spinbox.visible = false
|
||||
_option.visible = true
|
||||
_is_enum = true
|
||||
_is_int = true
|
||||
|
||||
for i in tokens.size():
|
||||
_option.add_item(_sanitize_option_name(tokens[i]), i)
|
||||
|
||||
set_value(int(_spinbox.get_value()))
|
||||
|
||||
|
||||
func get_value():
|
||||
if _is_enum:
|
||||
return _option.get_selected_id()
|
||||
if _is_int:
|
||||
return int(_spinbox.get_value())
|
||||
return _spinbox.get_value()
|
||||
|
||||
|
||||
func _set_value(val) -> void:
|
||||
if _is_int:
|
||||
val = int(val)
|
||||
if _is_enum:
|
||||
_option.select(val)
|
||||
else:
|
||||
_spinbox.set_value(val)
|
||||
|
||||
|
||||
func _set_range(start, end) -> void:
|
||||
if start < end:
|
||||
_spinbox.min_value = start
|
||||
_spinbox.max_value = end
|
||||
_spinbox.allow_greater = false
|
||||
_spinbox.allow_lesser = false
|
||||
|
||||
|
||||
func _sanitize_option_name(token: String) -> String:
|
||||
return token.left(token.find(":"))
|
||||
@@ -0,0 +1,55 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspbhkrpgak0e"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_scalar.gd" id="1"]
|
||||
|
||||
[node name="ParameterScalar" type="HBoxContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
layout_mode = 2
|
||||
offset_top = 2.0
|
||||
offset_right = 1833.0
|
||||
offset_bottom = 28.0
|
||||
size_flags_horizontal = 3
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
offset_left = 1837.0
|
||||
offset_right = 1920.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Panel" type="Panel" parent="MarginContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
offset_right = 83.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
offset_right = 83.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="SpinBox" type="SpinBox" parent="MarginContainer/MarginContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
offset_right = 83.0
|
||||
offset_bottom = 31.0
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="OptionButton" type="OptionButton" parent="MarginContainer/MarginContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
offset_right = 83.0
|
||||
offset_bottom = 31.0
|
||||
focus_mode = 0
|
||||
@@ -0,0 +1,27 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _line_edit: LineEdit = $MarginContainer/MarginContainer/LineEdit
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_line_edit.connect("text_entered", _on_value_changed)
|
||||
_line_edit.connect("focus_exited", _on_focus_exited)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func _set_value(val: String) -> void:
|
||||
_line_edit.text = val
|
||||
|
||||
|
||||
func get_value() -> String:
|
||||
return _line_edit.get_text()
|
||||
|
||||
|
||||
func _on_focus_exited() -> void:
|
||||
_on_value_changed(get_value())
|
||||
@@ -0,0 +1,56 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_string.gd" id="1"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id=1]
|
||||
bg_color = Color( 0, 0, 0, 0.392157 )
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id=2]
|
||||
bg_color = Color( 0.6, 0.6, 0.6, 0 )
|
||||
|
||||
[node name="ParameterString" type="HBoxContainer"]
|
||||
anchor_right = 1.0
|
||||
script = ExtResource( 1 )
|
||||
__meta__ = {
|
||||
"_edit_use_anchors_": false
|
||||
}
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
margin_top = 2.0
|
||||
margin_right = 638.0
|
||||
margin_bottom = 16.0
|
||||
size_flags_horizontal = 3
|
||||
text = "Parameter name"
|
||||
valign = 1
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
margin_left = 642.0
|
||||
margin_right = 1280.0
|
||||
margin_bottom = 18.0
|
||||
mouse_filter = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Panel" type="Panel" parent="MarginContainer"]
|
||||
margin_right = 638.0
|
||||
margin_bottom = 18.0
|
||||
mouse_filter = 2
|
||||
custom_styles/panel = SubResource( 1 )
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MarginContainer"]
|
||||
margin_right = 638.0
|
||||
margin_bottom = 18.0
|
||||
mouse_filter = 2
|
||||
custom_constants/margin_right = 4
|
||||
custom_constants/margin_top = 2
|
||||
custom_constants/margin_left = 4
|
||||
custom_constants/margin_bottom = 2
|
||||
|
||||
[node name="LineEdit" type="LineEdit" parent="MarginContainer/MarginContainer"]
|
||||
margin_left = 4.0
|
||||
margin_top = 2.0
|
||||
margin_right = 634.0
|
||||
margin_bottom = 16.0
|
||||
mouse_filter = 1
|
||||
custom_styles/focus = SubResource( 2 )
|
||||
custom_styles/normal = SubResource( 2 )
|
||||
clear_button_enabled = true
|
||||
@@ -0,0 +1,47 @@
|
||||
# warning-ignore-all:return_value_discarded
|
||||
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _x: SpinBox = $%X
|
||||
@onready var _y: SpinBox = $%Y
|
||||
@onready var _link: Button = $%LinkButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_x.value_changed.connect(_on_spinbox_value_changed)
|
||||
_y.value_changed.connect(_on_spinbox_value_changed)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func get_value() -> Vector2:
|
||||
var vec2 = Vector2.ZERO
|
||||
vec2.x = _x.get_value()
|
||||
vec2.y = _y.get_value()
|
||||
return vec2
|
||||
|
||||
|
||||
func _set_value(val: Vector2) -> void:
|
||||
_x.set_value(val.x)
|
||||
_y.set_value(val.y)
|
||||
|
||||
|
||||
func _on_clear_pressed():
|
||||
var old = get_value()
|
||||
set_value(Vector2.ZERO)
|
||||
_previous = old
|
||||
_on_value_changed(Vector2.ZERO)
|
||||
|
||||
|
||||
func _on_spinbox_value_changed(value: float) -> void:
|
||||
if _link.button_pressed:
|
||||
var old = get_value()
|
||||
set_value(Vector2(value, value))
|
||||
_previous = old
|
||||
|
||||
_on_value_changed(get_value())
|
||||
@@ -0,0 +1,108 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://bjn8ydwp80y7q"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_vector2.gd" id="1"]
|
||||
[ext_resource type="Texture2D" uid="uid://bosx22dy64f11" path="res://addons/proton_scatter/icons/clear.svg" id="2"]
|
||||
[ext_resource type="Texture2D" uid="uid://gbrmse47gdxb" path="res://addons/proton_scatter/icons/link.svg" id="3_u2lry"]
|
||||
|
||||
[node name="ParameterVector2" type="HBoxContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 5
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Panel" type="Panel" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
theme_override_constants/margin_left = 6
|
||||
theme_override_constants/margin_right = 6
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer"]
|
||||
modulate = Color(1, 0.447059, 0.368627, 1)
|
||||
layout_mode = 2
|
||||
text = "x"
|
||||
|
||||
[node name="X" type="SpinBox" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="HBoxContainer2" type="HBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer2"]
|
||||
modulate = Color(0.564706, 0.992157, 0.298039, 1)
|
||||
layout_mode = 2
|
||||
text = "y"
|
||||
|
||||
[node name="Y" type="SpinBox" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer2"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Control" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ClearButton" type="Button" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
icon = ExtResource("2")
|
||||
flat = true
|
||||
|
||||
[node name="Control2" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="LinkButton" type="Button" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
toggle_mode = true
|
||||
icon = ExtResource("3_u2lry")
|
||||
flat = true
|
||||
|
||||
[node name="Control3" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[connection signal="pressed" from="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer/ClearButton" to="." method="_on_clear_pressed"]
|
||||
@@ -0,0 +1,49 @@
|
||||
@tool
|
||||
extends "base_parameter.gd"
|
||||
|
||||
|
||||
@onready var _label: Label = $Label
|
||||
@onready var _x: SpinBox = $%X
|
||||
@onready var _y: SpinBox = $%Y
|
||||
@onready var _z: SpinBox = $%Z
|
||||
@onready var _link: Button = $%LinkButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_x.value_changed.connect(_on_spinbox_value_changed)
|
||||
_y.value_changed.connect(_on_spinbox_value_changed)
|
||||
_z.value_changed.connect(_on_spinbox_value_changed)
|
||||
|
||||
|
||||
func set_parameter_name(text: String) -> void:
|
||||
_label.text = text
|
||||
|
||||
|
||||
func get_value() -> Vector3:
|
||||
var vec3 = Vector3.ZERO
|
||||
vec3.x = _x.get_value()
|
||||
vec3.y = _y.get_value()
|
||||
vec3.z = _z.get_value()
|
||||
return vec3
|
||||
|
||||
|
||||
func _set_value(val: Vector3) -> void:
|
||||
_x.set_value(val.x)
|
||||
_y.set_value(val.y)
|
||||
_z.set_value(val.z)
|
||||
|
||||
|
||||
func _on_clear_pressed():
|
||||
var old = get_value()
|
||||
set_value(Vector3.ZERO)
|
||||
_previous = old
|
||||
_on_value_changed(Vector3.ZERO)
|
||||
|
||||
|
||||
func _on_spinbox_value_changed(value: float) -> void:
|
||||
if _link.button_pressed:
|
||||
var old = get_value()
|
||||
set_value(Vector3(value, value, value))
|
||||
_previous = old
|
||||
|
||||
_on_value_changed(get_value())
|
||||
@@ -0,0 +1,127 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://cdpfgf0447ph4"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_vector3.gd" id="1"]
|
||||
[ext_resource type="Texture2D" uid="uid://bosx22dy64f11" path="res://addons/proton_scatter/icons/clear.svg" id="2"]
|
||||
[ext_resource type="Texture2D" uid="uid://gbrmse47gdxb" path="res://addons/proton_scatter/icons/link.svg" id="3_gq2ti"]
|
||||
|
||||
[node name="ParameterVector3" type="HBoxContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 5
|
||||
text = "Parameter name"
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Panel" type="Panel" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
mouse_filter = 2
|
||||
theme_override_constants/margin_left = 6
|
||||
theme_override_constants/margin_right = 6
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer"]
|
||||
modulate = Color(1, 0.447059, 0.368627, 1)
|
||||
layout_mode = 2
|
||||
text = "x"
|
||||
|
||||
[node name="X" type="SpinBox" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="HBoxContainer2" type="HBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer2"]
|
||||
modulate = Color(0.564706, 0.992157, 0.298039, 1)
|
||||
layout_mode = 2
|
||||
text = "y"
|
||||
|
||||
[node name="Y" type="SpinBox" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer2"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="HBoxContainer3" type="HBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer3"]
|
||||
modulate = Color(0.14902, 0.8, 1, 1)
|
||||
layout_mode = 2
|
||||
text = "z"
|
||||
|
||||
[node name="Z" type="SpinBox" parent="MarginContainer/MarginContainer/HBoxContainer/GridContainer/HBoxContainer3"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
min_value = -100.0
|
||||
step = 0.001
|
||||
allow_greater = true
|
||||
allow_lesser = true
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
alignment = 1
|
||||
|
||||
[node name="Control3" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ClearButton" type="Button" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
icon = ExtResource("2")
|
||||
flat = true
|
||||
|
||||
[node name="Control" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="LinkButton" type="Button" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
toggle_mode = true
|
||||
icon = ExtResource("3_gq2ti")
|
||||
flat = true
|
||||
|
||||
[node name="Control2" type="Control" parent="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[connection signal="pressed" from="MarginContainer/MarginContainer/HBoxContainer/VBoxContainer/ClearButton" to="." method="_on_clear_pressed"]
|
||||
@@ -0,0 +1,96 @@
|
||||
@tool
|
||||
extends Container
|
||||
|
||||
# DragContainer
|
||||
# Custom containner similar to a VBoxContainer, but the user can rearrange the
|
||||
# children order via drag and drop. This is only used in the inspector plugin
|
||||
# for the modifier stack and won't work with arbitrary control nodes.
|
||||
|
||||
|
||||
signal child_moved(last_index: int, new_index: int)
|
||||
|
||||
|
||||
var _separation: int = 0
|
||||
var _drag_offset = null
|
||||
var _dragged_child = null
|
||||
var _old_index: int
|
||||
var _new_index: int
|
||||
var _map := [] # Stores the y top position of each child in the stack
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_separation = get_theme_constant("separation", "VBoxContainer")
|
||||
|
||||
|
||||
func _notification(what):
|
||||
if what == NOTIFICATION_SORT_CHILDREN or what == NOTIFICATION_RESIZED:
|
||||
_update_layout()
|
||||
|
||||
|
||||
func _can_drop_data(at_position, data) -> bool:
|
||||
if data.get_parent() != self:
|
||||
return false
|
||||
|
||||
# Drag just started
|
||||
if not _dragged_child:
|
||||
_dragged_child = data
|
||||
_drag_offset = at_position - data.position
|
||||
_old_index = data.get_index()
|
||||
_new_index = _old_index
|
||||
|
||||
# Dragged control only follow the y mouse position
|
||||
data.position.y = at_position.y - _drag_offset.y
|
||||
|
||||
# Check if the children order should be changed
|
||||
var computed_index = 0
|
||||
for pos_y in _map:
|
||||
if pos_y > data.position.y - 16:
|
||||
break
|
||||
computed_index += 1
|
||||
|
||||
# Prevents edge case when dragging the last item below its current position
|
||||
computed_index = clamp(computed_index, 0, get_child_count() - 1)
|
||||
|
||||
if computed_index != data.get_index():
|
||||
move_child(data, computed_index)
|
||||
_new_index = computed_index
|
||||
|
||||
return true
|
||||
|
||||
|
||||
# Called once at the end of the drag
|
||||
func _drop_data(at_position, data) -> void:
|
||||
_drag_offset = null
|
||||
_dragged_child = null
|
||||
_update_layout()
|
||||
|
||||
if _old_index != _new_index:
|
||||
child_moved.emit(_old_index, _new_index)
|
||||
|
||||
|
||||
# Detects if the user drops the children outside the container and treats it
|
||||
# as if the drop happened the moment the mouse left the container.
|
||||
func _unhandled_input(event):
|
||||
if not _dragged_child:
|
||||
return
|
||||
|
||||
if event is InputEventMouseButton and not event.pressed:
|
||||
_drop_data(_dragged_child.position, _dragged_child)
|
||||
|
||||
|
||||
func _update_layout() -> void:
|
||||
_map.clear()
|
||||
var offset := Vector2.ZERO
|
||||
|
||||
for c in get_children():
|
||||
if c is Control:
|
||||
_map.push_back(offset.y)
|
||||
var child_min_size = c.get_combined_minimum_size()
|
||||
var possible_space = Rect2(offset, Vector2(size.x, child_min_size.y))
|
||||
|
||||
if c != _dragged_child:
|
||||
fit_child_in_rect(c, possible_space)
|
||||
|
||||
offset.y += c.size.y + _separation
|
||||
|
||||
custom_minimum_size.y = offset.y - _separation
|
||||
@@ -0,0 +1,203 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
|
||||
signal value_changed
|
||||
signal removed
|
||||
signal documentation_requested
|
||||
signal duplication_requested
|
||||
|
||||
|
||||
const ParameterBool := preload("./components/parameter_bool.tscn")
|
||||
const ParameterScalar := preload("./components/parameter_scalar.tscn")
|
||||
const ParameterNodeSelector = preload("./components/parameter_node_selector.tscn")
|
||||
const ParameterFile = preload("./components/parameter_file.tscn")
|
||||
const ParameterCurve = preload("./components/parameter_curve.tscn")
|
||||
const ParameterBitmask = preload("./components/parameter_bitmask.tscn")
|
||||
const ParameterString = preload("./components/parameter_string.tscn")
|
||||
const ParameterVector3 = preload("./components/parameter_vector3.tscn")
|
||||
const ParameterVector2 = preload("./components/parameter_vector2.tscn")
|
||||
const PARAMETER_IGNORE_LIST := [
|
||||
"enabled",
|
||||
"override_global_seed",
|
||||
"custom_seed",
|
||||
"restrict_height",
|
||||
"reference_frame",
|
||||
]
|
||||
|
||||
var _scatter
|
||||
var _modifier
|
||||
|
||||
@onready var _parameters: Control = $%ParametersRoot
|
||||
@onready var _name: Label = $%ModifierName
|
||||
@onready var _expand: Button = $%Expand
|
||||
@onready var _enabled: Button = $%Enabled
|
||||
@onready var _remove: Button = $%Remove
|
||||
@onready var _warning: Button = $%Warning
|
||||
@onready var _warning_dialog: AcceptDialog = $WarningDialog
|
||||
@onready var _drag_control: Control = $%DragControl
|
||||
@onready var _override_ui = $%OverrideGlobalSeed
|
||||
@onready var _custom_seed_ui = $%CustomSeed
|
||||
@onready var _restrict_height_ui = $%RestrictHeight
|
||||
@onready var _transform_space_ui = $%TransformSpace
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_name.text = name
|
||||
_enabled.toggled.connect(_on_enable_toggled)
|
||||
_remove.pressed.connect(_on_remove_pressed)
|
||||
_warning.pressed.connect(_on_warning_icon_pressed)
|
||||
_expand.toggled.connect(_on_expand_toggled)
|
||||
$%MenuButton.get_popup().id_pressed.connect(_on_menu_item_pressed)
|
||||
|
||||
|
||||
func _get_drag_data(at_position: Vector2):
|
||||
var drag_control_position = _drag_control.global_position - global_position
|
||||
var drag_rect := Rect2(drag_control_position, _drag_control.size)
|
||||
if drag_rect.has_point(at_position):
|
||||
return self
|
||||
|
||||
return null
|
||||
|
||||
|
||||
func set_root(val) -> void:
|
||||
_scatter = val
|
||||
|
||||
|
||||
# Loops through all exposed parameters and create an UI component for each of
|
||||
# them. For special properties (listed in PARAMATER_IGNORE_LIST), a special
|
||||
# UI is created.
|
||||
func create_ui_for(modifier) -> void:
|
||||
_modifier = modifier
|
||||
_modifier.warning_changed.connect(_on_warning_changed)
|
||||
_on_warning_changed()
|
||||
|
||||
_name.text = modifier.display_name
|
||||
_enabled.button_pressed = modifier.enabled
|
||||
|
||||
# Enable or disable irrelevant controls for this modifier
|
||||
_override_ui.enable(modifier.can_override_seed)
|
||||
_restrict_height_ui.enable(modifier.can_restrict_height)
|
||||
_transform_space_ui.mark_as_enum(true)
|
||||
_transform_space_ui.toggle_option_item(0, modifier.global_reference_frame_available)
|
||||
_transform_space_ui.toggle_option_item(1, modifier.local_reference_frame_available)
|
||||
_transform_space_ui.toggle_option_item(2, modifier.individual_instances_reference_frame_available)
|
||||
if not modifier.global_reference_frame_available and \
|
||||
not modifier.local_reference_frame_available and \
|
||||
not modifier.individual_instances_reference_frame_available:
|
||||
_transform_space_ui.visible = false
|
||||
|
||||
# Setup header connections
|
||||
_override_ui.value_changed.connect(_on_parameter_value_changed.bind("override_global_seed", _override_ui))
|
||||
_custom_seed_ui.value_changed.connect(_on_parameter_value_changed.bind("custom_seed", _custom_seed_ui))
|
||||
_restrict_height_ui.value_changed.connect(_on_parameter_value_changed.bind("restrict_height", _restrict_height_ui))
|
||||
_transform_space_ui.value_changed.connect(_on_parameter_value_changed.bind("reference_frame", _transform_space_ui))
|
||||
|
||||
# Restore header values
|
||||
_override_ui.set_value(modifier.override_global_seed)
|
||||
_custom_seed_ui.set_value(modifier.custom_seed)
|
||||
_restrict_height_ui.set_value(modifier.restrict_height)
|
||||
_transform_space_ui.set_value(modifier.reference_frame)
|
||||
|
||||
# Loop over the other properties and create a ui component for each of them
|
||||
for property in modifier.get_property_list():
|
||||
if property.usage != PROPERTY_USAGE_DEFAULT + PROPERTY_USAGE_SCRIPT_VARIABLE:
|
||||
continue
|
||||
|
||||
if property.name in PARAMETER_IGNORE_LIST:
|
||||
continue
|
||||
|
||||
var parameter_ui
|
||||
match property.type:
|
||||
TYPE_BOOL:
|
||||
parameter_ui = ParameterBool.instantiate()
|
||||
TYPE_FLOAT:
|
||||
parameter_ui = ParameterScalar.instantiate()
|
||||
TYPE_INT:
|
||||
if property.hint == PROPERTY_HINT_LAYERS_3D_PHYSICS:
|
||||
parameter_ui = ParameterBitmask.instantiate()
|
||||
else:
|
||||
parameter_ui = ParameterScalar.instantiate()
|
||||
parameter_ui.mark_as_int(true)
|
||||
TYPE_STRING:
|
||||
if property.hint_string == "File" or property.hint_string == "Texture":
|
||||
parameter_ui = ParameterFile.instantiate()
|
||||
else:
|
||||
parameter_ui = ParameterString.instantiate()
|
||||
TYPE_VECTOR3:
|
||||
parameter_ui = ParameterVector3.instantiate()
|
||||
TYPE_VECTOR2:
|
||||
parameter_ui = ParameterVector2.instantiate()
|
||||
TYPE_NODE_PATH:
|
||||
parameter_ui = ParameterNodeSelector.instantiate()
|
||||
parameter_ui.set_root(_scatter)
|
||||
TYPE_OBJECT:
|
||||
if property.class_name == &"Curve":
|
||||
parameter_ui = ParameterCurve.instantiate()
|
||||
|
||||
if parameter_ui:
|
||||
_parameters.add_child(parameter_ui)
|
||||
parameter_ui.set_parameter_name(property.name.capitalize())
|
||||
parameter_ui.set_value(modifier.get(property.name))
|
||||
parameter_ui.set_hint_string(property.hint_string)
|
||||
parameter_ui.set_scatter(_scatter)
|
||||
parameter_ui.value_changed.connect(_on_parameter_value_changed.bind(property.name, parameter_ui))
|
||||
|
||||
_expand.button_pressed = _modifier.expanded
|
||||
|
||||
|
||||
func _restore_value(name, val, ui) -> void:
|
||||
_modifier.set(name, val)
|
||||
ui.set_value(val)
|
||||
value_changed.emit()
|
||||
|
||||
|
||||
func _on_expand_toggled(toggled: bool) -> void:
|
||||
$%ParametersContainer.visible = toggled
|
||||
_modifier.expanded = toggled
|
||||
|
||||
|
||||
func _on_remove_pressed() -> void:
|
||||
removed.emit()
|
||||
|
||||
|
||||
func _on_parameter_value_changed(value, previous, parameter_name, ui) -> void:
|
||||
if _scatter.undo_redo:
|
||||
_scatter.undo_redo.create_action("Change value " + parameter_name.capitalize())
|
||||
_scatter.undo_redo.add_undo_method(self, "_restore_value", parameter_name, previous, ui)
|
||||
_scatter.undo_redo.add_do_method(self, "_restore_value", parameter_name, value, ui)
|
||||
_scatter.undo_redo.commit_action()
|
||||
else:
|
||||
_modifier.set(parameter_name, value)
|
||||
value_changed.emit()
|
||||
|
||||
|
||||
func _on_enable_toggled(pressed: bool):
|
||||
_modifier.enabled = pressed
|
||||
value_changed.emit()
|
||||
|
||||
|
||||
func _on_removed_pressed() -> void:
|
||||
removed.emit()
|
||||
|
||||
|
||||
func _on_warning_changed() -> void:
|
||||
var warning = _modifier.get_warning()
|
||||
_warning.visible = (warning != "")
|
||||
_warning_dialog.dialog_text = warning
|
||||
|
||||
|
||||
func _on_warning_icon_pressed() -> void:
|
||||
_warning_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_menu_item_pressed(id) -> void:
|
||||
match id:
|
||||
0:
|
||||
documentation_requested.emit()
|
||||
2:
|
||||
duplication_requested.emit()
|
||||
3:
|
||||
_on_remove_pressed()
|
||||
_:
|
||||
pass
|
||||
@@ -0,0 +1,255 @@
|
||||
[gd_scene load_steps=21 format=3 uid="uid://blpobpd0eweog"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/modifier_panel.gd" id="1"]
|
||||
[ext_resource type="Texture2D" uid="uid://cu2t8yylseggu" path="res://addons/proton_scatter/icons/arrow_right.svg" id="2_2djuo"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/toggle_button.gd" id="4"]
|
||||
[ext_resource type="Texture2D" uid="uid://t8c6kjbvst0s" path="res://addons/proton_scatter/icons/arrow_down.svg" id="4_7nlfc"]
|
||||
[ext_resource type="Texture2D" uid="uid://dahwdjl2er75o" path="res://addons/proton_scatter/icons/close.svg" id="5"]
|
||||
[ext_resource type="Texture2D" uid="uid://n66mufjib4ds" path="res://addons/proton_scatter/icons/menu.svg" id="6_lmo8k"]
|
||||
[ext_resource type="Texture2D" uid="uid://d2ajwyebaobjt" path="res://addons/proton_scatter/icons/duplicate.svg" id="7_f6nan"]
|
||||
[ext_resource type="Texture2D" uid="uid://do8d3urxirjoa" path="res://addons/proton_scatter/icons/doc.svg" id="7_owhij"]
|
||||
[ext_resource type="Texture2D" uid="uid://dj0y6peid681t" path="res://addons/proton_scatter/icons/warning.svg" id="9"]
|
||||
[ext_resource type="Texture2D" uid="uid://ba6cx70dyeuhg" path="res://addons/proton_scatter/icons/drag_area.svg" id="9_t6pse"]
|
||||
[ext_resource type="Script" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/override_seed_button.gd" id="10_ptukr"]
|
||||
[ext_resource type="Texture2D" uid="uid://dmmefjvrdhf78" path="res://addons/proton_scatter/icons/dice.svg" id="11_qwhro"]
|
||||
[ext_resource type="PackedScene" uid="uid://w6ycb4oveqhd" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/header/parameter_button.tscn" id="11_y7srw"]
|
||||
[ext_resource type="Texture2D" uid="uid://cmvfdl1wnrw4" path="res://addons/proton_scatter/icons/restrict_volume.svg" id="12_lx60d"]
|
||||
[ext_resource type="Texture2D" uid="uid://dt0ctlr32stnn" path="res://addons/proton_scatter/icons/local.svg" id="13_txjs8"]
|
||||
[ext_resource type="PackedScene" uid="uid://c36gqn03pvlnr" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/header/parameter_spinbox.tscn" id="13_vhfch"]
|
||||
[ext_resource type="Texture2D" uid="uid://p2v2cqm7k60o" path="res://addons/proton_scatter/icons/restrict_volume_lock.svg" id="15_0w0as"]
|
||||
[ext_resource type="Texture2D" uid="uid://71efqwg3d70v" path="res://addons/proton_scatter/icons/global.svg" id="16_ocvvf"]
|
||||
[ext_resource type="PackedScene" uid="uid://bspbhkrpgak0e" path="res://addons/proton_scatter/src/stack/inspector_plugin/ui/modifier/components/parameter_scalar.tscn" id="17_aoulv"]
|
||||
[ext_resource type="Texture2D" uid="uid://vxd0iun0wq8i" path="res://addons/proton_scatter/icons/individual_instances.svg" id="19_ln8a3"]
|
||||
|
||||
[node name="ModifierPanel" type="MarginContainer"]
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
grow_horizontal = 2
|
||||
theme_type_variation = &"fg"
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Panel" type="Panel" parent="."]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/margin_left = 4
|
||||
theme_override_constants/margin_top = 4
|
||||
theme_override_constants/margin_right = 4
|
||||
theme_override_constants/margin_bottom = 4
|
||||
metadata/_edit_layout_mode = 1
|
||||
metadata/_edit_use_custom_anchors = false
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
|
||||
[node name="Expand" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Toggle the parameters view"
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
toggle_mode = true
|
||||
icon = ExtResource("2_2djuo")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
script = ExtResource("4")
|
||||
default_icon = ExtResource("2_2djuo")
|
||||
pressed_icon = ExtResource("4_7nlfc")
|
||||
|
||||
[node name="ModifierName" type="Label" parent="MarginContainer/VBoxContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "ModifierPanel"
|
||||
vertical_alignment = 1
|
||||
clip_text = true
|
||||
|
||||
[node name="Buttons" type="HBoxContainer" parent="MarginContainer/VBoxContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="Warning" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
icon = ExtResource("9")
|
||||
flat = true
|
||||
|
||||
[node name="MenuButton" type="MenuButton" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Show options"
|
||||
icon = ExtResource("6_lmo8k")
|
||||
item_count = 4
|
||||
popup/item_0/text = "Show documentation"
|
||||
popup/item_0/icon = ExtResource("7_owhij")
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = ""
|
||||
popup/item_1/id = 1
|
||||
popup/item_1/separator = true
|
||||
popup/item_2/text = "Duplicate"
|
||||
popup/item_2/icon = ExtResource("7_f6nan")
|
||||
popup/item_2/id = 2
|
||||
popup/item_3/text = "Delete"
|
||||
popup/item_3/icon = ExtResource("5")
|
||||
popup/item_3/id = 3
|
||||
|
||||
[node name="Enabled" type="CheckBox" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Toggle the modifier.
|
||||
|
||||
If the modifier is disabled, it will not contribute to the final result but will still remain in the stack.
|
||||
|
||||
Use this feature to quickly see how the modifier affects the overall stack."
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="Remove" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
tooltip_text = "Delete the modifier.
|
||||
This will remove it from the stack."
|
||||
focus_mode = 0
|
||||
mouse_filter = 1
|
||||
icon = ExtResource("5")
|
||||
flat = true
|
||||
icon_alignment = 1
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
modulate = Color(1, 1, 1, 0.54902)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DragControl" type="TextureRect" parent="MarginContainer/VBoxContainer/HBoxContainer/Buttons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
tooltip_text = "Drag and move this button to change the stack order.
|
||||
|
||||
Modifiers are processed from top to bottom."
|
||||
mouse_default_cursor_shape = 6
|
||||
texture = ExtResource("9_t6pse")
|
||||
stretch_mode = 3
|
||||
|
||||
[node name="ParametersContainer" type="MarginContainer" parent="MarginContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 3
|
||||
theme_override_constants/margin_top = 3
|
||||
theme_override_constants/margin_right = 3
|
||||
theme_override_constants/margin_bottom = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ParametersContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ParametersRoot" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CommonHeader" type="HBoxContainer" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
alignment = 1
|
||||
|
||||
[node name="ExpandButton" type="MarginContainer" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader"]
|
||||
layout_mode = 2
|
||||
script = ExtResource("10_ptukr")
|
||||
|
||||
[node name="OverrideGlobalSeed" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton" instance=ExtResource("11_y7srw")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Button" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton/OverrideGlobalSeed" index="0"]
|
||||
layout_mode = 2
|
||||
tooltip_text = "Random seed.
|
||||
|
||||
Enable to force a custom seed on this modifier only. If this option is disabled, the Global Seed from the ProtonScatter node will be used instead."
|
||||
icon = ExtResource("11_qwhro")
|
||||
icon_alignment = 0
|
||||
default_icon = ExtResource("11_qwhro")
|
||||
pressed_icon = ExtResource("11_qwhro")
|
||||
|
||||
[node name="SpinBoxRoot" type="HBoxContainer" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VSeparator" type="VSeparator" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton/SpinBoxRoot"]
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 28
|
||||
|
||||
[node name="CustomSeed" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton/SpinBoxRoot" instance=ExtResource("13_vhfch")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Control" type="Control" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="RestrictHeight" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader" instance=ExtResource("11_y7srw")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 1
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Button" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/RestrictHeight" index="0"]
|
||||
layout_mode = 2
|
||||
tooltip_text = "Restrict height.
|
||||
|
||||
If enabled, the modifier will try to remain in the local XZ plane instead of using the full volume defined by the ScatterShapes."
|
||||
icon = ExtResource("12_lx60d")
|
||||
default_icon = ExtResource("12_lx60d")
|
||||
pressed_icon = ExtResource("15_0w0as")
|
||||
|
||||
[node name="TransformSpace" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader" instance=ExtResource("17_aoulv")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/TransformSpace" index="0"]
|
||||
visible = false
|
||||
text = ""
|
||||
|
||||
[node name="SpinBox" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/TransformSpace/MarginContainer/MarginContainer" index="0"]
|
||||
visible = false
|
||||
|
||||
[node name="OptionButton" parent="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/TransformSpace/MarginContainer/MarginContainer" index="1"]
|
||||
visible = true
|
||||
item_count = 3
|
||||
fit_to_longest_item = false
|
||||
popup/item_0/text = "Global"
|
||||
popup/item_0/icon = ExtResource("16_ocvvf")
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "Local"
|
||||
popup/item_1/icon = ExtResource("13_txjs8")
|
||||
popup/item_1/id = 1
|
||||
popup/item_2/text = "Individual"
|
||||
popup/item_2/icon = ExtResource("19_ln8a3")
|
||||
popup/item_2/id = 2
|
||||
|
||||
[node name="WarningDialog" type="AcceptDialog" parent="."]
|
||||
title = "Warning"
|
||||
unresizable = true
|
||||
popup_window = true
|
||||
|
||||
[editable path="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/ExpandButton/OverrideGlobalSeed"]
|
||||
[editable path="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/RestrictHeight"]
|
||||
[editable path="MarginContainer/VBoxContainer/ParametersContainer/VBoxContainer/CommonHeader/TransformSpace"]
|
||||
@@ -0,0 +1,14 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
|
||||
@onready var _button: Button = $OverrideGlobalSeed/Button
|
||||
@onready var _spinbox_root: Control = $SpinBoxRoot
|
||||
|
||||
|
||||
func _ready():
|
||||
_button.toggled.connect(_on_toggled)
|
||||
|
||||
|
||||
func _on_toggled(enabled: bool) -> void:
|
||||
_spinbox_root.visible = enabled
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user