├── addons └── boxconstructor │ ├── boxconstructor.gd.uid │ ├── scripts │ ├── toolbar.gd.uid │ ├── cube_grid.gd.uid │ ├── cube_grid.gd │ └── toolbar.gd │ ├── textures │ ├── grid_shader.gdshader.uid │ ├── cube_grid.tres │ └── grid_shader.gdshader │ ├── plugin.cfg │ └── boxconstructor.gd ├── .gitattributes ├── .github └── images │ ├── cut_result.png │ ├── move_tool.png │ ├── extrude_cut.gif │ ├── edge_movement.gif │ ├── extrude_example.png │ ├── plane_example.png │ ├── plane_movement.gif │ ├── toolbar_guide.png │ ├── extrude_example_2.png │ ├── merge_mesh_result.png │ ├── boxconstructorbanner.png │ └── boxconstructoricon.png ├── .gitignore ├── LICENSE └── README.md /addons/boxconstructor/boxconstructor.gd.uid: -------------------------------------------------------------------------------- 1 | uid://qnfnr8wrpdsl 2 | -------------------------------------------------------------------------------- /addons/boxconstructor/scripts/toolbar.gd.uid: -------------------------------------------------------------------------------- 1 | uid://coo1do7f6lmce 2 | -------------------------------------------------------------------------------- /addons/boxconstructor/scripts/cube_grid.gd.uid: -------------------------------------------------------------------------------- 1 | uid://bgxvcscuumfyq 2 | -------------------------------------------------------------------------------- /addons/boxconstructor/textures/grid_shader.gdshader.uid: -------------------------------------------------------------------------------- 1 | uid://qnh11q0505w1 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/images/cut_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/cut_result.png -------------------------------------------------------------------------------- /.github/images/move_tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/move_tool.png -------------------------------------------------------------------------------- /.github/images/extrude_cut.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/extrude_cut.gif -------------------------------------------------------------------------------- /.github/images/edge_movement.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/edge_movement.gif -------------------------------------------------------------------------------- /.github/images/extrude_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/extrude_example.png -------------------------------------------------------------------------------- /.github/images/plane_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/plane_example.png -------------------------------------------------------------------------------- /.github/images/plane_movement.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/plane_movement.gif -------------------------------------------------------------------------------- /.github/images/toolbar_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/toolbar_guide.png -------------------------------------------------------------------------------- /.github/images/extrude_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/extrude_example_2.png -------------------------------------------------------------------------------- /.github/images/merge_mesh_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/merge_mesh_result.png -------------------------------------------------------------------------------- /.github/images/boxconstructorbanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/boxconstructorbanner.png -------------------------------------------------------------------------------- /.github/images/boxconstructoricon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hannogert/BoxConstructor/HEAD/.github/images/boxconstructoricon.png -------------------------------------------------------------------------------- /addons/boxconstructor/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="BoxConstructor" 4 | description="Easy-to-use grayboxing tool designed for rapid prototyping in Godot" 5 | author="Hannogert Otti" 6 | version="1.0.0" 7 | script="boxconstructor.gd" 8 | -------------------------------------------------------------------------------- /addons/boxconstructor/textures/cube_grid.tres: -------------------------------------------------------------------------------- 1 | [gd_resource type="ShaderMaterial" load_steps=2 format=3 uid="uid://iwvr14kdcfog"] 2 | 3 | [ext_resource type="Shader" uid="uid://qnh11q0505w1" path="res://addons/qb2/textures/grid_shader.gdshader" id="1_uxs6n"] 4 | 5 | [resource] 6 | render_priority = 0 7 | shader = ExtResource("1_uxs6n") 8 | shader_parameter/grid_scale = 5.0 9 | shader_parameter/camera_distance = 0.0 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | 4 | # Godot-specific ignores 5 | .import/ 6 | export.cfg 7 | export_presets.cfg 8 | 9 | # Imported translations (automatically generated from CSV files) 10 | *.translation 11 | 12 | # Mono-specific ignores 13 | .mono/ 14 | data_*/ 15 | mono_crash.*.json 16 | 17 | 18 | # Exclude files not needed for AssetLib 19 | .github/ export-ignore 20 | /.gitattributes export-ignore 21 | /.gitignore export-ignore 22 | /LICENSE export-ignore 23 | /README.md export-ignore 24 | -------------------------------------------------------------------------------- /addons/boxconstructor/textures/grid_shader.gdshader: -------------------------------------------------------------------------------- 1 | shader_type spatial; 2 | render_mode blend_mix, depth_draw_opaque, cull_disabled, diffuse_burley; 3 | 4 | uniform float grid_scale = 0.1; 5 | 6 | void fragment() { 7 | vec2 world_uv = UV * 4000.0; 8 | 9 | vec2 scaled_uv = world_uv / grid_scale; 10 | 11 | vec2 grid = abs(fract(scaled_uv - 0.5) - 0.5); 12 | vec2 grid_width = fwidth(scaled_uv) * 1.0; 13 | vec2 grid_lines = smoothstep(vec2(0.0), grid_width, grid); 14 | float line = min(grid_lines.x, grid_lines.y); 15 | 16 | if (line < 0.5) { 17 | ALBEDO = vec3(0.4); 18 | ALPHA = 0.8; 19 | } else { 20 | ALBEDO = vec3(0.15); 21 | ALPHA = 0.1; 22 | } 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hannogert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Banner](.github/images/boxconstructorbanner.png) 2 | 3 | # BoxConstructor 4 | Easy-to-use Grayboxing tool for Godot. 5 | 6 | ## Overview 7 | BoxConstructor is a Godot plugin designed for fast and efficient 3D prototyping directly within the editor. It features a grid-based system for block placement, extrusion, and cutting, enabling quick iteration of 3D layouts. 8 | 9 | ## Installation 10 | 1. Download or clone this repository. 11 | 2. Copy the `addons` folder into your Godot project's root directory. 12 | 3. In your Godot editor, go to `Project` → `Project Settings` → `Plugins`. 13 | 4. Find `BoxConstructor` in the plugin list and enable it by checking the box. 14 | 15 | Your directory structure should look like this: 16 | ``` 17 | your-project/ 18 | └── addons/ 19 | └── BoxConstructor/ 20 | └── plugin files... 21 | ``` 22 | 23 | ## Usage 24 | 1. Create a new scene. 25 | 2. Add a child node named CubeGrid3D. 26 | 3. Select the Move Mode from the Godot toolbar so the plugin toolbar stays visible while you click and drag 27 | ![MoveTool](.github/images/move_tool.png) 28 | 29 | ## Feature overview 30 | ![Toolbar](.github/images/toolbar_guide.png) 31 | - Middle mouse to cancel drawing 32 | - Clicking X on a face will move the plane to that face 33 | ### Extrusion 34 | The user can extrude cubes by: 35 | 1. Selecting Add Primitive from the toolbar. 36 | 2. Click to start drawing the base rectangle. 37 | 3. Clicking again to finalise the base rectangle, which enters extrusion mode. 38 | 4. Moving the mouse to set the desired extrusion depth. 39 | 5. Clicking once more to finalise the extrusion. 40 | 41 | ![Extrude_example](.github/images/extrude_cut.gif) 42 | 43 | Depending on the extrusion direction: 44 | - Extrusion along the surface normal adds geometry. 45 | - Extrusion in the opposite direction removes geometry. 46 | 47 | ## Plane movement 48 | The drawing plane can be snapped to a face by pressing the X key while the mouse is over the face. 49 | 50 | ![Plane_movement](.github/images/plane_movement.gif) 51 | 52 | You can reset the plane's transform by selecting Reset Grid on the toolbar or pressing Z. 53 | 54 | ## Edge movement 55 | ![Edge_movement](.github/images/edge_movement.gif) 56 | 57 | Edges can be moved by using the Select Edge tool 58 | 59 | -------------------------------------------------------------------------------- /addons/boxconstructor/scripts/cube_grid.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends StaticBody3D 3 | class_name CubeGrid3D 4 | var grid_scale: float = 1: 5 | set(value): 6 | grid_scale = value 7 | _update_material() 8 | emit_signal("grid_created", grid_scale) 9 | 10 | 11 | var mesh_instance: MeshInstance3D 12 | var grid_material: ShaderMaterial 13 | var csg_root: CSGCombiner3D 14 | signal grid_created(scale: float) 15 | 16 | func _enter_tree() -> void: 17 | if Engine.is_editor_hint(): 18 | set_meta("_edit_lock_", true) 19 | 20 | # Check if the CubeGridMesh3D already exists 21 | mesh_instance = get_node_or_null("CubeGridMesh3D") 22 | if not mesh_instance: 23 | mesh_instance = MeshInstance3D.new() 24 | mesh_instance.name = "CubeGridMesh3D" 25 | mesh_instance.set_meta("_edit_lock_", true) 26 | var plane_mesh = PlaneMesh.new() 27 | plane_mesh.size = Vector2(1, 1) 28 | mesh_instance.mesh = plane_mesh 29 | mesh_instance.scale = Vector3(4000, 0.001, 4000) 30 | add_child(mesh_instance) 31 | if Engine.is_editor_hint(): 32 | mesh_instance.owner = null 33 | 34 | # Check if the CSGCombiner3D already exists 35 | csg_root = self.get_node_or_null("CSGCombiner3D") 36 | if not csg_root: 37 | csg_root = CSGCombiner3D.new() 38 | csg_root.name = "CSGCombiner3D" 39 | csg_root.use_collision = true 40 | add_child(csg_root) 41 | if Engine.is_editor_hint(): 42 | csg_root.owner = get_tree().edited_scene_root 43 | 44 | # Check if the CubeGridCollisionShape3D already exists 45 | var collision_shape = get_node_or_null("CubeGridCollisionShape3D") 46 | if not collision_shape: 47 | collision_shape = CollisionShape3D.new() 48 | var box_shape = BoxShape3D.new() 49 | box_shape.size = Vector3(4000, 0.001, 4000) 50 | collision_shape.shape = box_shape 51 | collision_shape.name = "CubeGridCollisionShape3D" 52 | add_child(collision_shape) 53 | if Engine.is_editor_hint(): 54 | collision_shape.owner = null 55 | 56 | _setup_shader() 57 | emit_signal("grid_created", grid_scale) 58 | 59 | func _setup_shader() -> void: 60 | if not mesh_instance: 61 | mesh_instance = get_node_or_null("CubeGridMesh3D") 62 | if not mesh_instance: 63 | push_error("Grid mesh instance not found!") 64 | return 65 | 66 | if not grid_material: 67 | var base_material = preload("res://addons/boxconstructor/textures/cube_grid.tres") 68 | grid_material = base_material.duplicate() 69 | mesh_instance.material_override = grid_material 70 | grid_material.set_shader_parameter("grid_scale", grid_scale) 71 | 72 | func _update_material() -> void: 73 | grid_material.set_shader_parameter("grid_scale", grid_scale) 74 | 75 | -------------------------------------------------------------------------------- /addons/boxconstructor/scripts/toolbar.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends PanelContainer 3 | 4 | signal disable_button_pressed 5 | signal select_button_pressed 6 | signal add_button_pressed 7 | signal remove_button_pressed 8 | signal edit_mesh 9 | signal grid_size_changed(size: float) 10 | signal reset_grid_pressed 11 | signal merge_mesh 12 | 13 | var disable_button: Button 14 | var merge_button: Button 15 | var edit_button: Button 16 | var tooltip_button: Button 17 | var plugin: EditorPlugin 18 | var toolbar_buttons: HBoxContainer 19 | var select_button: Button 20 | var add_button: Button 21 | var active_button_stylebox: StyleBoxFlat 22 | var grid_sizes = [0.01, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10] 23 | 24 | func _init(p_plugin: EditorPlugin) -> void: 25 | plugin = p_plugin 26 | 27 | func _ready() -> void: 28 | _configure_style() 29 | _create_containers() 30 | _create_active_button_style() 31 | _create_all_buttons() 32 | 33 | func _create_all_buttons() -> void: 34 | disable_button = _create_mode_button("Disable", "GuiClose", "_on_disable_pressed") 35 | select_button = _create_mode_button("Select Edge", "ToolSelect", "_on_select_button_pressed") 36 | add_button = _create_mode_button("Add Primitive", "Add", "_on_add_button_pressed") 37 | _create_button("Reset Grid", "Reload", "_on_reset_button_pressed") 38 | _create_grid_size_selector() 39 | merge_button = _create_button("Merge Mesh", "BoxMesh", "_on_merge_mesh_pressed") 40 | edit_button = _create_button("Edit Mesh", "Edit", "_on_edit_pressed") 41 | tooltip_button = _create_button("", "Help", "pass") 42 | 43 | func _configure_style() -> void: 44 | var stylebox = StyleBoxFlat.new() 45 | stylebox.bg_color = Color(0.211, 0.239, 0.290) 46 | stylebox.set_corner_radius_all(20) 47 | add_theme_stylebox_override("panel", stylebox) 48 | 49 | add_theme_constant_override("margin_left", 20) 50 | add_theme_constant_override("margin_right", 20) 51 | add_theme_constant_override("margin_top", 10) 52 | add_theme_constant_override("margin_bottom", 10) 53 | 54 | func _create_containers() -> void: 55 | var margin_container = MarginContainer.new() 56 | margin_container.add_theme_constant_override("margin_left", 6) 57 | margin_container.add_theme_constant_override("margin_right", 6) 58 | margin_container.add_theme_constant_override("margin_top", 6) 59 | margin_container.add_theme_constant_override("margin_bottom", 6) 60 | add_child(margin_container) 61 | 62 | toolbar_buttons = HBoxContainer.new() 63 | toolbar_buttons.alignment = BoxContainer.ALIGNMENT_CENTER 64 | toolbar_buttons.add_theme_constant_override("separation", 8) 65 | margin_container.add_child(toolbar_buttons) 66 | 67 | func _create_active_button_style() -> void: 68 | active_button_stylebox = _create_button_stylebox() 69 | active_button_stylebox.bg_color = Color(0.3, 0.5, 0.7) 70 | active_button_stylebox.border_color = Color.WHITE 71 | 72 | func _create_buttons() -> void: 73 | disable_button = _create_mode_button("Disable", "GuiVisibilityHidden", "_on_disable_pressed") 74 | select_button = _create_mode_button("Select Edge", "ToolSelect", "_on_select_button_pressed") 75 | add_button = _create_mode_button("Add Primitive", "Add", "_on_add_button_pressed") 76 | _create_button("Reset Grid", "Reload", "_on_reset_button_pressed") 77 | 78 | func set_edit_button_enabled(enabled: bool) -> void: 79 | if edit_button: 80 | edit_button.disabled = not enabled 81 | 82 | func set_merge_button_enabled(enabled: bool) -> void: 83 | if merge_button: 84 | merge_button.disabled = not enabled 85 | 86 | func set_select_button_enabled(enabled: bool) -> void: 87 | if select_button: 88 | select_button.disabled = not enabled 89 | 90 | func update_button_states(is_merged: bool) -> void: 91 | 92 | if select_button: 93 | select_button.visible = !is_merged 94 | if add_button: 95 | add_button.visible = !is_merged 96 | if merge_button: 97 | merge_button.visible = !is_merged 98 | if edit_button: 99 | edit_button.visible = is_merged 100 | 101 | if add_button: 102 | add_button.queue_redraw() 103 | if merge_button: 104 | merge_button.queue_redraw() 105 | if edit_button: 106 | edit_button.queue_redraw() 107 | 108 | toolbar_buttons.custom_minimum_size = Vector2.ZERO 109 | toolbar_buttons.reset_size() 110 | custom_minimum_size = Vector2.ZERO 111 | reset_size() 112 | 113 | var viewport_base = get_parent() 114 | if viewport_base: 115 | position.x = (viewport_base.size.x - size.x) * 0.5 116 | 117 | func _create_mode_button(text: String, icon_name: String, callback: String) -> Button: 118 | var button = Button.new() 119 | button.text = text 120 | button.toggle_mode = true 121 | button.icon = plugin.get_editor_interface().get_base_control().get_theme_icon(icon_name, "EditorIcons") 122 | button.connect("pressed", Callable(self, callback)) 123 | 124 | var normal_style = _create_button_stylebox() 125 | var hover_style = _create_button_stylebox(true) 126 | 127 | button.tooltip_text = _get_tooltip_text(button.text) 128 | button.add_theme_stylebox_override("normal", normal_style) 129 | button.add_theme_stylebox_override("hover", hover_style) 130 | button.add_theme_stylebox_override("pressed", active_button_stylebox) 131 | button.add_theme_stylebox_override("disabled", normal_style) 132 | button.add_theme_stylebox_override("focus", normal_style) 133 | 134 | toolbar_buttons.add_child(button) 135 | return button 136 | 137 | func _create_button(text: String, icon_name: String, callback: String) -> Button: 138 | var button = Button.new() 139 | button.text = text 140 | button.icon = plugin.get_editor_interface().get_base_control().get_theme_icon(icon_name, "EditorIcons") 141 | button.connect("pressed", Callable(self, callback)) 142 | 143 | var normal_style = _create_button_stylebox() 144 | var hover_style = _create_button_stylebox(true) 145 | 146 | button.tooltip_text = _get_tooltip_text(button.text) 147 | button.add_theme_stylebox_override("normal", normal_style) 148 | button.add_theme_stylebox_override("hover", hover_style) 149 | button.add_theme_stylebox_override("pressed", hover_style) 150 | button.add_theme_stylebox_override("disabled", normal_style) 151 | button.add_theme_stylebox_override("focus", normal_style) 152 | 153 | toolbar_buttons.add_child(button) 154 | return button 155 | 156 | func _create_button_stylebox(is_hover: bool = false) -> StyleBoxFlat: 157 | var stylebox = StyleBoxFlat.new() 158 | stylebox.bg_color = Color(0.15, 0.17, 0.20) if not is_hover else Color(0.25, 0.27, 0.30) 159 | stylebox.set_corner_radius_all(20) 160 | 161 | stylebox.content_margin_left = 8 162 | stylebox.content_margin_right = 8 163 | stylebox.content_margin_top = 4 164 | stylebox.content_margin_bottom = 4 165 | 166 | stylebox.border_width_bottom = 0 167 | stylebox.border_width_left = 0 168 | stylebox.border_width_right = 0 169 | stylebox.border_width_top = 0 170 | 171 | return stylebox 172 | 173 | func _create_grid_size_selector() -> void: 174 | var grid_size_button = OptionButton.new() 175 | grid_size_button.name = "Grid Size" 176 | 177 | grid_size_button.add_item("0.01", 0) 178 | grid_size_button.add_item("0.1", 1) 179 | grid_size_button.add_item("0.25", 2) 180 | grid_size_button.add_item("0.5", 3) 181 | grid_size_button.add_item("0.75", 4) 182 | grid_size_button.add_item("1", 5) 183 | grid_size_button.add_item("2", 6) 184 | grid_size_button.add_item("5", 7) 185 | grid_size_button.add_item("10", 8) 186 | 187 | grid_size_button.select(5) 188 | 189 | grid_size_button.connect("item_selected", Callable(self, "_on_grid_size_selected")) 190 | grid_size_button.add_theme_stylebox_override("normal", _create_button_stylebox()) 191 | grid_size_button.add_theme_stylebox_override("hover", _create_button_stylebox(true)) 192 | 193 | toolbar_buttons.add_child(grid_size_button) 194 | 195 | func set_active_mode(mode: int) -> void: 196 | disable_button.button_pressed = (mode == 0) # DISABLE mode 197 | select_button.button_pressed = (mode == 1) # SELECT mode 198 | add_button.button_pressed = (mode == 2) # ADD mode 199 | 200 | func connect_to_grid(grid: CubeGrid3D) -> void: 201 | var grid_size_button = toolbar_buttons.get_node("Grid Size") as OptionButton 202 | if not grid_size_button: 203 | return 204 | 205 | var grid_sizes = [0.01, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10] 206 | var index = grid_sizes.find(grid.grid_scale) 207 | if index != -1: 208 | grid_size_button.select(index) 209 | 210 | func _on_grid_created(initial_scale: float) -> void: 211 | var grid_size_button = toolbar_buttons.get_node("Grid Size") as OptionButton 212 | if not grid_size_button: 213 | return 214 | 215 | var index = grid_sizes.find(initial_scale) 216 | if index != -1: 217 | grid_size_button.select(index) 218 | 219 | func _on_disable_pressed() -> void: 220 | emit_signal("disable_button_pressed") 221 | 222 | func _on_select_button_pressed() -> void: 223 | emit_signal("select_button_pressed") 224 | 225 | func _on_add_button_pressed() -> void: 226 | emit_signal("add_button_pressed") 227 | 228 | func _on_remove_button_pressed() -> void: 229 | emit_signal("remove_button_pressed") 230 | 231 | func _on_grid_size_selected(index: int) -> void: 232 | var selected_size = grid_sizes[index] 233 | emit_signal("grid_size_changed", selected_size) 234 | 235 | func _on_reset_button_pressed() -> void: 236 | emit_signal("reset_grid_pressed") 237 | 238 | func _on_merge_mesh_pressed() -> void: 239 | emit_signal("merge_mesh") 240 | 241 | func _on_edit_pressed() -> void: 242 | update_button_states(false) 243 | emit_signal("edit_mesh") 244 | 245 | func _get_tooltip_text(button_name: String) -> String: 246 | match button_name: 247 | "Disable": 248 | return "Disable\n Disables mouse input for Constructor." 249 | "Select Edge": 250 | return "Select Edge\n Allows you to select an edge to move it." 251 | "Add Primitive": 252 | return "Add Primitive\n Allows you to add or remove a box by drawing a rectangle and extruding it." 253 | "Reset Grid": 254 | return "Reset\n Resets the grid to its original state." 255 | "Grid Size": 256 | return "Grid Size\n Sets the size of the grid." 257 | "Merge Mesh": 258 | return "Merge Mesh\n Merges all the cubes into a single MeshInstance3D." 259 | "Edit Mesh": 260 | return "Edit Mesh\n Breaks the mesh into its original cubes." 261 | "": 262 | return """Box Constructor Help: 263 | 264 | Keyboard Shortcuts: 265 | - Press X to move the drawing plane. 266 | - Press Z key to reset the drawing plane. 267 | - Middle mouse will cancel the current operation for extrudsion and drawing. 268 | 269 | Tips: 270 | - You can use the edge select to move edges, but only use them to create ramps 271 | (Creating other shapes may result in CSG operations not working correctly). 272 | - The drawing does not work on slanted surfaces, only flat surfaces. 273 | """ 274 | _: 275 | return "" 276 | -------------------------------------------------------------------------------- /addons/boxconstructor/boxconstructor.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | # === Constants === 5 | enum BuildMode { 6 | DISABLE, 7 | SELECT, 8 | ADD 9 | } 10 | 11 | 12 | const GRID_SCALE_1 = 0.01 13 | const GRID_SCALE_2 = 0.1 14 | const GRID_SCALE_3 = 0.25 15 | const GRID_SCALE_4 = 0.5 16 | const GRID_SCALE_5 = 0.75 17 | const GRID_SCALE_6 = 1 18 | const GRID_SCALE_7 = 2 19 | const GRID_SCALE_8 = 5 20 | const GRID_SCALE_9 = 10 21 | const BASE_PREVIEW_THICKNESS = 0.05 22 | 23 | # === Editor properties === 24 | var current_mode: BuildMode = BuildMode.SELECT 25 | var toolbar: PanelContainer 26 | var editor_viewport = get_editor_interface().get_editor_viewport_3d() 27 | var camera = editor_viewport.get_camera_3d() 28 | 29 | # === Grid and CSG properties === 30 | var csg_root: CSGCombiner3D 31 | var selected_grid: CubeGrid3D 32 | var csg_mesh: MeshInstance3D = null 33 | 34 | # === Rectangle drawing properties === 35 | var is_drawing: bool = false 36 | var draw_normal: Vector3 = Vector3.UP 37 | var draw_start: Vector3 = Vector3() 38 | var draw_end: Vector3 = Vector3() 39 | var draw_preview: MeshInstance3D = null 40 | var draw_plane: Plane 41 | var base_rect_points: Array = [] 42 | 43 | # === Extrusion properties === 44 | var is_extruding: bool = false 45 | var has_started_extrusion: bool = false 46 | var extrude_distance: float = 0.0 47 | var initial_extrude_point: Vector3 48 | var extrude_line_normal: Vector3 49 | 50 | # === Highlight properties === 51 | var hover_preview: MeshInstance3D = null 52 | var hover_point: Vector3 = Vector3.ZERO 53 | 54 | # === Edge Movement properties === 55 | var edge_preview: MeshInstance3D = null 56 | var current_edge: Array = [] 57 | var is_dragging_edge: bool = false 58 | var dragged_mesh: CSGMesh3D = null 59 | var drag_start_position: Vector3 60 | var drag_plane: Plane 61 | var drag_start_offset: Vector3 62 | var is_mouse_in_viewport: bool = false 63 | 64 | var undo_redo 65 | var drag_start_vertices: PackedVector3Array 66 | var drag_start_indices: PackedInt32Array 67 | 68 | func _update_mesh_arrays(mesh_node: CSGMesh3D, vertices: PackedVector3Array, indices: PackedInt32Array) -> void: 69 | var arr_mesh = mesh_node.mesh as ArrayMesh 70 | if arr_mesh: 71 | var arrays = [] 72 | arrays.resize(Mesh.ARRAY_MAX) 73 | arrays[Mesh.ARRAY_VERTEX] = vertices 74 | arrays[Mesh.ARRAY_INDEX] = indices 75 | arr_mesh.clear_surfaces() 76 | arr_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) 77 | 78 | # === Lifecycle Methods === 79 | func _enter_tree() -> void: 80 | get_editor_interface().get_selection().selection_changed.connect(_on_selection_changed) 81 | editor_viewport = get_editor_interface().get_editor_viewport_3d() 82 | undo_redo = get_undo_redo() 83 | toolbar = preload("res://addons/boxconstructor/scripts/toolbar.gd").new(self) # Create the toolbar 84 | var viewport_base = editor_viewport.get_parent().get_parent() 85 | viewport_base.add_child(toolbar) 86 | toolbar.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP, 0, 10) 87 | 88 | toolbar.hide() # Hide toolbar by default 89 | _connect_toolbar_signals() # Connect signals 90 | 91 | 92 | func _exit_tree() -> void: 93 | if toolbar: 94 | toolbar.queue_free() # Remove the toolbar 95 | 96 | if get_editor_interface().get_selection().selection_changed.is_connected(_on_selection_changed): 97 | get_editor_interface().get_selection().selection_changed.disconnect(_on_selection_changed) # Disconnect signals 98 | 99 | 100 | # This section handles all of the inputs 101 | func _input(event: InputEvent) -> void: 102 | if not selected_grid or not selected_grid.is_inside_tree(): 103 | return 104 | 105 | # Pressing the X key will move the grid to mouse position 106 | if event is InputEventKey and event.pressed and event.keycode == KEY_X: 107 | if not camera or not selected_grid: 108 | return 109 | # Cast a ray and get the hit position 110 | var from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 111 | var to = from + camera.project_ray_normal(editor_viewport.get_mouse_position()) * 5000 112 | var ray_query = PhysicsRayQueryParameters3D.new() 113 | ray_query.from = from 114 | ray_query.to = to 115 | var hit = get_editor_interface().get_edited_scene_root().get_world_3d().direct_space_state.intersect_ray(ray_query) 116 | if hit: 117 | var snapped_pos = _snap_to_grid(hit.position) 118 | # Move the Grid to the hit position 119 | _align_grid_to_surface(hit.normal, snapped_pos) 120 | 121 | # Pressing Z resets the grid to the 0,0,0 position 122 | if event is InputEventKey and event.pressed and event.keycode == KEY_Z: 123 | _reset_grid_transform() 124 | 125 | # Edge movement logic 126 | if current_mode == BuildMode.SELECT: 127 | if event is InputEventMouseButton: 128 | if event.button_index == MOUSE_BUTTON_LEFT and event.pressed: 129 | if not is_dragging_edge: 130 | if edge_preview and edge_preview.visible: 131 | for child in csg_root.get_children(): 132 | if child is CSGBox3D or child is CSGMesh3D: 133 | # Get all of the edges of CSGBox3D or CSGMesh3D 134 | var edges = _get_edges(child) 135 | for edge in edges: 136 | 137 | # Check if the currently hovered edge is the same as the one we are dragging 138 | if edge[0].is_equal_approx(current_edge[0]) and edge[1].is_equal_approx(current_edge[1]): 139 | var from = camera.project_ray_origin(event.position) 140 | var dir = camera.project_ray_normal(event.position) 141 | 142 | # Create a plane for the edge to drag along 143 | var edge_dir = (edge[1] - edge[0]).normalized() 144 | drag_plane = Plane(edge_dir, edge[0].dot(edge_dir)) 145 | 146 | # Get the intersection point of the ray and the plane 147 | var intersection = drag_plane.intersects_ray(from, dir) 148 | if intersection: 149 | # We turn the CSGBox3D into a custom mesh that allows use to move the vertecies 150 | if child is CSGBox3D: 151 | dragged_mesh = _convert_box_to_CSGMesh(child) 152 | 153 | else: 154 | dragged_mesh = child 155 | 156 | var arr_mesh = dragged_mesh.mesh as ArrayMesh 157 | if arr_mesh: 158 | var arrays = arr_mesh.surface_get_arrays(0) 159 | drag_start_vertices = arrays[Mesh.ARRAY_VERTEX].duplicate() 160 | drag_start_indices = arrays[Mesh.ARRAY_INDEX].duplicate() 161 | is_dragging_edge = true # Set dragging to true 162 | current_edge = edge # Set the current edge to the one we are dragging 163 | drag_start_offset = _snap_to_grid(intersection) # Starting position of edge drag 164 | break 165 | else: 166 | if is_dragging_edge and dragged_mesh: 167 | var arr_mesh = dragged_mesh.mesh as ArrayMesh 168 | if arr_mesh: 169 | var final_arrays = arr_mesh.surface_get_arrays(0) 170 | undo_redo.create_action("Move Edge") 171 | 172 | # Store the current state 173 | var current_mesh = arr_mesh.duplicate(true) 174 | 175 | # Create original mesh 176 | var original_mesh = ArrayMesh.new() 177 | var arrays = [] 178 | arrays.resize(Mesh.ARRAY_MAX) 179 | arrays[Mesh.ARRAY_VERTEX] = drag_start_vertices 180 | arrays[Mesh.ARRAY_INDEX] = drag_start_indices 181 | original_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) 182 | 183 | undo_redo.add_do_property(dragged_mesh, "mesh", current_mesh) 184 | undo_redo.add_undo_property(dragged_mesh, "mesh", original_mesh) 185 | undo_redo.commit_action() 186 | 187 | is_dragging_edge = false 188 | dragged_mesh = null 189 | edge_preview.hide() 190 | 191 | if event is InputEventMouseMotion: 192 | if is_dragging_edge and dragged_mesh: 193 | edge_preview.hide() 194 | var from = camera.project_ray_origin(event.position) 195 | var dir = camera.project_ray_normal(event.position) 196 | 197 | # Project mouse position onto drag plane 198 | var intersection = drag_plane.intersects_ray(from, dir) 199 | if intersection: 200 | # Snapped position on the grid 201 | var snapped_pos = _snap_to_grid(intersection) 202 | # Offset from the start position 203 | var offset = snapped_pos - drag_start_offset 204 | 205 | var arr_mesh = dragged_mesh.mesh as ArrayMesh 206 | if arr_mesh: 207 | var arrays = arr_mesh.surface_get_arrays(0) 208 | var vertices = arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array 209 | 210 | # Get the edge points in local space 211 | var local_edge = [ 212 | dragged_mesh.to_local(current_edge[0]), 213 | dragged_mesh.to_local(current_edge[1]) 214 | ] 215 | # Calculate the new offset 216 | var local_offset = dragged_mesh.global_transform.basis.inverse() * offset 217 | var new_vertices = PackedVector3Array() 218 | new_vertices.resize(vertices.size()) 219 | 220 | # Move vertices that match the edge points 221 | for i in range(vertices.size()): 222 | var vertex = vertices[i] 223 | if vertex.is_equal_approx(local_edge[0]) or vertex.is_equal_approx(local_edge[1]): 224 | new_vertices[i] = vertex + local_offset 225 | else: 226 | new_vertices[i] = vertex 227 | 228 | # Update the mesh 229 | var new_arrays = [] 230 | new_arrays.resize(Mesh.ARRAY_MAX) 231 | new_arrays[Mesh.ARRAY_VERTEX] = new_vertices 232 | new_arrays[Mesh.ARRAY_INDEX] = arrays[Mesh.ARRAY_INDEX] 233 | 234 | arr_mesh.clear_surfaces() 235 | arr_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, new_arrays) 236 | 237 | current_edge = [ 238 | dragged_mesh.global_transform * (local_edge[0] + local_offset), 239 | dragged_mesh.global_transform * (local_edge[1] + local_offset) 240 | ] 241 | drag_start_offset = snapped_pos 242 | 243 | # Highlight the closest edge 244 | elif not is_dragging_edge: 245 | if not camera or not csg_root: 246 | if edge_preview: 247 | edge_preview.hide() 248 | return 249 | 250 | # Get the closest node 251 | var mouse_pos = editor_viewport.get_mouse_position() 252 | var closest_node = null 253 | var closest_distance = INF 254 | 255 | for child in csg_root.get_children(): 256 | if not (child is CSGBox3D or child is CSGMesh3D): 257 | continue 258 | 259 | var node_center = child.global_position 260 | var screen_pos = camera.unproject_position(node_center) # Takes the position in 3D converts it to 2D 261 | var distance = screen_pos.distance_to(mouse_pos) 262 | 263 | if distance < closest_distance: 264 | closest_node = child 265 | closest_distance = distance 266 | 267 | if closest_distance: 268 | var closest_edge = _find_closest_edge(closest_node, mouse_pos) 269 | current_edge = closest_edge 270 | _create_edge_preview(closest_edge) 271 | else: 272 | current_edge = [] 273 | if edge_preview: 274 | edge_preview.hide() 275 | 276 | # Handle Rectangle Drawing and Extrusion Logic 277 | if current_mode == BuildMode.ADD: 278 | if event is InputEventMouseButton: 279 | 280 | # If clicked inside toolbar ignore 281 | if toolbar and toolbar.get_global_rect().has_point(event.position): 282 | return 283 | 284 | if event.button_index == MOUSE_BUTTON_LEFT: 285 | if event.pressed: 286 | if not camera: return 287 | 288 | # Extruson end 289 | if is_extruding and has_started_extrusion: 290 | # Create the box when clicking during extrusion 291 | _create_CSGBox3D() 292 | is_drawing = false 293 | is_extruding = false 294 | has_started_extrusion = false 295 | if draw_preview: 296 | draw_preview.queue_free() 297 | draw_preview = null 298 | return 299 | 300 | # Start dragging the base rectangle 301 | var ray_query = PhysicsRayQueryParameters3D.new() 302 | ray_query.from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 303 | ray_query.to = ray_query.from + camera.project_ray_normal(editor_viewport.get_mouse_position()) * 1000 304 | 305 | var hit = get_editor_interface().get_edited_scene_root().get_world_3d().direct_space_state.intersect_ray(ray_query) 306 | if hit: 307 | is_drawing = true 308 | draw_normal = hit.normal 309 | draw_start = _snap_to_grid(hit.position) 310 | draw_end = draw_start 311 | draw_plane = Plane(draw_normal, hit.position.dot(draw_normal)) 312 | create_rectangle_preview() 313 | 314 | else: 315 | # End dragging and start extrusion if we were drawing 316 | if is_drawing and not is_extruding: 317 | is_extruding = true 318 | has_started_extrusion = false 319 | extrude_distance = 0.0 320 | 321 | var from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 322 | var dir = camera.project_ray_normal(editor_viewport.get_mouse_position()) 323 | var intersection = draw_plane.intersects_ray(from, dir) 324 | 325 | if intersection: 326 | initial_extrude_point = _snap_to_grid(intersection) 327 | extrude_line_normal = draw_normal 328 | _update_rectangle_preview() 329 | 330 | elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: 331 | is_drawing = false 332 | is_extruding = false 333 | has_started_extrusion = false 334 | if draw_preview: 335 | draw_preview.queue_free() 336 | draw_preview = null 337 | 338 | get_viewport().set_input_as_handled() 339 | 340 | # Update section 341 | elif event is InputEventMouseMotion: 342 | if current_mode == BuildMode.ADD: 343 | if not is_drawing: 344 | if not camera: return 345 | 346 | var ray_query = PhysicsRayQueryParameters3D.new() 347 | ray_query.from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 348 | ray_query.to = ray_query.from + camera.project_ray_normal(editor_viewport.get_mouse_position()) * 1000 349 | ray_query.collide_with_bodies = true 350 | 351 | var hit = get_editor_interface().get_edited_scene_root().get_world_3d().direct_space_state.intersect_ray(ray_query) 352 | if hit: 353 | hover_point = _snap_to_grid(hit.position) 354 | if not hover_preview: 355 | _create_hover_preview() 356 | _update_hover_preview() 357 | elif hover_preview: 358 | hover_preview.queue_free() 359 | 360 | if is_drawing and not is_extruding: 361 | if not camera: return 362 | 363 | var from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 364 | var dir = camera.project_ray_normal(editor_viewport.get_mouse_position()) 365 | 366 | var intersection = draw_plane.intersects_ray(from, dir) 367 | if intersection: 368 | draw_end = _snap_to_grid(intersection) 369 | _calculate_base_rect_points() 370 | _update_rectangle_preview() 371 | 372 | elif is_extruding: 373 | if not camera: return 374 | 375 | var from = camera.project_ray_origin(editor_viewport.get_mouse_position()) 376 | var dir = camera.project_ray_normal(editor_viewport.get_mouse_position()) 377 | 378 | if not has_started_extrusion: 379 | if event.relative.length() > 0.01: 380 | has_started_extrusion = true 381 | 382 | if has_started_extrusion: 383 | var grid_unit = selected_grid.grid_scale 384 | var e_line1 = initial_extrude_point - extrude_line_normal * 5000 385 | var e_line2 = initial_extrude_point + extrude_line_normal * 5000 386 | var m_line1 = from 387 | var m_line2 = from + dir * 5000 388 | var closest_point = Geometry3D.get_closest_points_between_segments(e_line1, e_line2, m_line1, m_line2) 389 | var mouse_on_exturde_line = closest_point[0] 390 | var distance_vec = mouse_on_exturde_line - initial_extrude_point 391 | var distance = distance_vec.length() * distance_vec.normalized().dot(extrude_line_normal) 392 | 393 | var new_distance = round(distance / grid_unit) * grid_unit 394 | #print(new_distance) 395 | if not is_equal_approx(extrude_distance, new_distance): 396 | extrude_distance = new_distance 397 | _update_rectangle_preview() 398 | 399 | 400 | # === Grid Methods === 401 | # Changes the grid size based on the input from the toolbar 402 | func _on_grid_size_changed(size: float) -> void: 403 | if selected_grid and selected_grid.grid_material: 404 | selected_grid.grid_scale = size 405 | selected_grid.grid_material.set_shader_parameter("grid_scale", size) 406 | 407 | # Destroy the hover preview so it gets updated 408 | if hover_preview: 409 | hover_preview.queue_free() 410 | hover_preview = null 411 | 412 | 413 | # Snaps the position to the grid size 414 | func _snap_to_grid(pos: Vector3) -> Vector3: 415 | if not selected_grid: 416 | return pos 417 | 418 | # Get the grid size of the selected grid 419 | var grid_unit = selected_grid.grid_scale 420 | 421 | # Divide the coordinates of the given position by the grid scale, to get how far it is from the origin 422 | # and round it to the nearest integer 423 | return Vector3( 424 | round(pos.x / grid_unit) * grid_unit, 425 | round(pos.y / grid_unit) * grid_unit, 426 | round(pos.z / grid_unit) * grid_unit 427 | ) 428 | 429 | 430 | func _align_grid_to_surface(normal: Vector3, hit_position: Vector3) -> void: 431 | if not selected_grid: 432 | return 433 | 434 | var mesh = selected_grid.get_node_or_null("CubeGridMesh3D") 435 | var collision = selected_grid.get_node_or_null("CubeGridCollisionShape3D") 436 | 437 | var mesh_size = mesh.scale 438 | var collision_size = collision.scale 439 | 440 | if not mesh or not collision: 441 | return 442 | 443 | # Normalize the normal vector 444 | normal = normal.normalized() 445 | 446 | var up = normal 447 | var right = up.cross(Vector3.FORWARD).normalized() 448 | if right.length() < 0.1: 449 | right = up.cross(Vector3.RIGHT).normalized() 450 | var forward = right.cross(up) 451 | 452 | # Create the Basis and Transform3D 453 | var basis = Basis(right, up, forward) 454 | var transform = Transform3D(basis, hit_position + up * 0.01) 455 | 456 | # Apply the transform 457 | mesh.transform = transform 458 | collision.transform = transform 459 | 460 | # Restore correct scale 461 | mesh.scale = mesh_size 462 | collision.scale = collision_size 463 | 464 | # Update the material 465 | selected_grid._update_material() 466 | 467 | 468 | func _reset_grid_transform() -> void: 469 | if not selected_grid: 470 | return 471 | 472 | var mesh = selected_grid.get_node_or_null("CubeGridMesh3D") 473 | var collision = selected_grid.get_node_or_null("CubeGridCollisionShape3D") 474 | 475 | if not mesh or not collision: 476 | return 477 | 478 | # Get the current mesh and collision scale 479 | var mesh_scale = mesh.scale 480 | var collision_scale = collision.scale 481 | 482 | # Reset the transform 483 | mesh.transform = Transform3D() 484 | collision.transform = Transform3D() 485 | 486 | # Restore the scale 487 | mesh.scale = mesh_scale 488 | collision.scale = collision_scale 489 | 490 | # Update the shader 491 | selected_grid._update_material() 492 | 493 | 494 | # === Drawing Methods === 495 | # Creates a MeshInstance3D shpere at the current hovered location when in ADD mode 496 | func _create_hover_preview() -> void: 497 | # Clear the existing preview 498 | if hover_preview: 499 | hover_preview.queue_free() 500 | 501 | # Create new hover preview 502 | hover_preview = MeshInstance3D.new() 503 | var sphere = SphereMesh.new() 504 | var scale = selected_grid.grid_scale * BASE_PREVIEW_THICKNESS 505 | sphere.radius = scale 506 | sphere.height = scale * 2 507 | hover_preview.mesh = sphere 508 | 509 | # Create the material for the hover preview 510 | var material = StandardMaterial3D.new() 511 | material.albedo_color = Color.RED 512 | material.no_depth_test = true # Always visible Renders ontop of other objects 513 | hover_preview.material_override = material 514 | 515 | # Add it to the scene 516 | if csg_root: 517 | csg_root.add_child(hover_preview) 518 | hover_preview.position = hover_point 519 | # Do not add owner 520 | 521 | 522 | # Changes the position of the hover preview 523 | func _update_hover_preview() -> void: 524 | if not hover_preview: 525 | return 526 | hover_preview.global_position = hover_point 527 | 528 | 529 | func _calculate_base_rect_points() -> void: 530 | if not selected_grid: 531 | return 532 | 533 | var grid_unit = selected_grid.grid_scale 534 | 535 | # Calculate the base rectangle points 536 | var min_x = floor(min(draw_start.x, draw_end.x) / grid_unit) * grid_unit 537 | var max_x = ceil(max(draw_start.x, draw_end.x) / grid_unit) * grid_unit 538 | var min_y = floor(min(draw_start.y, draw_end.y) / grid_unit) * grid_unit 539 | var max_y = ceil(max(draw_start.y, draw_end.y) / grid_unit) * grid_unit 540 | var min_z = floor(min(draw_start.z, draw_end.z) / grid_unit) * grid_unit 541 | var max_z = ceil(max(draw_start.z, draw_end.z) / grid_unit) * grid_unit 542 | 543 | if draw_normal.abs().is_equal_approx(Vector3.UP) or draw_normal.abs().is_equal_approx(Vector3.DOWN): 544 | base_rect_points = [ 545 | Vector3(min_x, draw_start.y, min_z), 546 | Vector3(max_x, draw_start.y, min_z), 547 | Vector3(max_x, draw_start.y, max_z), 548 | Vector3(min_x, draw_start.y, max_z) 549 | ] 550 | elif draw_normal.abs().is_equal_approx(Vector3.RIGHT) or draw_normal.abs().is_equal_approx(Vector3.LEFT): 551 | base_rect_points = [ 552 | Vector3(draw_start.x, min_y, min_z), 553 | Vector3(draw_start.x, max_y, min_z), 554 | Vector3(draw_start.x, max_y, max_z), 555 | Vector3(draw_start.x, min_y, max_z) 556 | ] 557 | else: 558 | base_rect_points = [ 559 | Vector3(min_x, min_y, draw_start.z), 560 | Vector3(max_x, min_y, draw_start.z), 561 | Vector3(max_x, max_y, draw_start.z), 562 | Vector3(min_x, max_y, draw_start.z) 563 | ] 564 | 565 | 566 | func create_rectangle_preview() -> void: 567 | # Clear the previous preview 568 | if draw_preview: 569 | draw_preview.queue_free() 570 | 571 | # Create a new preview 572 | draw_preview = MeshInstance3D.new() 573 | var immediate_mesh = ImmediateMesh.new() 574 | draw_preview.mesh = immediate_mesh 575 | 576 | var material = StandardMaterial3D.new() 577 | material.albedo_color = Color.RED 578 | material.cull_mode = BaseMaterial3D.CULL_DISABLED 579 | material.no_depth_test = true 580 | draw_preview.material_override = material 581 | 582 | if csg_root: 583 | csg_root.add_child(draw_preview) 584 | draw_preview.owner = get_editor_interface().get_edited_scene_root() 585 | 586 | 587 | func _update_rectangle_preview() -> void: 588 | if not draw_preview: return 589 | var immediate_mesh = draw_preview.mesh as ImmediateMesh 590 | immediate_mesh.clear_surfaces() 591 | 592 | var base_thickness = BASE_PREVIEW_THICKNESS * selected_grid.grid_scale 593 | var thickness = base_thickness 594 | var grid_unit = selected_grid.grid_scale 595 | var material = draw_preview.material_override as StandardMaterial3D 596 | 597 | if is_extruding: 598 | material.albedo_color = Color.GREEN if extrude_distance >= 0 else Color.RED 599 | var preview_offset = draw_normal * (grid_unit * 0.000001) 600 | immediate_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) 601 | 602 | var preview_points = [] 603 | for point in base_rect_points: 604 | preview_points.append(point + preview_offset) 605 | 606 | # Rectangle base lines 607 | for i in range(preview_points.size()): 608 | add_thick_line( 609 | immediate_mesh, 610 | preview_points[i], 611 | preview_points[(i + 1) % preview_points.size()], 612 | thickness 613 | ) 614 | # Extrusion lines 615 | if is_extruding: 616 | add_thick_line(immediate_mesh, 617 | initial_extrude_point + preview_offset, 618 | initial_extrude_point + draw_normal * (extrude_distance + preview_offset.length()), 619 | thickness * 0.5) 620 | 621 | if has_started_extrusion: 622 | var extrude_offset = draw_normal * extrude_distance 623 | 624 | for i in range(preview_points.size()): 625 | var extruded_point = preview_points[i] + extrude_offset 626 | add_thick_line(immediate_mesh, 627 | preview_points[i], 628 | extruded_point, 629 | thickness) 630 | add_thick_line( 631 | immediate_mesh, 632 | extruded_point, 633 | preview_points[(i + 1) % preview_points.size()] + extrude_offset, 634 | thickness 635 | ) 636 | 637 | immediate_mesh.surface_end() 638 | 639 | 640 | func add_thick_line(immediate_mesh: ImmediateMesh, start: Vector3, end: Vector3, thickness: float) -> void: 641 | var direction = (end - start).normalized() 642 | 643 | # Find a perpendicular vector to the line 644 | var perpendicular = Vector3.UP.cross(direction).normalized() 645 | if perpendicular.length() < 0.1: 646 | perpendicular = Vector3.RIGHT.cross(direction).normalized() 647 | 648 | # Calculate the four corners of the line 649 | var offset = perpendicular * thickness 650 | var v1 = start + offset 651 | var v2 = start - offset 652 | var v3 = end + offset 653 | var v4 = end - offset 654 | 655 | # Add the two faces to the line 656 | create_rectangle(immediate_mesh, v1, v2, v3, v4) 657 | 658 | 659 | # Creates a rectangle using the given vertecies out of two triangles 660 | func create_rectangle(immediate_mesh: ImmediateMesh, v1: Vector3, v2: Vector3, v3: Vector3, v4: Vector3) -> void: 661 | # Add two triangles to form a rectangle 662 | immediate_mesh.surface_add_vertex(v1) 663 | immediate_mesh.surface_add_vertex(v2) 664 | immediate_mesh.surface_add_vertex(v3) 665 | 666 | immediate_mesh.surface_add_vertex(v2) 667 | immediate_mesh.surface_add_vertex(v4) 668 | immediate_mesh.surface_add_vertex(v3) 669 | 670 | 671 | # === CSG Management Methods === 672 | func _create_CSGBox3D() -> void: 673 | var new_box = CSGBox3D.new() 674 | new_box.use_collision = true 675 | new_box.set_meta("_edit_lock_", true) 676 | new_box.set_meta("_edit_group_", true) 677 | 678 | 679 | var min_point = base_rect_points[0] 680 | var max_point = base_rect_points[0] 681 | 682 | # Minimum and maximum points of the base rectangle 683 | for point in base_rect_points: 684 | min_point = Vector3( 685 | min(min_point.x, point.x), 686 | min(min_point.y, point.y), 687 | min(min_point.z, point.z) 688 | ) 689 | max_point = Vector3( 690 | max(max_point.x, point.x), 691 | max(max_point.y, point.y), 692 | max(max_point.z, point.z) 693 | ) 694 | 695 | # Initial size and center of the box 696 | var size = (max_point - min_point) 697 | var center = (max_point + min_point) * 0.5 698 | 699 | # Adjust size and center based on the extrusion 700 | if draw_normal.abs().is_equal_approx(Vector3.UP) or draw_normal.abs().is_equal_approx(Vector3.DOWN): 701 | size.y = abs(extrude_distance) 702 | center += draw_normal * (extrude_distance * 0.5) 703 | elif draw_normal.abs().is_equal_approx(Vector3.RIGHT) or draw_normal.abs().is_equal_approx(Vector3.LEFT): 704 | size.x = abs(extrude_distance) 705 | center += draw_normal * (extrude_distance * 0.5) 706 | else: 707 | size.z = abs(extrude_distance) 708 | center += draw_normal * (extrude_distance * 0.5) 709 | 710 | if size.x < 0.0001 or size.y < 0.0001 or size.z < 0.0001: 711 | return 712 | 713 | new_box.size = size 714 | new_box.position = center 715 | 716 | # Depending on the extrusion distance set the operation 717 | if extrude_distance < 0: 718 | new_box.operation = CSGShape3D.OPERATION_SUBTRACTION 719 | 720 | undo_redo.create_action("Create CSGBox3D") 721 | undo_redo.add_do_method(csg_root, "add_child", new_box) 722 | undo_redo.add_do_method(new_box, "set_owner", get_editor_interface().get_edited_scene_root()) 723 | undo_redo.add_undo_method(csg_root, "remove_child", new_box) 724 | undo_redo.commit_action() 725 | _update_toolbar_states() 726 | 727 | 728 | func _on_merge_mesh() -> void: 729 | if not csg_root or csg_root.get_child_count() == 0: 730 | return 731 | # Dont allow to merge an already merged mesh 732 | if csg_root.has_node("CSGMesh"): 733 | push_warning("Already merged!") 734 | return 735 | 736 | if edge_preview: 737 | edge_preview.queue_free() 738 | edge_preview = null 739 | 740 | current_edge = [] 741 | is_dragging_edge = false 742 | 743 | var nodes_to_keep = [] 744 | # Go over all of the children of the csg root and check if they are CSGBox3D or CSGMesh3D 745 | for node in csg_root.get_children(): 746 | if node is MeshInstance3D: 747 | continue 748 | 749 | # For subtraction operations, check if it actually cuts something 750 | if node.operation == CSGShape3D.OPERATION_SUBTRACTION: 751 | var cuts_something = false 752 | for other_node in csg_root.get_children(): 753 | if other_node.operation == CSGShape3D.OPERATION_UNION: 754 | if node is CSGBox3D and other_node is CSGBox3D: 755 | var node_bounds = AABB( 756 | node.position - (node.size * 0.5), 757 | node.size 758 | ) 759 | var other_bounds = AABB( 760 | other_node.position - (other_node.size * 0.5), 761 | other_node.size 762 | ) 763 | # Check if the bounding boxes intersect if does keep it 764 | if node_bounds.intersects(other_bounds): 765 | cuts_something = true 766 | break 767 | else: 768 | cuts_something = false 769 | break 770 | # Only keep if it actually cuts something 771 | if cuts_something: 772 | nodes_to_keep.append(node) 773 | else: 774 | nodes_to_keep.append(node) 775 | 776 | var nodes_data = [] 777 | for node in nodes_to_keep: 778 | nodes_data.append(_store_mesh_data(node)) 779 | 780 | var meshes = csg_root.get_meshes() 781 | if meshes.size() > 1: 782 | if not csg_mesh: 783 | csg_mesh = MeshInstance3D.new() 784 | csg_mesh.name = "CSGMesh" 785 | csg_root.add_child(csg_mesh) 786 | csg_mesh.owner = get_editor_interface().get_edited_scene_root() 787 | 788 | csg_mesh.mesh = meshes[1] 789 | csg_mesh.set_meta("csg_data", { 790 | "nodes": nodes_data 791 | }) 792 | 793 | for child in csg_root.get_children(): 794 | if child != csg_mesh: 795 | child.queue_free() 796 | 797 | _update_toolbar_states() 798 | _change_mode(BuildMode.DISABLE) 799 | 800 | 801 | func _on_edit_mesh() -> void: 802 | if not csg_root: 803 | push_warning("No Mesh root found!") 804 | return 805 | 806 | if not csg_root.has_node("CSGMesh"): 807 | push_warning("No CSGMesh to edit!") 808 | return 809 | 810 | csg_mesh = csg_root.get_node("CSGMesh") 811 | var data = csg_mesh.get_meta("csg_data") 812 | if not data: 813 | push_warning("No CSG data found in mesh!") 814 | return 815 | # Deconstruct the mesh into CSGBox3D or CSGMesh3D 816 | _convert_to_boxes() 817 | 818 | # Stores the information about the CSGBox3D or CSGMesh3D 819 | func _store_mesh_data(node: Node) -> Dictionary: 820 | # Create a dictionary to store information 821 | var data = { 822 | "position": node.position, 823 | "operation": node.operation, 824 | "use_collision": node.use_collision, 825 | "type": "box" if node is CSGBox3D else "mesh" 826 | } 827 | # Store the size of the CSGBox3D or the vertices and indices of the CSGMesh3D 828 | if node is CSGBox3D: 829 | data["size"] = node.size 830 | # Store vertices and indices of the CSGMesh3D 831 | elif node is CSGMesh3D: 832 | var mesh = node.mesh as ArrayMesh 833 | if mesh: 834 | data["vertices"] = mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] 835 | data["indices"] = mesh.surface_get_arrays(0)[Mesh.ARRAY_INDEX] 836 | 837 | return data 838 | 839 | 840 | # Recreates the CSGBox3D or CSGMesh3D from the stored metadata 841 | func _convert_to_boxes() -> void: 842 | 843 | csg_mesh = csg_root.get_node("CSGMesh") 844 | if not csg_mesh: 845 | push_warning("No CSGMesh node found!") 846 | return 847 | var data = csg_mesh.get_meta("csg_data") 848 | if not data: 849 | push_warning("No CSG data found in mesh!") 850 | return 851 | 852 | # Go through all of the nodes and recreate them 853 | for node_info in data["nodes"]: 854 | var new_node 855 | 856 | # Based on the type, recreate CSGBox3D or CSGMesh3D 857 | if node_info["type"] == "box": 858 | new_node = CSGBox3D.new() # Create a new CSGBox3D 859 | new_node.size = node_info["size"] # Set the size of the box by getting the size from the metadata 860 | else: 861 | new_node = CSGMesh3D.new() # Create a new CSGMesh3D 862 | var arr_mesh = ArrayMesh.new() # Create a new ArrayMesh 863 | var arrays = [] 864 | arrays.resize(Mesh.ARRAY_MAX) 865 | 866 | arrays[Mesh.ARRAY_VERTEX] = node_info["vertices"] 867 | arrays[Mesh.ARRAY_INDEX] = node_info["indices"] 868 | arr_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) # Add the surface to the ArrayMesh 869 | new_node.mesh = arr_mesh 870 | 871 | new_node.position = node_info["position"] # Set the position 872 | new_node.operation = node_info["operation"] 873 | new_node.use_collision = node_info["use_collision"] 874 | 875 | new_node.set_meta("_edit_lock_", true) 876 | new_node.set_meta("_edit_group_", true) 877 | 878 | csg_root.add_child(new_node) 879 | new_node.owner = get_editor_interface().get_edited_scene_root() 880 | 881 | # Remove the CSGMesh 882 | csg_mesh.queue_free() 883 | csg_mesh = null 884 | 885 | toolbar.update_button_states(false) 886 | _change_mode(BuildMode.DISABLE) 887 | 888 | 889 | # === UI Management Methods === 890 | func _connect_toolbar_signals() -> void: 891 | toolbar.select_button_pressed.connect(func(): _change_mode(BuildMode.SELECT)) 892 | toolbar.add_button_pressed.connect(func(): _change_mode(BuildMode.ADD)) 893 | toolbar.disable_button_pressed.connect(func(): _change_mode(BuildMode.DISABLE)) 894 | toolbar.grid_size_changed.connect(_on_grid_size_changed) 895 | toolbar.reset_grid_pressed.connect(_reset_grid_transform) 896 | toolbar.merge_mesh.connect(_on_merge_mesh) 897 | toolbar.edit_mesh.connect(_on_edit_mesh) 898 | 899 | 900 | func _update_toolbar_states() -> void: 901 | if not csg_root: 902 | return 903 | 904 | var has_csg_mesh = csg_root.has_node("CSGMesh") 905 | var has_csg_boxes = false 906 | 907 | for child in csg_root.get_children(): 908 | if child is CSGBox3D or child is CSGMesh3D: 909 | has_csg_boxes = true 910 | break 911 | 912 | if has_csg_mesh: 913 | toolbar.update_button_states(true) 914 | else: 915 | toolbar.update_button_states(false) 916 | 917 | toolbar.set_merge_button_enabled(has_csg_boxes) 918 | toolbar.set_select_button_enabled(has_csg_boxes) 919 | toolbar.set_edit_button_enabled(has_csg_mesh) 920 | 921 | 922 | func _on_selection_changed() -> void: 923 | var selected = get_editor_interface().get_selection().get_selected_nodes() 924 | if selected.size() == 1 and selected[0] is CubeGrid3D: 925 | selected_grid = selected[0] 926 | csg_root = selected_grid.get_node("CSGCombiner3D") 927 | toolbar.show() 928 | toolbar.connect_to_grid(selected_grid) 929 | _update_toolbar_states() 930 | 931 | var root := EditorInterface.get_base_control() 932 | var toolbar = root.find_children("", "Node3DEditor", true, false)[0].get_child(0).find_children("", "HBoxContainer", true, false)[0] 933 | var btn = toolbar.get_child(2) 934 | btn.pressed.emit() 935 | if hover_preview: 936 | hover_preview.queue_free() 937 | hover_preview = null 938 | else: 939 | _change_mode(BuildMode.DISABLE) 940 | if hover_preview: 941 | hover_preview.queue_free() 942 | hover_preview = null 943 | selected_grid = null 944 | csg_root = null 945 | toolbar.hide() 946 | 947 | 948 | func _change_mode(new_mode: BuildMode) -> void: 949 | if new_mode == BuildMode.ADD and csg_root and csg_root.has_node("CSGMesh"): 950 | push_warning("Can't switch to ADD mode while CSGMesh exists. Use Edit to modify.") 951 | toolbar.set_active_mode(current_mode) 952 | return 953 | 954 | current_mode = new_mode 955 | toolbar.set_active_mode(current_mode) 956 | 957 | if edge_preview: 958 | edge_preview.queue_free() 959 | edge_preview = null 960 | current_edge = [] 961 | is_dragging_edge = false 962 | 963 | if csg_root: 964 | csg_root.set_meta("_edit_lock_", current_mode != BuildMode.SELECT) 965 | 966 | 967 | # === Edge Movement Methods === 968 | func _get_edges(node: Node) -> Array: 969 | var edges = [] 970 | 971 | if node is CSGBox3D: 972 | var aabb = AABB( 973 | node.global_position - (node.size * 0.5), 974 | node.size 975 | ) 976 | # Corners of the CSGBox3D 977 | var corners = [ 978 | Vector3(aabb.position.x, aabb.position.y, aabb.position.z), 979 | Vector3(aabb.end.x, aabb.position.y, aabb.position.z), 980 | Vector3(aabb.end.x, aabb.end.y, aabb.position.z), 981 | Vector3(aabb.position.x, aabb.end.y, aabb.position.z), 982 | Vector3(aabb.position.x, aabb.position.y, aabb.end.z), 983 | Vector3(aabb.end.x, aabb.position.y, aabb.end.z), 984 | Vector3(aabb.end.x, aabb.end.y, aabb.end.z), 985 | Vector3(aabb.position.x, aabb.end.y, aabb.end.z) 986 | ] 987 | # Edges of the CSGBox3D 988 | var edge_indices = [ 989 | [0, 1], [1, 2], [2, 3], [3, 0], 990 | [4, 5], [5, 6], [6, 7], [7, 4], 991 | [0, 4], [1, 5], [2, 6], [3, 7] 992 | ] 993 | # Create edges by taking pairs of corners 994 | for pair in edge_indices: 995 | edges.append([corners[pair[0]], corners[pair[1]]]) 996 | # CSGMesh3D get edges by getting the verticies and indicies 997 | elif node is CSGMesh3D: 998 | var arr_mesh = node.mesh as ArrayMesh 999 | if not arr_mesh: 1000 | return edges 1001 | 1002 | # Arraymesh 1003 | var arrays = arr_mesh.surface_get_arrays(0) 1004 | # Get the vertices and indices 1005 | var vertices = arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array 1006 | var indices = arrays[Mesh.ARRAY_INDEX] as PackedInt32Array 1007 | # Create a set to store edges 1008 | var edge_set = {} 1009 | 1010 | # Go through the indicies and create an edgemap 1011 | for i in range(0, indices.size(), 3): 1012 | var tri_indices = [ 1013 | indices[i], 1014 | indices[i + 1], 1015 | indices[i + 2] 1016 | ] 1017 | 1018 | for j in range(3): 1019 | var idx1 = tri_indices[j] 1020 | var idx2 = tri_indices[(j + 1) % 3] # Get the next 2 indicies 1021 | 1022 | var edge_key = str(min(idx1, idx2)) + "_" + str(max(idx1, idx2)) 1023 | if not edge_set.has(edge_key): 1024 | edge_set[edge_key] = true 1025 | var vert1 = node.global_transform * vertices[idx1] 1026 | var vert2 = node.global_transform * vertices[idx2] 1027 | edges.append([vert1, vert2]) 1028 | 1029 | return edges 1030 | 1031 | 1032 | # Finds the closest edge to the mouse 1033 | func _find_closest_edge(node: Node, mouse_pos: Vector2) -> Array: 1034 | if not camera: 1035 | return [] 1036 | 1037 | # Get all of the edges 1038 | var edges = _get_edges(node) 1039 | # Intialize closest edge 1040 | var closest_edge = [] 1041 | # Set the distance to infinity 1042 | var min_distance = INF 1043 | 1044 | # Cast ray from the camera to the mouse position 1045 | var from = camera.project_ray_origin(mouse_pos) 1046 | var dir = camera.project_ray_normal(mouse_pos) 1047 | var m_line1 = from 1048 | var m_line2 = from + dir * 5000 1049 | 1050 | # Go over all of the edges 1051 | for edge in edges: 1052 | # Take the two first endpoints of the edge 1053 | var e_line1 = edge[0] 1054 | var e_line2 = edge[1] 1055 | 1056 | # Get the closest points between the edge and the ray 1057 | var closest_points = Geometry3D.get_closest_points_between_segments(e_line1, e_line2, m_line1, m_line2) 1058 | # Return the closest edge 1059 | var point_on_edge = closest_points[0] 1060 | var point_on_ray = closest_points[1] 1061 | 1062 | # Calculate the distance between the two points 1063 | var distance_vec = point_on_ray - point_on_edge 1064 | var distance = distance_vec.length() 1065 | 1066 | # If the distance is smaller than the current minimum, update the closest edge 1067 | if distance < min_distance: 1068 | min_distance = distance 1069 | closest_edge = edge 1070 | 1071 | return closest_edge 1072 | 1073 | 1074 | # Method that draws a line on the currently hovered edge 1075 | func _create_edge_preview(edge: Array) -> void: 1076 | 1077 | if edge.is_empty(): 1078 | if edge_preview: 1079 | edge_preview.hide() 1080 | return 1081 | 1082 | # Create the edge preview material 1083 | if not edge_preview: 1084 | edge_preview = MeshInstance3D.new() 1085 | var immediate_mesh = ImmediateMesh.new() 1086 | edge_preview.mesh = immediate_mesh 1087 | 1088 | var material = StandardMaterial3D.new() 1089 | material.albedo_color = Color.RED 1090 | material.cull_mode = BaseMaterial3D.CULL_DISABLED 1091 | material.no_depth_test = true 1092 | edge_preview.material_override = material 1093 | 1094 | if csg_root: 1095 | csg_root.add_child(edge_preview) 1096 | 1097 | edge_preview.show() 1098 | var immediate_mesh = edge_preview.mesh as ImmediateMesh 1099 | immediate_mesh.clear_surfaces() 1100 | 1101 | immediate_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) 1102 | var thickness = selected_grid.grid_scale * BASE_PREVIEW_THICKNESS # Scale the thickness based on grid size 1103 | add_thick_line(immediate_mesh, edge[0], edge[1], thickness) # Use the add_thick_line methdod to create the line 1104 | immediate_mesh.surface_end() 1105 | 1106 | 1107 | func _convert_box_to_CSGMesh(box: CSGBox3D) -> CSGMesh3D: 1108 | var csg_mesh = CSGMesh3D.new() 1109 | var arr_mesh = ArrayMesh.new() 1110 | var vertices = PackedVector3Array() 1111 | var indices = PackedInt32Array() 1112 | var half_size = box.size * 0.5 1113 | 1114 | var local_verts = [ 1115 | Vector3(-half_size.x, -half_size.y, -half_size.z), # 0 1116 | Vector3(half_size.x, -half_size.y, -half_size.z), # 1 1117 | Vector3(half_size.x, half_size.y, -half_size.z), # 2 1118 | Vector3(-half_size.x, half_size.y, -half_size.z), # 3 1119 | Vector3(-half_size.x, -half_size.y, half_size.z), # 4 1120 | Vector3(half_size.x, -half_size.y, half_size.z), # 5 1121 | Vector3(half_size.x, half_size.y, half_size.z), # 6 1122 | Vector3(-half_size.x, half_size.y, half_size.z) # 7 1123 | ] 1124 | 1125 | vertices.append_array(local_verts) 1126 | 1127 | var faces = [ 1128 | [0, 1, 2, 2, 3, 0], # Front 1129 | [1, 5, 6, 6, 2, 1], # Right 1130 | [5, 4, 7, 7, 6, 5], # Back 1131 | [4, 0, 3, 3, 7, 4], # Left 1132 | [3, 2, 6, 6, 7, 3], # Top 1133 | [4, 5, 1, 1, 0, 4] # Bottom 1134 | ] 1135 | 1136 | for face in faces: 1137 | indices.append_array(face) 1138 | 1139 | var arrays = [] 1140 | arrays.resize(Mesh.ARRAY_MAX) 1141 | arrays[Mesh.ARRAY_VERTEX] = vertices 1142 | arrays[Mesh.ARRAY_INDEX] = indices 1143 | 1144 | arr_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) 1145 | 1146 | csg_mesh.mesh = arr_mesh 1147 | csg_mesh.transform = box.transform 1148 | csg_mesh.operation = box.operation 1149 | csg_mesh.use_collision = box.use_collision 1150 | 1151 | csg_root.add_child(csg_mesh) 1152 | csg_mesh.owner = get_editor_interface().get_edited_scene_root() 1153 | 1154 | box.queue_free() 1155 | 1156 | return csg_mesh 1157 | --------------------------------------------------------------------------------