Added footsteps, new tree, various other tweaks

This commit is contained in:
derek
2024-12-05 11:47:34 -06:00
parent 816ae85938
commit 023879ea9f
389 changed files with 20484 additions and 234 deletions

View 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()

View 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"

View 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)

View 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)

View 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))

View 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

View 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)

View 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

View 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

View 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)

View 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()

View 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(" ", " ")

View 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]")

View 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"]

View 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

View 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

View 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]"

View 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)

View 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

View 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

View File

@@ -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);
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View 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)

View 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

View 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

View 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)

View 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

View 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

View 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

View 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

View 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)

View 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()

View 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

View 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

View 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

View 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

View 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)

View 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

View File

@@ -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;
}

View File

@@ -0,0 +1,15 @@
shader_type particles;
render_mode keep_data;
uniform mat4 global_transform;
void start() {
}
void process() {
}

View 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

View 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")

View 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

View 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

View 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()

View 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()

View 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()

View 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

View 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

View 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]

View 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

View File

@@ -0,0 +1,3 @@
[gd_resource type="ButtonGroup" format=3 uid="uid://1xy55037k3k5"]
[resource]

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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()

View 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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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" )

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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"]

View File

@@ -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)

View 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"]

View File

@@ -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())

View File

@@ -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"]

View File

@@ -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(":"))

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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())

View File

@@ -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"]

View File

@@ -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())

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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