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