added CSG toolkit
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Successful in 7m54s
Create tag and build when new code gets to main / Export (push) Successful in 9m52s

This commit is contained in:
2026-02-06 18:35:38 +01:00
parent 77d405687c
commit 2b74c9e70c
65 changed files with 2723 additions and 1 deletions

View 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

View File

@@ -0,0 +1 @@
uid://b7isqiq2asnu6

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

View File

@@ -0,0 +1 @@
uid://c68dxahp0v5xg

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

View File

@@ -0,0 +1 @@
uid://1bqvx7teqnrl

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

View File

@@ -0,0 +1 @@
uid://dr5f1egll7hdq

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

View File

@@ -0,0 +1 @@
uid://rgfomqnhbhpk

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

View File

@@ -0,0 +1 @@
uid://w8ad8q4lneis

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

View File

@@ -0,0 +1 @@
uid://dk6dt8fk1s43s

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

View File

@@ -0,0 +1 @@
uid://b3ws8vwtsqmjt

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

View File

@@ -0,0 +1 @@
uid://dbavf0pl65chb

View 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

View File

@@ -0,0 +1 @@
uid://bc1sxg4vy464o

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

View File

@@ -0,0 +1 @@
uid://3il6xs7cr7gj

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

View File

@@ -0,0 +1 @@
uid://belgcjd0ys212