csg插件

This commit is contained in:
2026-10-01 05:36:12 +08:00
parent a1308cf8fd
commit 260a118b5d
93 changed files with 5217 additions and 0 deletions
@@ -0,0 +1,183 @@
@tool
extends RefCounted
class_name CsgBlockoutConfig
static var _instance: CsgBlockoutConfig
static func get_config() -> CsgBlockoutConfig:
if _instance == null:
_instance = CsgBlockoutConfig.new()
_instance._ensure_settings_exist()
return _instance
# ProjectSettings paths
const SETTING_ACTION_KEY = "addons/csg_blockout/action_key"
const SETTING_AUTO_HIDE = "addons/csg_blockout/auto_hide"
const SETTING_LANGUAGE_OVERRIDE = "addons/csg_blockout/language_override"
const SETTING_MATERIAL_PRESET = "addons/csg_blockout/material_preset"
const SETTING_DEFAULT_OPERATION = "addons/csg_blockout/default_operation"
const SETTING_CUSTOM_MATERIAL_PATH = "addons/csg_blockout/custom_material_path"
# Default values
const DEFAULT_ACTION_KEY = KEY_SHIFT
const DEFAULT_AUTO_HIDE = true
const DEFAULT_LANGUAGE_OVERRIDE = "auto"
const DEFAULT_MATERIAL_PRESET = MaterialPreset.GRID_LIGHT
enum MaterialPreset {
NONE = 0,
GRID_LIGHT = 1,
GRID_DARK = 2,
GRID_ORANGE = 3,
CUSTOM = 4
}
signal config_saved()
signal material_preset_changed(preset: MaterialPreset)
signal custom_material_changed(mat: Material)
signal default_operation_changed(op: CSGShape3D.Operation)
# Configurable properties
## Key to hold for primary action (e.g., opening pie menu)
var action_key: Key = KEY_SHIFT:
get: return _get_setting(SETTING_ACTION_KEY, DEFAULT_ACTION_KEY)
set(value): _set_setting(SETTING_ACTION_KEY, value)
## Whether to auto-hide the CSG blockout 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)
## Language override (auto, en, zh_CN, ja, ko, es, pt, ru)
var language_override: String = "auto":
get: return _get_setting(SETTING_LANGUAGE_OVERRIDE, DEFAULT_LANGUAGE_OVERRIDE)
set(value): _set_setting(SETTING_LANGUAGE_OVERRIDE, value)
## Material Preset (None, Light Grid, Dark Grid, Orange Grid, Custom)
var material_preset: MaterialPreset = MaterialPreset.GRID_LIGHT:
get: return _get_setting(SETTING_MATERIAL_PRESET, DEFAULT_MATERIAL_PRESET) as MaterialPreset
set(value):
_set_setting(SETTING_MATERIAL_PRESET, value)
material_preset_changed.emit(value)
## Default CSG boolean operation applied to newly created nodes (shared by pie menu & sidebar)
var default_operation: CSGShape3D.Operation = CSGShape3D.OPERATION_UNION:
get: return _get_setting(SETTING_DEFAULT_OPERATION, CSGShape3D.OPERATION_UNION) as CSGShape3D.Operation
set(value):
_set_setting(SETTING_DEFAULT_OPERATION, value)
default_operation_changed.emit(value)
var custom_material: Material = null:
get:
if custom_material == null:
var path: String = _get_setting(SETTING_CUSTOM_MATERIAL_PATH, "")
if not path.is_empty() and ResourceLoader.exists(path):
custom_material = ResourceLoader.load(path) as Material
return custom_material
set(value):
custom_material = value
_set_setting(SETTING_CUSTOM_MATERIAL_PATH, value.resource_path if value != null else "")
custom_material_changed.emit(value)
func _ensure_settings_exist() -> void:
"""Register settings in ProjectSettings if they don't exist."""
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_ENUM,
"hint_string": "Shift:%d,Ctrl:%d,Alt:%d,Meta:%d" % [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META]
})
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
})
if not ProjectSettings.has_setting(SETTING_LANGUAGE_OVERRIDE):
ProjectSettings.set_setting(SETTING_LANGUAGE_OVERRIDE, DEFAULT_LANGUAGE_OVERRIDE)
ProjectSettings.set_initial_value(SETTING_LANGUAGE_OVERRIDE, DEFAULT_LANGUAGE_OVERRIDE)
ProjectSettings.add_property_info({
"name": SETTING_LANGUAGE_OVERRIDE,
"type": TYPE_STRING,
"hint": PROPERTY_HINT_ENUM,
"hint_string": "auto,en,zh_CN,ja,ko,es,pt,ru"
})
if not ProjectSettings.has_setting(SETTING_MATERIAL_PRESET):
ProjectSettings.set_setting(SETTING_MATERIAL_PRESET, DEFAULT_MATERIAL_PRESET)
ProjectSettings.set_initial_value(SETTING_MATERIAL_PRESET, DEFAULT_MATERIAL_PRESET)
ProjectSettings.add_property_info({
"name": SETTING_MATERIAL_PRESET,
"type": TYPE_INT,
"hint": PROPERTY_HINT_ENUM,
"hint_string": "None,Light Grid,Dark Grid,Orange Grid,Custom"
})
if not ProjectSettings.has_setting(SETTING_DEFAULT_OPERATION):
ProjectSettings.set_setting(SETTING_DEFAULT_OPERATION, CSGShape3D.OPERATION_UNION)
ProjectSettings.set_initial_value(SETTING_DEFAULT_OPERATION, CSGShape3D.OPERATION_UNION)
ProjectSettings.add_property_info({
"name": SETTING_DEFAULT_OPERATION,
"type": TYPE_INT,
"hint": PROPERTY_HINT_ENUM,
"hint_string": "Union:0,Intersection:1,Subtraction:2"
})
func get_preset_material(preset: MaterialPreset) -> Material:
var file_name := ""
match preset:
MaterialPreset.GRID_LIGHT:
file_name = "mat_grid_light.tres"
MaterialPreset.GRID_DARK:
file_name = "mat_grid_dark.tres"
MaterialPreset.GRID_ORANGE:
file_name = "mat_grid_orange.tres"
_:
return null
var paths := [
"res://addons/csg_blockout/res/materials/" + file_name,
"res://res/materials/" + file_name
]
if not CsgBlockout.csg_plugin_path.is_empty():
paths.push_front(CsgBlockout.csg_plugin_path.path_join("res/materials").path_join(file_name))
for p in paths:
if ResourceLoader.exists(p):
return ResourceLoader.load(p) as Material
return null
func get_active_material() -> Material:
match material_preset:
MaterialPreset.NONE:
return null
MaterialPreset.GRID_LIGHT, MaterialPreset.GRID_DARK, MaterialPreset.GRID_ORANGE:
return get_preset_material(material_preset)
MaterialPreset.CUSTOM:
return custom_material
_:
return null
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) -> void:
"""Set a setting in ProjectSettings."""
ProjectSettings.set_setting(path, value)
func save_config() -> void:
"""Save settings to project.godot file."""
var err = ProjectSettings.save()
if err == OK:
print("CsgBlockout: Saved Config to ProjectSettings")
config_saved.emit()
else:
push_error("CsgBlockout: Failed to save config - error code %d" % err)
@@ -0,0 +1 @@
uid://w8ad8q4lneis
@@ -0,0 +1,429 @@
@tool
class_name CsgBlockoutI18n
extends RefCounted
const TABLE: Dictionary = {
"CSG_BLOCKOUT_SETTINGS": {
"zh": "CSG Blockout 设置",
"en": "CSG Blockout Settings",
"ja": "CSG Blockout 設定",
"ko": "CSG Blockout 설정",
"es": "Configuración de CSG Blockout",
"pt": "Configurações do CSG Blockout",
"ru": "Настройки CSG Blockout"
},
"BOX": {
"zh": "立方体",
"en": "Box",
"ja": "直方体",
"ko": "상자",
"es": "Caja",
"pt": "Caixa",
"ru": "Куб"
},
"CYLINDER": {
"zh": "圆柱体",
"en": "Cylinder",
"ja": "円柱",
"ko": "원기둥",
"es": "Cilindro",
"pt": "Cilindro",
"ru": "Цилиндр"
},
"MESH": {
"zh": "网格",
"en": "Mesh",
"ja": "メッシュ",
"ko": "메시",
"es": "Malla",
"pt": "Malha",
"ru": "Сетка"
},
"POLYGON": {
"zh": "多边形",
"en": "Polygon",
"ja": "ポリゴン",
"ko": "다각형",
"es": "Polígono",
"pt": "Polígono",
"ru": "Многоугольник"
},
"SPHERE": {
"zh": "球体",
"en": "Sphere",
"ja": "球体",
"ko": "구",
"es": "Esfera",
"pt": "Esfera",
"ru": "Сфера"
},
"TORUS": {
"zh": "圆环",
"en": "Torus",
"ja": "トーラス",
"ko": "토러스",
"es": "Toro",
"pt": "Toro",
"ru": "Тор"
},
"UNION": {
"zh": "并集",
"en": "Union",
"ja": "和集合",
"ko": "합집합",
"es": "Unión",
"pt": "União",
"ru": "Объединение"
},
"INTERSECTION": {
"zh": "交集",
"en": "Intersection",
"ja": "積集合",
"ko": "교집합",
"es": "Intersección",
"pt": "Interseção",
"ru": "Пересечение"
},
"SUBTRACTION": {
"zh": "差集",
"en": "Subtraction",
"ja": "差集合",
"ko": "차집합",
"es": "Sustracción",
"pt": "Subtração",
"ru": "Вычитание"
},
"MATERIAL": {
"zh": "材质",
"en": "Material",
"ja": "マテリアル",
"ko": "머티리얼",
"es": "Material",
"pt": "Material",
"ru": "Материал"
},
"REGEN_PREVIEW_TOOLTIP": {
"zh": "重新生成预览实例",
"en": "Regenerate Preview Instances",
"ja": "プレビューインスタンスを再生成",
"ko": "미리보기 인스턴스 재생성",
"es": "Regenerar instancias de vista previa",
"pt": "Regenerar instâncias de pré-visualização",
"ru": "Перегенерировать экземпляры предпросмотра"
},
"BAKE_INSTANCES_TOOLTIP": {
"zh": "将生成的实例烘焙至场景中 (使其永久保留)",
"en": "Bake generated instances into scene (Make permanent)",
"ja": "生成されたインスタンスをシーンにベイク (永続化)",
"ko": "생성된 인스턴스를 씬에 베이크 (영구 유지)",
"es": "Hornear instancias generadas en la escena (hacer permanente)",
"pt": "Assar instâncias geradas na cena (tornar permanente)",
"ru": "Запечь созданные экземпляры в сцену (сделать постоянными)"
},
"SELECT_MATERIAL": {
"zh": "选择材质",
"en": "Select Material",
"ja": "マテリアルを選択",
"ko": "머티리얼 선택",
"es": "Seleccionar material",
"pt": "Selecionar material",
"ru": "Выбрать материал"
},
"WARN_SELECT_CSG_SHAPE": {
"zh": "请先选择一个 CSGShape3D 节点以添加新 CSG 节点",
"en": "Please select a CSGShape3D node first to add a new CSG node",
"ja": "新しいCSGノードを追加するには、まずCSGShape3Dノードを選択してください",
"ko": "새 CSG 노드를 추가하려면 먼저 CSGShape3D 노드를 선택하세요",
"es": "Por favor, seleccione un nodo CSGShape3D primero para agregar un nuevo nodo CSG",
"pt": "Selecione primeiro um nó CSGShape3D para adicionar um novo nó CSG",
"ru": "Пожалуйста, сначала выберите узел CSGShape3D, чтобы добавить новый узел CSG"
},
"WARN_UNSUPPORTED_CSG_TYPE": {
"zh": "未知或不支持的 CSG 节点类型",
"en": "Unknown or unsupported CSG node type",
"ja": "不明または未対応のCSGノードタイプです",
"ko": "알 수 없거나 지원되지 않는 CSG 노드 유형입니다",
"es": "Tipo de nodo CSG desconocido o no compatible",
"pt": "Tipo de nó CSG desconhecido ou não suportado",
"ru": "Неизвестный или неподдерживаемый тип узла CSG"
},
"ADD_NODE": {
"zh": "添加 %s",
"en": "Add %s",
"ja": "%s を追加",
"ko": "%s 추가",
"es": "Añadir %s",
"pt": "Adicionar %s",
"ru": "Добавить %s"
},
"REFRESH": {
"zh": "刷新",
"en": "Refresh",
"ja": "更新",
"ko": "새로고침",
"es": "Actualizar",
"pt": "Atualizar",
"ru": "Обновить"
},
"BAKE": {
"zh": "烘焙",
"en": "Bake",
"ja": "ベイク",
"ko": "베이크",
"es": "Hornear",
"pt": "Assar",
"ru": "Запечь"
},
"CHANGE_CSG_OP": {
"zh": "更改 CSG 操作",
"en": "Change CSG Operation",
"ja": "CSG操作を変更",
"ko": "CSG 작업 변경",
"es": "Cambiar operación CSG",
"pt": "Alterar operação CSG",
"ru": "Изменить операцию CSG"
},
"CREATE_NODE": {
"zh": "创建 %s",
"en": "Create %s",
"ja": "%s を作成",
"ko": "%s 생성",
"es": "Crear %s",
"pt": "Criar %s",
"ru": "Создать %s"
},
"GRID_LIGHT": {
"zh": "灰白网格",
"en": "Light Grid",
"ja": "ライトグリッド",
"ko": "라이트 그리드",
"es": "Cuadrícula clara",
"pt": "Grade clara",
"ru": "Светлая сетка"
},
"GRID_DARK": {
"zh": "深灰网格",
"en": "Dark Grid",
"ja": "ダークグリッド",
"ko": "다크 그리드",
"es": "Cuadrícula oscura",
"pt": "Grade escura",
"ru": "Тёмная сетка"
},
"GRID_ORANGE": {
"zh": "橙色网格",
"en": "Orange Grid",
"ja": "オレンジグリッド",
"ko": "오렌지 그리드",
"es": "Cuadrícula naranja",
"pt": "Grade laranja",
"ru": "Оранжевая сетка"
},
"MATERIAL_NONE": {
"zh": "无材质(白模)",
"en": "No Material (White)",
"ja": "マテリアルなし(白モデル)",
"ko": "머티리얼 없음(흰색)",
"es": "Sin material (blanco)",
"pt": "Sem material (branco)",
"ru": "Без материала (белый)"
},
"MATERIAL_CUSTOM": {
"zh": "自定义材质",
"en": "Custom Material",
"ja": "カスタムマテリアル",
"ko": "사용자 정의 머티리얼",
"es": "Material personalizado",
"pt": "Material personalizado",
"ru": "Пользовательский материал"
},
"APPLY_MATERIAL_TO_SELECTED": {
"zh": "应用材质到选中节点",
"en": "Apply Material to Selected",
"ja": "選択したノードにマテリアルを適用",
"ko": "선택한 노드에 머티리얼 적용",
"es": "Aplicar material a seleccionados",
"pt": "Aplicar material aos selecionados",
"ru": "Применить материал к выбранным"
},
"APPLY_MATERIAL": {
"zh": "应用材质",
"en": "Apply Material",
"ja": "マテリアルを適用",
"ko": "머티리얼 적용",
"es": "Aplicar material",
"pt": "Aplicar material",
"ru": "Применить материал"
},
"CLEAR_MATERIAL": {
"zh": "清除材质",
"en": "Clear Material",
"ja": "マテリアルをクリア",
"ko": "머티리얼 제거",
"es": "Limpiar material",
"pt": "Limpar material",
"ru": "Очистить материал"
},
"LANGUAGE_TOOLTIP": {
"zh": "语言 / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"en": "Language / 语言 (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"ja": "言語 / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"ko": "언어 / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"es": "Idioma / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"pt": "Idioma / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)",
"ru": "Язык / Language (Auto / EN / 中文 / 日本語 / 한국어 / Español / Português / Русский)"
}
}
static var _reverse_initialized: bool = false
static var _reverse_map: Dictionary = {}
static func _ensure_reverse_map() -> void:
if _reverse_initialized:
return
_reverse_initialized = true
for key in TABLE:
var entry: Dictionary = TABLE[key]
for lang in entry:
var text: String = entry[lang]
_reverse_map[text] = key
_reverse_map[key] = key
# Node name and legacy compatibility aliases
_reverse_map["PresetLight"] = "GRID_LIGHT"
_reverse_map["PRESET_LIGHT"] = "GRID_LIGHT"
_reverse_map["PresetDark"] = "GRID_DARK"
_reverse_map["PRESET_DARK"] = "GRID_DARK"
_reverse_map["PresetOrange"] = "GRID_ORANGE"
_reverse_map["PRESET_ORANGE"] = "GRID_ORANGE"
_reverse_map["PresetNone"] = "MATERIAL_NONE"
_reverse_map["PRESET_NONE"] = "MATERIAL_NONE"
_reverse_map["MaterialPicker"] = "MATERIAL_CUSTOM"
_reverse_map["MATERIAL_PICKER"] = "MATERIAL_CUSTOM"
_reverse_map["ApplyToSelected"] = "APPLY_MATERIAL_TO_SELECTED"
_reverse_map["APPLY_TO_SELECTED"] = "APPLY_MATERIAL_TO_SELECTED"
_reverse_map["Refresh"] = "REFRESH"
_reverse_map["Bake"] = "BAKE"
_reverse_map["创建 "] = "CREATE_NODE"
_reverse_map["Create "] = "CREATE_NODE"
_reverse_map["Language / 语言"] = "LANGUAGE_TOOLTIP"
static func _normalize_locale(loc: String) -> String:
var l := loc.to_lower()
if l.begins_with("zh"):
return "zh"
elif l.begins_with("ja"):
return "ja"
elif l.begins_with("ko"):
return "ko"
elif l.begins_with("es"):
return "es"
elif l.begins_with("pt"):
return "pt"
elif l.begins_with("ru"):
return "ru"
return "en"
static func get_locale() -> String:
var config = CsgBlockoutConfig.get_config()
if config:
var override_lang: String = config.language_override
if override_lang != "" and override_lang != "auto":
return _normalize_locale(override_lang)
# 1. Safely check EditorInterface editor settings in editor hint
if Engine.is_editor_hint():
var editor_settings = EditorInterface.get_editor_settings()
if editor_settings:
var lang_setting = editor_settings.get_setting("interface/editor/editor_language")
if lang_setting != null:
var s := String(lang_setting).strip_edges()
if not s.is_empty() and s != "default" and s != "auto":
return _normalize_locale(s)
# 2. Check TranslationServer tool locale
var tool_locale := TranslationServer.get_tool_locale()
if not tool_locale.is_empty() and tool_locale != "en":
return _normalize_locale(tool_locale)
var general_locale := TranslationServer.get_locale()
if not general_locale.is_empty() and general_locale != "en":
return _normalize_locale(general_locale)
# 3. Check OS locale language
var os_locale := OS.get_locale_language()
if not os_locale.is_empty():
return _normalize_locale(os_locale)
var os_full := OS.get_locale()
if not os_full.is_empty():
return _normalize_locale(os_full)
return "en"
static func t(key_or_text: String) -> String:
_ensure_reverse_map()
var canonical_key: String = _reverse_map.get(key_or_text, key_or_text)
if TABLE.has(canonical_key):
var lang = get_locale()
return TABLE[canonical_key].get(lang, TABLE[canonical_key].get("en", key_or_text))
return key_or_text
static func tf(key_or_text: String, args: Array = []) -> String:
var tmpl := t(key_or_text)
if args.is_empty():
return tmpl
return tmpl % args
static func translate_node(node: Node) -> void:
if node == null:
return
# Disable Godot engine auto_translate to prevent Godot's built-in PO dictionary
# from hijacking common English words (e.g. Box, Cylinder, Mesh, Polygon, Sphere) back into Chinese.
if "auto_translate_mode" in node:
node.set("auto_translate_mode", 2) # Node.AUTO_TRANSLATE_MODE_DISABLED
if "auto_translate" in node:
node.set("auto_translate", false)
if node is OptionButton or node.name == "LanguageToggle":
for child in node.get_children():
translate_node(child)
return
_ensure_reverse_map()
if node is Control:
if not node.has_meta("i18n_tooltip_key"):
var key := ""
if node.tooltip_text != "":
key = _reverse_map.get(node.tooltip_text, "")
if key == "":
var node_name_str := String(node.name)
key = _reverse_map.get(node_name_str, "")
if key == "":
var snake_key := node_name_str.to_snake_case().to_upper()
key = _reverse_map.get(snake_key, "")
if key != "":
node.set_meta("i18n_tooltip_key", key)
if node.has_meta("i18n_tooltip_key"):
node.tooltip_text = t(node.get_meta("i18n_tooltip_key"))
if node is Button or node is Label:
if "text" in node and typeof(node.get("text")) == TYPE_STRING and node.get("text") != "":
if not node.has_meta("i18n_text_key"):
var key := _reverse_map.get(node.get("text"), "")
if key == "":
var node_name_str := String(node.name)
key = _reverse_map.get(node_name_str, "")
if key == "":
var snake_key := node_name_str.to_snake_case().to_upper()
key = _reverse_map.get(snake_key, "")
if key != "":
node.set_meta("i18n_text_key", key)
if node.has_meta("i18n_text_key"):
node.set("text", t(node.get_meta("i18n_text_key")))
for child in node.get_children():
translate_node(child)
@@ -0,0 +1 @@
uid://bvf7nvtsq1c06
+208
View File
@@ -0,0 +1,208 @@
@tool
class_name CsgPieMenu extends Control
signal action_triggered(item: Dictionary)
signal close_requested()
const BASE_INNER_RADIUS: float = 30.0
const BASE_OUTER_RADIUS: float = 120.0
const BASE_LINE_WIDTH: float = 2.0
const BASE_FONT_SIZE: int = 14
var editor_scale: float = 1.0
var items: Array = [] # Array[Dictionary]
var menu_stack: Array = [] # Array[Array]
var inner_radius: float = BASE_INNER_RADIUS
var outer_radius: float = BASE_OUTER_RADIUS
var active_item_index: int = -1
var is_in_deadzone: bool = true
# Style configuration
var bg_color: Color = Color(0.1, 0.1, 0.15, 0.85)
var hover_color: Color = Color(0.3, 0.5, 0.8, 0.95)
var text_color: Color = Color.WHITE
var line_color: Color = Color(0.2, 0.2, 0.2, 0.8)
var line_width: float = BASE_LINE_WIDTH
var font_size: int = BASE_FONT_SIZE
# Animation state
var anim_progress: float = 0.0
var popup_tween: Tween
var item_hover_progress: Array[float] = []
func _ready() -> void:
set_process(true)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func apply_editor_scale(scale: float) -> void:
editor_scale = maxf(scale, 0.1)
inner_radius = BASE_INNER_RADIUS * editor_scale
outer_radius = BASE_OUTER_RADIUS * editor_scale
line_width = BASE_LINE_WIDTH * editor_scale
font_size = int(round(BASE_FONT_SIZE * editor_scale))
queue_redraw()
func setup(menu_items: Array, p_scale: float = -1.0) -> void:
if p_scale > 0.0:
apply_editor_scale(p_scale)
items = menu_items
menu_stack.clear()
active_item_index = -1
_play_popup_anim()
func _play_popup_anim() -> void:
item_hover_progress.resize(items.size())
item_hover_progress.fill(0.0)
anim_progress = 0.0
if popup_tween and popup_tween.is_valid():
popup_tween.kill()
popup_tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
popup_tween.tween_property(self, "anim_progress", 1.0, 0.25)
queue_redraw()
func _process(delta: float) -> void:
if items.is_empty():
return
var mouse_pos: Vector2 = get_local_mouse_position()
var distance: float = mouse_pos.length()
var new_active_index: int = -1
is_in_deadzone = distance < inner_radius
if not is_in_deadzone and distance < outer_radius * 2.0:
var angle: float = mouse_pos.angle()
var sector_angle: float = TAU / items.size()
var shifted_angle: float = wrapf(angle + sector_angle / 2.0, 0.0, TAU)
new_active_index = int(shifted_angle / sector_angle) % items.size()
if new_active_index != active_item_index:
active_item_index = new_active_index
var needs_redraw: bool = false
if popup_tween and popup_tween.is_running():
needs_redraw = true
for i in range(items.size()):
var target: float = 1.0 if i == active_item_index else 0.0
if abs(item_hover_progress[i] - target) > 0.01:
item_hover_progress[i] = lerpf(item_hover_progress[i], target, 20.0 * delta)
needs_redraw = true
if needs_redraw:
queue_redraw()
func _draw() -> void:
if items.is_empty():
return
var current_outer: float = outer_radius * anim_progress
var current_inner: float = inner_radius * anim_progress
if current_outer <= 0.01:
return
var num_items: int = items.size()
var sector_angle: float = TAU / num_items
var font: Font = get_theme_font("font", "Label")
# Draw shadow
draw_circle(Vector2.ZERO, current_outer + 2.0 * editor_scale, Color(0, 0, 0, 0.3 * anim_progress))
for i in range(num_items):
var start_angle: float = i * sector_angle - sector_angle / 2.0
var end_angle: float = start_angle + sector_angle
var hover_t: float = item_hover_progress[i]
# Base color lerp + global alpha
var color: Color = bg_color.lerp(hover_color, hover_t)
color.a *= anim_progress
# Draw sector polygon
var points: PackedVector2Array = _get_sector_polygon(start_angle, end_angle, current_inner, current_outer, 16)
draw_polygon(points, PackedColorArray([color]))
# Draw separator lines
var line_start: Vector2 = Vector2(cos(start_angle), sin(start_angle)) * current_inner
var line_end: Vector2 = Vector2(cos(start_angle), sin(start_angle)) * current_outer
var l_color: Color = line_color
l_color.a *= anim_progress
draw_line(line_start, line_end, l_color, line_width, true)
# Draw item text
var center_angle: float = i * sector_angle
var text_radius: float = (current_inner + current_outer) / 2.0
var text_pos: Vector2 = Vector2(cos(center_angle), sin(center_angle)) * text_radius
var item: Dictionary = items[i]
var text: String = item.get("label", "")
var text_size: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
text_pos -= text_size / 2.0
text_pos.y += font.get_ascent(font_size) / 2.0
var t_color: Color = text_color
t_color.a *= anim_progress
draw_string(font, text_pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, t_color)
# Draw center deadzone indicator
if not menu_stack.is_empty():
var back_color: Color = hover_color if is_in_deadzone else Color(0.2, 0.2, 0.2, 0.8)
back_color.a *= anim_progress
draw_circle(Vector2.ZERO, current_inner - 2.0 * editor_scale, back_color)
var back_text: String = "<"
var bt_size: Vector2 = font.get_string_size(back_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var bt_pos: Vector2 = - bt_size / 2.0
bt_pos.y += font.get_ascent(font_size) / 2.0
var bt_color: Color = text_color
bt_color.a *= anim_progress
draw_string(font, bt_pos, back_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, bt_color)
func _get_sector_polygon(start_angle: float, end_angle: float, inner_r: float, outer_r: float, point_count: int) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(point_count + 1):
var t: float = float(i) / float(point_count)
var angle: float = lerpf(start_angle, end_angle, t)
points.append(Vector2(cos(angle), sin(angle)) * outer_r)
for i in range(point_count + 1):
var t: float = float(point_count - i) / float(point_count)
var angle: float = lerpf(start_angle, end_angle, t)
points.append(Vector2(cos(angle), sin(angle)) * inner_r)
return points
func execute_active_item(trigger_close_on_empty: bool = false) -> void:
if is_in_deadzone:
if not menu_stack.is_empty():
go_back()
elif trigger_close_on_empty:
close_requested.emit()
return
if active_item_index >= 0 and active_item_index < items.size():
var item: Dictionary = items[active_item_index]
if item.get("type") == "submenu":
action_triggered.emit(item)
menu_stack.push_back(items)
items = item.get("children", [])
active_item_index = -1
_play_popup_anim()
else:
action_triggered.emit(item)
close_requested.emit()
else:
if trigger_close_on_empty:
close_requested.emit()
func execute_back() -> void:
if not menu_stack.is_empty():
go_back()
else:
close_requested.emit()
func go_back() -> void:
if not menu_stack.is_empty():
items = menu_stack.pop_back()
active_item_index = -1
_play_popup_anim()
@@ -0,0 +1 @@
uid://brrnjx8pre80s
@@ -0,0 +1,596 @@
@tool
class_name CSGRepeater3D extends CSGCombiner3D
# NOTE: Registered as custom type in plugin (csg_blockout.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_blockout/scripts/patterns/grid_pattern.gd") # ensure subclass scripts loaded
const _REF_CIRC = preload("res://addons/csg_blockout/scripts/patterns/circular_pattern.gd")
const _REF_SPIRAL = preload("res://addons/csg_blockout/scripts/patterns/spiral_pattern.gd")
const _REF_NOISE = preload("res://addons/csg_blockout/scripts/patterns/noise_pattern.gd")
const REPEATER_NODE_META = "REPEATED_NODE_META"
const MAX_INSTANCES = 200
const REGEN_THROTTLE_MS: int = 150
const _CORNER_MASKS: Array[Vector3] = [
Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1),
Vector3(1, 1, 0), Vector3(1, 0, 1), Vector3(0, 1, 1), Vector3(1, 1, 1)
]
var _dirty: bool = false
var _last_regen_ms: int = 0
var _template_node: Node3D
@export var template_node: Node3D:
get: return _template_node
set(value):
_template_node = 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)
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)
# NOTE: get_class() returns the native class ("Resource"), not the script class_name,
# so the check must go through the attached script's global name.
if value != null and _is_base_pattern_script(value.get_script()):
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 _is_base_pattern_script(script: Script) -> bool:
return script != null and script.get_global_name() == "CSGPattern"
func _ready() -> void:
rng = RandomNumberGenerator.new()
# Provide a default pattern if none assigned (through setter for signal wiring).
if pattern == null:
pattern = CSGGridPattern.new()
_mark_dirty()
func _on_pattern_changed() -> void:
# Called when the assigned pattern resource's exported properties are edited in inspector.
_mark_dirty()
func _process(_delta: float) -> void:
if not Engine.is_editor_hint(): return
if _dirty and not _generation_in_progress:
var now := Time.get_ticks_msec()
if now - _last_regen_ms < REGEN_THROTTLE_MS:
return # 保留 _dirty,下帧再试,形成节流
_last_regen_ms = now
_dirty = false
call_deferred("repeat_template")
func _exit_tree() -> void:
# Only clean up in editor; at runtime instances belong to the scene
if not Engine.is_editor_hint():
return
clear_children()
func _mark_dirty() -> void:
_dirty = true
func _update_template_visibility() -> void:
if not is_inside_tree():
return
if is_instance_valid(_template_node):
_template_node.visible = not _hide_template
func clear_children() -> void:
# Clear existing children except the template node
var children_to_remove: Array[Node] = []
for child in get_children():
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() -> void:
if _generation_in_progress:
return
_generation_in_progress = true
clear_children()
var template_node = _template_node if is_instance_valid(_template_node) else null
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 early-out 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:
estimated_instances = 0
if using_scene:
remove_child(template_node)
template_node.queue_free()
_generation_in_progress = false
return
rng.seed = _random_seed
var positions = _generate_positions(template_size)
# Cap by truncation instead of aborting (preserve generated instances)
if positions.size() > MAX_INSTANCES:
push_warning("CSGRepeater3D: Generated count %s exceeds cap %s. Truncating." % [positions.size(), MAX_INSTANCES])
positions = positions.slice(0, MAX_INSTANCES)
# Exact count: deduct the origin template only when index 0 is the origin
estimated_instances = positions.size()
if not positions.is_empty() and positions[0].is_zero_approx():
estimated_instances -= 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[Vector3]:
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: bool = false
var combined: AABB = AABB()
if node is Node3D and node.has_method("get_aabb"):
var aabb: AABB = node.get_aabb()
combined = aabb
found = true
for child in node.get_children():
if not (child is Node3D):
continue
var child_aabb: AABB = _get_combined_aabb(child)
if child_aabb.size == Vector3.ZERO:
continue
var child_3d: Node3D = child
for mask in _CORNER_MASKS:
var corner: Vector3 = child_3d.transform * (child_aabb.position + mask * child_aabb.size)
if found:
combined = combined.expand(corner)
else:
combined = AABB(corner, Vector3.ZERO)
found = true
return combined if found else AABB(Vector3.ZERO, Vector3.ZERO)
func _apply_material_recursive(node: Node, material: Material) -> void:
if node is CSGShape3D:
node.material_override = material
for child in node.get_children():
_apply_material_recursive(child, material)
func _apply_variations(instance: Node3D) -> void:
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() -> void:
_mark_dirty()
# -- Custom property list (Godot 4.5 group enable support) -------------------------
func _get_property_list() -> Array[Dictionary]:
var props: Array[Dictionary] = []
# 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"))
# Read-only instance count display
props.append({
"name": "estimated_instances",
"type": TYPE_INT,
"usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_READ_ONLY
})
return props
func _prop_bool(name: String) -> Dictionary:
return {
"name": name,
"type": TYPE_BOOL,
"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 mini(pattern.get_estimated_count(ctx), MAX_INSTANCES)
func apply_template() -> void:
if get_child_count() == 0:
return
var target_owner: Node = owner
if target_owner == null:
if get_tree() and get_tree().edited_scene_root:
target_owner = get_tree().edited_scene_root
else:
target_owner = self
var stack: Array[Node] = []
stack.append_array(get_children())
while stack.size() > 0:
var node: Node = stack.pop_back()
node.set_owner(target_owner)
stack.append_array(node.get_children())
# Alias for clarity in UI
func bake_instances() -> void:
if get_child_count() == 0:
return
var target_owner: Node = owner
if target_owner == null:
if get_tree() and get_tree().edited_scene_root:
target_owner = get_tree().edited_scene_root
else:
target_owner = self
var baked: Array[Node] = []
for child in get_children(true):
if child.has_meta(REPEATER_NODE_META):
baked.append(child)
var stack: Array[Node] = []
stack.append_array(child.get_children())
while stack.size() > 0:
var node: Node = stack.pop_back()
baked.append(node)
stack.append_array(node.get_children())
if baked.is_empty():
return
if Engine.is_editor_hint() and CsgBlockout.undo_manager:
var um: EditorUndoRedoManager = CsgBlockout.undo_manager
um.create_action(CsgBlockoutI18n.t("BAKE"))
for node in baked:
um.add_undo_reference(node)
um.add_do_property(node, "owner", target_owner)
um.add_undo_property(node, "owner", node.owner)
um.add_do_method(self, "_bake_clear_meta", baked)
um.add_undo_method(self, "_bake_restore_meta", baked)
um.commit_action()
else:
for node in baked:
node.set_owner(target_owner)
_bake_clear_meta(baked)
func _bake_clear_meta(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node):
node.remove_meta(REPEATER_NODE_META)
func _bake_restore_meta(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node):
node.set_meta(REPEATER_NODE_META, true)
@@ -0,0 +1 @@
uid://c68dxahp0v5xg
@@ -0,0 +1,3 @@
@tool
# CSG Shortcut Manager has been removed. Shortcuts disabled.
extends Node
@@ -0,0 +1 @@
uid://1bqvx7teqnrl
@@ -0,0 +1,467 @@
@tool
class_name CSGSideBlockoutBar extends Control
var config: CsgBlockoutConfig:
get: return CsgBlockoutConfig.get_config()
const BASE_BAR_MIN_WIDTH: float = 52.0
const BASE_BTN_SIZE: Vector2 = Vector2(42.0, 42.0)
const BASE_MATERIAL_BTN_SIZE: Vector2 = Vector2(42.0, 36.0)
const BASE_ICON_MAX_WIDTH: int = 24
const BASE_MATERIAL_ICON_MAX_WIDTH: int = 22
const BASE_CONTAINER_SEPARATION: int = 12
const BASE_MARGIN_VERTICAL: int = 8
const BASE_LANG_BTN_SIZE: Vector2 = Vector2(36.0, 22.0)
const BASE_LANG_FONT_SIZE: int = 11
var button_tweens: Dictionary = {}
var visibility_tween: Tween
func _enter_tree() -> void:
add_to_group("csg_blockout_ui")
var sel := EditorInterface.get_selection()
if sel and not sel.selection_changed.is_connected(_on_selection_changed):
sel.selection_changed.connect(_on_selection_changed)
var cfg := config
if cfg and not cfg.default_operation_changed.is_connected(_on_default_operation_changed):
cfg.default_operation_changed.connect(_on_default_operation_changed)
CsgBlockoutI18n.translate_node(self)
func _exit_tree() -> void:
var sel := EditorInterface.get_selection()
if sel and sel.selection_changed.is_connected(_on_selection_changed):
sel.selection_changed.disconnect(_on_selection_changed)
var cfg := config
if cfg and cfg.default_operation_changed.is_connected(_on_default_operation_changed):
cfg.default_operation_changed.disconnect(_on_default_operation_changed)
func _on_default_operation_changed(_op: CSGShape3D.Operation) -> void:
_sync_operation_buttons()
func _on_selection_changed() -> void:
var auto_hide_enabled := config.auto_hide if config else true
if not auto_hide_enabled:
_fade_in()
return
var selection = EditorInterface.get_selection().get_selected_nodes()
if selection.any(func(node): return node is CSGShape3D):
_fade_in()
else:
_fade_out()
func _fade_in() -> void:
if visible and modulate.a >= 0.99: return
if visibility_tween and visibility_tween.is_valid():
visibility_tween.kill()
if not visible:
modulate.a = 0.0
show()
visibility_tween = create_tween().set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
visibility_tween.tween_property(self, "modulate:a", 1.0, 0.2)
func _fade_out() -> void:
if not visible: return
if visibility_tween and visibility_tween.is_valid():
visibility_tween.kill()
visibility_tween = create_tween().set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
visibility_tween.tween_property(self, "modulate:a", 0.0, 0.15)
visibility_tween.tween_callback(self.hide)
func _apply_editor_scale() -> void:
var ed_scale: float = 1.0
if Engine.is_editor_hint():
ed_scale = EditorInterface.get_editor_scale()
ed_scale = maxf(ed_scale, 0.1)
custom_minimum_size.x = round(BASE_BAR_MIN_WIDTH * ed_scale)
add_theme_constant_override("margin_top", int(round(BASE_MARGIN_VERTICAL * ed_scale)))
add_theme_constant_override("margin_bottom", int(round(BASE_MARGIN_VERTICAL * ed_scale)))
var hbox: Container = find_child("HBoxContainer", true, false) as Container
if hbox:
hbox.add_theme_constant_override("separation", int(round(BASE_CONTAINER_SEPARATION * ed_scale)))
var csg_box: Container = find_child("CSG", true, false) as Container
if csg_box:
for child in csg_box.get_children():
if child is Button:
child.custom_minimum_size = BASE_BTN_SIZE * ed_scale
child.add_theme_constant_override("icon_max_width", int(round(BASE_ICON_MAX_WIDTH * ed_scale)))
var op_box: Container = find_child("Operation", true, false) as Container
if op_box:
for child in op_box.get_children():
if child is Button:
child.custom_minimum_size = BASE_BTN_SIZE * ed_scale
child.add_theme_constant_override("icon_max_width", int(round(BASE_ICON_MAX_WIDTH * ed_scale)))
var mat_box: Container = find_child("Material", true, false) as Container
if mat_box:
for child in mat_box.get_children():
if child is Button:
child.custom_minimum_size = BASE_MATERIAL_BTN_SIZE * ed_scale
child.add_theme_constant_override("icon_max_width", int(round(BASE_MATERIAL_ICON_MAX_WIDTH * ed_scale)))
var lang_btn: OptionButton = find_child("LanguageToggle", true, false) as OptionButton
if lang_btn:
lang_btn.custom_minimum_size = BASE_LANG_BTN_SIZE * ed_scale
lang_btn.add_theme_font_size_override("font_size", int(round(BASE_LANG_FONT_SIZE * ed_scale)))
func _ready() -> void:
add_to_group("csg_blockout_ui")
_apply_editor_scale()
CsgBlockoutI18n.translate_node(self)
var lang_btn = find_child("LanguageToggle", true, false)
if lang_btn and lang_btn is OptionButton:
lang_btn.clear()
lang_btn.add_item("Auto", 0)
lang_btn.add_item("EN", 1)
lang_btn.add_item("中文", 2)
lang_btn.add_item("日本語", 3)
lang_btn.add_item("한국어", 4)
lang_btn.add_item("Español", 5)
lang_btn.add_item("Português", 6)
lang_btn.add_item("Русский", 7)
_update_language_toggle_text()
_setup_button_animations(self)
_sync_preset_buttons()
_sync_operation_buttons()
func update_language() -> void:
CsgBlockoutI18n.translate_node(self)
_update_language_toggle_text()
func _update_language_toggle_text() -> void:
var btn = find_child("LanguageToggle", true, false)
if btn and btn is OptionButton:
var override_lang = config.language_override if config else "auto"
match override_lang:
"auto", "":
btn.select(0)
"en":
btn.select(1)
"zh_CN", "zh":
btn.select(2)
"ja":
btn.select(3)
"ko":
btn.select(4)
"es":
btn.select(5)
"pt", "pt_BR", "pt_PT":
btn.select(6)
"ru":
btn.select(7)
_:
btn.select(0)
func _sync_preset_buttons() -> void:
if not config:
return
var preset = config.material_preset
var btn_light: Button = find_child("PresetLight", true, false) as Button
var btn_dark: Button = find_child("PresetDark", true, false) as Button
var btn_orange: Button = find_child("PresetOrange", true, false) as Button
var btn_none: Button = find_child("PresetNone", true, false) as Button
var btn_picker: Button = find_child("MaterialPicker", true, false) as Button
if btn_light: btn_light.button_pressed = (preset == CsgBlockoutConfig.MaterialPreset.GRID_LIGHT)
if btn_dark: btn_dark.button_pressed = (preset == CsgBlockoutConfig.MaterialPreset.GRID_DARK)
if btn_orange: btn_orange.button_pressed = (preset == CsgBlockoutConfig.MaterialPreset.GRID_ORANGE)
if btn_none: btn_none.button_pressed = (preset == CsgBlockoutConfig.MaterialPreset.NONE)
if btn_picker: btn_picker.button_pressed = (preset == CsgBlockoutConfig.MaterialPreset.CUSTOM)
func _setup_button_animations(node: Node) -> void:
if node is Button and not (node is OptionButton):
call_deferred("_apply_pivot", node)
if not node.resized.is_connected(_apply_pivot.bind(node)):
node.resized.connect(_apply_pivot.bind(node))
node.mouse_entered.connect(_on_btn_mouse_entered.bind(node))
node.mouse_exited.connect(_on_btn_mouse_exited.bind(node))
node.button_down.connect(_on_btn_down.bind(node))
node.button_up.connect(_on_btn_up.bind(node))
if node.toggle_mode:
node.toggled.connect(_on_btn_toggled.bind(node))
for child in node.get_children():
_setup_button_animations(child)
func _apply_pivot(btn: Button) -> void:
btn.pivot_offset = btn.size / 2.0
func _animate_button(btn: Button, target_scale: Vector2, target_modulate: Color, duration: float, is_punch: bool = false) -> void:
if button_tweens.has(btn) and is_instance_valid(button_tweens[btn]):
button_tweens[btn].kill()
var tw = create_tween()
if not is_punch:
tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
else:
tw.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.set_parallel(true)
tw.tween_property(btn, "scale", target_scale, duration)
tw.tween_property(btn, "modulate", target_modulate, duration)
button_tweens[btn] = tw
func _on_btn_mouse_entered(btn: Button) -> void:
var color = Color(1.3, 1.3, 1.3, 1.0) if not btn.button_pressed else Color(1.3, 1.5, 2.0, 1.0)
_animate_button(btn, Vector2(1.08, 1.08), color, 0.2)
func _on_btn_mouse_exited(btn: Button) -> void:
var color = Color.WHITE if not btn.button_pressed else Color(0.8, 1.2, 2.0, 1.0)
_animate_button(btn, Vector2(1.0, 1.0), color, 0.2)
func _on_btn_down(btn: Button) -> void:
_animate_button(btn, Vector2(0.9, 0.9), btn.modulate, 0.1, true)
func _on_btn_up(btn: Button) -> void:
var is_hovered = btn.get_global_rect().has_point(get_global_mouse_position())
var scale = Vector2(1.08, 1.08) if is_hovered else Vector2(1.0, 1.0)
var mod = Color(1.3, 1.3, 1.3, 1.0) if is_hovered else Color.WHITE
if btn.toggle_mode and btn.button_pressed:
mod = Color(1.3, 1.5, 2.0, 1.0) if is_hovered else Color(0.8, 1.2, 2.0, 1.0)
_animate_button(btn, scale, mod, 0.2)
func _on_btn_toggled(pressed: bool, btn: Button) -> void:
var is_hovered = btn.get_global_rect().has_point(get_global_mouse_position())
var scale = Vector2(1.08, 1.08) if is_hovered else Vector2(1.0, 1.0)
var mod = Color(1.3, 1.3, 1.3, 1.0) if is_hovered else Color.WHITE
if pressed:
mod = Color(1.3, 1.5, 2.0, 1.0) if is_hovered else Color(0.8, 1.2, 2.0, 1.0)
_animate_button(btn, scale, mod, 0.2)
func _on_box_pressed() -> void:
create_csg(CSGBox3D)
func _on_cylinder_pressed() -> void:
create_csg(CSGCylinder3D)
func _on_mesh_pressed() -> void:
create_csg(CSGMesh3D)
func _on_polygon_pressed() -> void:
create_csg(CSGPolygon3D)
func _on_sphere_pressed() -> void:
create_csg(CSGSphere3D)
func _on_torus_pressed() -> void:
create_csg(CSGTorus3D)
func _on_operation_pressed(val := 0) -> void:
set_operation(val)
func set_operation(val: int) -> void:
if not config:
return
match val:
0: config.default_operation = CSGShape3D.OPERATION_UNION
1: config.default_operation = CSGShape3D.OPERATION_INTERSECTION
2: config.default_operation = CSGShape3D.OPERATION_SUBTRACTION
_: config.default_operation = CSGShape3D.OPERATION_UNION
func _sync_operation_buttons() -> void:
var op: CSGShape3D.Operation = config.default_operation if config else CSGShape3D.OPERATION_UNION
var btn_union: Button = find_child("Union", true, false) as Button
var btn_intersection: Button = find_child("Intersection", true, false) as Button
var btn_subtraction: Button = find_child("Subtraction", true, false) as Button
if btn_union: btn_union.button_pressed = (op == CSGShape3D.OPERATION_UNION)
if btn_intersection: btn_intersection.button_pressed = (op == CSGShape3D.OPERATION_INTERSECTION)
if btn_subtraction: btn_subtraction.button_pressed = (op == CSGShape3D.OPERATION_SUBTRACTION)
# Material Preset Handlers
func _on_preset_light_pressed() -> void:
if config:
config.material_preset = CsgBlockoutConfig.MaterialPreset.GRID_LIGHT
config.save_config()
_sync_preset_buttons()
func _on_preset_dark_pressed() -> void:
if config:
config.material_preset = CsgBlockoutConfig.MaterialPreset.GRID_DARK
config.save_config()
_sync_preset_buttons()
func _on_preset_orange_pressed() -> void:
if config:
config.material_preset = CsgBlockoutConfig.MaterialPreset.GRID_ORANGE
config.save_config()
_sync_preset_buttons()
func _on_preset_none_pressed() -> void:
if config:
config.material_preset = CsgBlockoutConfig.MaterialPreset.NONE
config.save_config()
_sync_preset_buttons()
func _on_material_picker_pressed() -> void:
_request_material()
func _on_apply_to_selected_pressed() -> void:
var selection = EditorInterface.get_selection()
if not selection:
return
var selected_nodes = selection.get_selected_nodes().filter(func(n): return n is CSGShape3D)
if selected_nodes.is_empty():
push_warning(CsgBlockoutI18n.t("WARN_SELECT_CSG_SHAPE"))
return
var active_mat: Material = config.get_active_material() if config else null
if CsgBlockout.undo_manager:
CsgBlockout.undo_manager.create_action(CsgBlockoutI18n.t("APPLY_MATERIAL"))
for node in selected_nodes:
CsgBlockout.undo_manager.add_do_property(node, "material", active_mat)
CsgBlockout.undo_manager.add_undo_property(node, "material", (node as CSGShape3D).material)
CsgBlockout.undo_manager.commit_action()
else:
for node in selected_nodes:
(node as CSGShape3D).material = active_mat
func _request_material() -> void:
var dialog := EditorFileDialog.new()
dialog.title = CsgBlockoutI18n.t("SELECT_MATERIAL")
dialog.display_mode = EditorFileDialog.DISPLAY_LIST
dialog.filters = ["*.tres, *.material, *.res"]
dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
if EditorInterface.get_base_control():
var ed_scale: float = EditorInterface.get_editor_scale() if Engine.is_editor_hint() else 1.0
dialog.popup_centered_clamped(Vector2(680, 480) * ed_scale)
var cleanup_dialog := func() -> void:
if is_instance_valid(dialog) and dialog.get_parent():
dialog.get_parent().remove_child(dialog)
dialog.queue_free()
var on_file_selected := func(path: String) -> void:
cleanup_dialog.call()
if path.is_empty():
return
var res = ResourceLoader.load(path)
if res == null or not (res is Material):
return
if config:
config.custom_material = res as Material
config.material_preset = CsgBlockoutConfig.MaterialPreset.CUSTOM
config.save_config()
_sync_preset_buttons()
var previewer = EditorInterface.get_resource_previewer()
if previewer:
previewer.queue_edited_resource_preview(res, self, "_update_picker_icon", null)
var on_canceled := func() -> void:
cleanup_dialog.call()
_sync_preset_buttons()
dialog.file_selected.connect(on_file_selected)
dialog.canceled.connect(on_canceled)
get_tree().root.add_child(dialog)
dialog.show()
func _update_picker_icon(path: String, preview: Texture2D, thumbnail: Texture2D, userdata: Variant) -> void:
var btn_picker: Button = find_child("MaterialPicker", true, false) as Button
if preview and btn_picker:
btn_picker.icon = preview
func create_csg(type: Variant) -> void:
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(CsgBlockoutI18n.t("WARN_SELECT_CSG_SHAPE"))
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()
_:
push_warning(CsgBlockoutI18n.t("WARN_UNSUPPORTED_CSG_TYPE"))
return
csg.operation = config.default_operation if config else CSGShape3D.OPERATION_UNION
if config:
csg.material = config.get_active_material()
var owner_ref = EditorInterface.get_edited_scene_root()
if owner_ref == null:
owner_ref = selected_node.get_owner()
if owner_ref == null:
owner_ref = selected_node
var parent: Node = null
var insert_index: int = -1
if selected_node is CSGCombiner3D:
parent = selected_node
insert_index = parent.get_child_count()
else:
parent = selected_node.get_parent()
if parent == null:
parent = selected_node
insert_index = parent.get_child_count()
else:
insert_index = selected_node.get_index() + 1
if CsgBlockout.undo_manager:
CsgBlockout.undo_manager.create_action(CsgBlockoutI18n.tf("ADD_NODE", [csg.get_class()]))
CsgBlockout.undo_manager.add_undo_reference(csg)
CsgBlockout.undo_manager.add_do_method(self, "_undoable_add_csg", parent, csg, owner_ref, selected_node.global_position, insert_index)
CsgBlockout.undo_manager.add_do_method(self, "_select_created_csg", csg)
CsgBlockout.undo_manager.add_undo_method(self, "_undoable_remove_csg", parent, csg)
CsgBlockout.undo_manager.add_undo_method(self, "_clear_selection_if", csg)
CsgBlockout.undo_manager.commit_action()
else:
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 = selected_node.global_position
call_deferred("_select_created_csg", csg)
func _deferred_select(csg: Node) -> void:
call_deferred("_select_created_csg", csg)
func _undoable_add_csg(parent: Node, csg: CSGShape3D, owner_ref: Node, global_pos: Vector3, insert_index: int) -> void:
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) -> void:
if is_instance_valid(csg) and csg.get_parent() == parent:
parent.remove_child(csg)
func _clear_selection_if(csg: Node) -> void:
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) -> void:
if not is_instance_valid(csg) or not csg.is_inside_tree():
return
var selection = EditorInterface.get_selection()
selection.clear()
selection.add_node(csg)
func _on_language_toggle_item_selected(index: int) -> void:
if config:
match index:
0: config.language_override = "auto"
1: config.language_override = "en"
2: config.language_override = "zh_CN"
3: config.language_override = "ja"
4: config.language_override = "ko"
5: config.language_override = "es"
6: config.language_override = "pt"
7: config.language_override = "ru"
config.save_config()
get_tree().call_group("csg_blockout_ui", "update_language")
@@ -0,0 +1 @@
uid://dr5f1egll7hdq
@@ -0,0 +1,388 @@
@tool
class_name CSGSpreader3D extends CSGCombiner3D
const SPREADER_NODE_META = "SPREADER_NODE_META"
const MAX_INSTANCES = 200
const REGEN_THROTTLE_MS: int = 150
var _dirty: bool = false
var _last_regen_ms: int = 0
var _generation_in_progress := false
var _template_node: Node3D
@export var template_node: Node3D:
get: return _template_node
set(value):
_template_node = 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: float = 0.0
@export var snap_distance: float = 0.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()
var estimated_instances: int = 0
var rng: RandomNumberGenerator
func _ready() -> void:
rng = RandomNumberGenerator.new()
_mark_dirty()
func _process(_delta: float) -> void:
if not Engine.is_editor_hint(): return
if _dirty and not _generation_in_progress:
var now := Time.get_ticks_msec()
if now - _last_regen_ms < REGEN_THROTTLE_MS:
return # 保留 _dirty,下帧再试,形成节流
_last_regen_ms = now
_dirty = false
call_deferred("spread_template")
func _exit_tree() -> void:
if not Engine.is_editor_hint():
return
clear_children()
func _mark_dirty() -> void:
_dirty = true
func _update_template_visibility() -> void:
if not is_inside_tree():
return
if is_instance_valid(_template_node):
_template_node.visible = not _hide_template
func clear_children() -> void:
var children_to_remove: Array[Node] = []
for child in get_children():
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 == null:
return Vector3.ZERO
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 capsule: CapsuleShape3D = spread_area_3d
var radius: float = capsule.radius
var half_mid: float = maxf(0.0, capsule.height - 2.0 * capsule.radius) * 0.5
if rng.randf() < 0.5:
var angle: float = rng.randf() * TAU
var r: float = radius * sqrt(rng.randf())
return Vector3(r * cos(angle), rng.randf_range(-half_mid, half_mid), r * sin(angle))
var hemisphere_y: float = half_mid if rng.randf() < 0.5 else -half_mid
var theta: float = rng.randf() * TAU
var phi: float = acos(1.0 - rng.randf())
var cap_r: float = radius * pow(rng.randf(), 1.0 / 3.0)
var cap_sign: float = 1.0 if hemisphere_y > 0.0 else -1.0
return Vector3(
cap_r * sin(phi) * cos(theta),
hemisphere_y + cap_r * cos(phi) * cap_sign,
cap_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 ConcavePolygonShape3D:
var faces: PackedVector3Array = spread_area_3d.get_faces()
if faces.size() < 3:
return Vector3.ZERO
var tri_count = faces.size() / 3
var tri_idx = rng.randi_range(0, tri_count - 1) * 3
var a = faces[tri_idx]
var b = faces[tri_idx+1]
var c = faces[tri_idx+2]
var r1 = sqrt(rng.randf())
var r2 = rng.randf()
return (1.0 - r1) * a + (r1 * (1.0 - r2)) * b + (r1 * r2) * c
if spread_area_3d is ConvexPolygonShape3D:
var pts: PackedVector3Array = spread_area_3d.points
if pts.is_empty():
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() -> void:
if _generation_in_progress:
return
_generation_in_progress = true
if not spread_area_3d:
_generation_in_progress = false
return
clear_children()
if not is_instance_valid(_template_node):
_generation_in_progress = false
return
var template_node: Node3D = _template_node
rng.seed = _seed
var instances_created: int = 0
var placed_positions: Array[Vector3] = []
var budget: int = min(_max_count, MAX_INSTANCES)
if _max_count > MAX_INSTANCES:
push_warning("CSGSpreader3D: max_count %s exceeds cap %s. Limiting." % [_max_count, MAX_INSTANCES])
# Spatial Hash Grid for O(1) distance checks
# Cell edge must be >= min_distance so any point within min_distance of a
# candidate is always inside the +/-1 neighborhood (27 cells).
var cell_size: float = maxf(_min_distance, 0.001)
var spatial_grid: Dictionary = {} # Vector3i -> Array[Vector3]
for i in range(budget):
var noise_value: float = rng.randf()
if noise_value <= _noise_threshold:
continue
var position_found: bool = false
var final_position: Vector3 = Vector3.ZERO
var attempts: int = _max_placement_attempts if _avoid_overlaps else 1
for attempt in range(attempts):
var test_position: Vector3 = get_random_position_in_area()
if not _avoid_overlaps:
final_position = test_position
position_found = true
break
var overlap: bool = false
if cell_size > 0.001:
# O(1) spatial grid lookup
var grid_x: int = int(floor(test_position.x / cell_size))
var grid_y: int = int(floor(test_position.y / cell_size))
var grid_z: int = int(floor(test_position.z / cell_size))
# Check surrounding 27 cells (each cell holds all positions in it)
for dx in range(-1, 2):
for dy in range(-1, 2):
for dz in range(-1, 2):
var cell_key := Vector3i(grid_x + dx, grid_y + dy, grid_z + dz)
if not spatial_grid.has(cell_key):
continue
for existing_pos: Vector3 in spatial_grid[cell_key]:
if test_position.distance_to(existing_pos) < _min_distance:
overlap = true
break
if overlap: break
if overlap: break
if overlap: break
else:
# Fallback if min_distance is virtually zero
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: Node = template_node.duplicate()
if instance == null:
continue
instance.set_meta(SPREADER_NODE_META, true)
if instance is Node3D:
instance.transform.origin = final_position
instance.visible = true
if _allow_rotation:
var rotation_y: float = rng.randf_range(0.0, TAU)
instance.rotate_y(rotation_y)
if _allow_scale:
var scale_factor: float = rng.randf_range(0.5, 2.0)
instance.scale *= scale_factor
placed_positions.append(final_position)
if _avoid_overlaps and cell_size > 0.001:
var key := Vector3i(
int(floor(final_position.x / cell_size)),
int(floor(final_position.y / cell_size)),
int(floor(final_position.z / cell_size))
)
var cell_list: Array[Vector3] = []
if spatial_grid.has(key):
cell_list = spatial_grid[key]
cell_list.append(final_position)
spatial_grid[key] = cell_list
add_child(instance)
instances_created += 1
estimated_instances = instances_created
_update_template_visibility()
_generation_in_progress = false
func bake_instances() -> void:
if get_child_count() == 0:
return
var target_owner: Node = owner
if target_owner == null:
if get_tree() and get_tree().edited_scene_root:
target_owner = get_tree().edited_scene_root
else:
target_owner = self
var baked: Array[Node] = []
for child in get_children(true):
if child.has_meta(SPREADER_NODE_META):
baked.append(child)
var stack: Array[Node] = []
stack.append_array(child.get_children())
while stack.size() > 0:
var node: Node = stack.pop_back()
baked.append(node)
stack.append_array(node.get_children())
if baked.is_empty():
return
if Engine.is_editor_hint() and CsgBlockout.undo_manager:
var um: EditorUndoRedoManager = CsgBlockout.undo_manager
um.create_action(CsgBlockoutI18n.t("BAKE"))
for node in baked:
um.add_undo_reference(node)
um.add_do_property(node, "owner", target_owner)
um.add_undo_property(node, "owner", node.owner)
um.add_do_method(self, "_bake_clear_meta", baked)
um.add_undo_method(self, "_bake_restore_meta", baked)
um.commit_action()
else:
for node in baked:
node.set_owner(target_owner)
_bake_clear_meta(baked)
func _bake_clear_meta(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node):
node.remove_meta(SPREADER_NODE_META)
func _bake_restore_meta(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node):
node.set_meta(SPREADER_NODE_META, true)
func _get_property_list() -> Array[Dictionary]:
return [{
"name": "estimated_instances",
"type": TYPE_INT,
"usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_READ_ONLY
}]
@@ -0,0 +1 @@
uid://rgfomqnhbhpk
@@ -0,0 +1,54 @@
@tool
class_name CSGTopBlockoutBar extends Control
func _enter_tree() -> void:
add_to_group("csg_blockout_ui")
var sel := EditorInterface.get_selection()
if sel and not sel.selection_changed.is_connected(_on_selection_changed):
sel.selection_changed.connect(_on_selection_changed)
_on_selection_changed()
var refresh_btn = find_child("Refresh", true, false)
if refresh_btn and refresh_btn is Button:
refresh_btn.set_meta("i18n_text_key", "REFRESH")
refresh_btn.set_meta("i18n_tooltip_key", "REGEN_PREVIEW_TOOLTIP")
var bake_btn = find_child("Bake", true, false)
if bake_btn and bake_btn is Button:
bake_btn.set_meta("i18n_text_key", "BAKE")
bake_btn.set_meta("i18n_tooltip_key", "BAKE_INSTANCES_TOOLTIP")
CsgBlockoutI18n.translate_node(self)
func update_language() -> void:
CsgBlockoutI18n.translate_node(self)
func _exit_tree() -> void:
var sel := EditorInterface.get_selection()
if sel and sel.selection_changed.is_connected(_on_selection_changed):
sel.selection_changed.disconnect(_on_selection_changed)
func _on_selection_changed() -> void:
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() -> void:
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() -> void:
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")
@@ -0,0 +1 @@
uid://dk6dt8fk1s43s
@@ -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
@@ -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()
@@ -0,0 +1 @@
uid://dbavf0pl65chb
@@ -0,0 +1,36 @@
@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.get("rng", null)
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 and rng != null:
position += Vector3(
rng.randf_range(-jitter, jitter),
rng.randf_range(-jitter, jitter),
rng.randf_range(-jitter, jitter)
)
positions.append(position)
return positions
func get_estimated_count(_ctx: Dictionary) -> int:
return max(1, count_x) * max(1, count_y) * max(1, count_z)
@@ -0,0 +1 @@
uid://bc1sxg4vy464o
@@ -0,0 +1,130 @@
@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", "FBM", "Ridged", "Ping Pong") var fractal_type: int = 0
##
@export_range(1, 8) var fractal_octaves: int = 3
## When enabled, sampling spacing never drops below the template AABB size (prevents overlap)
@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)
)
# When enabled, spacing never drops below the template AABB size (prevents overlap),
# mirroring grid_pattern's use_template_size semantics. Bounds are re-centered afterwards.
if use_template_size and template_size != Vector3.ZERO:
step = Vector3(
maxf(step.x, template_size.x),
maxf(step.y, template_size.y),
maxf(step.z, template_size.z)
)
effective_bounds = step * Vector3(sample_count)
# 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 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)
@@ -0,0 +1 @@
uid://3il6xs7cr7gj
@@ -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