@tool extends Control enum ShapeType { AUTO, BOX, SPHERE, CAPSULE, CYLINDER, CONVEX, TRIMESH } enum BodyType { STATIC, RIGID, CHARACTER, AREA, NONE } enum Axis { X, Y, Z } # Constants for auto-detection thresholds const SPHERE_RATIO_THRESHOLD = 1.3 const CAPSULE_RATIO_THRESHOLD = 2.0 var target_mesh: MeshInstance3D var preview_node: MeshInstance3D @onready var shape_option: OptionButton @onready var body_option: OptionButton @onready var as_child_check: CheckBox @onready var auto_center_check: CheckBox @onready var margin_spin: SpinBox @onready var capsule_axis_option: OptionButton @onready var cylinder_axis_option: OptionButton @onready var axis_container: Control @onready var create_button: Button @onready var preview_check: CheckBox @onready var target_label: Label class ShapeFactory: static func create_box(size: Vector3) -> BoxShape3D: var shape = BoxShape3D.new() shape.size = size return shape static func create_sphere(size: Vector3) -> SphereShape3D: var shape = SphereShape3D.new() shape.radius = size.length() / 3.0 return shape static func create_capsule(size: Vector3, axis: Axis) -> CapsuleShape3D: var shape = CapsuleShape3D.new() var dims = _get_capsule_dimensions(size, axis) shape.radius = dims.x shape.height = dims.y return shape static func create_cylinder(size: Vector3, axis: Axis) -> CylinderShape3D: var shape = CylinderShape3D.new() var dims = _get_capsule_dimensions(size, axis) shape.radius = dims.x shape.height = dims.y return shape static func _get_capsule_dimensions(size: Vector3, axis: Axis) -> Vector2: match axis: Axis.X: return Vector2((size.y + size.z) / 4.0, size.x) Axis.Y: return Vector2((size.x + size.z) / 4.0, size.y) Axis.Z: return Vector2((size.x + size.y) / 4.0, size.z) _: return Vector2.ZERO func _ready(): _setup_ui() _connect_signals() func _setup_ui(): var vbox = _create_main_container() _create_header(vbox) _create_controls(vbox) _create_actions(vbox) vbox.add_spacer(false) func _create_main_container() -> VBoxContainer: var vbox = VBoxContainer.new() vbox.name = "VBox" add_child(vbox) return vbox func _create_header(parent: VBoxContainer): var title = Label.new() title.text = "DC Collision Shape Creator" title.add_theme_font_size_override("font_size", 16) parent.add_child(title) parent.add_child(HSeparator.new()) target_label = Label.new() target_label.name = "TargetLabel" target_label.text = "No MeshInstance3D selected" target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART parent.add_child(target_label) parent.add_child(HSeparator.new()) func _create_controls(parent: VBoxContainer): shape_option = _create_option_row(parent, "Shape Type:", ShapeType.keys()) body_option = _create_option_row(parent, "Body Type:", BodyType.keys()) _create_checkbox_options(parent) _create_margin_control(parent) _create_axis_controls(parent) func _create_option_row(parent: Control, label_text: String, options: Array) -> OptionButton: var container = HBoxContainer.new() parent.add_child(container) var label = Label.new() label.text = label_text label.custom_minimum_size.x = 100 container.add_child(label) var option_button = OptionButton.new() for option in options: option_button.add_item(option.capitalize()) container.add_child(option_button) return option_button func _create_checkbox_options(parent: VBoxContainer): var options_container = VBoxContainer.new() parent.add_child(options_container) as_child_check = _create_checkbox(options_container, "Create as child of mesh", true) auto_center_check = _create_checkbox(options_container, "Auto-center collision shape", true) func _create_checkbox(parent: Control, text: String, pressed: bool) -> CheckBox: var checkbox = CheckBox.new() checkbox.text = text checkbox.button_pressed = pressed parent.add_child(checkbox) return checkbox func _create_margin_control(parent: VBoxContainer): var container = HBoxContainer.new() parent.add_child(container) var label = Label.new() label.text = "Margin:" label.custom_minimum_size.x = 100 container.add_child(label) margin_spin = SpinBox.new() margin_spin.min_value = 0.0 margin_spin.max_value = 1.0 margin_spin.step = 0.01 margin_spin.value = 0.0 container.add_child(margin_spin) func _create_axis_controls(parent: VBoxContainer): axis_container = VBoxContainer.new() axis_container.visible = false parent.add_child(axis_container) capsule_axis_option = _create_axis_option(axis_container, "Capsule Axis:", Axis.Y) cylinder_axis_option = _create_axis_option(axis_container, "Cylinder Axis:", Axis.Y) func _create_axis_option(parent: Control, label_text: String, default: Axis) -> OptionButton: var container = HBoxContainer.new() parent.add_child(container) var label = Label.new() label.text = label_text label.custom_minimum_size.x = 100 container.add_child(label) var option = OptionButton.new() for axis in Axis.keys(): option.add_item(axis) option.selected = default container.add_child(option) return option func _create_actions(parent: VBoxContainer): parent.add_child(HSeparator.new()) preview_check = CheckBox.new() preview_check.text = "Preview Shape" parent.add_child(preview_check) create_button = Button.new() create_button.text = "Create Collision Shape" create_button.disabled = true parent.add_child(create_button) func _connect_signals(): EditorInterface.get_selection().selection_changed.connect(_on_selection_changed) shape_option.item_selected.connect(_on_shape_changed) preview_check.toggled.connect(_on_preview_toggled) create_button.pressed.connect(_on_create_pressed) func _on_selection_changed(): target_mesh = _get_selected_mesh() _update_ui() func _get_selected_mesh() -> MeshInstance3D: var selection = EditorInterface.get_selection().get_selected_nodes() for node in selection: if node is MeshInstance3D: return node return null func _update_ui(): var has_valid_target = target_mesh and target_mesh.mesh if has_valid_target: target_label.text = "Target: " + target_mesh.name create_button.disabled = false preview_check.disabled = false else: target_label.text = "No MeshInstance3D selected" create_button.disabled = true preview_check.disabled = true preview_check.button_pressed = false _clear_preview() func _on_shape_changed(index: int): var needs_axis = index in [ShapeType.CAPSULE, ShapeType.CYLINDER] axis_container.visible = needs_axis if axis_container.visible: capsule_axis_option.get_parent().visible = index == ShapeType.CAPSULE cylinder_axis_option.get_parent().visible = index == ShapeType.CYLINDER if preview_check.button_pressed: _update_preview() func _on_preview_toggled(pressed: bool): if pressed and target_mesh: _update_preview() else: _clear_preview() func _on_create_pressed(): if not _validate_target(): return _clear_preview() var collision = _create_collision_shape() if not collision: push_error("Failed to create collision shape") return _add_to_scene(collision) func _validate_target() -> bool: return target_mesh != null and target_mesh.mesh != null func _create_collision_shape() -> CollisionShape3D: var shape = _create_shape() if not shape: return null var collision = CollisionShape3D.new() collision.shape = shape collision.name = ShapeType.keys()[shape_option.selected] + "Collision" if auto_center_check.button_pressed: collision.position = target_mesh.get_aabb().get_center() _apply_shape_rotation(collision) return collision func _create_shape() -> Shape3D: var aabb = target_mesh.get_aabb() var size = aabb.size + Vector3.ONE * margin_spin.value * 2 var shape_type = _get_effective_shape_type() match shape_type: ShapeType.BOX: return ShapeFactory.create_box(size) ShapeType.SPHERE: return ShapeFactory.create_sphere(size) ShapeType.CAPSULE: return ShapeFactory.create_capsule(size, capsule_axis_option.selected) ShapeType.CYLINDER: return ShapeFactory.create_cylinder(size, cylinder_axis_option.selected) ShapeType.CONVEX: return target_mesh.mesh.create_convex_shape() ShapeType.TRIMESH: return target_mesh.mesh.create_trimesh_shape() _: return null func _get_effective_shape_type() -> ShapeType: if shape_option.selected != ShapeType.AUTO: return shape_option.selected return _auto_detect_shape_type() func _auto_detect_shape_type() -> ShapeType: var size = target_mesh.get_aabb().size var sorted = [size.x, size.y, size.z] sorted.sort() var uniformity_ratio = sorted[2] / sorted[0] if sorted[0] > 0 else INF var elongation_ratio = sorted[2] / sorted[1] if sorted[1] > 0 else INF if uniformity_ratio < SPHERE_RATIO_THRESHOLD: return ShapeType.SPHERE elif elongation_ratio > CAPSULE_RATIO_THRESHOLD: return ShapeType.CAPSULE else: return ShapeType.BOX func _apply_shape_rotation(node: Node3D): var shape_type = _get_effective_shape_type() if shape_type not in [ShapeType.CAPSULE, ShapeType.CYLINDER]: return var axis = capsule_axis_option.selected if shape_type == ShapeType.CAPSULE else cylinder_axis_option.selected match axis: Axis.X: node.rotation_degrees.z = 90 Axis.Z: node.rotation_degrees.x = 90 func _add_to_scene(collision: CollisionShape3D): var parent = _determine_parent() var body = _create_body_if_needed() if body: body.add_child(collision, true) parent.add_child(body, true) body.owner = EditorInterface.get_edited_scene_root() if not as_child_check.button_pressed: body.transform = target_mesh.transform else: parent.add_child(collision, true) collision.owner = EditorInterface.get_edited_scene_root() func _determine_parent() -> Node: return target_mesh if as_child_check.button_pressed else target_mesh.get_parent() func _create_body_if_needed() -> Node3D: if body_option.selected == BodyType.NONE: return null match body_option.selected: BodyType.STATIC: return StaticBody3D.new() BodyType.RIGID: return RigidBody3D.new() BodyType.CHARACTER: return CharacterBody3D.new() BodyType.AREA: return Area3D.new() _: return null func _update_preview(): _clear_preview() if not target_mesh: return var mesh = _create_preview_mesh() if not mesh: return preview_node = MeshInstance3D.new() preview_node.mesh = mesh preview_node.material_override = _create_preview_material() _apply_shape_rotation(preview_node) target_mesh.add_child(preview_node) func _create_preview_mesh() -> Mesh: var size = target_mesh.get_aabb().size + Vector3.ONE * margin_spin.value * 2 var shape_type = _get_effective_shape_type() match shape_type: ShapeType.BOX: return _mesh_from_shape(ShapeFactory.create_box(size)) ShapeType.SPHERE: return _mesh_from_shape(ShapeFactory.create_sphere(size)) ShapeType.CAPSULE: return _mesh_from_shape(ShapeFactory.create_capsule(size, capsule_axis_option.selected)) ShapeType.CYLINDER: return _mesh_from_shape(ShapeFactory.create_cylinder(size, cylinder_axis_option.selected)) _: return null func _mesh_from_shape(shape: Shape3D) -> Mesh: if shape is BoxShape3D: var mesh = BoxMesh.new() mesh.size = shape.size return mesh elif shape is SphereShape3D: var mesh = SphereMesh.new() mesh.radius = shape.radius mesh.height = shape.radius * 2.0 return mesh elif shape is CapsuleShape3D: var mesh = CapsuleMesh.new() mesh.radius = shape.radius mesh.height = shape.height return mesh elif shape is CylinderShape3D: var mesh = CylinderMesh.new() mesh.top_radius = shape.radius mesh.bottom_radius = shape.radius mesh.height = shape.height return mesh return null func _create_preview_material() -> StandardMaterial3D: var mat = StandardMaterial3D.new() mat.albedo_color = Color.GREEN mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color.a = 0.3 mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.no_depth_test = true return mat func _clear_preview(): if is_instance_valid(preview_node): preview_node.queue_free() preview_node = null