added CSG toolkit
This commit is contained in:
46
addons/csg_toolkit/scripts/config_window.gd
Normal file
46
addons/csg_toolkit/scripts/config_window.gd
Normal file
@@ -0,0 +1,46 @@
|
||||
@tool
|
||||
extends Window
|
||||
|
||||
@onready var config: CsgTkConfig:
|
||||
get: return get_tree().root.get_node(CsgToolkit.AUTOLOAD_NAME) as CsgTkConfig
|
||||
|
||||
@onready var default_behavior_option: OptionButton = $MarginContainer/VBoxContainer/HBoxContainer/OptionButton
|
||||
@onready var action_key_button: Button = $MarginContainer/VBoxContainer/HBoxContainer2/Button
|
||||
@onready var behvaior_toogle_button: Button = $MarginContainer/VBoxContainer/HBoxContainer4/Button
|
||||
@onready var auto_hide_switch: CheckBox = $MarginContainer/VBoxContainer/HBoxContainer3/CheckButton
|
||||
|
||||
signal key_press(key: InputEventKey)
|
||||
|
||||
func _ready():
|
||||
default_behavior_option.select(config.default_behavior)
|
||||
action_key_button.text = OS.get_keycode_string(config.action_key)
|
||||
behvaior_toogle_button.text = OS.get_keycode_string(config.secondary_action_key)
|
||||
auto_hide_switch.button_pressed = config.auto_hide
|
||||
|
||||
func _on_option_button_item_selected(index):
|
||||
match index:
|
||||
0: config.default_behavior = CsgTkConfig.CSGBehavior.SIBLING
|
||||
1: config.default_behavior = CsgTkConfig.CSGBehavior.CHILD
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event is InputEventKey:
|
||||
if event.pressed:
|
||||
key_press.emit(event)
|
||||
|
||||
func _on_save_pressed():
|
||||
config.save_config()
|
||||
hide()
|
||||
|
||||
func _on_button_pressed():
|
||||
var key_event: InputEventKey = await key_press
|
||||
config.action_key = key_event.keycode
|
||||
action_key_button.text = key_event.as_text_key_label()
|
||||
|
||||
func _on_second_button_pressed():
|
||||
var key_event: InputEventKey = await key_press
|
||||
config.secondary_action_key = key_event.keycode
|
||||
behvaior_toogle_button.text = key_event.as_text_key_label()
|
||||
|
||||
|
||||
func _on_check_box_toggled(toggled_on):
|
||||
config.auto_hide = toggled_on
|
||||
1
addons/csg_toolkit/scripts/config_window.gd.uid
Normal file
1
addons/csg_toolkit/scripts/config_window.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b7isqiq2asnu6
|
||||
522
addons/csg_toolkit/scripts/csg_repeater_3d.gd
Normal file
522
addons/csg_toolkit/scripts/csg_repeater_3d.gd
Normal file
@@ -0,0 +1,522 @@
|
||||
@tool
|
||||
class_name CSGRepeater3D extends CSGCombiner3D
|
||||
|
||||
# NOTE: Registered as custom type in plugin (csg_toolkit.gd) inheriting CSGCombiner3D.
|
||||
# Ensure pattern resource scripts are loaded (Godot should handle via class_name, but we force references for safety):
|
||||
const _REF_GRID = preload("res://addons/csg_toolkit/scripts/patterns/grid_pattern.gd") # ensure subclass scripts loaded
|
||||
const _REF_CIRC = preload("res://addons/csg_toolkit/scripts/patterns/circular_pattern.gd")
|
||||
const _REF_SPIRAL = preload("res://addons/csg_toolkit/scripts/patterns/spiral_pattern.gd")
|
||||
const _REF_NOISE = preload("res://addons/csg_toolkit/scripts/patterns/noise_pattern.gd")
|
||||
|
||||
const REPEATER_NODE_META = "REPEATED_NODE_META"
|
||||
const MAX_INSTANCES = 20000
|
||||
|
||||
var _dirty: bool = false
|
||||
var _template_node_path: NodePath
|
||||
@export var template_node_path: NodePath:
|
||||
get: return _template_node_path
|
||||
set(value):
|
||||
_template_node_path = value
|
||||
_mark_dirty()
|
||||
|
||||
var _template_node_scene: PackedScene
|
||||
@export var template_node_scene: PackedScene:
|
||||
get: return _template_node_scene
|
||||
set(value):
|
||||
_template_node_scene = value
|
||||
_mark_dirty()
|
||||
|
||||
var _hide_template: bool = true
|
||||
@export var hide_template: bool = true:
|
||||
get: return _hide_template
|
||||
set(value):
|
||||
_hide_template = value
|
||||
_update_template_visibility()
|
||||
|
||||
## repeat & spacing removed (migrated into pattern resources)
|
||||
|
||||
@export_group("Pattern Options")
|
||||
# A single exported pattern resource (`pattern`) defines generation behavior.
|
||||
|
||||
|
||||
@export_group("Variation Options")
|
||||
# Rotation variation properties now managed via custom property list for collapsible enable group.
|
||||
var _randomize_rotation: bool = false
|
||||
var randomize_rotation: bool:
|
||||
get: return _randomize_rotation
|
||||
set(value):
|
||||
_randomize_rotation = value
|
||||
_mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _randomize_rot_x: bool = false
|
||||
var randomize_rot_x: bool:
|
||||
get: return _randomize_rot_x
|
||||
set(value):
|
||||
_randomize_rot_x = value
|
||||
if _randomize_rotation: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _randomize_rot_y: bool = false
|
||||
var randomize_rot_y: bool:
|
||||
get: return _randomize_rot_y
|
||||
set(value):
|
||||
_randomize_rot_y = value
|
||||
if _randomize_rotation: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _randomize_rot_z: bool = false
|
||||
var randomize_rot_z: bool:
|
||||
get: return _randomize_rot_z
|
||||
set(value):
|
||||
_randomize_rot_z = value
|
||||
if _randomize_rotation: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
# Per-axis rotation variance in degrees (0 = full 0..360 random for that axis; >0 jitters around original)
|
||||
var _rotation_variance_x_deg: float = 0.0
|
||||
var rotation_variance_x_deg: float:
|
||||
get: return _rotation_variance_x_deg
|
||||
set(value):
|
||||
_rotation_variance_x_deg = clamp(value, 0.0, 360.0)
|
||||
if _randomize_rotation and _randomize_rot_x: _mark_dirty()
|
||||
|
||||
var _rotation_variance_y_deg: float = 0.0
|
||||
var rotation_variance_y_deg: float:
|
||||
get: return _rotation_variance_y_deg
|
||||
set(value):
|
||||
_rotation_variance_y_deg = clamp(value, 0.0, 360.0)
|
||||
if _randomize_rotation and _randomize_rot_y: _mark_dirty()
|
||||
|
||||
var _rotation_variance_z_deg: float = 0.0
|
||||
var rotation_variance_z_deg: float:
|
||||
get: return _rotation_variance_z_deg
|
||||
set(value):
|
||||
_rotation_variance_z_deg = clamp(value, 0.0, 360.0)
|
||||
if _randomize_rotation and _randomize_rot_z: _mark_dirty()
|
||||
|
||||
var _randomize_scale: bool = false
|
||||
var randomize_scale: bool:
|
||||
get: return _randomize_scale
|
||||
set(value):
|
||||
_randomize_scale = value
|
||||
_mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _scale_variance: float = 0.0
|
||||
var scale_variance: float:
|
||||
get: return _scale_variance
|
||||
set(value):
|
||||
_scale_variance = clamp(value, 0.0, 1.0)
|
||||
if _randomize_scale:
|
||||
_mark_dirty()
|
||||
|
||||
# Per-axis scale variance (if zero => use global variance when axis toggle active)
|
||||
var _scale_variance_x: float = 0.0
|
||||
var scale_variance_x: float:
|
||||
get: return _scale_variance_x
|
||||
set(value):
|
||||
_scale_variance_x = clamp(value, 0.0, 1.0)
|
||||
if _randomize_scale and _randomize_scale_x: _mark_dirty()
|
||||
|
||||
var _scale_variance_y: float = 0.0
|
||||
var scale_variance_y: float:
|
||||
get: return _scale_variance_y
|
||||
set(value):
|
||||
_scale_variance_y = clamp(value, 0.0, 1.0)
|
||||
if _randomize_scale and _randomize_scale_y: _mark_dirty()
|
||||
|
||||
var _scale_variance_z: float = 0.0
|
||||
var scale_variance_z: float:
|
||||
get: return _scale_variance_z
|
||||
set(value):
|
||||
_scale_variance_z = clamp(value, 0.0, 1.0)
|
||||
if _randomize_scale and _randomize_scale_z: _mark_dirty()
|
||||
|
||||
# Per-axis scale randomization toggles (optional – if none enabled acts as uniform variance on all axes)
|
||||
var _randomize_scale_x: bool = false
|
||||
var randomize_scale_x: bool:
|
||||
get: return _randomize_scale_x
|
||||
set(value):
|
||||
_randomize_scale_x = value
|
||||
if _randomize_scale: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _randomize_scale_y: bool = false
|
||||
var randomize_scale_y: bool:
|
||||
get: return _randomize_scale_y
|
||||
set(value):
|
||||
_randomize_scale_y = value
|
||||
if _randomize_scale: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _randomize_scale_z: bool = false
|
||||
var randomize_scale_z: bool:
|
||||
get: return _randomize_scale_z
|
||||
set(value):
|
||||
_randomize_scale_z = value
|
||||
if _randomize_scale: _mark_dirty()
|
||||
notify_property_list_changed()
|
||||
|
||||
var _position_jitter: float = 0.0
|
||||
@export var position_jitter: float = 0.0:
|
||||
get: return _position_jitter
|
||||
set(value):
|
||||
_position_jitter = max(0.0, value)
|
||||
_mark_dirty()
|
||||
|
||||
var _random_seed: int = 0
|
||||
@export var random_seed: int = 0:
|
||||
get: return _random_seed
|
||||
set(value):
|
||||
_random_seed = value
|
||||
_mark_dirty()
|
||||
|
||||
# Estimated instance count (read-only in inspector; updated internally)
|
||||
@export var estimated_instances: int = 0
|
||||
|
||||
var rng: RandomNumberGenerator
|
||||
var _generation_in_progress := false
|
||||
var _pattern: CSGPattern
|
||||
@export var pattern: CSGPattern:
|
||||
get: return _pattern
|
||||
set(value):
|
||||
if value == _pattern:
|
||||
return
|
||||
# Reject non-CSGPattern resources
|
||||
if value != null and not (value is CSGPattern):
|
||||
push_warning("Assigned pattern is not a CSGPattern-derived resource; ignoring.")
|
||||
return
|
||||
# Prevent assigning the abstract base directly (must use subclass)
|
||||
if value != null and value.get_class() == "CSGPattern":
|
||||
push_warning("Cannot assign base CSGPattern directly. Please use a concrete pattern (Grid, Circular, Spiral...).")
|
||||
return
|
||||
# Disconnect old
|
||||
if _pattern and _pattern.is_connected("changed", Callable(self, "_on_pattern_changed")):
|
||||
_pattern.disconnect("changed", Callable(self, "_on_pattern_changed"))
|
||||
_pattern = value
|
||||
if _pattern and not _pattern.is_connected("changed", Callable(self, "_on_pattern_changed")):
|
||||
_pattern.connect("changed", Callable(self, "_on_pattern_changed"))
|
||||
_mark_dirty()
|
||||
|
||||
func _ready():
|
||||
rng = RandomNumberGenerator.new()
|
||||
# Provide a default pattern if none assigned (through setter for signal wiring).
|
||||
if pattern == null:
|
||||
pattern = CSGGridPattern.new()
|
||||
_mark_dirty()
|
||||
# Generate instances in-game on ready
|
||||
if not Engine.is_editor_hint():
|
||||
call_deferred("repeat_template")
|
||||
|
||||
func _on_pattern_changed():
|
||||
# Called when the assigned pattern resource's exported properties are edited in inspector.
|
||||
_mark_dirty()
|
||||
|
||||
func _process(_delta):
|
||||
if not Engine.is_editor_hint(): return
|
||||
if _dirty and not _generation_in_progress:
|
||||
_dirty = false
|
||||
call_deferred("repeat_template")
|
||||
|
||||
func _exit_tree():
|
||||
# Clean up any remaining repeated nodes
|
||||
clear_children()
|
||||
|
||||
func _mark_dirty():
|
||||
_dirty = true
|
||||
|
||||
func _update_template_visibility():
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var template_node = get_node_or_null(template_node_path)
|
||||
if template_node and template_node is Node3D:
|
||||
template_node.visible = not _hide_template
|
||||
|
||||
func clear_children():
|
||||
# Clear existing children except the template node
|
||||
var children_to_remove = []
|
||||
for child in get_children(true):
|
||||
if child.has_meta(REPEATER_NODE_META):
|
||||
children_to_remove.append(child)
|
||||
# Remove children immediately for better performance
|
||||
for child in children_to_remove:
|
||||
remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
func repeat_template():
|
||||
if _generation_in_progress:
|
||||
return
|
||||
_generation_in_progress = true
|
||||
clear_children()
|
||||
|
||||
var template_node = get_node_or_null(template_node_path)
|
||||
var using_scene = false
|
||||
# Determine template source
|
||||
if not template_node:
|
||||
if not template_node_scene or not template_node_scene.can_instantiate():
|
||||
_generation_in_progress = false
|
||||
return
|
||||
template_node = template_node_scene.instantiate()
|
||||
using_scene = true
|
||||
add_child(template_node)
|
||||
|
||||
# Use pattern estimation for cap check
|
||||
var template_size := _get_template_size(template_node)
|
||||
var ctx_cap := {"template_size": template_size, "rng": rng, "position_jitter": _position_jitter}
|
||||
var estimate := 0
|
||||
if pattern:
|
||||
estimate = pattern.get_estimated_count(ctx_cap)
|
||||
if estimate <= 1:
|
||||
if using_scene:
|
||||
remove_child(template_node)
|
||||
template_node.queue_free()
|
||||
_generation_in_progress = false
|
||||
return
|
||||
if estimate > MAX_INSTANCES:
|
||||
push_warning("CSGRepeater3D: Estimated count %s exceeds cap %s. Aborting generation." % [estimate, MAX_INSTANCES])
|
||||
_generation_in_progress = false
|
||||
return
|
||||
|
||||
rng.seed = _random_seed
|
||||
# template_size already computed earlier (template_size variable)
|
||||
var positions = _generate_positions(template_size)
|
||||
estimated_instances = positions.size() - 1
|
||||
|
||||
for i in range(positions.size()):
|
||||
var position = positions[i]
|
||||
if i == 0 and position.is_zero_approx():
|
||||
continue
|
||||
var instance = template_node.duplicate()
|
||||
if instance == null:
|
||||
continue
|
||||
instance.set_meta(REPEATER_NODE_META, true)
|
||||
instance.transform.origin = position
|
||||
# Ensure instance is visible regardless of template visibility
|
||||
if instance is Node3D:
|
||||
instance.visible = true
|
||||
_apply_variations(instance)
|
||||
add_child(instance)
|
||||
|
||||
if using_scene:
|
||||
remove_child(template_node)
|
||||
template_node.queue_free()
|
||||
else:
|
||||
_update_template_visibility()
|
||||
_generation_in_progress = false
|
||||
|
||||
func _generate_positions(template_size: Vector3) -> Array:
|
||||
var ctx: Dictionary = {"template_size": template_size, "rng": rng, "position_jitter": _position_jitter}
|
||||
if pattern == null:
|
||||
return []
|
||||
return pattern.generate(ctx)
|
||||
|
||||
|
||||
# -- Geometry-based spacing helpers -------------------------------------------------
|
||||
|
||||
func _get_template_size(template_node: Node) -> Vector3:
|
||||
if template_node == null or not (template_node is Node3D):
|
||||
return Vector3.ONE
|
||||
var aabb := _get_combined_aabb(template_node)
|
||||
var size: Vector3 = aabb.size
|
||||
if size.x <= 0.0001: size.x = 1.0
|
||||
if size.y <= 0.0001: size.y = 1.0
|
||||
if size.z <= 0.0001: size.z = 1.0
|
||||
return size
|
||||
|
||||
func _get_combined_aabb(node: Node) -> AABB:
|
||||
var found := false
|
||||
var combined := AABB()
|
||||
if node is Node3D and node.has_method("get_aabb"):
|
||||
var aabb = node.get_aabb()
|
||||
combined = aabb
|
||||
found = true
|
||||
for child in node.get_children():
|
||||
if child is Node3D:
|
||||
var child_aabb = _get_combined_aabb(child)
|
||||
if child_aabb.size != Vector3.ZERO:
|
||||
if not found:
|
||||
combined = child_aabb
|
||||
found = true
|
||||
else:
|
||||
combined = combined.merge(child_aabb)
|
||||
return combined if found else AABB(Vector3.ZERO, Vector3.ZERO)
|
||||
|
||||
func _apply_material_recursive(node: Node, material: Material):
|
||||
if node is CSGShape3D:
|
||||
node.material_override = material
|
||||
for child in node.get_children():
|
||||
_apply_material_recursive(child, material)
|
||||
|
||||
|
||||
func _apply_variations(instance: Node3D):
|
||||
if _randomize_rotation:
|
||||
var final_rot := instance.rotation
|
||||
if _randomize_rot_x:
|
||||
if _rotation_variance_x_deg > 0.0:
|
||||
final_rot.x += rng.randf_range(-deg_to_rad(_rotation_variance_x_deg), deg_to_rad(_rotation_variance_x_deg))
|
||||
else:
|
||||
final_rot.x = rng.randf() * TAU
|
||||
if _randomize_rot_y:
|
||||
if _rotation_variance_y_deg > 0.0:
|
||||
final_rot.y += rng.randf_range(-deg_to_rad(_rotation_variance_y_deg), deg_to_rad(_rotation_variance_y_deg))
|
||||
else:
|
||||
final_rot.y = rng.randf() * TAU
|
||||
if _randomize_rot_z:
|
||||
if _rotation_variance_z_deg > 0.0:
|
||||
final_rot.z += rng.randf_range(-deg_to_rad(_rotation_variance_z_deg), deg_to_rad(_rotation_variance_z_deg))
|
||||
else:
|
||||
final_rot.z = rng.randf() * TAU
|
||||
instance.rotation = final_rot
|
||||
if _randomize_scale:
|
||||
# If any axis toggles are on, apply independent variance per axis; else uniform.
|
||||
var use_axes = _randomize_scale_x or _randomize_scale_y or _randomize_scale_z
|
||||
if use_axes:
|
||||
var sx = instance.scale.x
|
||||
var sy = instance.scale.y
|
||||
var sz = instance.scale.z
|
||||
if _randomize_scale_x:
|
||||
var vx = (_scale_variance_x if _scale_variance_x > 0.0 else _scale_variance)
|
||||
sx *= max(0.1, 1.0 + rng.randf_range(-vx, vx))
|
||||
if _randomize_scale_y:
|
||||
var vy = (_scale_variance_y if _scale_variance_y > 0.0 else _scale_variance)
|
||||
sy *= max(0.1, 1.0 + rng.randf_range(-vy, vy))
|
||||
if _randomize_scale_z:
|
||||
var vz = (_scale_variance_z if _scale_variance_z > 0.0 else _scale_variance)
|
||||
sz *= max(0.1, 1.0 + rng.randf_range(-vz, vz))
|
||||
instance.scale = Vector3(sx, sy, sz)
|
||||
else:
|
||||
var scale_factor = max(0.1, 1.0 + rng.randf_range(-_scale_variance, _scale_variance))
|
||||
instance.scale *= scale_factor
|
||||
|
||||
func regenerate():
|
||||
_mark_dirty()
|
||||
|
||||
# -- Custom property list (Godot 4.5 group enable support) -------------------------
|
||||
func _get_property_list() -> Array:
|
||||
var props: Array = []
|
||||
|
||||
# Keep default exported properties (engine already exposes them). Only inject
|
||||
# the rotation variation cluster with group enable + subgroup organization.
|
||||
|
||||
# Group header for random rotation feature.
|
||||
# Variation Options parent group
|
||||
props.append({
|
||||
"name": "Variation Options",
|
||||
"type": TYPE_NIL,
|
||||
"usage": PROPERTY_USAGE_GROUP
|
||||
})
|
||||
# Rotation subgroup under Variation Options
|
||||
props.append({
|
||||
"name": "Rotation Randomization",
|
||||
"type": TYPE_NIL,
|
||||
"usage": PROPERTY_USAGE_SUBGROUP
|
||||
})
|
||||
# Enabling checkbox on group header via PROPERTY_HINT_GROUP_ENABLE.
|
||||
props.append({
|
||||
"name": "randomize_rotation",
|
||||
"type": TYPE_BOOL,
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR,
|
||||
"hint": PROPERTY_HINT_GROUP_ENABLE
|
||||
})
|
||||
# Per-axis random toggles
|
||||
props.append(_prop_bool("randomize_rot_x"))
|
||||
if _randomize_rotation and _randomize_rot_x:
|
||||
props.append({
|
||||
"name": "rotation_variance_x_deg",
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,360,0.1,degrees",
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
})
|
||||
props.append(_prop_bool("randomize_rot_y"))
|
||||
if _randomize_rotation and _randomize_rot_y:
|
||||
props.append({
|
||||
"name": "rotation_variance_y_deg",
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,360,0.1,degrees",
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
})
|
||||
props.append(_prop_bool("randomize_rot_z"))
|
||||
if _randomize_rotation and _randomize_rot_z:
|
||||
props.append({
|
||||
"name": "rotation_variance_z_deg",
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,360,0.1,degrees",
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
})
|
||||
|
||||
# Subgroup for locked rotations (should reside inside Rotation Randomization group)
|
||||
# (Locked rotations removed as per user request)
|
||||
|
||||
# Scale variation subgroup under Variation Options
|
||||
props.append({
|
||||
"name": "Scale Variation",
|
||||
"type": TYPE_NIL,
|
||||
"usage": PROPERTY_USAGE_SUBGROUP
|
||||
})
|
||||
props.append({
|
||||
"name": "randomize_scale",
|
||||
"type": TYPE_BOOL,
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR,
|
||||
"hint": PROPERTY_HINT_GROUP_ENABLE
|
||||
})
|
||||
props.append(_prop_float_range("scale_variance", "0,1,0.01"))
|
||||
props.append(_prop_bool("randomize_scale_x"))
|
||||
if _randomize_scale and _randomize_scale_x:
|
||||
props.append(_prop_float_range("scale_variance_x", "0,1,0.01"))
|
||||
props.append(_prop_bool("randomize_scale_y"))
|
||||
if _randomize_scale and _randomize_scale_y:
|
||||
props.append(_prop_float_range("scale_variance_y", "0,1,0.01"))
|
||||
props.append(_prop_bool("randomize_scale_z"))
|
||||
if _randomize_scale and _randomize_scale_z:
|
||||
props.append(_prop_float_range("scale_variance_z", "0,1,0.01"))
|
||||
|
||||
return props
|
||||
|
||||
func _prop_bool(name: String) -> Dictionary:
|
||||
return {
|
||||
"name": name,
|
||||
"type": TYPE_BOOL,
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
}
|
||||
|
||||
func _prop_float_deg(name: String, value: float) -> Dictionary:
|
||||
return {
|
||||
"name": name,
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "-360,360,0.1,degrees",
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
}
|
||||
|
||||
func _prop_float_range(name: String, hint_str: String) -> Dictionary:
|
||||
return {
|
||||
"name": name,
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": hint_str,
|
||||
"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR
|
||||
}
|
||||
|
||||
func get_instance_count() -> int:
|
||||
if pattern == null:
|
||||
return 0
|
||||
var ctx := {"template_size": Vector3.ONE, "rng": rng, "position_jitter": _position_jitter}
|
||||
return max(0, pattern.get_estimated_count(ctx) - 1)
|
||||
|
||||
func apply_template():
|
||||
if get_child_count() == 0:
|
||||
return
|
||||
var stack = []
|
||||
stack.append_array(get_children())
|
||||
while stack.size() > 0:
|
||||
var node = stack.pop_back()
|
||||
node.set_owner(owner)
|
||||
stack.append_array(node.get_children())
|
||||
|
||||
# Alias for clarity in UI
|
||||
func bake_instances():
|
||||
apply_template()
|
||||
1
addons/csg_toolkit/scripts/csg_repeater_3d.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_repeater_3d.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c68dxahp0v5xg
|
||||
86
addons/csg_toolkit/scripts/csg_shortcut_manager.gd
Normal file
86
addons/csg_toolkit/scripts/csg_shortcut_manager.gd
Normal file
@@ -0,0 +1,86 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CsgShortcutManager
|
||||
|
||||
# Provides global key handling for quick CSG creation & operation switching (Layers 1 & 2)
|
||||
# Delegates actual creation to the sidebar instance to reuse UndoRedo + material logic.
|
||||
|
||||
var sidebar: CSGSideToolkitBar
|
||||
var config: CsgTkConfig
|
||||
|
||||
# Mapping shape keycode -> factory id (string used for log / optional future use)
|
||||
var _shape_key_map: Dictionary = {
|
||||
KEY_B: CSGBox3D,
|
||||
KEY_S: CSGSphere3D,
|
||||
KEY_C: CSGCylinder3D,
|
||||
KEY_T: CSGTorus3D,
|
||||
KEY_M: CSGMesh3D,
|
||||
KEY_P: CSGPolygon3D,
|
||||
}
|
||||
|
||||
# Layer 2 operation selection numbers
|
||||
var _op_number_map: Dictionary = {
|
||||
KEY_1: 0, # Union
|
||||
KEY_2: 1, # Intersection
|
||||
KEY_3: 2, # Subtraction
|
||||
}
|
||||
|
||||
# Optional cycle order
|
||||
var _op_cycle: Array = [0,1,2]
|
||||
var _cycle_index := 0
|
||||
|
||||
func _enter_tree():
|
||||
set_process_unhandled_key_input(true)
|
||||
|
||||
func _unhandled_key_input(event: InputEvent):
|
||||
if not event is InputEventKey: return
|
||||
var ev := event as InputEventKey
|
||||
if not ev.pressed or ev.echo: return
|
||||
if config == null:
|
||||
config = get_tree().root.get_node_or_null(CsgToolkit.AUTOLOAD_NAME) as CsgTkConfig
|
||||
if sidebar == null:
|
||||
# Try to find existing sidebar if not explicitly set
|
||||
var candidates = get_tree().get_nodes_in_group("CSGSideToolkit")
|
||||
if candidates.size() > 0:
|
||||
sidebar = candidates[0]
|
||||
# Prevent interfering with text input fields
|
||||
var focus_owner = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner and (focus_owner is LineEdit or focus_owner is TextEdit):
|
||||
return
|
||||
|
||||
# Operation & shape shortcuts only trigger when primary action key is held (secondary key reserved for behavior inversion in creation)
|
||||
if Input.is_key_pressed(config.action_key):
|
||||
if ev.physical_keycode in _op_number_map:
|
||||
var op_val = _op_number_map[ev.physical_keycode]
|
||||
sidebar.set_operation(op_val)
|
||||
_print_feedback("Op -> %s" % _op_label(op_val))
|
||||
return
|
||||
# Cycle operation with backtick (`) or TAB
|
||||
if ev.physical_keycode in [KEY_APOSTROPHE, KEY_QUOTELEFT, KEY_TAB]:
|
||||
_cycle_index = (_cycle_index + 1) % _op_cycle.size()
|
||||
var cyc_op = _op_cycle[_cycle_index]
|
||||
sidebar.set_operation(cyc_op)
|
||||
_print_feedback("Op Cycle -> %s" % _op_label(cyc_op))
|
||||
return
|
||||
# Direct shape create (Layer 1)
|
||||
if ev.physical_keycode in _shape_key_map:
|
||||
_create_shape(_shape_key_map[ev.physical_keycode])
|
||||
return
|
||||
|
||||
func _create_shape(type_ref: Variant):
|
||||
if sidebar == null:
|
||||
_print_feedback("No sidebar found for creation")
|
||||
return
|
||||
# Delegates to sidebar logic (handles operation, insertion mode, UndoRedo, materials)
|
||||
sidebar.create_csg(type_ref)
|
||||
_print_feedback("Create %s (%s)" % [type_ref, _op_label(sidebar.operation)])
|
||||
|
||||
func _op_label(op: int) -> String:
|
||||
match op:
|
||||
0: return "Union"
|
||||
1: return "Intersect"
|
||||
2: return "Subtract"
|
||||
_: return str(op)
|
||||
|
||||
func _print_feedback(msg: String):
|
||||
print("CSG Toolkit: %s" % msg)
|
||||
1
addons/csg_toolkit/scripts/csg_shortcut_manager.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_shortcut_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://1bqvx7teqnrl
|
||||
236
addons/csg_toolkit/scripts/csg_side_toolkit_bar.gd
Normal file
236
addons/csg_toolkit/scripts/csg_side_toolkit_bar.gd
Normal file
@@ -0,0 +1,236 @@
|
||||
@tool
|
||||
class_name CSGSideToolkitBar extends Control
|
||||
|
||||
@onready var config: CsgTkConfig:
|
||||
get:
|
||||
return get_tree().root.get_node_or_null(CsgToolkit.AUTOLOAD_NAME) as CsgTkConfig
|
||||
|
||||
var operation: CSGShape3D.Operation = CSGShape3D.OPERATION_UNION
|
||||
var selected_material: BaseMaterial3D
|
||||
var selected_shader: ShaderMaterial
|
||||
|
||||
@onready var picker_button: Button = $ScrollContainer/HBoxContainer/Material/MaterialPicker
|
||||
|
||||
func _enter_tree():
|
||||
EditorInterface.get_selection().selection_changed.connect(_on_selection_changed)
|
||||
|
||||
func _exit_tree():
|
||||
EditorInterface.get_selection().selection_changed.disconnect(_on_selection_changed)
|
||||
|
||||
func _on_selection_changed():
|
||||
if not config.auto_hide:
|
||||
return
|
||||
var selection = EditorInterface.get_selection().get_selected_nodes()
|
||||
if selection.any(func (node): return node is CSGShape3D):
|
||||
show()
|
||||
else:
|
||||
hide()
|
||||
|
||||
func _ready():
|
||||
picker_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
# Connect material picker button if not connected via scene
|
||||
if not picker_button.pressed.is_connected(_on_material_picker_pressed):
|
||||
picker_button.pressed.connect(_on_material_picker_pressed)
|
||||
set_process_unhandled_key_input(true)
|
||||
|
||||
func _unhandled_key_input(event: InputEvent):
|
||||
# Shortcut: action_key + 1/2/3 to set operation (Union / Intersection / Subtraction)
|
||||
if not (event is InputEventKey):
|
||||
return
|
||||
var ev := event as InputEventKey
|
||||
if ev.pressed and not ev.echo:
|
||||
# Ensure action key is held (config.action_key)
|
||||
if Input.is_key_pressed(config.action_key):
|
||||
match ev.physical_keycode:
|
||||
KEY_1, KEY_KP_1:
|
||||
set_operation(0)
|
||||
_accept_shortcut_feedback("Union")
|
||||
KEY_2, KEY_KP_2:
|
||||
set_operation(1)
|
||||
_accept_shortcut_feedback("Intersection")
|
||||
KEY_3, KEY_KP_3:
|
||||
set_operation(2)
|
||||
_accept_shortcut_feedback("Subtraction")
|
||||
|
||||
func _accept_shortcut_feedback(label: String):
|
||||
# Provide lightweight visual/editor feedback. Avoid static call to non-existent get_status_bar in Godot 4.
|
||||
# Fallback: print to output.
|
||||
var ei = EditorInterface
|
||||
if ei:
|
||||
# Some editor builds expose status bar via base control's children - skip deep search for now.
|
||||
print("CSG Operation: %s" % label)
|
||||
else:
|
||||
print("CSG Operation: %s" % label)
|
||||
|
||||
func _on_box_pressed():
|
||||
create_csg(CSGBox3D)
|
||||
|
||||
func _on_cylinder_pressed():
|
||||
create_csg(CSGCylinder3D)
|
||||
|
||||
func _on_mesh_pressed():
|
||||
create_csg(CSGMesh3D)
|
||||
|
||||
func _on_polygon_pressed():
|
||||
create_csg(CSGPolygon3D)
|
||||
|
||||
func _on_sphere_pressed():
|
||||
create_csg(CSGSphere3D)
|
||||
|
||||
func _on_torus_pressed():
|
||||
create_csg(CSGTorus3D)
|
||||
|
||||
# Operation Toggle (accept optional arg for signal variations)
|
||||
func _on_operation_pressed(val := 0):
|
||||
set_operation(val)
|
||||
|
||||
func _on_config_pressed():
|
||||
var config_view_scene = preload("res://addons/csg_toolkit/scenes/config_window.tscn")
|
||||
var config_view = config_view_scene.instantiate()
|
||||
config_view.close_requested.connect(func ():
|
||||
get_tree().root.remove_child(config_view)
|
||||
config_view.queue_free()
|
||||
)
|
||||
get_tree().root.add_child(config_view)
|
||||
|
||||
func _request_material():
|
||||
var dialog = EditorFileDialog.new()
|
||||
dialog.title = "Select Material"
|
||||
dialog.display_mode = EditorFileDialog.DISPLAY_LIST
|
||||
dialog.filters = ["*.tres, *.material, *.res"]
|
||||
dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
|
||||
dialog.position = ((EditorInterface.get_base_control().size / 2) as Vector2i) - dialog.size
|
||||
dialog.close_requested.connect(func ():
|
||||
get_tree().root.remove_child(dialog)
|
||||
dialog.queue_free()
|
||||
)
|
||||
get_tree().root.add_child(dialog)
|
||||
dialog.show()
|
||||
var res_path = await dialog.file_selected
|
||||
var res = ResourceLoader.load(res_path)
|
||||
if res == null:
|
||||
return
|
||||
if res is BaseMaterial3D:
|
||||
update_material(res)
|
||||
elif res is ShaderMaterial:
|
||||
update_shader(res)
|
||||
else:
|
||||
return
|
||||
var previewer = EditorInterface.get_resource_previewer()
|
||||
previewer.queue_edited_resource_preview(res, self, "_update_picker_icon", null)
|
||||
|
||||
func _update_picker_icon(path, preview, thumbnail, userdata):
|
||||
if preview:
|
||||
picker_button.icon = preview
|
||||
|
||||
func set_operation(val: int):
|
||||
match val:
|
||||
0: operation = CSGShape3D.OPERATION_UNION
|
||||
1: operation = CSGShape3D.OPERATION_INTERSECTION
|
||||
2: operation = CSGShape3D.OPERATION_SUBTRACTION
|
||||
_: operation = CSGShape3D.OPERATION_UNION
|
||||
|
||||
func update_material(material: BaseMaterial3D):
|
||||
selected_material = material
|
||||
selected_shader = null
|
||||
|
||||
func update_shader(shader: ShaderMaterial):
|
||||
selected_material = null
|
||||
selected_shader = shader
|
||||
|
||||
func create_csg(type: Variant):
|
||||
var selection = EditorInterface.get_selection()
|
||||
var selected_nodes = selection.get_selected_nodes()
|
||||
if selected_nodes.is_empty() or !(selected_nodes[0] is CSGShape3D):
|
||||
push_warning("Select a CSGShape3D to add a new CSG node")
|
||||
return
|
||||
var selected_node: CSGShape3D = selected_nodes[0]
|
||||
var csg: CSGShape3D
|
||||
match type:
|
||||
CSGBox3D: csg = CSGBox3D.new()
|
||||
CSGCylinder3D: csg = CSGCylinder3D.new()
|
||||
CSGSphere3D: csg = CSGSphere3D.new()
|
||||
CSGMesh3D: csg = CSGMesh3D.new()
|
||||
CSGPolygon3D: csg = CSGPolygon3D.new()
|
||||
CSGTorus3D: csg = CSGTorus3D.new()
|
||||
|
||||
csg.operation = operation
|
||||
if selected_material:
|
||||
csg.material = selected_material
|
||||
elif selected_shader:
|
||||
csg.material = selected_shader
|
||||
|
||||
if (selected_node.get_owner() == null):
|
||||
return
|
||||
|
||||
var parent: Node
|
||||
var add_as_child := false
|
||||
|
||||
# Behavior inversion now uses secondary_action_key (e.g. Alt) instead of primary action key
|
||||
var invert := Input.is_key_pressed(config.secondary_action_key)
|
||||
if config.default_behavior == CsgTkConfig.CSGBehavior.SIBLING:
|
||||
add_as_child = invert
|
||||
else:
|
||||
add_as_child = !invert
|
||||
|
||||
parent = selected_node if add_as_child else selected_node.get_parent()
|
||||
if parent == null:
|
||||
return
|
||||
|
||||
# Try undo manager path if plugin provided one
|
||||
if CsgToolkit.undo_manager:
|
||||
var insert_index := parent.get_child_count()
|
||||
CsgToolkit.undo_manager.create_action("Add %s" % csg.get_class())
|
||||
# DO methods
|
||||
CsgToolkit.undo_manager.add_do_method(self, "_undoable_add_csg", parent, csg, selected_node.get_owner(), selected_node.global_position, insert_index)
|
||||
CsgToolkit.undo_manager.add_do_method(self, "_select_created_csg", csg)
|
||||
# UNDO methods
|
||||
CsgToolkit.undo_manager.add_undo_method(self, "_undoable_remove_csg", parent, csg)
|
||||
CsgToolkit.undo_manager.add_undo_method(self, "_clear_selection_if", csg)
|
||||
CsgToolkit.undo_manager.commit_action()
|
||||
else:
|
||||
parent.add_child(csg, true)
|
||||
csg.owner = selected_node.get_owner()
|
||||
csg.global_position = selected_node.global_position
|
||||
call_deferred("_select_created_csg", csg)
|
||||
|
||||
func _deferred_select(csg: Node):
|
||||
call_deferred("_select_created_csg", csg)
|
||||
|
||||
func _undoable_add_csg(parent: Node, csg: CSGShape3D, owner_ref: Node, global_pos: Vector3, insert_index: int):
|
||||
if csg.get_parent() != parent:
|
||||
parent.add_child(csg, true)
|
||||
if insert_index >= 0 and insert_index < parent.get_child_count():
|
||||
parent.move_child(csg, insert_index)
|
||||
csg.owner = owner_ref
|
||||
csg.global_position = global_pos
|
||||
|
||||
func _undoable_remove_csg(parent: Node, csg: CSGShape3D):
|
||||
if csg.get_parent() == parent:
|
||||
parent.remove_child(csg)
|
||||
# Intentionally do NOT free node so redo can re-add it. If you need memory, implement a recreate pattern instead.
|
||||
|
||||
func _clear_selection_if(csg: Node):
|
||||
var selection = EditorInterface.get_selection()
|
||||
if selection:
|
||||
var nodes: Array = selection.get_selected_nodes()
|
||||
if csg in nodes:
|
||||
selection.remove_node(csg)
|
||||
|
||||
func _select_created_csg(csg: Node):
|
||||
var selection = EditorInterface.get_selection()
|
||||
selection.clear()
|
||||
selection.add_node(csg)
|
||||
|
||||
func _add_as_child(selected_node: CSGShape3D, csg: CSGShape3D):
|
||||
selected_node.add_child(csg, true)
|
||||
csg.owner = selected_node.get_owner()
|
||||
csg.global_position = selected_node.global_position
|
||||
|
||||
func _add_as_sibling(selected_node: CSGShape3D, csg: CSGShape3D):
|
||||
selected_node.get_parent().add_child(csg, true)
|
||||
csg.owner = selected_node.get_owner()
|
||||
csg.global_position = selected_node.global_position
|
||||
|
||||
func _on_material_picker_pressed() -> void:
|
||||
_request_material()
|
||||
1
addons/csg_toolkit/scripts/csg_side_toolkit_bar.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_side_toolkit_bar.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dr5f1egll7hdq
|
||||
280
addons/csg_toolkit/scripts/csg_spreader_3d.gd
Normal file
280
addons/csg_toolkit/scripts/csg_spreader_3d.gd
Normal file
@@ -0,0 +1,280 @@
|
||||
@tool
|
||||
class_name CSGSpreader3D extends CSGCombiner3D
|
||||
|
||||
const SPREADER_NODE_META = "SPREADER_NODE_META"
|
||||
const MAX_INSTANCES = 20000
|
||||
|
||||
var _dirty: bool = false
|
||||
var _generation_in_progress := false
|
||||
var _template_node_path: NodePath
|
||||
@export var template_node_path: NodePath:
|
||||
get: return _template_node_path
|
||||
set(value):
|
||||
_template_node_path = value
|
||||
_mark_dirty()
|
||||
|
||||
var _hide_template: bool = true
|
||||
@export var hide_template: bool = true:
|
||||
get: return _hide_template
|
||||
set(value):
|
||||
_hide_template = value
|
||||
_update_template_visibility()
|
||||
|
||||
var _spread_area_3d: Shape3D = null
|
||||
@export var spread_area_3d: Shape3D = null:
|
||||
get: return _spread_area_3d
|
||||
set(value):
|
||||
_spread_area_3d = value
|
||||
_mark_dirty()
|
||||
|
||||
var _max_count: int = 10
|
||||
@export var max_count: int = 10:
|
||||
get: return _max_count
|
||||
set(value):
|
||||
_max_count = clamp(value, 1, 100000)
|
||||
_mark_dirty()
|
||||
|
||||
@export_group("Spread Options")
|
||||
var _noise_threshold: float = 0.5
|
||||
@export var noise_threshold: float = 0.5:
|
||||
get: return _noise_threshold
|
||||
set(value):
|
||||
_noise_threshold = clamp(value, 0.0, 1.0)
|
||||
_mark_dirty()
|
||||
|
||||
var _seed: int = 0
|
||||
@export var seed: int = 0:
|
||||
get: return _seed
|
||||
set(value):
|
||||
_seed = value
|
||||
_mark_dirty()
|
||||
|
||||
var _allow_rotation: bool = false
|
||||
@export var allow_rotation: bool = false:
|
||||
get: return _allow_rotation
|
||||
set(value):
|
||||
_allow_rotation = value
|
||||
_mark_dirty()
|
||||
|
||||
var _allow_scale: bool = false
|
||||
@export var allow_scale: bool = false:
|
||||
get: return _allow_scale
|
||||
set(value):
|
||||
_allow_scale = value
|
||||
_mark_dirty()
|
||||
|
||||
var _snap_distance = 0
|
||||
@export var snap_distance = 0:
|
||||
get: return _snap_distance
|
||||
set(value):
|
||||
_snap_distance = value
|
||||
_mark_dirty()
|
||||
|
||||
@export_group("Collision Options")
|
||||
var _avoid_overlaps: bool = false
|
||||
@export var avoid_overlaps: bool = false:
|
||||
get: return _avoid_overlaps
|
||||
set(value):
|
||||
_avoid_overlaps = value
|
||||
_mark_dirty()
|
||||
|
||||
var _min_distance: float = 1.0
|
||||
@export var min_distance: float = 1.0:
|
||||
get: return _min_distance
|
||||
set(value):
|
||||
_min_distance = max(0.0, value)
|
||||
_mark_dirty()
|
||||
|
||||
var _max_placement_attempts: int = 100
|
||||
@export var max_placement_attempts: int = 100:
|
||||
get: return _max_placement_attempts
|
||||
set(value):
|
||||
_max_placement_attempts = clamp(value, 10, 1000)
|
||||
_mark_dirty()
|
||||
|
||||
@export var estimated_instances: int = 0
|
||||
|
||||
var rng: RandomNumberGenerator
|
||||
|
||||
func _ready():
|
||||
rng = RandomNumberGenerator.new()
|
||||
_mark_dirty()
|
||||
# Generate instances in-game on ready
|
||||
if not Engine.is_editor_hint():
|
||||
call_deferred("spread_template")
|
||||
|
||||
func _process(_delta):
|
||||
if not Engine.is_editor_hint(): return
|
||||
if _dirty and not _generation_in_progress:
|
||||
_dirty = false
|
||||
call_deferred("spread_template")
|
||||
|
||||
func _exit_tree():
|
||||
if not Engine.is_editor_hint():
|
||||
return
|
||||
clear_children()
|
||||
|
||||
func _mark_dirty():
|
||||
_dirty = true
|
||||
|
||||
func _update_template_visibility():
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var template_node = get_node_or_null(template_node_path)
|
||||
if template_node and template_node is Node3D:
|
||||
template_node.visible = not _hide_template
|
||||
|
||||
func clear_children():
|
||||
var children_to_remove = []
|
||||
for child in get_children(true):
|
||||
if child.has_meta(SPREADER_NODE_META):
|
||||
children_to_remove.append(child)
|
||||
for child in children_to_remove:
|
||||
remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
func get_random_position_in_area() -> Vector3:
|
||||
if spread_area_3d is SphereShape3D:
|
||||
var radius = spread_area_3d.get_radius()
|
||||
var u = rng.randf()
|
||||
var v = rng.randf()
|
||||
var theta = u * TAU
|
||||
var phi = acos(2.0 * v - 1.0)
|
||||
var r = radius * pow(rng.randf(), 1.0/3.0)
|
||||
return Vector3(r * sin(phi) * cos(theta), r * sin(phi) * sin(theta), r * cos(phi))
|
||||
if spread_area_3d is BoxShape3D:
|
||||
var size = spread_area_3d.size
|
||||
return Vector3(
|
||||
rng.randf_range(-size.x * 0.5, size.x * 0.5),
|
||||
rng.randf_range(-size.y * 0.5, size.y * 0.5),
|
||||
rng.randf_range(-size.z * 0.5, size.z * 0.5)
|
||||
)
|
||||
if spread_area_3d is CapsuleShape3D:
|
||||
var radius = spread_area_3d.get_radius()
|
||||
var height = spread_area_3d.get_height() * 0.5
|
||||
if rng.randf() < noise_threshold:
|
||||
var angle = rng.randf() * TAU
|
||||
var r = radius * sqrt(rng.randf())
|
||||
return Vector3(r * cos(angle), rng.randf_range(-height, height), r * sin(angle))
|
||||
else:
|
||||
var hemisphere_y = height if rng.randf() < noise_threshold else -height
|
||||
var u = rng.randf()
|
||||
var v = rng.randf()
|
||||
var theta = u * TAU
|
||||
var phi = acos(1.0 - v)
|
||||
var r = radius * pow(rng.randf(), 1.0/3.0)
|
||||
return Vector3(
|
||||
r * sin(phi) * cos(theta),
|
||||
hemisphere_y + r * cos(phi) * (1 if hemisphere_y > 0 else -1),
|
||||
r * sin(phi) * sin(theta)
|
||||
)
|
||||
if spread_area_3d is CylinderShape3D:
|
||||
var radius = spread_area_3d.get_radius()
|
||||
var height = spread_area_3d.get_height() * 0.5
|
||||
var angle = rng.randf() * TAU
|
||||
var r = radius * sqrt(rng.randf())
|
||||
return Vector3(r * cos(angle), rng.randf_range(-height, height), r * sin(angle))
|
||||
if spread_area_3d is HeightMapShape3D:
|
||||
var width = spread_area_3d.map_width
|
||||
var depth = spread_area_3d.map_depth
|
||||
if width <= 0 or depth <= 0 or spread_area_3d.map_data.size() == 0:
|
||||
return Vector3.ZERO
|
||||
var x = rng.randi_range(0, width - 1)
|
||||
var z = rng.randi_range(0, depth - 1)
|
||||
var index = x + z * width
|
||||
if index < spread_area_3d.map_data.size():
|
||||
return Vector3(x, spread_area_3d.map_data[index], z)
|
||||
return Vector3.ZERO
|
||||
if spread_area_3d is WorldBoundaryShape3D:
|
||||
var bound = 100.0
|
||||
return Vector3(rng.randf_range(-bound, bound), 0, rng.randf_range(-bound, bound))
|
||||
if spread_area_3d is ConvexPolygonShape3D or spread_area_3d is ConcavePolygonShape3D:
|
||||
var pts = spread_area_3d.points if spread_area_3d.has_method("get_points") else []
|
||||
if pts.size() == 0:
|
||||
return Vector3.ZERO
|
||||
var min_point = pts[0]
|
||||
var max_point = pts[0]
|
||||
for p in pts:
|
||||
min_point = min_point.min(p)
|
||||
max_point = max_point.max(p)
|
||||
return Vector3(
|
||||
rng.randf_range(min_point.x, max_point.x),
|
||||
rng.randf_range(min_point.y, max_point.y),
|
||||
rng.randf_range(min_point.z, max_point.z)
|
||||
)
|
||||
push_warning("CSGSpreader3D: Shape type not supported")
|
||||
return Vector3.ZERO
|
||||
|
||||
func spread_template():
|
||||
if _generation_in_progress:
|
||||
return
|
||||
_generation_in_progress = true
|
||||
if not spread_area_3d:
|
||||
_generation_in_progress = false
|
||||
return
|
||||
clear_children()
|
||||
var template_node = get_node_or_null(template_node_path)
|
||||
if not template_node:
|
||||
_generation_in_progress = false
|
||||
return
|
||||
|
||||
rng.seed = _seed
|
||||
var instances_created = 0
|
||||
var placed_positions = []
|
||||
var budget = min(_max_count, MAX_INSTANCES)
|
||||
if _max_count > MAX_INSTANCES:
|
||||
push_warning("CSGSpreader3D: max_count %s exceeds cap %s. Limiting." % [_max_count, MAX_INSTANCES])
|
||||
for i in range(budget):
|
||||
var noise_value = rng.randf()
|
||||
if noise_value <= _noise_threshold:
|
||||
continue
|
||||
var position_found = false
|
||||
var final_position = Vector3.ZERO
|
||||
var attempts = _max_placement_attempts if _avoid_overlaps else 1
|
||||
for attempt in range(attempts):
|
||||
var test_position = get_random_position_in_area()
|
||||
if not _avoid_overlaps:
|
||||
final_position = test_position
|
||||
position_found = true
|
||||
break
|
||||
var overlap = false
|
||||
for existing_pos in placed_positions:
|
||||
if test_position.distance_to(existing_pos) < _min_distance:
|
||||
overlap = true
|
||||
break
|
||||
if not overlap:
|
||||
final_position = test_position
|
||||
position_found = true
|
||||
break
|
||||
if not position_found:
|
||||
continue
|
||||
var instance = template_node.duplicate()
|
||||
if instance == null:
|
||||
continue
|
||||
instance.set_meta(SPREADER_NODE_META, true)
|
||||
instance.transform.origin = final_position
|
||||
# Ensure instance is visible regardless of template visibility
|
||||
if instance is Node3D:
|
||||
instance.visible = true
|
||||
placed_positions.append(final_position)
|
||||
if _allow_rotation:
|
||||
var rotation_y = rng.randf_range(0, TAU)
|
||||
instance.rotate_y(rotation_y)
|
||||
if _allow_scale:
|
||||
var scale_factor = rng.randf_range(0.5, 2.0)
|
||||
instance.scale *= scale_factor
|
||||
add_child(instance)
|
||||
instances_created += 1
|
||||
estimated_instances = instances_created
|
||||
_update_template_visibility()
|
||||
_generation_in_progress = false
|
||||
|
||||
func bake_instances():
|
||||
if get_child_count() == 0:
|
||||
return
|
||||
var stack = []
|
||||
stack.append_array(get_children())
|
||||
while stack.size() > 0:
|
||||
var node = stack.pop_back()
|
||||
node.set_owner(owner)
|
||||
stack.append_array(node.get_children())
|
||||
1
addons/csg_toolkit/scripts/csg_spreader_3d.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_spreader_3d.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://rgfomqnhbhpk
|
||||
99
addons/csg_toolkit/scripts/csg_toolkit_config.gd
Normal file
99
addons/csg_toolkit/scripts/csg_toolkit_config.gd
Normal file
@@ -0,0 +1,99 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name CsgTkConfig
|
||||
|
||||
# ProjectSettings paths
|
||||
const SETTING_DEFAULT_BEHAVIOR = "addons/csg_toolkit/default_behavior"
|
||||
const SETTING_ACTION_KEY = "addons/csg_toolkit/action_key"
|
||||
const SETTING_SECONDARY_ACTION_KEY = "addons/csg_toolkit/secondary_action_key"
|
||||
const SETTING_AUTO_HIDE = "addons/csg_toolkit/auto_hide"
|
||||
|
||||
# Default values
|
||||
const DEFAULT_DEFAULT_BEHAVIOR = CSGBehavior.SIBLING
|
||||
const DEFAULT_ACTION_KEY = KEY_SHIFT
|
||||
const DEFAULT_SECONDARY_ACTION_KEY = KEY_ALT
|
||||
const DEFAULT_AUTO_HIDE = true
|
||||
|
||||
# Configurable properties
|
||||
|
||||
## Default behavior when adding new CSG nodes
|
||||
var default_behavior: CSGBehavior = CSGBehavior.SIBLING:
|
||||
get: return _get_setting(SETTING_DEFAULT_BEHAVIOR, DEFAULT_DEFAULT_BEHAVIOR)
|
||||
set(value): _set_setting(SETTING_DEFAULT_BEHAVIOR, value)
|
||||
|
||||
## Key to hold for primary action (e.g., adding CSG nodes)
|
||||
var action_key: Key = KEY_SHIFT:
|
||||
get: return _get_setting(SETTING_ACTION_KEY, DEFAULT_ACTION_KEY)
|
||||
set(value): _set_setting(SETTING_ACTION_KEY, value)
|
||||
|
||||
## Key to hold for secondary action (e.g., alternative CSG operations)
|
||||
var secondary_action_key: Key = KEY_ALT:
|
||||
get: return _get_setting(SETTING_SECONDARY_ACTION_KEY, DEFAULT_SECONDARY_ACTION_KEY)
|
||||
set(value): _set_setting(SETTING_SECONDARY_ACTION_KEY, value)
|
||||
|
||||
## Whether to auto-hide the CSG toolkit UI when not in use
|
||||
var auto_hide: bool = true:
|
||||
get: return _get_setting(SETTING_AUTO_HIDE, DEFAULT_AUTO_HIDE)
|
||||
set(value): _set_setting(SETTING_AUTO_HIDE, value)
|
||||
|
||||
signal config_saved()
|
||||
|
||||
enum CSGBehavior { SIBLING, CHILD }
|
||||
|
||||
func _enter_tree():
|
||||
_ensure_settings_exist()
|
||||
|
||||
func _ensure_settings_exist():
|
||||
"""Register settings in ProjectSettings if they don't exist."""
|
||||
if not ProjectSettings.has_setting(SETTING_DEFAULT_BEHAVIOR):
|
||||
ProjectSettings.set_setting(SETTING_DEFAULT_BEHAVIOR, DEFAULT_DEFAULT_BEHAVIOR)
|
||||
ProjectSettings.set_initial_value(SETTING_DEFAULT_BEHAVIOR, DEFAULT_DEFAULT_BEHAVIOR)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": SETTING_DEFAULT_BEHAVIOR,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_ENUM,
|
||||
"hint_string": "Sibling,Child"
|
||||
})
|
||||
|
||||
if not ProjectSettings.has_setting(SETTING_ACTION_KEY):
|
||||
ProjectSettings.set_setting(SETTING_ACTION_KEY, DEFAULT_ACTION_KEY)
|
||||
ProjectSettings.set_initial_value(SETTING_ACTION_KEY, DEFAULT_ACTION_KEY)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": SETTING_ACTION_KEY,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_NONE
|
||||
})
|
||||
|
||||
if not ProjectSettings.has_setting(SETTING_SECONDARY_ACTION_KEY):
|
||||
ProjectSettings.set_setting(SETTING_SECONDARY_ACTION_KEY, DEFAULT_SECONDARY_ACTION_KEY)
|
||||
ProjectSettings.set_initial_value(SETTING_SECONDARY_ACTION_KEY, DEFAULT_SECONDARY_ACTION_KEY)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": SETTING_SECONDARY_ACTION_KEY,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_NONE
|
||||
})
|
||||
|
||||
if not ProjectSettings.has_setting(SETTING_AUTO_HIDE):
|
||||
ProjectSettings.set_setting(SETTING_AUTO_HIDE, DEFAULT_AUTO_HIDE)
|
||||
ProjectSettings.set_initial_value(SETTING_AUTO_HIDE, DEFAULT_AUTO_HIDE)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": SETTING_AUTO_HIDE,
|
||||
"type": TYPE_BOOL
|
||||
})
|
||||
|
||||
func _get_setting(path: String, default_value: Variant) -> Variant:
|
||||
"""Get a setting from ProjectSettings."""
|
||||
return ProjectSettings.get_setting(path, default_value)
|
||||
|
||||
func _set_setting(path: String, value: Variant):
|
||||
"""Set a setting in ProjectSettings."""
|
||||
ProjectSettings.set_setting(path, value)
|
||||
|
||||
func save_config():
|
||||
"""Save settings to project.godot file."""
|
||||
var err = ProjectSettings.save()
|
||||
if err == OK:
|
||||
print("CsgToolkit: Saved Config to ProjectSettings")
|
||||
config_saved.emit()
|
||||
else:
|
||||
push_error("CsgToolkit: Failed to save config - error code %d" % err)
|
||||
1
addons/csg_toolkit/scripts/csg_toolkit_config.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_toolkit_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://w8ad8q4lneis
|
||||
41
addons/csg_toolkit/scripts/csg_top_toolkit_bar.gd
Normal file
41
addons/csg_toolkit/scripts/csg_top_toolkit_bar.gd
Normal file
@@ -0,0 +1,41 @@
|
||||
@tool
|
||||
class_name CSGTopToolkitBar extends Control
|
||||
|
||||
func _enter_tree():
|
||||
EditorInterface.get_selection().selection_changed.connect(_on_selection_changed)
|
||||
_on_selection_changed()
|
||||
# Attempt to find buttons and add tooltips if present
|
||||
var refresh_btn = find_child("Refresh", true, false)
|
||||
if refresh_btn and refresh_btn is Button:
|
||||
refresh_btn.tooltip_text = "Regenerate preview instances"
|
||||
var bake_btn = find_child("Bake", true, false)
|
||||
if bake_btn and bake_btn is Button:
|
||||
bake_btn.tooltip_text = "Bake generated instances into the scene (makes them persistent)"
|
||||
|
||||
func _exit_tree():
|
||||
EditorInterface.get_selection().selection_changed.disconnect(_on_selection_changed)
|
||||
|
||||
func _on_selection_changed():
|
||||
var selection = EditorInterface.get_selection().get_selected_nodes()
|
||||
if selection.is_empty():
|
||||
hide()
|
||||
elif selection[0] is CSGRepeater3D or selection[0] is CSGSpreader3D:
|
||||
show()
|
||||
else:
|
||||
hide()
|
||||
|
||||
func _on_refresh_pressed():
|
||||
var selection = EditorInterface.get_selection().get_selected_nodes()
|
||||
if (selection.is_empty()):
|
||||
return
|
||||
if selection[0] is CSGRepeater3D:
|
||||
selection[0].call("repeat_template")
|
||||
elif selection[0] is CSGSpreader3D:
|
||||
selection[0].call("spread_template")
|
||||
|
||||
func _on_bake_pressed():
|
||||
var selection = EditorInterface.get_selection().get_selected_nodes()
|
||||
if selection.is_empty():
|
||||
return
|
||||
if selection[0] is CSGRepeater3D or selection[0] is CSGSpreader3D:
|
||||
selection[0].call("bake_instances")
|
||||
1
addons/csg_toolkit/scripts/csg_top_toolkit_bar.gd.uid
Normal file
1
addons/csg_toolkit/scripts/csg_top_toolkit_bar.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dk6dt8fk1s43s
|
||||
31
addons/csg_toolkit/scripts/patterns/circular_pattern.gd
Normal file
31
addons/csg_toolkit/scripts/patterns/circular_pattern.gd
Normal file
@@ -0,0 +1,31 @@
|
||||
@tool
|
||||
class_name CSGCircularPattern
|
||||
extends CSGPattern
|
||||
|
||||
@export var radius: float = 5.0
|
||||
@export var points: int = 8
|
||||
@export var layers: int = 1
|
||||
## If 0 use template_size.y
|
||||
@export var layer_height: float = 0.0
|
||||
## Additional gap added per layer beyond base height
|
||||
@export var layer_spacing: float = 0.0
|
||||
|
||||
func _generate(ctx: Dictionary) -> Array:
|
||||
var positions: Array = []
|
||||
var template_size: Vector3 = ctx.get("template_size", Vector3.ONE)
|
||||
var rad: float = max(0.0, radius)
|
||||
var count: int = max(1, points)
|
||||
if count <= 1:
|
||||
return [Vector3.ZERO]
|
||||
var lyr_count = max(1, layers)
|
||||
var base_y = layer_height if layer_height > 0.0 else template_size.y
|
||||
var step_y = base_y + max(0.0, layer_spacing)
|
||||
for i in range(count):
|
||||
var angle = (i * TAU) / count
|
||||
var base_pos = Vector3(cos(angle) * rad, 0, sin(angle) * rad)
|
||||
for layer in range(lyr_count):
|
||||
positions.append(base_pos + Vector3(0, layer * step_y, 0))
|
||||
return positions
|
||||
|
||||
func get_estimated_count(ctx: Dictionary) -> int:
|
||||
return max(1, points) * max(1, layers)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3ws8vwtsqmjt
|
||||
19
addons/csg_toolkit/scripts/patterns/csg_pattern.gd
Normal file
19
addons/csg_toolkit/scripts/patterns/csg_pattern.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
@tool
|
||||
@abstract
|
||||
class_name CSGPattern
|
||||
extends Resource
|
||||
|
||||
## Base pattern interface. Subclasses implement _generate(RepeaterContext) returning Array[Vector3].
|
||||
|
||||
# Common interface call
|
||||
func generate(ctx: Dictionary) -> Array:
|
||||
# ctx expected keys: repeat: Vector3i, spacing: Vector3, rng: RandomNumberGenerator, step_spacing: Vector3, user: Node (repeater)
|
||||
return _generate(ctx)
|
||||
|
||||
func _generate(_ctx: Dictionary) -> Array:
|
||||
return []
|
||||
|
||||
func get_estimated_count(ctx: Dictionary) -> int:
|
||||
# Default: fallback to generating (may be overridden for performance/accuracy)
|
||||
var arr = _generate(ctx)
|
||||
return arr.size()
|
||||
1
addons/csg_toolkit/scripts/patterns/csg_pattern.gd.uid
Normal file
1
addons/csg_toolkit/scripts/patterns/csg_pattern.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dbavf0pl65chb
|
||||
33
addons/csg_toolkit/scripts/patterns/grid_pattern.gd
Normal file
33
addons/csg_toolkit/scripts/patterns/grid_pattern.gd
Normal file
@@ -0,0 +1,33 @@
|
||||
@tool
|
||||
class_name CSGGridPattern
|
||||
extends CSGPattern
|
||||
|
||||
@export var count_x: int = 2
|
||||
@export var count_y: int = 1
|
||||
@export var count_z: int = 1
|
||||
@export var spacing: Vector3 = Vector3.ZERO
|
||||
|
||||
## If true, automatically adds template AABB size to spacing for proper object separation
|
||||
@export var use_template_size: bool = true
|
||||
|
||||
func _generate(ctx: Dictionary) -> Array:
|
||||
var positions: Array = []
|
||||
var template_size: Vector3 = ctx.get("template_size", Vector3.ONE)
|
||||
var jitter: float = ctx.get("position_jitter", 0.0)
|
||||
var rng: RandomNumberGenerator = ctx.rng
|
||||
var cx = max(1, count_x)
|
||||
var cy = max(1, count_y)
|
||||
var cz = max(1, count_z)
|
||||
var base_step: Vector3 = (template_size if use_template_size else Vector3.ZERO) + spacing
|
||||
for x in range(cx):
|
||||
for y in range(cy):
|
||||
for z in range(cz):
|
||||
var position = Vector3(x * base_step.x, y * base_step.y, z * base_step.z)
|
||||
if jitter > 0.0:
|
||||
position += Vector3(
|
||||
rng.randf_range(-jitter, jitter),
|
||||
rng.randf_range(-jitter, jitter),
|
||||
rng.randf_range(-jitter, jitter)
|
||||
)
|
||||
positions.append(position)
|
||||
return positions
|
||||
1
addons/csg_toolkit/scripts/patterns/grid_pattern.gd.uid
Normal file
1
addons/csg_toolkit/scripts/patterns/grid_pattern.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bc1sxg4vy464o
|
||||
124
addons/csg_toolkit/scripts/patterns/noise_pattern.gd
Normal file
124
addons/csg_toolkit/scripts/patterns/noise_pattern.gd
Normal file
@@ -0,0 +1,124 @@
|
||||
@tool
|
||||
class_name CSGNoisePattern
|
||||
extends CSGPattern
|
||||
|
||||
## Generates instance positions based on noise sampling in a 3D volume
|
||||
## Instances are placed where noise value exceeds the threshold
|
||||
|
||||
##
|
||||
@export var bounds: Vector3 = Vector3(10, 10, 10)
|
||||
|
||||
##
|
||||
@export var sample_density: Vector3i = Vector3i(20, 1, 20)
|
||||
|
||||
##
|
||||
@export_range(0.0, 1.0) var noise_threshold: float = 0.5
|
||||
|
||||
##
|
||||
@export var noise_seed: int = 0
|
||||
|
||||
##
|
||||
@export_range(0.01, 100) var noise_frequency: float = 0.1
|
||||
|
||||
##
|
||||
@export_enum("Simplex", "Simplex Smooth", "Cellular", "Perlin", "Value Cubic", "Value") var noise_type: int = 0
|
||||
|
||||
##
|
||||
@export_enum("None", "OpenSimplex2", "OpenSimplex2S", "Cellular", "Perlin", "Value Cubic", "Value") var fractal_type: int = 0
|
||||
|
||||
##
|
||||
@export_range(1, 8) var fractal_octaves: int = 3
|
||||
|
||||
##
|
||||
@export var use_template_size: bool = false
|
||||
|
||||
var noise: FastNoiseLite
|
||||
|
||||
func _init():
|
||||
noise = FastNoiseLite.new()
|
||||
_update_noise()
|
||||
|
||||
func _update_noise():
|
||||
if not noise:
|
||||
noise = FastNoiseLite.new()
|
||||
|
||||
noise.seed = noise_seed
|
||||
noise.frequency = noise_frequency
|
||||
noise.fractal_octaves = fractal_octaves
|
||||
|
||||
# Map noise_type enum to FastNoiseLite types
|
||||
match noise_type:
|
||||
0: noise.noise_type = FastNoiseLite.TYPE_SIMPLEX
|
||||
1: noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
2: noise.noise_type = FastNoiseLite.TYPE_CELLULAR
|
||||
3: noise.noise_type = FastNoiseLite.TYPE_PERLIN
|
||||
4: noise.noise_type = FastNoiseLite.TYPE_VALUE_CUBIC
|
||||
5: noise.noise_type = FastNoiseLite.TYPE_VALUE
|
||||
|
||||
# Map fractal_type enum to FastNoiseLite fractal types
|
||||
match fractal_type:
|
||||
0: noise.fractal_type = FastNoiseLite.FRACTAL_NONE
|
||||
1: noise.fractal_type = FastNoiseLite.FRACTAL_FBM
|
||||
2: noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED
|
||||
3: noise.fractal_type = FastNoiseLite.FRACTAL_PING_PONG
|
||||
|
||||
func _generate(ctx: Dictionary) -> Array:
|
||||
_update_noise()
|
||||
|
||||
var positions: Array = []
|
||||
var template_size: Vector3 = ctx.get("template_size", Vector3.ONE) if use_template_size else Vector3.ZERO
|
||||
var jitter: float = ctx.get("position_jitter", 0.0)
|
||||
var rng: RandomNumberGenerator = ctx.get("rng", RandomNumberGenerator.new())
|
||||
|
||||
var effective_bounds = bounds
|
||||
var sample_count = sample_density
|
||||
|
||||
# Calculate step size for sampling
|
||||
var step = Vector3(
|
||||
effective_bounds.x / max(1, sample_count.x),
|
||||
effective_bounds.y / max(1, sample_count.y),
|
||||
effective_bounds.z / max(1, sample_count.z)
|
||||
)
|
||||
|
||||
# Start from negative half to center the pattern around origin
|
||||
var start_pos = -effective_bounds * 0.5
|
||||
|
||||
# Sample noise at regular intervals
|
||||
for x in range(sample_count.x):
|
||||
for y in range(sample_count.y):
|
||||
for z in range(sample_count.z):
|
||||
var sample_pos = start_pos + Vector3(
|
||||
x * step.x + step.x * 0.5,
|
||||
y * step.y + step.y * 0.5,
|
||||
z * step.z + step.z * 0.5
|
||||
)
|
||||
|
||||
# Get noise value at this position (normalized to 0-1)
|
||||
var noise_value = (noise.get_noise_3d(sample_pos.x, sample_pos.y, sample_pos.z) + 1.0) * 0.5
|
||||
|
||||
# Only place instance if noise exceeds threshold
|
||||
if noise_value >= noise_threshold:
|
||||
var final_pos = sample_pos
|
||||
|
||||
# Apply template size offset if enabled
|
||||
if use_template_size:
|
||||
final_pos += template_size * Vector3(x, y, z)
|
||||
|
||||
# Apply jitter
|
||||
if jitter > 0.0:
|
||||
final_pos += Vector3(
|
||||
rng.randf_range(-jitter, jitter),
|
||||
rng.randf_range(-jitter, jitter),
|
||||
rng.randf_range(-jitter, jitter)
|
||||
)
|
||||
|
||||
positions.append(final_pos)
|
||||
|
||||
return positions
|
||||
|
||||
func get_estimated_count(ctx: Dictionary) -> int:
|
||||
# Rough estimate: total samples * (1 - threshold)
|
||||
# Higher threshold = fewer instances
|
||||
var total_samples = max(1, sample_density.x) * max(1, sample_density.y) * max(1, sample_density.z)
|
||||
var estimated = int(total_samples * (1.0 - noise_threshold))
|
||||
return max(1, estimated)
|
||||
1
addons/csg_toolkit/scripts/patterns/noise_pattern.gd.uid
Normal file
1
addons/csg_toolkit/scripts/patterns/noise_pattern.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3il6xs7cr7gj
|
||||
39
addons/csg_toolkit/scripts/patterns/spiral_pattern.gd
Normal file
39
addons/csg_toolkit/scripts/patterns/spiral_pattern.gd
Normal file
@@ -0,0 +1,39 @@
|
||||
@tool
|
||||
class_name CSGSpiralPattern
|
||||
extends CSGPattern
|
||||
|
||||
@export var turns: float = 2.0
|
||||
@export var start_radius: float = 0.5
|
||||
@export var end_radius: float = 5.0
|
||||
## If > 0 overrides vertical spread based on repeat & step
|
||||
@export var total_height: float = 0.0
|
||||
@export var use_radius_curve: bool = false
|
||||
@export var radius_curve: Curve
|
||||
@export var points: int = 32
|
||||
|
||||
func _generate(ctx: Dictionary) -> Array:
|
||||
var positions: Array = []
|
||||
var template_size: Vector3 = ctx.get("template_size", Vector3.ONE)
|
||||
var t_turns: float = max(0.1, turns)
|
||||
var r_start: float = max(0.0, start_radius)
|
||||
var r_end: float = max(r_start, end_radius)
|
||||
var total: int = max(2, points)
|
||||
if total <= 1:
|
||||
return [Vector3.ZERO]
|
||||
for i in range(total):
|
||||
var t: float = float(i) / float(total - 1)
|
||||
var angle = t * t_turns * TAU
|
||||
var curve_t = t
|
||||
if use_radius_curve and radius_curve and radius_curve.get_point_count() > 0:
|
||||
curve_t = clamp(radius_curve.sample(t), 0.0, 1.0)
|
||||
var radius = lerp(r_start, r_end, curve_t)
|
||||
var y_pos: float = t * (total_height if total_height > 0.0 else template_size.y * 1.0)
|
||||
positions.append(Vector3(
|
||||
cos(angle) * radius,
|
||||
y_pos,
|
||||
sin(angle) * radius
|
||||
))
|
||||
return positions
|
||||
|
||||
func get_estimated_count(ctx: Dictionary) -> int:
|
||||
return max(2, points)
|
||||
@@ -0,0 +1 @@
|
||||
uid://belgcjd0ys212
|
||||
Reference in New Issue
Block a user