csg插件
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user