diff --git a/ADDON_LIZENZ.txt b/ADDON_LIZENZ.txt new file mode 100644 index 0000000..cf93022 --- /dev/null +++ b/ADDON_LIZENZ.txt @@ -0,0 +1,5 @@ +addons/modular_builder — "Modular Builder" v0.2.0-alpha von Michel Savadim +Quelle: Godot Asset Store (https://store.godotengine.org/asset/michel-savadim/modular-buider/) +Lizenz: CC0 1.0 Universal (siehe addons/modular_builder/LICENSE) — gilt für Code, +Icons und Dokumentation. Die Demo-Szene/Demo-Texturen des Originals sind NICHT +CC0 und wurden beim Import ENTFERNT (addons/modular_builder/demo fehlt bewusst). diff --git a/addons/modular_builder/CHANGELOG.md b/addons/modular_builder/CHANGELOG.md new file mode 100644 index 0000000..fd474ea --- /dev/null +++ b/addons/modular_builder/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.2.0 + +- Add gable walls, gable/shed/pyramid roofs, stairs, fences, and parapets. +- Add plain pillars between vertices or vertically from one vertex with `Alt+click`. +- Add manual vertex creation, movement, removal, merging, and simplified marker display. +- Add wall/gable/pillar centering controls and editable asymmetric dimensions. +- Export a finished structure as one textured mesh in a new scene or as a copy beside the editable structure. +- Include the demo scene and its example textures. +- Split the editor and geometry code into focused scripts. + +## 0.1.0 — Initial release + +- Create square and triangular foundations, walls, and square or triangular floors directly in the Godot editor. +- Snap pieces together using visible vertices and directional foundation placement controls. +- Split walls at intermediate foundation vertices for separate texturing. +- Add windows, doors, and hatches as openings or separate editable objects. +- Generate general concave collision and save reusable building modules. diff --git a/addons/modular_builder/LICENSE b/addons/modular_builder/LICENSE new file mode 100644 index 0000000..1e93566 --- /dev/null +++ b/addons/modular_builder/LICENSE @@ -0,0 +1,125 @@ +CC0 1.0 Universal + +Copyright (c) 2026 Michel Savadim + +This CC0 dedication applies only to the original Modular Builder source code, +icons, and documentation in this directory authored by Michel Savadim. It does +not apply to Godot Engine, user-created project content, trademarks, patents, +or third-party materials distributed separately. + +CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE +LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN +ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS +INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES +REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS +PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM +THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED +HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. + +https://creativecommons.org/publicdomain/zero/1.0/legalcode diff --git a/addons/modular_builder/README.md b/addons/modular_builder/README.md new file mode 100644 index 0000000..8d9d3b1 --- /dev/null +++ b/addons/modular_builder/README.md @@ -0,0 +1,37 @@ +# Modular Builder 0.2.0 + +Build modular structures directly in the Godot 4.7 editor. The panel appears at the bottom of the 3D editor. Dimensions are shown in meters; one Godot 3D unit equals one meter. + +## Install and try the demo + +Open this repository as a Godot project to explore `addons/modular_builder/demo/Demo.tscn`. After Godot finishes importing the assets, enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. The demo and its five textures are contained in `addons/modular_builder/demo/`. + +To use the plugin in an existing project, extract the ZIP at the project root so it creates only `addons/modular_builder/`, then enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. Open a 3D scene to use the editor panel. The demo remains inside the addon folder and can be removed if you do not need it. + +## Build + +Click **Add ModularBuilderPiece** to create a structure container and its first foundation. Green tools choose square or triangular foundations; a green `+` on a free edge attaches another foundation. Red tools place walls and triangular gables between vertices. Yellow tools place square floors using opposite diagonal vertices or triangular floors using three vertices. Wall segments crossing several foundations are split at intermediate foundation vertices so each segment can be textured separately. + +Blue markers show connection vertices; selected points turn yellow. Enable **Only show points on selected pieces** to reduce clutter, or **Simplify vertices** to show points at the middle of a piece's thickness. The separate vertex tools merge markers and create, move, or remove manual connection points. Manual vertices belong to their selected piece or detail and do not deform its mesh. + +The blue roof and stairs tools create gable, shed, and pyramid roofs and stairs. Gable roofs use two gable peaks or diagonal top corners of opposite walls. Shed roofs use a low point and a diagonally opposite high point; pyramid roofs use two diagonal points at the same height. The Inspector lets you adjust roof width and rise, stair width, and step count. Walls can be placed on other walls or floors. **Center walls** and **Center** for gables align them with supporting walls. + +Pink detail tools place windows, doors, hatches, pillars, fences, and parapets. Windows, doors, and hatches can be openings alone or openings with separately editable and textured objects. Pillars connect two vertices; `Alt+click` one vertex to create a vertical pillar from it. The **Center** option aligns a pillar with the supporting piece. Fences have two rails; parapets have one thicker rail. Their height and intermediate post count are editable. + +Building pieces start with **Symmetric dimensions** enabled. Turn it off to edit width, height, and depth independently. **Thickness** continues to control the relevant dimension for walls, floors, and foundations. Select a piece in the Scene tree to edit its material and dimensions, or use the rotation button to rotate it by 90 degrees. The active creation tool remains selected until you click it again or choose another tool. `Esc` clears the current points; `Ctrl+Z` undoes placements and vertex edits. + +## Collision, modules, and export + +Pieces have no collision by default. Select the `ModularBuilderPiece` container and use **Add general collision** to generate or update one concave static collision shape for the structure. Connection markers and internal contact faces are excluded. + +Save a selected structure as a reusable `.tscn` module, or insert a saved module into the open scene. For a finished structure, **Export** combines visible meshes into one `MeshInstance3D`, preserving material surfaces and UVs. Choose **New scene** for an independent `.tscn`, or **Copy in this scene** to add a sibling of the editable `ModularBuilderPiece`. The original remains editable. + +## Files and license + +The English plugin lives in `addons/modular_builder/`. Editor code is split into UI, scene actions, detail placement, vertex editing, and viewport interaction. Geometry generation, mesh baking, roof/pillar placement, and vertex simplification have separate scripts. + +The original plugin code, icons, and documentation are dedicated to the public domain under [CC0 1.0](LICENSE). The bundled demo textures are example project assets; the CC0 dedication in this repository's license does not automatically cover them. Check their provenance and permission before redistributing the textures separately. + +## AI usage disclosure + +Generative AI (OpenAI ChatGPT/Codex) assisted with GDScript implementation and revisions, debugging, translation from Portuguese to English, and documentation. Michel Savadim defined the requirements and reviewed and tested the project. diff --git a/addons/modular_builder/icons/add_building.svg b/addons/modular_builder/icons/add_building.svg new file mode 100644 index 0000000..5d224d5 --- /dev/null +++ b/addons/modular_builder/icons/add_building.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/add_building.svg.import b/addons/modular_builder/icons/add_building.svg.import new file mode 100644 index 0000000..b29ea9f --- /dev/null +++ b/addons/modular_builder/icons/add_building.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kwgsfdxprj4p" +path="res://.godot/imported/add_building.svg-02348d81d48c2064f5946a668739ab6e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/add_building.svg" +dest_files=["res://.godot/imported/add_building.svg-02348d81d48c2064f5946a668739ab6e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/cancel.svg b/addons/modular_builder/icons/cancel.svg new file mode 100644 index 0000000..a7e71d1 --- /dev/null +++ b/addons/modular_builder/icons/cancel.svg @@ -0,0 +1,3 @@ + + + diff --git a/addons/modular_builder/icons/cancel.svg.import b/addons/modular_builder/icons/cancel.svg.import new file mode 100644 index 0000000..ca90d51 --- /dev/null +++ b/addons/modular_builder/icons/cancel.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://np6bb2n6ohj3" +path="res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/cancel.svg" +dest_files=["res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/collision.svg b/addons/modular_builder/icons/collision.svg new file mode 100644 index 0000000..c7f4a5f --- /dev/null +++ b/addons/modular_builder/icons/collision.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/collision.svg.import b/addons/modular_builder/icons/collision.svg.import new file mode 100644 index 0000000..9644e48 --- /dev/null +++ b/addons/modular_builder/icons/collision.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjc2d332bqgfy" +path="res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/collision.svg" +dest_files=["res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/create_vertex.svg b/addons/modular_builder/icons/create_vertex.svg new file mode 100644 index 0000000..17004d7 --- /dev/null +++ b/addons/modular_builder/icons/create_vertex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/create_vertex.svg.import b/addons/modular_builder/icons/create_vertex.svg.import new file mode 100644 index 0000000..f9f8226 --- /dev/null +++ b/addons/modular_builder/icons/create_vertex.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8ivgqetbm82q" +path="res://.godot/imported/create_vertex.svg-935b2fd3add0dedfb62239b6f1e7a4db.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/create_vertex.svg" +dest_files=["res://.godot/imported/create_vertex.svg-935b2fd3add0dedfb62239b6f1e7a4db.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/door.svg b/addons/modular_builder/icons/door.svg new file mode 100644 index 0000000..91e44b4 --- /dev/null +++ b/addons/modular_builder/icons/door.svg @@ -0,0 +1 @@ + diff --git a/addons/modular_builder/icons/door.svg.import b/addons/modular_builder/icons/door.svg.import new file mode 100644 index 0000000..bd38587 --- /dev/null +++ b/addons/modular_builder/icons/door.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://booy2vg0ci6p7" +path="res://.godot/imported/door.svg-5067a7f9805041b1e0019db73b7f247e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/door.svg" +dest_files=["res://.godot/imported/door.svg-5067a7f9805041b1e0019db73b7f247e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/fence.svg b/addons/modular_builder/icons/fence.svg new file mode 100644 index 0000000..5b9f2bb --- /dev/null +++ b/addons/modular_builder/icons/fence.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/modular_builder/icons/fence.svg.import b/addons/modular_builder/icons/fence.svg.import new file mode 100644 index 0000000..a014f04 --- /dev/null +++ b/addons/modular_builder/icons/fence.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://62ci7tsy4yeq" +path="res://.godot/imported/fence.svg-b115d5a88a7e382552216868149e7186.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/fence.svg" +dest_files=["res://.godot/imported/fence.svg-b115d5a88a7e382552216868149e7186.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/floor_square.svg b/addons/modular_builder/icons/floor_square.svg new file mode 100644 index 0000000..caabc7d --- /dev/null +++ b/addons/modular_builder/icons/floor_square.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/floor_square.svg.import b/addons/modular_builder/icons/floor_square.svg.import new file mode 100644 index 0000000..59cdc0f --- /dev/null +++ b/addons/modular_builder/icons/floor_square.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bey5yf5dc53ms" +path="res://.godot/imported/floor_square.svg-c870ce008800866d2fb4f4c19615fe80.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/floor_square.svg" +dest_files=["res://.godot/imported/floor_square.svg-c870ce008800866d2fb4f4c19615fe80.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/floor_triangle.svg b/addons/modular_builder/icons/floor_triangle.svg new file mode 100644 index 0000000..0e6440b --- /dev/null +++ b/addons/modular_builder/icons/floor_triangle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/floor_triangle.svg.import b/addons/modular_builder/icons/floor_triangle.svg.import new file mode 100644 index 0000000..a316a12 --- /dev/null +++ b/addons/modular_builder/icons/floor_triangle.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dghymeocq75is" +path="res://.godot/imported/floor_triangle.svg-cbffdc894d9762c4cb9806dbfa52405c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/floor_triangle.svg" +dest_files=["res://.godot/imported/floor_triangle.svg-cbffdc894d9762c4cb9806dbfa52405c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/foundation_square.svg b/addons/modular_builder/icons/foundation_square.svg new file mode 100644 index 0000000..79ff5cb --- /dev/null +++ b/addons/modular_builder/icons/foundation_square.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/foundation_square.svg.import b/addons/modular_builder/icons/foundation_square.svg.import new file mode 100644 index 0000000..d9f65e9 --- /dev/null +++ b/addons/modular_builder/icons/foundation_square.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3df01la6lxht" +path="res://.godot/imported/foundation_square.svg-23bf2f453c993134ea4ea05145d98488.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/foundation_square.svg" +dest_files=["res://.godot/imported/foundation_square.svg-23bf2f453c993134ea4ea05145d98488.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/foundation_triangle.svg b/addons/modular_builder/icons/foundation_triangle.svg new file mode 100644 index 0000000..a170042 --- /dev/null +++ b/addons/modular_builder/icons/foundation_triangle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/foundation_triangle.svg.import b/addons/modular_builder/icons/foundation_triangle.svg.import new file mode 100644 index 0000000..a54566b --- /dev/null +++ b/addons/modular_builder/icons/foundation_triangle.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ln0jh5ibvhe0" +path="res://.godot/imported/foundation_triangle.svg-4b3e87f2198d6b6a8427029009457134.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/foundation_triangle.svg" +dest_files=["res://.godot/imported/foundation_triangle.svg-4b3e87f2198d6b6a8427029009457134.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/gable_wall.svg b/addons/modular_builder/icons/gable_wall.svg new file mode 100644 index 0000000..498153c --- /dev/null +++ b/addons/modular_builder/icons/gable_wall.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/gable_wall.svg.import b/addons/modular_builder/icons/gable_wall.svg.import new file mode 100644 index 0000000..f6dd668 --- /dev/null +++ b/addons/modular_builder/icons/gable_wall.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b16auokjgcjuv" +path="res://.godot/imported/gable_wall.svg-1d82415321f6b6ad59f51ba7640a6c53.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/gable_wall.svg" +dest_files=["res://.godot/imported/gable_wall.svg-1d82415321f6b6ad59f51ba7640a6c53.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/gable_wall_right.svg b/addons/modular_builder/icons/gable_wall_right.svg new file mode 100644 index 0000000..3bd8173 --- /dev/null +++ b/addons/modular_builder/icons/gable_wall_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/gable_wall_right.svg.import b/addons/modular_builder/icons/gable_wall_right.svg.import new file mode 100644 index 0000000..16f3cad --- /dev/null +++ b/addons/modular_builder/icons/gable_wall_right.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0jckxct26pqs" +path="res://.godot/imported/gable_wall_right.svg-3336022dcfa05da5960f0dc8e3ecc6ff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/gable_wall_right.svg" +dest_files=["res://.godot/imported/gable_wall_right.svg-3336022dcfa05da5960f0dc8e3ecc6ff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/hatch.svg b/addons/modular_builder/icons/hatch.svg new file mode 100644 index 0000000..8d23a6f --- /dev/null +++ b/addons/modular_builder/icons/hatch.svg @@ -0,0 +1 @@ + diff --git a/addons/modular_builder/icons/hatch.svg.import b/addons/modular_builder/icons/hatch.svg.import new file mode 100644 index 0000000..3776c46 --- /dev/null +++ b/addons/modular_builder/icons/hatch.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf2okj2f2eq1h" +path="res://.godot/imported/hatch.svg-f0ee2bc7f824245d3082af2deb5cb59a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/hatch.svg" +dest_files=["res://.godot/imported/hatch.svg-f0ee2bc7f824245d3082af2deb5cb59a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/load_module.svg b/addons/modular_builder/icons/load_module.svg new file mode 100644 index 0000000..4e4116a --- /dev/null +++ b/addons/modular_builder/icons/load_module.svg @@ -0,0 +1 @@ + diff --git a/addons/modular_builder/icons/load_module.svg.import b/addons/modular_builder/icons/load_module.svg.import new file mode 100644 index 0000000..4698dce --- /dev/null +++ b/addons/modular_builder/icons/load_module.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dqhgwj72rp5o1" +path="res://.godot/imported/load_module.svg-308877482b25606c5b7ed43292d640f3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/load_module.svg" +dest_files=["res://.godot/imported/load_module.svg-308877482b25606c5b7ed43292d640f3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/merge_vertices.svg b/addons/modular_builder/icons/merge_vertices.svg new file mode 100644 index 0000000..eba17d8 --- /dev/null +++ b/addons/modular_builder/icons/merge_vertices.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/merge_vertices.svg.import b/addons/modular_builder/icons/merge_vertices.svg.import new file mode 100644 index 0000000..ca06062 --- /dev/null +++ b/addons/modular_builder/icons/merge_vertices.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n7jw3p1fa5eu" +path="res://.godot/imported/merge_vertices.svg-d9204aed762a783069511285341aaecc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/merge_vertices.svg" +dest_files=["res://.godot/imported/merge_vertices.svg-d9204aed762a783069511285341aaecc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/move_vertex.svg b/addons/modular_builder/icons/move_vertex.svg new file mode 100644 index 0000000..c364f64 --- /dev/null +++ b/addons/modular_builder/icons/move_vertex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/move_vertex.svg.import b/addons/modular_builder/icons/move_vertex.svg.import new file mode 100644 index 0000000..704908b --- /dev/null +++ b/addons/modular_builder/icons/move_vertex.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://buetyw0nroq6k" +path="res://.godot/imported/move_vertex.svg-a43c7dd371bb862b05efa6f900fd0556.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/move_vertex.svg" +dest_files=["res://.godot/imported/move_vertex.svg-a43c7dd371bb862b05efa6f900fd0556.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/parapet.svg b/addons/modular_builder/icons/parapet.svg new file mode 100644 index 0000000..0644128 --- /dev/null +++ b/addons/modular_builder/icons/parapet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/parapet.svg.import b/addons/modular_builder/icons/parapet.svg.import new file mode 100644 index 0000000..fcef7b1 --- /dev/null +++ b/addons/modular_builder/icons/parapet.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkxxyh8ec5oed" +path="res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/parapet.svg" +dest_files=["res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/pillar.svg b/addons/modular_builder/icons/pillar.svg new file mode 100644 index 0000000..7877c19 --- /dev/null +++ b/addons/modular_builder/icons/pillar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/pillar.svg.import b/addons/modular_builder/icons/pillar.svg.import new file mode 100644 index 0000000..8c825f7 --- /dev/null +++ b/addons/modular_builder/icons/pillar.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dukm4mprtq7l" +path="res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/pillar.svg" +dest_files=["res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/remove_vertex.svg b/addons/modular_builder/icons/remove_vertex.svg new file mode 100644 index 0000000..3a52a69 --- /dev/null +++ b/addons/modular_builder/icons/remove_vertex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/remove_vertex.svg.import b/addons/modular_builder/icons/remove_vertex.svg.import new file mode 100644 index 0000000..197a638 --- /dev/null +++ b/addons/modular_builder/icons/remove_vertex.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjvg4halxhoy5" +path="res://.godot/imported/remove_vertex.svg-824eacf485182fddca49a06831d65906.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/remove_vertex.svg" +dest_files=["res://.godot/imported/remove_vertex.svg-824eacf485182fddca49a06831d65906.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/roof_gable.svg b/addons/modular_builder/icons/roof_gable.svg new file mode 100644 index 0000000..8e749a2 --- /dev/null +++ b/addons/modular_builder/icons/roof_gable.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/roof_gable.svg.import b/addons/modular_builder/icons/roof_gable.svg.import new file mode 100644 index 0000000..bc80e81 --- /dev/null +++ b/addons/modular_builder/icons/roof_gable.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvtqw0h5dd5yl" +path="res://.godot/imported/roof_gable.svg-8b4da12734ae02486f4e916ee6f2b16d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/roof_gable.svg" +dest_files=["res://.godot/imported/roof_gable.svg-8b4da12734ae02486f4e916ee6f2b16d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/roof_pyramid.svg b/addons/modular_builder/icons/roof_pyramid.svg new file mode 100644 index 0000000..6bddd90 --- /dev/null +++ b/addons/modular_builder/icons/roof_pyramid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/roof_pyramid.svg.import b/addons/modular_builder/icons/roof_pyramid.svg.import new file mode 100644 index 0000000..1f08d81 --- /dev/null +++ b/addons/modular_builder/icons/roof_pyramid.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccew6wsdsj0kn" +path="res://.godot/imported/roof_pyramid.svg-97d144eff93367622d2cd47f9133b57d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/roof_pyramid.svg" +dest_files=["res://.godot/imported/roof_pyramid.svg-97d144eff93367622d2cd47f9133b57d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/roof_shed.svg b/addons/modular_builder/icons/roof_shed.svg new file mode 100644 index 0000000..113fb1f --- /dev/null +++ b/addons/modular_builder/icons/roof_shed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/roof_shed.svg.import b/addons/modular_builder/icons/roof_shed.svg.import new file mode 100644 index 0000000..bf14f27 --- /dev/null +++ b/addons/modular_builder/icons/roof_shed.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wyg331tl3rdt" +path="res://.godot/imported/roof_shed.svg-47c9ee22596d5277674eb88f312c9478.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/roof_shed.svg" +dest_files=["res://.godot/imported/roof_shed.svg-47c9ee22596d5277674eb88f312c9478.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/rotate.svg b/addons/modular_builder/icons/rotate.svg new file mode 100644 index 0000000..5792b7c --- /dev/null +++ b/addons/modular_builder/icons/rotate.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/modular_builder/icons/rotate.svg.import b/addons/modular_builder/icons/rotate.svg.import new file mode 100644 index 0000000..ba4ee3b --- /dev/null +++ b/addons/modular_builder/icons/rotate.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxcb3kjn18udy" +path="res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/rotate.svg" +dest_files=["res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/save_module.svg b/addons/modular_builder/icons/save_module.svg new file mode 100644 index 0000000..86d5084 --- /dev/null +++ b/addons/modular_builder/icons/save_module.svg @@ -0,0 +1 @@ + diff --git a/addons/modular_builder/icons/save_module.svg.import b/addons/modular_builder/icons/save_module.svg.import new file mode 100644 index 0000000..8880bd3 --- /dev/null +++ b/addons/modular_builder/icons/save_module.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pnq0qqniy5hx" +path="res://.godot/imported/save_module.svg-eecd37f83ae3c8dc3d40dabc8e8213e4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/save_module.svg" +dest_files=["res://.godot/imported/save_module.svg-eecd37f83ae3c8dc3d40dabc8e8213e4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/stairs.svg b/addons/modular_builder/icons/stairs.svg new file mode 100644 index 0000000..7b4ca8d --- /dev/null +++ b/addons/modular_builder/icons/stairs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/addons/modular_builder/icons/stairs.svg.import b/addons/modular_builder/icons/stairs.svg.import new file mode 100644 index 0000000..ef825db --- /dev/null +++ b/addons/modular_builder/icons/stairs.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6ibyaujhybao" +path="res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/stairs.svg" +dest_files=["res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/wall.svg b/addons/modular_builder/icons/wall.svg new file mode 100644 index 0000000..ad9aa0d --- /dev/null +++ b/addons/modular_builder/icons/wall.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/modular_builder/icons/wall.svg.import b/addons/modular_builder/icons/wall.svg.import new file mode 100644 index 0000000..801d1f0 --- /dev/null +++ b/addons/modular_builder/icons/wall.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmkpq8teurcwt" +path="res://.godot/imported/wall.svg-b57c613ae16e9db4887d98c30036dee9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/wall.svg" +dest_files=["res://.godot/imported/wall.svg-b57c613ae16e9db4887d98c30036dee9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/icons/window.svg b/addons/modular_builder/icons/window.svg new file mode 100644 index 0000000..67d2700 --- /dev/null +++ b/addons/modular_builder/icons/window.svg @@ -0,0 +1 @@ + diff --git a/addons/modular_builder/icons/window.svg.import b/addons/modular_builder/icons/window.svg.import new file mode 100644 index 0000000..1f976ce --- /dev/null +++ b/addons/modular_builder/icons/window.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu21qr4i7dns1" +path="res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/modular_builder/icons/window.svg" +dest_files=["res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/modular_builder/modular_builder_editor_actions.gd b/addons/modular_builder/modular_builder_editor_actions.gd new file mode 100644 index 0000000..60faa34 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_actions.gd @@ -0,0 +1,596 @@ +@tool +extends "res://addons/modular_builder/modular_builder_editor_ui.gd" + +func _add_piece() -> void: + var root := get_editor_interface().get_edited_scene_root() + if root == null or not root is Node3D: + status_label.text = "Open a 3D scene before adding a piece." + return + var piece := Node3D.new() + piece.name = "ModularBuilderPiece" + piece.set_script(PIECE_SCRIPT) + piece.set("piece_type", ModularBuilderPiece.PieceType.BUILDING) + var module_size_units := _meters_to_godot_units(size_spin.value) + piece.set("unit_size", module_size_units) + var foundation := Node3D.new() + foundation.name = "Foundation" + foundation.set_script(PIECE_SCRIPT) + var chosen_type := _foundation_type_selected + if chosen_type == 1: + foundation.set("piece_type", ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE) + else: + foundation.set("piece_type", ModularBuilderPiece.PieceType.FOUNDATION_SQUARE) + foundation.set("unit_size", module_size_units) + foundation.set("centered_origin", true) + foundation.position.y = foundation.thickness * 0.5 + piece.add_child(foundation, true) + foundation.owner = root + var undo := get_undo_redo() + undo.create_action("Add ModularBuilderPiece") + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", root) + undo.add_do_property(foundation, "owner", root) + undo.add_undo_method(root, "remove_child", piece) + undo.add_do_method(get_editor_interface().get_selection(), "clear") + undo.add_do_method(get_editor_interface().get_selection(), "add_node", piece) + undo.commit_action() + _last_created_piece = foundation + _active_button_key = TOOL_FOUNDATION_TRIANGLE if _foundation_type_selected == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE else TOOL_FOUNDATION_SQUARE + _set_tool(TOOL_FOUNDATION) + +func _meters_to_godot_units(meters: float) -> float: + return meters * METERS_TO_GODOT_UNITS + +func _show_save_module_dialog() -> void: + var building := _get_selected_piece() + if building == null: + status_label.text = "Select a ModularBuilderPiece structure to save as a module." + return + _save_module_dialog.current_file = "%s.tscn" % building.name + _save_module_dialog.popup_centered_ratio(0.7) + +func _show_load_module_dialog() -> void: + _load_module_dialog.popup_centered_ratio(0.7) + +func _on_export_pressed() -> void: + var building := _get_selected_piece() + if building == null: + status_label.text = "Select a ModularBuilderPiece to export." + return + var baked_mesh := _bake_building_mesh(building) + if baked_mesh == null or baked_mesh.get_surface_count() == 0: + status_label.text = "No visible meshes were found to export." + return + if _export_mode_option.get_selected_id() == EXPORT_SCENE_COPY: + _add_baked_copy(building, baked_mesh) + return + _pending_export_mesh = baked_mesh + _pending_export_name = "%s_Baked" % building.name + _pending_export_basis = building.global_transform.basis + _export_scene_dialog.current_file = "%s.tscn" % _pending_export_name + _export_scene_dialog.popup_centered_ratio(0.7) + +func _bake_building_mesh(building: ModularBuilderPiece) -> ArrayMesh: + return MESH_BAKER.new().bake(building) + +func _add_baked_copy(building: ModularBuilderPiece, mesh: ArrayMesh) -> void: + var edited_scene_root := get_editor_interface().get_edited_scene_root() + if edited_scene_root == null or building.get_parent() == null: + status_label.text = "Open a scene before adding the exported copy." + return + var sibling_parent := building.get_parent() + var baked_root := _make_baked_scene_root(building.name + "_Baked", mesh) + var undo := get_undo_redo() + undo.create_action("Export structure as a mesh copy") + undo.add_do_method(self, "_attach_baked_copy", sibling_parent, edited_scene_root, baked_root, building.global_transform) + undo.add_undo_method(self, "_detach_baked_copy", sibling_parent, baked_root) + undo.commit_action() + status_label.text = "Copy exported as one mesh with %d material surface(s). Ctrl+Z removes the copy." % mesh.get_surface_count() + +func _attach_baked_copy(sibling_parent: Node, edited_scene_root: Node, baked_root: Node3D, target_transform: Transform3D) -> void: + if baked_root.get_parent() != sibling_parent: + if baked_root.get_parent() != null: + baked_root.get_parent().remove_child(baked_root) + sibling_parent.add_child(baked_root, true) + baked_root.global_transform = target_transform + baked_root.owner = edited_scene_root + _set_export_owner_recursive(baked_root, edited_scene_root) + var editor_selection := get_editor_interface().get_selection() + editor_selection.clear() + editor_selection.add_node(baked_root) + +func _detach_baked_copy(sibling_parent: Node, baked_root: Node3D) -> void: + if baked_root.get_parent() == sibling_parent: + sibling_parent.remove_child(baked_root) + +func _make_baked_scene_root(root_name: String, mesh: ArrayMesh) -> Node3D: + var baked_root := Node3D.new() + baked_root.name = root_name + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "BakedMesh" + mesh_instance.mesh = mesh + baked_root.add_child(mesh_instance) + return baked_root + +func _export_baked_scene_to_path(path: String) -> void: + if _pending_export_mesh == null: + status_label.text = "The exported mesh is no longer available. Bake it again." + return + var baked_root := _make_baked_scene_root(_pending_export_name, _pending_export_mesh) + baked_root.transform = Transform3D(_pending_export_basis, Vector3.ZERO) + _set_export_owner_recursive(baked_root, baked_root) + var packed := PackedScene.new() + var pack_error := packed.pack(baked_root) + baked_root.free() + if pack_error != OK: + status_label.text = "Could not pack the exported mesh (%s)." % error_string(pack_error) + return + var save_error := ResourceSaver.save(packed, path) + if save_error != OK: + status_label.text = "Could not save the exported scene (%s)." % error_string(save_error) + return + get_editor_interface().get_resource_filesystem().scan() + status_label.text = "Exported scene: %s (%d material surface(s))." % [path, _pending_export_mesh.get_surface_count()] + _pending_export_mesh = null + +func _set_export_owner_recursive(node: Node, scene_owner: Node) -> void: + for child in node.get_children(): + child.owner = scene_owner + _set_export_owner_recursive(child, scene_owner) + +func _save_module_to_path(path: String) -> void: + var building := _get_selected_piece() + if building == null: + status_label.text = "Select a ModularBuilderPiece structure to save as a module." + return + var descendants: Array[Node] = [] + _collect_module_nodes(building, descendants) + var original_owners: Dictionary = {} + for node in descendants: + original_owners[node] = node.owner + node.owner = building + var packed := PackedScene.new() + var pack_error := packed.pack(building) + for node in descendants: + node.owner = original_owners[node] + if pack_error != OK: + status_label.text = "Could not pack the structure as a scene (%s)." % error_string(pack_error) + return + var save_error := ResourceSaver.save(packed, path) + if save_error != OK: + status_label.text = "Could not save the module (%s)." % error_string(save_error) + return + status_label.text = "Module saved: %s" % path + +func _collect_module_nodes(node: Node, result: Array[Node]) -> void: + for child in node.get_children(): + if child.name == "ModularBuilderMarkers" or child.has_meta("modular_builder_preview"): + continue + result.append(child) + _collect_module_nodes(child, result) + +func _load_module_from_path(path: String) -> void: + var packed := ResourceLoader.load(path) as PackedScene + if packed == null: + status_label.text = "The selected file is not a valid Godot scene." + return + var instance := packed.instantiate() + if not instance is Node3D: + instance.free() + status_label.text = "The module scene must have a Node3D root." + return + var scene_root := get_editor_interface().get_edited_scene_root() + if scene_root == null: + instance.free() + status_label.text = "Open a scene before inserting a module." + return + var anchor := _get_selected_piece() + if anchor != null: + var target_world := anchor.global_position + anchor.global_transform.basis.x * maxf(anchor.unit_size, 1.0) + instance.position = scene_root.to_local(target_world) + var undo := get_undo_redo() + undo.create_action("Insert module %s" % instance.name) + undo.add_do_method(self, "_attach_module", scene_root, instance) + undo.add_undo_method(self, "_detach_module", scene_root, instance) + undo.commit_action() + status_label.text = "Module inserted. Move the piece to the desired location." + +func _attach_module(scene_root: Node, instance: Node) -> void: + if instance.get_parent() != scene_root: + scene_root.add_child(instance, true) + instance.owner = scene_root + var editor_selection := get_editor_interface().get_selection() + editor_selection.clear() + editor_selection.add_node(instance) + +func _detach_module(scene_root: Node, instance: Node) -> void: + if instance.get_parent() == scene_root: + scene_root.remove_child(instance) + +func _rotate_selected_piece() -> void: + var selected_piece := _raw_selected_piece() + var target := selected_piece + if selected_piece == null: + target = _last_created_piece if is_instance_valid(_last_created_piece) and _last_created_piece.is_inside_tree() else null + elif selected_piece.piece_type == ModularBuilderPiece.PieceType.BUILDING: + target = null + if is_instance_valid(_last_created_piece) and _last_created_piece.is_inside_tree() and _is_descendant_of(_last_created_piece, selected_piece): + target = _last_created_piece + if target == null: + status_label.text = "Select or create a piece to rotate." + return + var old_rotation := target.rotation_degrees + var undo := get_undo_redo() + undo.create_action("Rotate piece 90 degrees") + undo.add_do_property(target, "rotation_degrees", old_rotation + Vector3(0.0, 90.0, 0.0)) + undo.add_undo_property(target, "rotation_degrees", old_rotation) + undo.commit_action() + _last_created_piece = target + status_label.text = "%s rotated by 90°." % target.name + update_overlays() + +func _is_descendant_of(node: Node, ancestor: Node) -> bool: + var current := node.get_parent() + while current != null: + if current == ancestor: + return true + current = current.get_parent() + return false + +func _raw_selected_piece() -> ModularBuilderPiece: + var selected := get_editor_interface().get_selection().get_selected_nodes() + if selected.size() == 1 and selected[0] is ModularBuilderPiece: + return selected[0] as ModularBuilderPiece + return null + +func _add_general_collision() -> void: + var piece := _get_selected_piece() + if piece == null: + status_label.text = "Select a structure to generate general collision." + return + var scene_root := get_editor_interface().get_edited_scene_root() + var faces := PackedVector3Array() + var structure_pieces: Array[ModularBuilderPiece] = [] + _collect_structure_pieces(piece, structure_pieces) + _collect_scene_pieces(scene_root, structure_pieces) + _collect_collision_faces(piece, piece, faces, {}, structure_pieces) + if faces.is_empty(): + status_label.text = "The selected structure has no geometry for collision." + return + var concave := ConcavePolygonShape3D.new() + concave.set_faces(faces) + var body := piece.find_child("GeneralCollision", true, false) as StaticBody3D + var undo := get_undo_redo() + if body != null: + var shape_node := body.get_node_or_null("Shape") as CollisionShape3D + if shape_node == null: + shape_node = CollisionShape3D.new() + shape_node.name = "Shape" + shape_node.shape = concave + undo.create_action("Add shape to general collision") + undo.add_do_method(body, "add_child", shape_node, true) + undo.add_do_property(shape_node, "owner", scene_root) + undo.add_undo_method(body, "remove_child", shape_node) + else: + var old_shape: Shape3D = shape_node.shape + undo.create_action("Update general collision") + undo.add_do_property(shape_node, "shape", concave) + undo.add_undo_property(shape_node, "shape", old_shape) + else: + body = StaticBody3D.new() + body.name = "GeneralCollision" + var shape_node := CollisionShape3D.new() + shape_node.name = "Shape" + shape_node.shape = concave + body.add_child(shape_node) + undo.create_action("Add general collision") + undo.add_do_method(piece, "add_child", body, true) + undo.add_do_property(body, "owner", scene_root) + undo.add_do_property(shape_node, "owner", scene_root) + undo.add_undo_method(piece, "remove_child", body) + undo.commit_action() + status_label.text = "General collision added or updated. Snap markers are excluded." + +func _collect_structure_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece: + pieces.append(node) + for child in node.get_children(): + if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision": + continue + _collect_structure_pieces(child, pieces) + +func _collect_scene_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and not pieces.has(node): + pieces.append(node) + for child in node.get_children(): + if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision": + continue + _collect_scene_pieces(child, pieces) + +func _collect_collision_faces(node: Node, piece_root: ModularBuilderPiece, faces: PackedVector3Array, seen_faces: Dictionary, structure_pieces: Array[ModularBuilderPiece]) -> void: + if node is MeshInstance3D and node.name == "Geometry" and node.mesh != null: + var relative_transform: Transform3D = piece_root.global_transform.affine_inverse() * node.global_transform + var mesh_faces: PackedVector3Array = node.mesh.get_faces() + var owner_piece := node.get_parent() as ModularBuilderPiece + for i in range(0, mesh_faces.size(), 3): + if i + 2 >= mesh_faces.size(): + break + var local_a: Vector3 = mesh_faces[i] + var local_b: Vector3 = mesh_faces[i + 1] + var local_c: Vector3 = mesh_faces[i + 2] + if _is_connection_face(owner_piece, local_a, local_b, local_c, structure_pieces): + continue + # Procedural meshes have both sides. Deduplication may retain + # either orientation; normalize top faces beforehand to + # keep custom foundations navigable. + if _is_piece_top_face(owner_piece, local_a, local_b, local_c): + if (local_b - local_a).cross(local_c - local_a).y < 0.0: + var swap := local_b + local_b = local_c + local_c = swap + var a: Vector3 = relative_transform * local_a + var b: Vector3 = relative_transform * local_b + var c: Vector3 = relative_transform * local_c + var key_parts := [_collision_vertex_key(a), _collision_vertex_key(b), _collision_vertex_key(c)] + key_parts.sort() + var face_key := "%s|%s|%s" % key_parts + if seen_faces.has(face_key): + continue + seen_faces[face_key] = true + faces.append_array(PackedVector3Array([a, b, c])) + for child in node.get_children(): + if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision": + continue + _collect_collision_faces(child, piece_root, faces, seen_faces, structure_pieces) + +func _is_piece_top_face(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3) -> bool: + if piece == null: + return false + if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + return false + if absf(a.y - b.y) > 0.002 or absf(a.y - c.y) > 0.002: + return false + var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness + if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and piece.custom_points.size() >= 3: + top_y = piece.custom_points[0].y + return absf(a.y - top_y) < 0.002 and absf(b.y - top_y) < 0.002 and absf(c.y - top_y) < 0.002 + +func _is_connection_face(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3, structure_pieces: Array[ModularBuilderPiece]) -> bool: + if piece == null: + return false + if (piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE) and _foundation_edge_is_shared(piece, a, b, c, structure_pieces): + return true + if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and piece.custom_points.size() >= 3: + var bottom_point := piece.custom_points[0] - Vector3.UP * piece.thickness + return _triangle_on_plane(a, b, c, bottom_point, Vector3.UP) + if piece.piece_type != ModularBuilderPiece.PieceType.WALL or piece.custom_points.size() < 2: + return false + var start: Vector3 = piece.custom_points[0] + var finish: Vector3 = piece.custom_points[1] + var direction := (finish - start).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * piece.thickness * 0.5 + var base_normal := (finish - start).cross(side).normalized() + if _triangle_on_plane(a, b, c, start, base_normal): + return true + if _wall_has_floor_contact(piece, structure_pieces): + if _triangle_on_plane(a, b, c, start + Vector3.UP * piece.unit_size, base_normal): + return true + for other in structure_pieces: + if other == piece or other.piece_type != ModularBuilderPiece.PieceType.WALL or other.custom_points.size() < 2: + continue + for endpoint in piece.custom_points: + var endpoint_world := piece.to_global(endpoint) + for other_endpoint in other.custom_points: + if endpoint_world.distance_to(other.to_global(other_endpoint)) < 0.02: + var local_endpoint := piece.to_local(endpoint_world) + if _triangle_on_plane(a, b, c, local_endpoint, direction): + return true + return false + +func _wall_has_floor_contact(wall: ModularBuilderPiece, structure_pieces: Array[ModularBuilderPiece]) -> bool: + var endpoints: PackedVector3Array = wall.custom_points + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5 + for endpoint in endpoints: + var top := endpoint + Vector3.UP * wall.unit_size + for wall_corner in [wall.to_global(top + side), wall.to_global(top - side)]: + for other in structure_pieces: + if other.piece_type != ModularBuilderPiece.PieceType.FLOOR or other.custom_points.size() < 3: + continue + for floor_point in other.custom_points: + if wall_corner.distance_to(other.to_global(floor_point)) < 0.02: + return true + return false + +func _foundation_edge_is_shared(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3, structure_pieces: Array[ModularBuilderPiece]) -> bool: + var polygon := _foundation_polygon(piece) + for other in structure_pieces: + if other == piece or (other.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and other.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE): + continue + var other_polygon := _foundation_polygon(other) + for i in range(polygon.size()): + var p0: Vector2 = polygon[i] + var p1: Vector2 = polygon[(i + 1) % polygon.size()] + for j in range(other_polygon.size()): + var q0: Vector2 = other_polygon[j] + var q1: Vector2 = other_polygon[(j + 1) % other_polygon.size()] + var other_base_y := -other.thickness * 0.5 if other.centered_origin else 0.0 + var q0_local := piece.to_local(other.to_global(Vector3(q0.x, other_base_y, q0.y))) + var q1_local := piece.to_local(other.to_global(Vector3(q1.x, other_base_y, q1.y))) + var shared := _segments_overlap(p0, p1, Vector2(q0_local.x, q0_local.z), Vector2(q1_local.x, q1_local.z)) + var piece_base_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0 + if shared and absf(q0_local.y - piece_base_y) < 0.02 and absf(q1_local.y - piece_base_y) < 0.02 and _triangle_on_edge(a, b, c, p0, p1): + return true + return false + +func _triangle_on_edge(a: Vector3, b: Vector3, c: Vector3, edge_a: Vector2, edge_b: Vector2) -> bool: + return _point_on_edge(Vector2(a.x, a.z), edge_a, edge_b) and _point_on_edge(Vector2(b.x, b.z), edge_a, edge_b) and _point_on_edge(Vector2(c.x, c.z), edge_a, edge_b) + +func _point_on_edge(point: Vector2, edge_a: Vector2, edge_b: Vector2) -> bool: + var edge := edge_b - edge_a + var length_squared := edge.length_squared() + if length_squared < 0.000001: + return false + var t := (point - edge_a).dot(edge) / length_squared + var closest := edge_a + edge * clampf(t, 0.0, 1.0) + return t >= -0.002 and t <= 1.002 and point.distance_to(closest) < 0.002 + +func _segments_overlap(a: Vector2, b: Vector2, c: Vector2, d: Vector2) -> bool: + var direction := b - a + var length := direction.length() + if length < 0.0001: + return false + var unit := direction / length + if absf(unit.x * (c - a).y - unit.y * (c - a).x) > 0.02 or absf(unit.x * (d - a).y - unit.y * (d - a).x) > 0.02: + return false + var c_proj := (c - a).dot(unit) + var d_proj := (d - a).dot(unit) + var overlap := minf(length, maxf(c_proj, d_proj)) - maxf(0.0, minf(c_proj, d_proj)) + return overlap > 0.02 + +func _triangle_on_plane(a: Vector3, b: Vector3, c: Vector3, plane_point: Vector3, plane_normal: Vector3) -> bool: + if plane_normal.length_squared() < 0.000001: + return false + var normal := plane_normal.normalized() + return absf((a - plane_point).dot(normal)) < 0.002 and absf((b - plane_point).dot(normal)) < 0.002 and absf((c - plane_point).dot(normal)) < 0.002 + +func _collision_vertex_key(point: Vector3) -> String: + return "%.4f,%.4f,%.4f" % [point.x, point.y, point.z] + +func _set_tool(tool: String) -> void: + active_tool = tool + _detail_options_row.visible = _is_detail_tool(tool) + _rise_options_row.visible = tool == TOOL_WALL or tool == TOOL_GABLE_WALL or tool == TOOL_GABLE_WALL_RIGHT or tool == TOOL_ROOF_GABLE or tool == TOOL_ROOF_PYRAMID + _linear_options_row.visible = tool == TOOL_STAIRS or tool == TOOL_FENCE + _stair_count_label.visible = tool == TOOL_STAIRS + stair_count_spin.visible = tool == TOOL_STAIRS + _stair_width_label.visible = tool == TOOL_STAIRS + stair_width_spin.visible = tool == TOOL_STAIRS + _fence_divisions_label.visible = tool == TOOL_FENCE + fence_divisions_spin.visible = tool == TOOL_FENCE + _gable_centralize_check.visible = tool == TOOL_WALL or tool == TOOL_GABLE_WALL or tool == TOOL_GABLE_WALL_RIGHT + _rise_label.text = "Peak rise:" + if not _is_detail_tool(tool): + call("_clear_detail_preview") + if tool == TOOL_NONE: + _active_button_key = "" + pending_points.clear() + for button_tool in _tool_buttons: + _tool_buttons[button_tool].set_pressed_no_signal(button_tool == _active_button_key) + if tool == TOOL_NONE: + status_label.text = "Tool deactivated." + elif _get_selected_piece() == null: + status_label.text = "Select the corresponding piece in the Scene tree." + elif tool == TOOL_FOUNDATION: + status_label.text = "Click a + button beside any foundation. Click the tool again to deactivate it." + elif tool == TOOL_WINDOW or tool == TOOL_DOOR: + status_label.text = "Click a wall face to insert the detail. Edit its mode and dimensions in the Inspector." + elif tool == TOOL_HATCH: + status_label.text = "Click a foundation or floor face to insert the hatch. Edit it in the Inspector." + elif tool == TOOL_GABLE_WALL: + status_label.text = "Click the two base vertices of the gable. Adjust peak height below." + elif tool == TOOL_GABLE_WALL_RIGHT: + status_label.text = "Click the base vertices; the second point sets the high side of the right triangular gable." + elif tool == TOOL_WALL: + status_label.text = "Click two vertices on foundations, floors, or wall tops. Enable Center walls to align the endpoints with supporting walls." + elif tool == TOOL_ROOF_GABLE: + status_label.text = "Click the peaks of two gables or diagonal corners atop opposite walls." + elif tool == TOOL_ROOF_SHED: + status_label.text = "Click a low point, then a diagonally opposite high point. The roof finds the other corners; adjust width in the Inspector." + elif tool == TOOL_ROOF_PYRAMID: + status_label.text = "Click two diagonal vertices at the same height. Adjust pyramid peak height below or in the Inspector." + elif tool == TOOL_STAIRS: + status_label.text = "Click the low point, then the high point to create stairs. Adjust steps and width below." + elif tool == TOOL_FENCE or tool == TOOL_PARAPET: + status_label.text = "Click two vertices to set the path. Adjust height and intermediate posts below." + elif tool == TOOL_MERGE_VERTICES: + status_label.text = "Click two vertices to merge their markers. Ctrl+Z undoes the merge." + elif tool == TOOL_CREATE_VERTEX: + status_label.text = "Click a surface to create a connection point linked to the selected piece or detail." + elif tool == TOOL_MOVE_VERTEX: + status_label.text = "Click a blue manual point, then the surface where you want to move it." + elif tool == TOOL_REMOVE_VERTEX: + status_label.text = "Click a blue manual point to remove it." + elif tool == TOOL_PILLAR: + status_label.text = "Click two vertices to connect them. Alt+click one vertex to create a vertical pillar with the panel length." + else: + status_label.text = "Click the blue structure vertices. Click the tool again to deactivate it." + call("_sync_vertex_markers") + update_overlays() + +func _get_selected_piece() -> ModularBuilderPiece: + var selected := get_editor_interface().get_selection().get_selected_nodes() + if selected.is_empty(): + return null + var common_root: ModularBuilderPiece + for selected_node in selected: + var selected_piece := selected_node as ModularBuilderPiece + if selected_piece == null and selected_node is ModularBuilderDetail: + var parent := selected_node.get_parent() + while parent != null and not parent is ModularBuilderPiece: + parent = parent.get_parent() + selected_piece = parent as ModularBuilderPiece + if selected_piece == null: + return null + var piece_root := selected_piece + var ancestor := selected_piece.get_parent() + while ancestor != null: + if ancestor is ModularBuilderPiece and ancestor.piece_type == ModularBuilderPiece.PieceType.BUILDING: + piece_root = ancestor + break + ancestor = ancestor.get_parent() + if common_root == null: + common_root = piece_root + elif common_root != piece_root: + return null + return common_root + +func _on_editor_selection_changed() -> void: + if _redirecting_selection: + return + var selected := get_editor_interface().get_selection().get_selected_nodes() + var redirected_selection: Array[Node] = [] + var needs_redirect := false + for selected_node in selected: + var target_node: Node = selected_node + if selected_node is MeshInstance3D: + var ancestor := selected_node.get_parent() + while ancestor != null and not (ancestor is ModularBuilderPiece or ancestor is ModularBuilderDetail): + ancestor = ancestor.get_parent() + if ancestor != null: + target_node = ancestor + needs_redirect = true + if not redirected_selection.has(target_node): + redirected_selection.append(target_node) + if needs_redirect: + _redirecting_selection = true + var editor_selection := get_editor_interface().get_selection() + editor_selection.clear() + for target_node in redirected_selection: + editor_selection.add_node(target_node) + _redirecting_selection = false + if active_tool != TOOL_NONE: + pending_points.clear() + _sync_pillar_centralize_control() + call("_sync_vertex_markers") + update_overlays() + +func _on_marker_scope_toggled(_enabled: bool) -> void: + call("_sync_vertex_markers") + update_overlays() + +func _on_simplify_vertices_toggled(_enabled: bool) -> void: + pending_points.clear() + call("_sync_vertex_markers") + update_overlays() + +func _foundation_polygon(root: ModularBuilderPiece) -> Array[Vector2]: + if root.custom_points.size() >= 3: + var custom: Array[Vector2] = [] + for point in root.custom_points: + custom.append(Vector2(point.x, point.z)) + return custom + var half := root.unit_size * 0.5 + if root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + return [Vector2(-half, -half), Vector2(half, -half), Vector2(-half, half)] + return [Vector2(-half, -half), Vector2(half, -half), Vector2(half, half), Vector2(-half, half)] diff --git a/addons/modular_builder/modular_builder_editor_actions.gd.uid b/addons/modular_builder/modular_builder_editor_actions.gd.uid new file mode 100644 index 0000000..2db4b45 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_actions.gd.uid @@ -0,0 +1 @@ +uid://bem5uer4jw8ik diff --git a/addons/modular_builder/modular_builder_editor_details.gd b/addons/modular_builder/modular_builder_editor_details.gd new file mode 100644 index 0000000..e831d12 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_details.gd @@ -0,0 +1,430 @@ +@tool +extends "res://addons/modular_builder/modular_builder_editor_actions.gd" + +const PILLAR_SUPPORT = preload("res://addons/modular_builder/modular_pillar_support.gd") +const VERTEX_SIMPLIFIER = preload("res://addons/modular_builder/modular_vertex_simplifier.gd") + +func _create_detail_from_click(camera: Camera3D, screen_position: Vector2) -> bool: + var placement := _find_detail_placement(camera, screen_position) + if placement.is_empty(): + return false + var detail := _new_detail(placement, false) + var best_piece := placement.host as ModularBuilderPiece + if best_piece == null: + return false + var undo := get_undo_redo() + undo.create_action("Add %s" % detail.name) + undo.add_do_method(self, "_attach_detail", best_piece, detail, get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(self, "_detach_detail", best_piece, detail) + undo.commit_action() + get_editor_interface().get_selection().clear() + get_editor_interface().get_selection().add_node(detail) + _set_tool(TOOL_NONE) + status_label.text = "%s created. Edit style, materials, and dimensions in the Inspector." % detail.name + return true + +func _create_pillar_from_vertices(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> void: + var placement := _find_pillar_placement(root, first, second) + if placement.is_empty(): + pending_points.clear() + status_label.text = "Choose two different structure vertices to connect with the pillar." + return + _commit_pillar_placement(placement, "Plain pillar created between vertices. Edit its section and material in the Inspector.") + +func _create_vertical_pillar_from_vertex(root: ModularBuilderPiece, anchor: Vector3) -> void: + pending_points.clear() + var length := maxf(0.2, detail_height_spin.value * METERS_TO_GODOT_UNITS) + var direction := _vertical_pillar_direction(root, anchor) + var placement := _find_pillar_placement(root, anchor, anchor + direction * length) + if placement.is_empty(): + status_label.text = "Could not place a vertical pillar at this vertex." + return + _commit_pillar_placement(placement, "Vertical pillar created from the vertex. Edit its length in the Inspector.") + +func _commit_pillar_placement(placement: Dictionary, message: String) -> void: + var detail := _new_detail(placement, false) + var host := placement.host as ModularBuilderPiece + detail.set("height", float(placement.length)) + var undo := get_undo_redo() + undo.create_action("Add plain pillar") + undo.add_do_method(self, "_attach_detail", host, detail, get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(self, "_detach_detail", host, detail) + undo.commit_action() + get_editor_interface().get_selection().clear() + get_editor_interface().get_selection().add_node(detail) + _set_tool(TOOL_NONE) + status_label.text = message + +func _vertical_pillar_direction(root: ModularBuilderPiece, anchor: Vector3) -> Vector3: + var selected_pillar := _get_selected_pillar_detail() + if selected_pillar != null: + var pillar_local := selected_pillar.to_local(anchor) + if absf(pillar_local.x) <= selected_pillar.width * 0.5 + 0.05 and absf(pillar_local.z) <= selected_pillar.width * 0.5 + 0.05 and absf(absf(pillar_local.y) - selected_pillar.height * 0.5) <= 0.05: + return PILLAR_SUPPORT.vertical_direction_for_pillar(selected_pillar, anchor) + var source := _get_pillar_vertex_source(root, anchor) + if source != null: + return PILLAR_SUPPORT.vertical_direction_for_piece(source, anchor) + var pillar := _find_pillar_at_vertex(root, anchor) + return PILLAR_SUPPORT.vertical_direction_for_pillar(pillar, anchor) if pillar != null else Vector3.UP + +func _find_pillar_at_vertex(node: Node, anchor: Vector3) -> ModularBuilderDetail: + if node is ModularBuilderDetail and node.detail_type == ModularBuilderDetail.DetailType.PILLAR: + var detail := node as ModularBuilderDetail + var local := detail.to_local(anchor) + var half := detail.width * 0.5 + var at_corner := absf(absf(local.x) - half) <= 0.05 and absf(absf(local.z) - half) <= 0.05 + var at_center := _simplify_vertices_check != null and _simplify_vertices_check.button_pressed and absf(local.x) <= 0.05 and absf(local.z) <= 0.05 + if (at_corner or at_center) and absf(absf(local.y) - detail.height * 0.5) <= 0.05: + return detail + for child in node.get_children(): + var pillar := _find_pillar_at_vertex(child, anchor) + if pillar != null: + return pillar + return null + +func _create_fence_decoration(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> void: + if first.distance_to(second) < 0.05: + pending_points.clear() + status_label.text = "Choose two vertices far enough apart." + return + var is_parapet := active_tool == TOOL_PARAPET + var detail := Node3D.new() + detail.set_script(DETAIL_SCRIPT) + detail.name = "Parapet" if is_parapet else "Fence" + detail.set("detail_type", ModularBuilderDetail.DetailType.PARAPET if is_parapet else ModularBuilderDetail.DetailType.FENCE) + detail.set("detail_mode", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT) + detail.set("path_points", PackedVector3Array([root.to_local(first), root.to_local(second)])) + detail.set("fence_height", fence_height_spin.value * METERS_TO_GODOT_UNITS) + detail.set("fence_divisions", int(fence_divisions_spin.value)) + var undo := get_undo_redo() + undo.create_action("Add parapet" if is_parapet else "Add decorative fence") + undo.add_do_method(self, "_attach_detail", root, detail, get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(self, "_detach_detail", root, detail) + undo.commit_action() + get_editor_interface().get_selection().clear() + get_editor_interface().get_selection().add_node(detail) + _set_tool(TOOL_NONE) + pending_points.clear() + status_label.text = "%s created with %d intermediate post(s)." % [detail.name, detail.fence_divisions] + +func _find_pillar_placement(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> Dictionary: + if first.distance_to(second) < 0.02: + return {} + var local_first := root.to_local(first) + var local_second := root.to_local(second) + var segment := local_second - local_first + var segment_length := segment.length() + if segment_length < 0.02: + return {} + var y_axis := segment / segment_length + var reference_axis := Vector3.FORWARD if absf(y_axis.dot(Vector3.UP)) > 0.99 else Vector3.UP + var x_axis := reference_axis.cross(y_axis).normalized() + var z_axis := x_axis.cross(y_axis).normalized() + var basis := Basis(x_axis, y_axis, z_axis).orthonormalized() + var source := _get_pillar_vertex_source(root, first) + var alignment_basis := Basis.IDENTITY + if source != null: + alignment_basis = (root.global_transform.basis.inverse() * source.global_transform.basis).orthonormalized() + return { + "host": root, + "position": (local_first + local_second) * 0.5, + "rotation": Vector3.ZERO, + "basis": basis, + "direction": segment, + "alignment_basis": alignment_basis, + "length": segment_length + } + +func _get_pillar_vertex_source(root: ModularBuilderPiece, point: Vector3) -> ModularBuilderPiece: + var best_piece: ModularBuilderPiece + var best_distance := 0.05 + var pieces: Array[ModularBuilderPiece] = [] + call("_collect_all_modular_pieces", root, pieces) + for piece in pieces: + if piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE and piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT: + continue + var vertices: Array[Vector3] = [] + call("_collect_piece_geometry_vertices", piece, vertices) + for vertex in vertices: + var distance := vertex.distance_to(point) + if _simplify_vertices_check != null and _simplify_vertices_check.button_pressed: + distance = minf(distance, VERTEX_SIMPLIFIER.piece_vertex(piece, vertex).distance_to(point)) + if distance < best_distance: + best_distance = distance + best_piece = piece + return best_piece + +func _point_inside_surface_or_edge(piece: ModularBuilderPiece, point: Vector2, edge_tolerance: float) -> bool: + if _point_inside_surface(piece, point): + return true + var polygon := _foundation_polygon(piece) if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR else _piece_polygon_xz(piece) + for i in range(polygon.size()): + var a: Vector2 = polygon[i] + var b: Vector2 = polygon[(i + 1) % polygon.size()] + var edge := b - a + var t := clampf((point - a).dot(edge) / maxf(edge.length_squared(), 0.000001), 0.0, 1.0) + if point.distance_to(a + edge * t) <= edge_tolerance + 0.01: + return true + return false + +func _find_detail_placement(camera: Camera3D, screen_position: Vector2) -> Dictionary: + var root := _get_selected_piece() + if root == null or not _is_detail_tool(active_tool): + return {} + var ray_origin := camera.project_ray_origin(screen_position) + var ray_direction := camera.project_ray_normal(screen_position) + var candidates: Array[ModularBuilderPiece] = [] + if active_tool == TOOL_HATCH or active_tool == TOOL_PILLAR: + _collect_surface_pieces(root, candidates) + else: + _collect_wall_pieces(root, candidates) + var best_distance := INF + var best_piece: ModularBuilderPiece + var best_local := Vector3.ZERO + var best_surface_y := 0.0 + for candidate in candidates: + if not is_instance_valid(candidate) or candidate.piece_type == ModularBuilderPiece.PieceType.BUILDING: + continue + var local_origin := candidate.to_local(ray_origin) + var local_direction := candidate.to_local(ray_origin + ray_direction) - local_origin + if active_tool != TOOL_HATCH and candidate.custom_points.size() < 2: + continue + var uses_surface := active_tool == TOOL_HATCH or active_tool == TOOL_PILLAR + var plane_normal := Vector3.UP if uses_surface else Vector3(-candidate.custom_points[1].z + candidate.custom_points[0].z, 0.0, candidate.custom_points[1].x - candidate.custom_points[0].x).normalized() + if uses_surface and candidate.custom_points.size() >= 3: + plane_normal = Vector3.UP + if absf(local_direction.dot(plane_normal)) < 0.0001: + continue + var plane_y := 0.0 + var plane_point := Vector3.ZERO + if uses_surface: + plane_y = candidate.thickness * 0.5 if candidate.centered_origin else candidate.thickness + plane_point.y = plane_y + var distance := (plane_point - local_origin).dot(plane_normal) / local_direction.dot(plane_normal) + if distance < 0.0 or distance >= best_distance: + continue + var hit := local_origin + local_direction * distance + if uses_surface: + if candidate.piece_type != ModularBuilderPiece.PieceType.FLOOR and candidate.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and candidate.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + continue + if not _point_inside_surface(candidate, Vector2(hit.x, hit.z)): + continue + else: + if candidate.piece_type != ModularBuilderPiece.PieceType.WALL or candidate.custom_points.size() < 2: + continue + var a: Vector3 = candidate.custom_points[0] + var b: Vector3 = candidate.custom_points[1] + var wall_dir := (b - a).normalized() + var along := (hit - a).dot(wall_dir) + var vertical := hit.y - a.y + if along < 0.0 or along > a.distance_to(b) or vertical < 0.0 or vertical > candidate.unit_size: + continue + best_distance = distance + best_piece = candidate + best_local = hit + best_surface_y = plane_y + if best_piece == null: + return {} + # best_local uses the selected piece’s local coordinates. The child detail + # must use the same space, especially for attached foundations. + var host_local := best_local + var detail_position: Vector3 + var detail_rotation := Vector3.ZERO + if active_tool == TOOL_HATCH: + detail_position = Vector3(host_local.x, 0.0, host_local.z) + elif active_tool == TOOL_PILLAR: + detail_position = Vector3(host_local.x, best_surface_y, host_local.z) + else: + var wall_a: Vector3 = best_piece.custom_points[0] + var wall_b: Vector3 = best_piece.custom_points[1] + var direction := (wall_b - wall_a).normalized() + var n := Vector3(-direction.z, 0.0, direction.x) + detail_rotation.y = atan2(n.x, n.z) + var opening_center_y := wall_a.y + detail_height_spin.value * 0.5 + detail_height_offset_spin.value + detail_position = Vector3((wall_a.x + wall_b.x) * 0.5, opening_center_y, (wall_a.z + wall_b.z) * 0.5) + return {"host": best_piece, "position": detail_position, "rotation": detail_rotation} + +func _new_detail(placement: Dictionary, preview: bool) -> ModularBuilderDetail: + var detail := Node3D.new() + detail.set_script(DETAIL_SCRIPT) + if active_tool == TOOL_WINDOW: + detail.name = "Window" + elif active_tool == TOOL_DOOR: + detail.name = "Door" + elif active_tool == TOOL_HATCH: + detail.name = "Hatch" + elif active_tool == TOOL_PILLAR: + detail.name = "Pillar" + elif active_tool == TOOL_FENCE: + detail.name = "Fence" + else: + detail.name = "Parapet" + if active_tool == TOOL_WINDOW: + detail.set("detail_type", ModularBuilderDetail.DetailType.WINDOW) + elif active_tool == TOOL_DOOR: + detail.set("detail_type", ModularBuilderDetail.DetailType.DOOR) + elif active_tool == TOOL_HATCH: + detail.set("detail_type", ModularBuilderDetail.DetailType.HATCH) + elif active_tool == TOOL_FENCE: + detail.set("detail_type", ModularBuilderDetail.DetailType.FENCE) + elif active_tool == TOOL_PARAPET: + detail.set("detail_type", ModularBuilderDetail.DetailType.PARAPET) + else: + detail.set("detail_type", ModularBuilderDetail.DetailType.PILLAR) + detail.set("width", detail_width_spin.value * METERS_TO_GODOT_UNITS) + detail.set("height", float(placement.length) if active_tool == TOOL_PILLAR and placement.has("length") else detail_height_spin.value * METERS_TO_GODOT_UNITS) + detail.set("detail_mode", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT if preview or active_tool == TOOL_PILLAR else detail_mode_option.get_selected_id()) + detail.set("surface_material", _preview_material if preview else null) + if preview: + var host := placement.host as ModularBuilderPiece + var preview_depth := host.thickness if host != null else 0.08 + if active_tool == TOOL_WINDOW or active_tool == TOOL_DOOR: + preview_depth *= 1.25 + detail.set("follow_host_thickness", false) + detail.set("frame_depth", preview_depth) + if active_tool == TOOL_WINDOW: + detail.set("window_style", _selected_window_style) + elif active_tool == TOOL_DOOR: + detail.set("door_style", _selected_door_style) + if preview: + detail.name = "ModularBuilderPreview" + detail.set_meta("modular_builder_preview", true) + var detail_basis: Basis = placement.basis if placement.has("basis") else Basis.from_euler(placement.rotation) + detail.transform = Transform3D(detail_basis, placement.position) + if active_tool == TOOL_PILLAR: + detail.set("pillar_direction", placement.direction) + detail.set("pillar_alignment_basis", placement.alignment_basis) + detail.set("center_align", _pillar_centralize_check.button_pressed) + if active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET: + detail.set("fence_height", fence_height_spin.value * METERS_TO_GODOT_UNITS) + detail.set("fence_divisions", int(fence_divisions_spin.value)) + return detail as ModularBuilderDetail + +func _update_detail_preview() -> void: + if not _is_detail_tool(active_tool) or active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET or _preview_camera == null: + return + var placement: Dictionary + if active_tool == TOOL_PILLAR: + var root := _get_selected_piece() + if root == null or pending_points.is_empty(): + if is_instance_valid(_detail_preview): + _detail_preview.visible = false + return + var second_vertex := _nearest_vertex(_preview_camera, _preview_screen_position, call("_get_tool_vertices", root)) + if second_vertex == null: + if is_instance_valid(_detail_preview): + _detail_preview.visible = false + return + placement = _find_pillar_placement(root, pending_points[0], second_vertex) + else: + placement = _find_detail_placement(_preview_camera, _preview_screen_position) + if placement.is_empty(): + if is_instance_valid(_detail_preview): + _detail_preview.visible = false + return + if not is_instance_valid(_detail_preview) or _detail_preview.detail_type != _detail_type_for_tool(active_tool): + _clear_detail_preview() + _detail_preview = _new_detail(placement, true) + var scene_root := get_editor_interface().get_edited_scene_root() + if scene_root == null: + _detail_preview = null + return + var preview_host := placement.host as ModularBuilderPiece + if active_tool == TOOL_PILLAR and preview_host != null: + preview_host.add_child(_detail_preview, true) + else: + scene_root.add_child(_detail_preview, true) + _detail_preview.owner = scene_root + _detail_preview.visible = true + if not is_equal_approx(_detail_preview.width, detail_width_spin.value): + _detail_preview.width = detail_width_spin.value + var preview_height := float(placement.length) if active_tool == TOOL_PILLAR and placement.has("length") else detail_height_spin.value + if not is_equal_approx(_detail_preview.height, preview_height): + _detail_preview.height = preview_height + var host := placement.host as ModularBuilderPiece + if host != null: + var preview_basis: Basis = placement.basis if placement.has("basis") else Basis.from_euler(placement.rotation) + if active_tool == TOOL_PILLAR: + _detail_preview.transform = Transform3D(preview_basis, placement.position) + _detail_preview.pillar_direction = placement.direction + _detail_preview.pillar_alignment_basis = placement.alignment_basis + _detail_preview.center_align = _pillar_centralize_check.button_pressed + else: + _detail_preview.global_transform = host.global_transform * Transform3D(preview_basis, placement.position) + +func _detail_type_for_tool(tool: String) -> int: + if tool == TOOL_DOOR: + return ModularBuilderDetail.DetailType.DOOR + if tool == TOOL_HATCH: + return ModularBuilderDetail.DetailType.HATCH + if tool == TOOL_PILLAR: + return ModularBuilderDetail.DetailType.PILLAR + if tool == TOOL_FENCE: + return ModularBuilderDetail.DetailType.FENCE + if tool == TOOL_PARAPET: + return ModularBuilderDetail.DetailType.PARAPET + return ModularBuilderDetail.DetailType.WINDOW + +func _refresh_detail_preview(_value: float = 0.0) -> void: + if _preview_camera != null: + _update_detail_preview() + +func _clear_detail_preview() -> void: + if is_instance_valid(_detail_preview): + _detail_preview.queue_free() + _detail_preview = null + +func _attach_detail(host: ModularBuilderPiece, detail: Node, scene_root: Node) -> void: + if detail.get_parent() != host: + host.add_child(detail, true) + detail.owner = scene_root + host._rebuild() + +func _detach_detail(host: ModularBuilderPiece, detail: Node) -> void: + if detail.get_parent() == host: + host.remove_child(detail) + host._rebuild() + +func _collect_surface_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.FLOOR or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE): + pieces.append(node) + for child in node.get_children(): + _collect_surface_pieces(child, pieces) + +func _collect_wall_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL: + pieces.append(node) + for child in node.get_children(): + _collect_wall_pieces(child, pieces) + +func _point_inside_surface(piece: ModularBuilderPiece, point: Vector2) -> bool: + var polygon := _foundation_polygon(piece) if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR else _piece_polygon_xz(piece) + if polygon.size() < 3: + return false + var inside := false + var j := polygon.size() - 1 + for i in range(polygon.size()): + var a: Vector2 = polygon[i] + var b: Vector2 = polygon[j] + if (a.y > point.y) != (b.y > point.y) and point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x: + inside = not inside + j = i + return inside + +func _piece_polygon_xz(piece: ModularBuilderPiece) -> Array[Vector2]: + var polygon: Array[Vector2] = [] + for point in piece.custom_points: + polygon.append(Vector2(point.x, point.z)) + return polygon + +func _nearest_vertex(camera: Camera3D, screen_position: Vector2, vertices: Array[Vector3]) -> Variant: + var nearest: Variant = null + var nearest_distance := MARKER_HIT_RADIUS + for world_point in vertices: + if camera.is_position_behind(world_point): + continue + var distance := camera.unproject_position(world_point).distance_to(screen_position) + if distance < nearest_distance: + nearest_distance = distance + nearest = world_point + return nearest diff --git a/addons/modular_builder/modular_builder_editor_details.gd.uid b/addons/modular_builder/modular_builder_editor_details.gd.uid new file mode 100644 index 0000000..44d7aae --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_details.gd.uid @@ -0,0 +1 @@ +uid://dd8jw301grvq8 diff --git a/addons/modular_builder/modular_builder_editor_ui.gd b/addons/modular_builder/modular_builder_editor_ui.gd new file mode 100644 index 0000000..7e012b6 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_ui.gd @@ -0,0 +1,542 @@ +@tool +extends EditorPlugin + +const PIECE_SCRIPT = preload("res://addons/modular_builder/modular_piece.gd") +const DETAIL_SCRIPT = preload("res://addons/modular_builder/modular_building_detail.gd") +const MESH_BAKER = preload("res://addons/modular_builder/modular_mesh_baker.gd") +const TOOL_NONE := "" +const TOOL_FOUNDATION := "foundation" +const TOOL_FOUNDATION_SQUARE := "foundation_square" +const TOOL_FOUNDATION_TRIANGLE := "foundation_triangle" +const TOOL_WALL := "wall" +const TOOL_GABLE_WALL := "gable_wall" +const TOOL_GABLE_WALL_RIGHT := "gable_wall_right" +const TOOL_FLOOR_SQUARE := "floor_square" +const TOOL_FLOOR_TRIANGLE := "floor_triangle" +const TOOL_ROOF_GABLE := "roof_gable" +const TOOL_ROOF_SHED := "roof_shed" +const TOOL_ROOF_PYRAMID := "roof_pyramid" +const TOOL_STAIRS := "stairs" +const TOOL_FENCE := "fence" +const TOOL_PARAPET := "parapet" +const TOOL_MERGE_VERTICES := "merge_vertices" +const TOOL_CREATE_VERTEX := "create_vertex" +const TOOL_MOVE_VERTEX := "move_vertex" +const TOOL_REMOVE_VERTEX := "remove_vertex" +const MERGED_VERTEX_GROUPS_META := &"modular_builder_merged_vertex_groups" +const CUSTOM_VERTICES_META := &"modular_builder_custom_vertices" +const TOOL_WINDOW := "detail_window" +const TOOL_DOOR := "detail_door" +const TOOL_HATCH := "detail_hatch" +const TOOL_PILLAR := "detail_pillar" +const EXPORT_NEW_SCENE := 0 +const EXPORT_SCENE_COPY := 1 +const METERS_TO_GODOT_UNITS := 1.0 +const DOOR_DEFAULT_SCALE := 0.8 +const DOOR_DEFAULT_WIDTH_METERS := 0.87 * DOOR_DEFAULT_SCALE +const DOOR_DEFAULT_HEIGHT_METERS := 2.15 * DOOR_DEFAULT_SCALE +const MARKER_RADIUS := 8.0 +const MARKER_HIT_RADIUS := 18.0 + +var dock: VBoxContainer +var _creation_row: HFlowContainer +var _export_row: HFlowContainer +var _tool_row: HFlowContainer +var _vertex_edit_row: HFlowContainer +var _marker_scope_check: CheckBox +var _simplify_vertices_check: CheckBox +var _rise_options_row: HFlowContainer +var _linear_options_row: HFlowContainer +var _stair_count_label: Label +var _stair_width_label: Label +var _fence_divisions_label: Label +var _detail_options_row: HFlowContainer +var _foundation_type_selected := ModularBuilderPiece.PieceType.FOUNDATION_SQUARE +var size_spin: SpinBox +var rise_spin: SpinBox +var stair_count_spin: SpinBox +var stair_width_spin: SpinBox +var fence_divisions_spin: SpinBox +var fence_height_spin: SpinBox +var _fence_height_label: Label +var _rise_label: Label +var _gable_centralize_check: CheckBox +var _gable_centralize_default := false +var detail_width_spin: SpinBox +var detail_height_spin: SpinBox +var detail_height_offset_spin: SpinBox +var _detail_height_offset_label: Label +var _detail_width_label: Label +var _detail_height_label: Label +var detail_mode_option: OptionButton +var detail_style_option: OptionButton +var status_label: Label +var active_tool := TOOL_NONE +var pending_points: Array[Vector3] = [] +var _redirecting_selection := false +var _marker_container: Node3D +var _marker_root: ModularBuilderPiece +var _marker_point_nodes: Array[Label3D] = [] +var _side_button_nodes: Array[Label3D] = [] +var _side_material: StandardMaterial3D +var _tool_button_group: ButtonGroup +var _tool_buttons: Dictionary = {} +var _pillar_centralize_check: CheckBox +var _pillar_centralize_default := false +var _active_button_key := "" +var _last_created_piece: ModularBuilderPiece +var _detail_preview: ModularBuilderDetail +var _preview_material: StandardMaterial3D +var _preview_camera: Camera3D +var _preview_screen_position := Vector2.ZERO +var _save_module_dialog: FileDialog +var _load_module_dialog: FileDialog +var _export_scene_dialog: FileDialog +var _export_mode_option: OptionButton +var _pending_export_mesh: ArrayMesh +var _pending_export_name := "BakedBuilding" +var _pending_export_basis := Basis.IDENTITY +var _selected_window_style := ModularBuilderDetail.WindowStyle.SINGLE_PANE +var _selected_door_style := ModularBuilderDetail.DoorStyle.FLAT +var _editor_sync_elapsed := 0.0 + +func _ready() -> void: + set_process(true) + _side_material = StandardMaterial3D.new() + _side_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _side_material.albedo_color = Color(0.18, 0.95, 0.52) + _side_material.no_depth_test = true + _side_material.cull_mode = BaseMaterial3D.CULL_DISABLED + +func _process(delta: float) -> void: + _editor_sync_elapsed += delta + if _editor_sync_elapsed < 1.0 / 15.0: + return + _editor_sync_elapsed = 0.0 + call("_sync_vertex_markers") + _sync_pillar_centralize_control() + _sync_gable_centralize_control() + +func _enter_tree() -> void: + dock = VBoxContainer.new() + dock.name = "Modular Builder" + dock.custom_minimum_size = Vector2(0, 110) + _creation_row = HFlowContainer.new() + dock.add_child(_creation_row) + _creation_row.add_child(_label("Size:")) + size_spin = SpinBox.new() + size_spin.min_value = 0.5 + size_spin.max_value = 8.0 + size_spin.step = 0.001 + size_spin.value = 2.0 + size_spin.suffix = " m" + size_spin.custom_minimum_size = Vector2(100, 0) + _creation_row.add_child(size_spin) + var create_button := _make_icon_button("res://addons/modular_builder/icons/add_building.svg", "Add ModularBuilderPiece", Color(0.22, 0.30, 0.38)) + create_button.pressed.connect(Callable(self, "_add_piece")) + _creation_row.add_child(create_button) + var rotate_button := _make_icon_button("res://addons/modular_builder/icons/rotate.svg", "Rotate selected piece 90°", Color(0.22, 0.30, 0.38)) + rotate_button.pressed.connect(Callable(self, "_rotate_selected_piece")) + _creation_row.add_child(rotate_button) + var collision_button := _make_icon_button("res://addons/modular_builder/icons/collision.svg", "Add general collision to selected structure", Color(0.22, 0.30, 0.38)) + collision_button.pressed.connect(Callable(self, "_add_general_collision")) + _creation_row.add_child(collision_button) + var save_module_button := _make_icon_button("res://addons/modular_builder/icons/save_module.svg", "Save selected structure as a .tscn module", Color(0.22, 0.30, 0.38)) + save_module_button.pressed.connect(Callable(self, "_show_save_module_dialog")) + _creation_row.add_child(save_module_button) + var load_module_button := _make_icon_button("res://addons/modular_builder/icons/load_module.svg", "Insert saved module into the scene", Color(0.22, 0.30, 0.38)) + load_module_button.pressed.connect(Callable(self, "_show_load_module_dialog")) + _creation_row.add_child(load_module_button) + _export_row = HFlowContainer.new() + dock.add_child(_export_row) + _export_row.add_child(_label("Export structure as mesh:") ) + _export_mode_option = OptionButton.new() + _export_mode_option.add_item("New scene", EXPORT_NEW_SCENE) + _export_mode_option.add_item("Copy in this scene", EXPORT_SCENE_COPY) + _export_mode_option.tooltip_text = "Choose whether to save the combined mesh as a new scene or add a copy to the current scene." + _export_row.add_child(_export_mode_option) + var export_button := Button.new() + export_button.text = "Export" + export_button.tooltip_text = "Generate one static mesh with materials and UVs from the selected structure." + export_button.pressed.connect(Callable(self, "_on_export_pressed")) + _export_row.add_child(export_button) + _tool_row = HFlowContainer.new() + dock.add_child(_tool_row) + _tool_button_group = ButtonGroup.new() + _tool_button_group.allow_unpress = true + _add_tool_button("res://addons/modular_builder/icons/foundation_square.svg", "Square foundation", TOOL_FOUNDATION_SQUARE, Color(0.08, 0.48, 0.22)) + _add_tool_button("res://addons/modular_builder/icons/foundation_triangle.svg", "Triangular foundation", TOOL_FOUNDATION_TRIANGLE, Color(0.08, 0.48, 0.22)) + _add_tool_button("res://addons/modular_builder/icons/wall.svg", "Wall", TOOL_WALL, Color(0.65, 0.12, 0.12)) + _add_tool_button("res://addons/modular_builder/icons/gable_wall.svg", "Gable wall", TOOL_GABLE_WALL, Color(0.65, 0.12, 0.12)) + _add_tool_button("res://addons/modular_builder/icons/gable_wall_right.svg", "Right triangular gable wall", TOOL_GABLE_WALL_RIGHT, Color(0.65, 0.12, 0.12)) + _add_tool_button("res://addons/modular_builder/icons/floor_square.svg", "Square floor", TOOL_FLOOR_SQUARE, Color(0.68, 0.48, 0.06)) + _add_tool_button("res://addons/modular_builder/icons/floor_triangle.svg", "Triangular floor", TOOL_FLOOR_TRIANGLE, Color(0.68, 0.48, 0.06)) + _add_tool_button("res://addons/modular_builder/icons/roof_gable.svg", "Gable roof", TOOL_ROOF_GABLE, Color(0.10, 0.38, 0.62)) + _add_tool_button("res://addons/modular_builder/icons/roof_shed.svg", "Shed roof", TOOL_ROOF_SHED, Color(0.10, 0.38, 0.62)) + _add_tool_button("res://addons/modular_builder/icons/roof_pyramid.svg", "Pyramid roof", TOOL_ROOF_PYRAMID, Color(0.10, 0.38, 0.62)) + _add_tool_button("res://addons/modular_builder/icons/stairs.svg", "Stairs", TOOL_STAIRS, Color(0.10, 0.38, 0.62)) + _add_tool_button("res://addons/modular_builder/icons/fence.svg", "Decorative fence", TOOL_FENCE, Color(0.88, 0.22, 0.55)) + _add_tool_button("res://addons/modular_builder/icons/parapet.svg", "Parapet: single rail and posts", TOOL_PARAPET, Color(0.88, 0.22, 0.55)) + _add_tool_button("res://addons/modular_builder/icons/window.svg", "Detail: window", TOOL_WINDOW, Color(0.88, 0.22, 0.55)) + _add_tool_button("res://addons/modular_builder/icons/door.svg", "Detail: door", TOOL_DOOR, Color(0.88, 0.22, 0.55)) + _add_tool_button("res://addons/modular_builder/icons/hatch.svg", "Detail: hatch", TOOL_HATCH, Color(0.88, 0.22, 0.55)) + _add_tool_button("res://addons/modular_builder/icons/pillar.svg", "Plain pillar: two vertices or Alt+click for a vertical pillar", TOOL_PILLAR, Color(0.88, 0.22, 0.55)) + var cancel_button := _make_icon_button("res://addons/modular_builder/icons/cancel.svg", "Deactivate tool", Color(0.34, 0.20, 0.20)) + cancel_button.pressed.connect(Callable(self, "_set_tool").bind(TOOL_NONE)) + _tool_row.add_child(cancel_button) + _vertex_edit_row = HFlowContainer.new() + dock.add_child(_vertex_edit_row) + _vertex_edit_row.add_child(_label("Vertices:")) + _add_tool_button("res://addons/modular_builder/icons/merge_vertices.svg", "Merge vertices to reduce markers", TOOL_MERGE_VERTICES, Color(0.38, 0.32, 0.52), _vertex_edit_row) + _add_tool_button("res://addons/modular_builder/icons/create_vertex.svg", "Create a connection vertex on the clicked surface", TOOL_CREATE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row) + _add_tool_button("res://addons/modular_builder/icons/move_vertex.svg", "Move a manual connection vertex", TOOL_MOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row) + _add_tool_button("res://addons/modular_builder/icons/remove_vertex.svg", "Remove a manual connection vertex", TOOL_REMOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row) + var marker_options_row := HFlowContainer.new() + dock.add_child(marker_options_row) + _marker_scope_check = CheckBox.new() + _marker_scope_check.text = "Only show points on selected pieces" + _marker_scope_check.tooltip_text = "Limit markers to pieces selected in the Scene tree. Selecting the structure container shows all its points." + _marker_scope_check.toggled.connect(Callable(self, "_on_marker_scope_toggled")) + marker_options_row.add_child(_marker_scope_check) + _simplify_vertices_check = CheckBox.new() + _simplify_vertices_check.text = "Simplify vertices" + _simplify_vertices_check.tooltip_text = "Merge front and back points at the middle of walls and pillars; floors and foundations show their top outline. Vertex editing tools show the exact points." + _simplify_vertices_check.toggled.connect(Callable(self, "_on_simplify_vertices_toggled")) + marker_options_row.add_child(_simplify_vertices_check) + _rise_options_row = HFlowContainer.new() + dock.add_child(_rise_options_row) + _rise_label = _label("Peak rise:") + _rise_options_row.add_child(_rise_label) + rise_spin = _detail_spin(1.0, 0.1, 8.0, 0.001) + _rise_options_row.add_child(rise_spin) + _linear_options_row = HFlowContainer.new() + dock.add_child(_linear_options_row) + _stair_count_label = _label("Steps:") + _linear_options_row.add_child(_stair_count_label) + stair_count_spin = SpinBox.new() + stair_count_spin.min_value = 1 + stair_count_spin.max_value = 100 + stair_count_spin.step = 1 + stair_count_spin.value = 10 + stair_count_spin.custom_minimum_size = Vector2(72, 0) + _linear_options_row.add_child(stair_count_spin) + _stair_width_label = _label("Width:") + _linear_options_row.add_child(_stair_width_label) + stair_width_spin = _detail_spin(1.0, 0.01, 8.0, 0.001) + _linear_options_row.add_child(stair_width_spin) + _fence_divisions_label = _label("Intermediate posts:") + _linear_options_row.add_child(_fence_divisions_label) + fence_divisions_spin = SpinBox.new() + fence_divisions_spin.min_value = 0 + fence_divisions_spin.max_value = 100 + fence_divisions_spin.step = 1 + fence_divisions_spin.value = 1 + fence_divisions_spin.custom_minimum_size = Vector2(72, 0) + _linear_options_row.add_child(fence_divisions_spin) + _fence_height_label = _label("Height:") + _linear_options_row.add_child(_fence_height_label) + fence_height_spin = _detail_spin(0.7, 0.1, 8.0, 0.001) + _linear_options_row.add_child(fence_height_spin) + _linear_options_row.visible = false + _gable_centralize_check = CheckBox.new() + _gable_centralize_check.text = "Center" + _gable_centralize_check.tooltip_text = "Align the gable with the center of the supporting wall thickness." + _gable_centralize_check.visible = false + _gable_centralize_check.toggled.connect(_on_gable_centralize_toggled) + _rise_options_row.add_child(_gable_centralize_check) + _rise_options_row.visible = false + _detail_options_row = HFlowContainer.new() + dock.add_child(_detail_options_row) + _detail_width_label = _label("Width:") + _detail_options_row.add_child(_detail_width_label) + detail_width_spin = _detail_spin(1.0, 0.01, 5.0, 0.001) + _detail_options_row.add_child(detail_width_spin) + _detail_height_label = _label("Height:") + _detail_options_row.add_child(_detail_height_label) + detail_height_spin = _detail_spin(1.0, 0.2, 5.0, 0.001) + _detail_options_row.add_child(detail_height_spin) + _pillar_centralize_check = CheckBox.new() + _pillar_centralize_check.text = "Center" + _pillar_centralize_check.tooltip_text = "Align the pillar with the nearest axis of the first vertex’s piece while keeping it centered between the points." + _pillar_centralize_check.visible = false + _pillar_centralize_check.toggled.connect(_on_pillar_centralize_toggled) + _detail_options_row.add_child(_pillar_centralize_check) + _detail_height_offset_label = _label("Starting height:") + _detail_options_row.add_child(_detail_height_offset_label) + detail_height_offset_spin = _detail_spin(0.0, -2.0, 2.0, 0.001) + detail_height_offset_spin.suffix = " m" + _detail_options_row.add_child(detail_height_offset_spin) + detail_mode_option = OptionButton.new() + detail_mode_option.add_item("Opening", ModularBuilderDetail.DetailMode.OPENING) + detail_mode_option.add_item("Opening + object", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT) + detail_mode_option.select(0) + detail_mode_option.tooltip_text = "Choose an opening alone or an opening with a separately textured object." + _detail_options_row.add_child(detail_mode_option) + detail_style_option = OptionButton.new() + detail_style_option.tooltip_text = "Window or door style." + detail_style_option.item_selected.connect(_on_detail_style_selected) + _detail_options_row.add_child(detail_style_option) + detail_width_spin.value_changed.connect(Callable(self, "_refresh_detail_preview")) + detail_height_spin.value_changed.connect(Callable(self, "_refresh_detail_preview")) + detail_height_offset_spin.value_changed.connect(Callable(self, "_refresh_detail_preview")) + detail_mode_option.item_selected.connect(_on_detail_mode_selected) + _detail_options_row.visible = false + _preview_material = StandardMaterial3D.new() + _preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _preview_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _preview_material.albedo_color = Color(1.0, 0.35, 0.72, 0.48) + _save_module_dialog = FileDialog.new() + _save_module_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _save_module_dialog.access = FileDialog.ACCESS_RESOURCES + _save_module_dialog.add_filter("*.tscn", "Godot Scene") + _save_module_dialog.file_selected.connect(Callable(self, "_save_module_to_path")) + add_child(_save_module_dialog) + _load_module_dialog = FileDialog.new() + _load_module_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _load_module_dialog.access = FileDialog.ACCESS_RESOURCES + _load_module_dialog.add_filter("*.tscn", "Godot Scene") + _load_module_dialog.file_selected.connect(Callable(self, "_load_module_from_path")) + add_child(_load_module_dialog) + _export_scene_dialog = FileDialog.new() + _export_scene_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _export_scene_dialog.access = FileDialog.ACCESS_RESOURCES + _export_scene_dialog.add_filter("*.tscn", "Godot Scene") + _export_scene_dialog.file_selected.connect(Callable(self, "_export_baked_scene_to_path")) + add_child(_export_scene_dialog) + status_label = _label("Select a tool to show the vertices.") + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + dock.add_child(status_label) + add_control_to_bottom_panel(dock, "Modular Builder") + set_input_event_forwarding_always_enabled() + get_editor_interface().get_selection().selection_changed.connect(Callable(self, "_on_editor_selection_changed")) + var inspector := get_editor_interface().get_inspector() + if not inspector.property_edited.is_connected(_on_inspector_property_edited): + inspector.property_edited.connect(_on_inspector_property_edited) + +func _exit_tree() -> void: + set_process(false) + var editor_selection := get_editor_interface().get_selection() + if editor_selection.selection_changed.is_connected(Callable(self, "_on_editor_selection_changed")): + editor_selection.selection_changed.disconnect(Callable(self, "_on_editor_selection_changed")) + var inspector := get_editor_interface().get_inspector() + if inspector.property_edited.is_connected(_on_inspector_property_edited): + inspector.property_edited.disconnect(_on_inspector_property_edited) + if dock: + remove_control_from_bottom_panel(dock) + dock.queue_free() + call("_clear_marker_container") + call("_clear_detail_preview") + update_overlays() + +func _handles(object: Object) -> bool: + return object is ModularBuilderPiece + +func _label(text: String) -> Label: + var label := Label.new() + label.text = text + return label + +func _detail_spin(value: float, minimum: float, maximum: float, step: float) -> SpinBox: + var spin := SpinBox.new() + spin.min_value = minimum + spin.max_value = maximum + spin.step = step + spin.value = value + spin.suffix = " m" + spin.custom_minimum_size = Vector2(84, 0) + return spin + +func _make_icon_button(icon_path: String, tooltip: String, color: Color) -> Button: + var button := Button.new() + button.icon = load(icon_path) as Texture2D + button.tooltip_text = tooltip + button.custom_minimum_size = Vector2(48, 48) + button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 30) + var normal := StyleBoxFlat.new() + normal.bg_color = color + normal.set_corner_radius_all(4) + normal.set_border_width_all(1) + normal.border_color = Color(0.02, 0.02, 0.02) + button.add_theme_stylebox_override("normal", normal) + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = color.lightened(0.16) + button.add_theme_stylebox_override("hover", hover) + var pressed := normal.duplicate() as StyleBoxFlat + pressed.bg_color = color.lightened(0.28) + pressed.border_width_bottom = 3 + button.add_theme_stylebox_override("pressed", pressed) + return button + +func _add_tool_button(icon_path: String, tooltip: String, tool: String, color: Color, target_row: Container = null) -> void: + var button := _make_icon_button(icon_path, tooltip, color) + button.toggle_mode = true + button.button_group = _tool_button_group + button.toggled.connect(_on_tool_button_toggled.bind(tool)) + (_tool_row if target_row == null else target_row).add_child(button) + _tool_buttons[tool] = button + +func _on_tool_button_toggled(pressed: bool, tool: String) -> void: + var is_foundation := tool == TOOL_FOUNDATION_SQUARE or tool == TOOL_FOUNDATION_TRIANGLE + if pressed: + _active_button_key = tool + if is_foundation: + _foundation_type_selected = ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE if tool == TOOL_FOUNDATION_TRIANGLE else ModularBuilderPiece.PieceType.FOUNDATION_SQUARE + call("_set_tool", TOOL_FOUNDATION) + else: + call("_set_tool", tool) + _update_detail_options(tool) + else: + call_deferred("_finish_tool_button_toggle_off", tool) + +func _finish_tool_button_toggle_off(tool: String) -> void: + var button := _tool_buttons.get(tool) as Button + if button != null and not button.button_pressed and _active_button_key == tool: + call("_set_tool", TOOL_NONE) + +func _update_detail_options(tool: String) -> void: + var is_detail := _is_detail_tool(tool) + var is_fence := tool == TOOL_FENCE or tool == TOOL_PARAPET + _detail_options_row.visible = is_detail + detail_width_spin.visible = is_detail and not is_fence + _detail_width_label.visible = is_detail and not is_fence + detail_height_spin.visible = is_detail and not is_fence + _detail_height_label.visible = is_detail and not is_fence + detail_height_offset_spin.visible = tool != TOOL_HATCH and tool != TOOL_PILLAR and not is_fence + _detail_height_offset_label.visible = detail_height_offset_spin.visible + detail_mode_option.visible = tool != TOOL_PILLAR and not is_fence + _detail_width_label.text = "Side:" if tool == TOOL_PILLAR else "Width:" + _detail_height_label.text = "Length:" if tool == TOOL_PILLAR else "Height:" + _linear_options_row.visible = tool == TOOL_STAIRS or is_fence + _stair_count_label.visible = tool == TOOL_STAIRS + stair_count_spin.visible = tool == TOOL_STAIRS + _stair_width_label.visible = tool == TOOL_STAIRS + stair_width_spin.visible = tool == TOOL_STAIRS + _fence_divisions_label.visible = is_fence + fence_divisions_spin.visible = is_fence + _fence_height_label.visible = is_fence + fence_height_spin.visible = is_fence + if not is_detail: + return + var width := 1.0 + var height := 1.0 + var bottom_offset := 0.5 + if tool == TOOL_PILLAR: + width = 0.2 + height = 2.0 + bottom_offset = 0.0 + elif tool == TOOL_DOOR: + width = DOOR_DEFAULT_WIDTH_METERS + height = DOOR_DEFAULT_HEIGHT_METERS + bottom_offset = 0.0 + elif tool == TOOL_HATCH: + bottom_offset = 0.0 + detail_width_spin.set_value_no_signal(width) + detail_height_spin.set_value_no_signal(height) + detail_height_offset_spin.set_value_no_signal(bottom_offset) + detail_style_option.clear() + detail_style_option.visible = tool == TOOL_WINDOW or tool == TOOL_DOOR + if tool == TOOL_WINDOW: + detail_style_option.add_item("Single pane", ModularBuilderDetail.WindowStyle.SINGLE_PANE) + detail_style_option.add_item("Cross pane", ModularBuilderDetail.WindowStyle.CROSS_PANE) + detail_style_option.select(_selected_window_style) + elif tool == TOOL_DOOR: + detail_style_option.add_item("Flat", ModularBuilderDetail.DoorStyle.FLAT) + detail_style_option.add_item("Paneled", ModularBuilderDetail.DoorStyle.PANELED) + detail_style_option.select(_selected_door_style) + call("_refresh_detail_preview") + +func _on_detail_style_selected(index: int) -> void: + if active_tool == TOOL_WINDOW: + _selected_window_style = index + if is_instance_valid(_detail_preview): + _detail_preview.window_style = index + elif active_tool == TOOL_DOOR: + _selected_door_style = index + if is_instance_valid(_detail_preview): + _detail_preview.door_style = index + call("_refresh_detail_preview") + +func _on_detail_mode_selected(_index: int) -> void: + call("_refresh_detail_preview") + +func _on_pillar_centralize_toggled(enabled: bool) -> void: + _pillar_centralize_default = enabled + var selected_pillar := _get_selected_pillar_detail() + if selected_pillar != null: + selected_pillar.center_align = enabled + if is_instance_valid(_detail_preview) and _detail_preview.detail_type == ModularBuilderDetail.DetailType.PILLAR: + _detail_preview.center_align = enabled + call("_refresh_detail_preview") + +func _on_gable_centralize_toggled(enabled: bool) -> void: + _gable_centralize_default = enabled + var selected := _get_selected_gable_wall() + if selected != null: + selected.center_align = enabled + +func _sync_gable_centralize_control() -> void: + if _gable_centralize_check == null: + return + var selected := _get_selected_gable_wall() + var show_option := active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or selected != null + var show_rise := active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_PYRAMID + _rise_options_row.visible = show_rise or selected != null + _rise_label.visible = show_rise + rise_spin.visible = show_rise + _gable_centralize_check.visible = show_option + _gable_centralize_check.text = "Center walls" if active_tool == TOOL_WALL or (selected != null and selected.piece_type == ModularBuilderPiece.PieceType.WALL) else "Center" + _gable_centralize_check.tooltip_text = "Align wall endpoints with the centerlines of supporting walls." if active_tool == TOOL_WALL or (selected != null and selected.piece_type == ModularBuilderPiece.PieceType.WALL) else "Align the gable with the center of the supporting wall thickness." + if selected != null: + _gable_centralize_default = selected.center_align + _gable_centralize_check.set_pressed_no_signal(selected.center_align) + else: + _gable_centralize_check.set_pressed_no_signal(_gable_centralize_default) + +func _get_selected_gable_wall() -> ModularBuilderPiece: + var selected := get_editor_interface().get_selection().get_selected_nodes() + if selected.size() == 1 and selected[0] is ModularBuilderPiece and (selected[0].piece_type == ModularBuilderPiece.PieceType.WALL or selected[0].piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or selected[0].piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT): + return selected[0] as ModularBuilderPiece + return null + +func _sync_pillar_centralize_control() -> void: + if _pillar_centralize_check == null: + return + var selected_pillar := _get_selected_pillar_detail() + var show_pillar_options := active_tool == TOOL_PILLAR or selected_pillar != null + _detail_options_row.visible = _is_detail_tool(active_tool) or show_pillar_options + _pillar_centralize_check.visible = show_pillar_options + if selected_pillar != null and active_tool != TOOL_PILLAR and not _is_detail_tool(active_tool): + _detail_width_label.visible = false + detail_width_spin.visible = false + _detail_height_label.visible = false + detail_height_spin.visible = false + _detail_height_offset_label.visible = false + detail_height_offset_spin.visible = false + detail_mode_option.visible = false + detail_style_option.visible = false + elif active_tool == TOOL_PILLAR: + _detail_width_label.visible = true + detail_width_spin.visible = true + _detail_height_label.visible = true + detail_height_spin.visible = true + if selected_pillar != null: + _pillar_centralize_default = selected_pillar.center_align + _pillar_centralize_check.set_pressed_no_signal(selected_pillar.center_align) + else: + _pillar_centralize_check.set_pressed_no_signal(_pillar_centralize_default) + +func _get_selected_pillar_detail() -> ModularBuilderDetail: + var selected := get_editor_interface().get_selection().get_selected_nodes() + if selected.size() != 1 or not selected[0] is ModularBuilderDetail: + return null + var detail := selected[0] as ModularBuilderDetail + return detail if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR else null + +func _on_inspector_property_edited(property: String) -> void: + if property == "center_align": + _sync_pillar_centralize_control() + +func _is_detail_tool(tool: String) -> bool: + return tool == TOOL_WINDOW or tool == TOOL_DOOR or tool == TOOL_HATCH or tool == TOOL_PILLAR or tool == TOOL_FENCE or tool == TOOL_PARAPET diff --git a/addons/modular_builder/modular_builder_editor_ui.gd.uid b/addons/modular_builder/modular_builder_editor_ui.gd.uid new file mode 100644 index 0000000..7831c48 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_ui.gd.uid @@ -0,0 +1 @@ +uid://duvk2kap6rjef diff --git a/addons/modular_builder/modular_builder_editor_vertices.gd b/addons/modular_builder/modular_builder_editor_vertices.gd new file mode 100644 index 0000000..0053962 --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_vertices.gd @@ -0,0 +1,727 @@ +@tool +extends "res://addons/modular_builder/modular_builder_editor_details.gd" + +const MESH_PICKER = preload("res://addons/modular_builder/modular_mesh_picker.gd") + +func _sync_vertex_markers() -> void: + var root := _get_selected_piece() + var vertex_points: Array[Vector3] = [] + var side_points: Array[Dictionary] = [] + if root != null: + if active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_FLOOR_SQUARE or active_tool == TOOL_FLOOR_TRIANGLE or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID or active_tool == TOOL_STAIRS or active_tool == TOOL_FENCE or active_tool == TOOL_PILLAR or active_tool == TOOL_MERGE_VERTICES or active_tool == TOOL_CREATE_VERTEX or active_tool == TOOL_MOVE_VERTEX or active_tool == TOOL_REMOVE_VERTEX: + vertex_points = _get_tool_vertices(root) + elif active_tool == TOOL_FOUNDATION: + side_points = _get_side_button_points(root) + if root != _marker_root or (root == null and _marker_container != null) or vertex_points.size() != _marker_point_nodes.size() or side_points.size() != _side_button_nodes.size(): + _clear_marker_container() + _marker_root = root + if root == null: + return + _marker_container = Node3D.new() + _marker_container.name = "ModularBuilderMarkers" + root.add_child(_marker_container) + for _point in vertex_points: + var marker := Label3D.new() + marker.text = "●" + marker.font_size = 56 + marker.pixel_size = 0.004 + marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + marker.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + marker.billboard = BaseMaterial3D.BILLBOARD_ENABLED + marker.no_depth_test = true + marker.modulate = Color(0.05, 0.58, 1.0) + marker.outline_size = 8 + marker.outline_modulate = Color.BLACK + _marker_container.add_child(marker) + _marker_point_nodes.append(marker) + for _button in side_points: + var label := Label3D.new() + label.text = "+" + label.font_size = 56 + label.outline_size = 8 + label.outline_modulate = Color.BLACK + label.pixel_size = 0.01 + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.no_depth_test = true + label.modulate = Color(0.2, 1.0, 0.55) + _marker_container.add_child(label) + _side_button_nodes.append(label) + for i in range(vertex_points.size()): + _marker_point_nodes[i].position = root.to_local(vertex_points[i]) + var is_pending := false + for pending_point in pending_points: + if pending_point.distance_to(vertex_points[i]) < 0.025: + is_pending = true + break + _marker_point_nodes[i].modulate = Color(1.0, 0.88, 0.08) if is_pending else Color(0.05, 0.58, 1.0) + for i in range(side_points.size()): + _side_button_nodes[i].position = root.to_local(side_points[i].position) + if _marker_container: + _marker_container.visible = active_tool != TOOL_NONE or not side_points.is_empty() + +func _clear_marker_container() -> void: + if is_instance_valid(_marker_container): + _marker_container.queue_free() + _marker_container = null + _marker_root = null + _marker_point_nodes.clear() + _side_button_nodes.clear() + +func _get_side_button_points(root: ModularBuilderPiece) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for foundation in _get_foundations(root): + var top_y := foundation.thickness * 0.5 if foundation.centered_origin else foundation.thickness + var y := top_y + 0.22 + var points := _foundation_polygon(foundation) + var centroid := Vector2.ZERO + for point in points: + centroid += point + centroid /= points.size() + for i in range(points.size()): + var a: Vector2 = points[i] + var b: Vector2 = points[(i + 1) % points.size()] + if _foundation_edge_is_occupied(root, foundation, a, b): + continue + var midpoint := (a + b) * 0.5 + var outward := (midpoint - centroid).normalized() + var edge_length := a.distance_to(b) + var neighbor_polygon: Array[Vector2] = [] + if _foundation_type_selected == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE: + var square_side := minf(foundation.unit_size, edge_length) + var edge_direction := (b - a).normalized() + var square_a := midpoint - edge_direction * square_side * 0.5 + var square_b := midpoint + edge_direction * square_side * 0.5 + neighbor_polygon = [square_a, square_b, square_b + outward * square_side, square_a + outward * square_side] + elif points.size() == 3: + for source_point in points: + neighbor_polygon.append(_reflect_point_across_edge(source_point, a, b)) + else: + neighbor_polygon = [a, b, a + outward * edge_length] + var custom_points := PackedVector3Array() + for neighbor_point in neighbor_polygon: + var base_y := -foundation.thickness * 0.5 if foundation.centered_origin else 0.0 + var foundation_local := Vector3(neighbor_point.x, base_y, neighbor_point.y) + custom_points.append(root.to_local(foundation.to_global(foundation_local))) + result.append({ + "position": foundation.to_global(Vector3(midpoint.x + outward.x * 0.55, y, midpoint.y + outward.y * 0.55)), + "custom_points": custom_points, + "foundation": foundation + }) + return result + +func _foundation_edge_is_occupied(root: ModularBuilderPiece, foundation: ModularBuilderPiece, edge_a: Vector2, edge_b: Vector2) -> bool: + var foundation_base_y := -foundation.thickness * 0.5 if foundation.centered_origin else 0.0 + var edge_a_3d := root.to_local(foundation.to_global(Vector3(edge_a.x, foundation_base_y, edge_a.y))) + var edge_b_3d := root.to_local(foundation.to_global(Vector3(edge_b.x, foundation_base_y, edge_b.y))) + var target_a := Vector2(edge_a_3d.x, edge_a_3d.z) + var target_b := Vector2(edge_b_3d.x, edge_b_3d.z) + for other in _get_foundations(root): + if other == foundation: + continue + var other_polygon := _foundation_polygon(other) + for i in range(other_polygon.size()): + var a := other_polygon[i] + var b := other_polygon[(i + 1) % other_polygon.size()] + var other_base_y := -other.thickness * 0.5 if other.centered_origin else 0.0 + var a_in_root := root.to_local(other.to_global(Vector3(a.x, other_base_y, a.y))) + var b_in_root := root.to_local(other.to_global(Vector3(b.x, other_base_y, b.y))) + var other_a := Vector2(a_in_root.x, a_in_root.z) + var other_b := Vector2(b_in_root.x, b_in_root.z) + if absf(a_in_root.y - edge_a_3d.y) < 0.02 and absf(b_in_root.y - edge_b_3d.y) < 0.02 and _segments_overlap(target_a, target_b, other_a, other_b): + return true + return false + +func _get_foundations(root: ModularBuilderPiece) -> Array[ModularBuilderPiece]: + var result: Array[ModularBuilderPiece] = [] + if root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + result.append(root) + for child in root.get_children(): + _collect_foundations(child, result) + return result + +func _collect_foundations(node: Node, result: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE): + result.append(node) + for child in node.get_children(): + _collect_foundations(child, result) + +func _reflect_point_across_edge(point: Vector2, a: Vector2, b: Vector2) -> Vector2: + var edge := b - a + var projection := a + edge * ((point - a).dot(edge) / edge.length_squared()) + return projection * 2.0 - point + +func _nearest_side_button(camera: Camera3D, screen_position: Vector2) -> int: + var root := _get_selected_piece() + if root == null: + return -1 + var buttons := _get_side_button_points(root) + var closest := MARKER_HIT_RADIUS + 5.0 + var result := -1 + for i in range(buttons.size()): + var point: Vector3 = buttons[i].position + if camera.is_position_behind(point): + continue + var distance := camera.unproject_position(point).distance_to(screen_position) + if distance < closest: + closest = distance + result = i + return result + +func _add_foundation_at_side(root: ModularBuilderPiece, side: int) -> void: + var buttons := _get_side_button_points(root) + if side < 0 or side >= buttons.size(): + return + var scene_root := get_editor_interface().get_edited_scene_root() as Node3D + var piece := Node3D.new() + piece.name = "Foundation" + piece.set_script(PIECE_SCRIPT) + var source_foundation: ModularBuilderPiece = buttons[side].foundation + piece.set("unit_size", source_foundation.unit_size) + piece.set("thickness", source_foundation.thickness) + piece.set("centered_origin", true) + piece.set("piece_type", _foundation_type_selected) + var points_in_root: PackedVector3Array = buttons[side].custom_points + var pivot := _packed_points_bounds_center(points_in_root) + pivot.y += source_foundation.thickness * 0.5 + var custom_points := PackedVector3Array() + for point in points_in_root: + custom_points.append(point - pivot) + piece.position = pivot + var undo := get_undo_redo() + undo.create_action("Add foundation to selected side") + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "custom_points", custom_points) + undo.add_do_property(piece, "owner", scene_root) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + status_label.text = "Foundation added to structure." + +func _packed_points_bounds_center(points: PackedVector3Array) -> Vector3: + if points.is_empty(): + return Vector3.ZERO + var minimum := points[0] + var maximum := points[0] + for point in points: + minimum = minimum.min(point) + maximum = maximum.max(point) + return (minimum + maximum) * 0.5 + +func _get_tool_vertices(root: ModularBuilderPiece) -> Array[Vector3]: + var result: Array[Vector3] = [] + if active_tool == TOOL_MERGE_VERTICES: + _collect_all_connection_vertices(root, result) + elif active_tool == TOOL_CREATE_VERTEX or active_tool == TOOL_MOVE_VERTEX or active_tool == TOOL_REMOVE_VERTEX: + _collect_all_connection_vertices(root, result) + elif active_tool == TOOL_STAIRS: + _collect_stair_vertices(root, result) + elif active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET: + _collect_fence_vertices(root, result) + elif active_tool == TOOL_PILLAR: + var surfaces: Array[ModularBuilderPiece] = [] + _collect_surface_pieces(root, surfaces) + for piece in surfaces: + if piece.piece_type == ModularBuilderPiece.PieceType.BUILDING: + continue + var polygon := _piece_polygon_xz(piece) if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(piece) + var top_y := piece.custom_points[0].y if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and not piece.custom_points.is_empty() else (piece.thickness * 0.5 if piece.centered_origin else piece.thickness) + var bottom_y := top_y - piece.thickness + for point in polygon: + _add_unique_vertex(result, piece.to_global(Vector3(point.x, top_y, point.y))) + _add_unique_vertex(result, piece.to_global(Vector3(point.x, bottom_y, point.y))) + var walls: Array[Node] = [] + _collect_walls(root, walls) + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5 + for endpoint in endpoints: + for offset in [Vector3.ZERO, Vector3.UP * wall.unit_size]: + _add_unique_vertex(result, wall.to_global(endpoint + offset + side)) + _add_unique_vertex(result, wall.to_global(endpoint + offset - side)) + _add_gable_wall_vertices(root, result) + elif active_tool == TOOL_WALL: + for foundation in _get_foundations(root): + var top_y := foundation.thickness * 0.5 if foundation.centered_origin else foundation.thickness + for point in _foundation_polygon(foundation): + _add_unique_vertex(result, foundation.to_global(Vector3(point.x, top_y, point.y))) + var surfaces: Array[ModularBuilderPiece] = [] + _collect_surface_pieces(root, surfaces) + for surface in surfaces: + if surface.piece_type != ModularBuilderPiece.PieceType.FLOOR: + continue + for point in surface.custom_points: + _add_unique_vertex(result, surface.to_global(point)) + var existing_walls: Array[Node] = [] + _collect_walls(root, existing_walls) + for wall_node in existing_walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5 + for endpoint in endpoints: + var top := endpoint + Vector3.UP * wall.unit_size + _add_unique_vertex(result, wall.to_global(top + side)) + _add_unique_vertex(result, wall.to_global(top - side)) + _add_gable_wall_vertices(root, result) + else: + var walls: Array[Node] = [] + _collect_walls(root, walls) + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * float(wall.get("thickness")) * 0.5 + for endpoint in endpoints: + if active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED: + _add_unique_vertex(result, wall.to_global(endpoint + side)) + _add_unique_vertex(result, wall.to_global(endpoint - side)) + var top := endpoint + Vector3.UP * float(wall.get("unit_size")) + _add_unique_vertex(result, wall.to_global(top + side)) + _add_unique_vertex(result, wall.to_global(top - side)) + _add_gable_wall_vertices(root, result) + _collect_detail_connection_vertices(root, result) + for manual_vertex in _get_manual_vertices(root): + _add_unique_vertex(result, manual_vertex) + _collapse_merged_vertex_groups(root, result) + if _marker_scope_check != null and _marker_scope_check.button_pressed: + var scope_pieces := _get_marker_scope_pieces(root) + var scope_vertices: Array[Vector3] = [] + for piece in scope_pieces: + _collect_piece_geometry_vertices(piece, scope_vertices) + for manual_vertex in _get_manual_vertices(piece): + _add_unique_vertex(scope_vertices, manual_vertex) + for selected_node in get_editor_interface().get_selection().get_selected_nodes(): + if selected_node is ModularBuilderDetail: + _collect_detail_connection_vertices(selected_node, scope_vertices) + _collapse_merged_vertex_groups(root, scope_vertices) + var filtered: Array[Vector3] = [] + for vertex in result: + for scope_vertex in scope_vertices: + if vertex.distance_to(scope_vertex) < 0.025: + _add_unique_vertex(filtered, vertex) + break + result = filtered + if _simplify_vertices_check != null and _simplify_vertices_check.button_pressed and active_tool != TOOL_MERGE_VERTICES and active_tool != TOOL_CREATE_VERTEX and active_tool != TOOL_MOVE_VERTEX and active_tool != TOOL_REMOVE_VERTEX: + result = _simplify_tool_vertices(root, result) + return result + +func _simplify_tool_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> Array[Vector3]: + var replacements := {} + var pieces: Array[ModularBuilderPiece] = [] + _collect_all_modular_pieces(root, pieces) + for piece in pieces: + if piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE and piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT: + continue + var piece_vertices: Array[Vector3] = [] + _collect_piece_geometry_vertices(piece, piece_vertices) + for original in piece_vertices: + var key := _vertex_position_key(original) + if not replacements.has(key): + replacements[key] = VERTEX_SIMPLIFIER.piece_vertex(piece, original) + _add_pillar_simplification_candidates(root, replacements) + var result: Array[Vector3] = [] + for original in vertices: + _add_unique_vertex(result, replacements.get(_vertex_position_key(original), original)) + return result + +func _add_pillar_simplification_candidates(node: Node, replacements: Dictionary) -> void: + if node is ModularBuilderDetail: + var detail := node as ModularBuilderDetail + if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR: + var half := detail.width * 0.5 + for x in [-half, half]: + for y in [-detail.height * 0.5, detail.height * 0.5]: + for z in [-half, half]: + var original := detail.to_global(Vector3(x, y, z)) + var key := _vertex_position_key(original) + if not replacements.has(key): + replacements[key] = VERTEX_SIMPLIFIER.pillar_vertex(detail, original) + for child in node.get_children(): + _add_pillar_simplification_candidates(child, replacements) + +func _vertex_position_key(point: Vector3) -> Vector3i: + return Vector3i(roundi(point.x * 1000.0), roundi(point.y * 1000.0), roundi(point.z * 1000.0)) + +func _collect_stair_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + var surfaces: Array[ModularBuilderPiece] = [] + _collect_surface_pieces(root, surfaces) + for surface in surfaces: + var polygon := _piece_polygon_xz(surface) if surface.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(surface) + var y := surface.thickness * 0.5 if surface.centered_origin else surface.thickness + for point in polygon: + _add_unique_vertex(vertices, surface.to_global(Vector3(point.x, y, point.y))) + var walls: Array[Node] = [] + _collect_walls(root, walls) + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5 + for endpoint in endpoints: + var top := endpoint + Vector3.UP * wall.unit_size + _add_unique_vertex(vertices, wall.to_global(top + side)) + _add_unique_vertex(vertices, wall.to_global(top - side)) + _add_gable_wall_vertices(root, vertices) + +func _collect_fence_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + var surfaces: Array[ModularBuilderPiece] = [] + _collect_surface_pieces(root, surfaces) + for surface in surfaces: + var polygon := _piece_polygon_xz(surface) if surface.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(surface) + var top_y := surface.thickness * 0.5 if surface.centered_origin else surface.thickness + for point in polygon: + _add_unique_vertex(vertices, surface.to_global(Vector3(point.x, top_y, point.y))) + var walls: Array[Node] = [] + _collect_walls(root, walls) + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5 + for endpoint in endpoints: + for height in [0.0, wall.unit_size]: + _add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height + side)) + _add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height - side)) + _add_gable_wall_vertices(root, vertices) + +func _get_marker_scope_pieces(root: ModularBuilderPiece) -> Array[ModularBuilderPiece]: + var result: Array[ModularBuilderPiece] = [] + var selected := get_editor_interface().get_selection().get_selected_nodes() + for selected_node in selected: + var piece := selected_node as ModularBuilderPiece + if piece == null and selected_node is ModularBuilderDetail: + var parent := selected_node.get_parent() + while parent != null and not parent is ModularBuilderPiece: + parent = parent.get_parent() + piece = parent as ModularBuilderPiece + if piece == root: + _collect_all_modular_pieces(root, result) + return result + if piece != null and _is_descendant_of_piece(piece, root): + if not result.has(piece): + result.append(piece) + return result + +func _collect_all_modular_pieces(node: Node, result: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and not result.has(node): + result.append(node) + for child in node.get_children(): + _collect_all_modular_pieces(child, result) + +func _is_descendant_of_piece(node: Node, ancestor: Node) -> bool: + var current := node.get_parent() + while current != null: + if current == ancestor: + return true + current = current.get_parent() + return false + +func _collect_piece_geometry_vertices(piece: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + if piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness + var bottom_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0 + for point in _foundation_polygon(piece): + _add_unique_vertex(vertices, piece.to_global(Vector3(point.x, top_y, point.y))) + _add_unique_vertex(vertices, piece.to_global(Vector3(point.x, bottom_y, point.y))) + elif piece.piece_type == ModularBuilderPiece.PieceType.FLOOR: + for point in piece.custom_points: + _add_unique_vertex(vertices, piece.to_global(point)) + _add_unique_vertex(vertices, piece.to_global(point - Vector3.UP * piece.thickness)) + elif piece.piece_type == ModularBuilderPiece.PieceType.WALL: + var endpoints: PackedVector3Array = piece.call("_get_centered_wall_points") if piece.center_align else piece.custom_points + if endpoints.size() < 2: + return + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x) * piece.thickness * 0.5 + for endpoint in endpoints: + for height in [0.0, piece.unit_size]: + _add_unique_vertex(vertices, piece.to_global(endpoint + Vector3.UP * height + side)) + _add_unique_vertex(vertices, piece.to_global(endpoint + Vector3.UP * height - side)) + elif piece.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or piece.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT: + _add_single_gable_wall_vertices(piece, vertices) + elif piece.piece_type == ModularBuilderPiece.PieceType.ROOF_GABLE or piece.piece_type == ModularBuilderPiece.PieceType.ROOF_SHED or piece.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID: + for point in piece.custom_points: + _add_unique_vertex(vertices, piece.to_global(point)) + if piece.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID and piece.custom_points.size() >= 4: + var base_center := (piece.custom_points[0] + piece.custom_points[1] + piece.custom_points[2] + piece.custom_points[3]) * 0.25 + _add_unique_vertex(vertices, piece.to_global(base_center + Vector3.UP * piece.rise)) + +func _collect_all_connection_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + var surfaces: Array[ModularBuilderPiece] = [] + _collect_surface_pieces(root, surfaces) + for piece in surfaces: + var polygon := _piece_polygon_xz(piece) if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(piece) + var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness + var bottom_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0 + for point in polygon: + _add_unique_vertex(vertices, piece.to_global(Vector3(point.x, top_y, point.y))) + _add_unique_vertex(vertices, piece.to_global(Vector3(point.x, bottom_y, point.y))) + var walls: Array[Node] = [] + _collect_walls(root, walls) + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5 + for endpoint in endpoints: + for height in [0.0, wall.unit_size]: + _add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height + side)) + _add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height - side)) + _add_gable_wall_vertices(root, vertices) + _collect_detail_connection_vertices(root, vertices) + var roofs: Array[ModularBuilderPiece] = [] + _collect_roof_pieces(root, roofs) + for roof in roofs: + for point in roof.custom_points: + _add_unique_vertex(vertices, roof.to_global(point)) + if roof.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID and roof.custom_points.size() >= 4: + var base_center := (roof.custom_points[0] + roof.custom_points[1] + roof.custom_points[2] + roof.custom_points[3]) * 0.25 + _add_unique_vertex(vertices, roof.to_global(base_center + Vector3.UP * roof.rise)) + +func _collect_roof_pieces(node: Node, roofs: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.ROOF_GABLE or node.piece_type == ModularBuilderPiece.PieceType.ROOF_SHED or node.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID): + roofs.append(node) + for child in node.get_children(): + _collect_roof_pieces(child, roofs) + +func _collect_detail_connection_vertices(node: Node, vertices: Array[Vector3]) -> void: + if node is ModularBuilderDetail: + var detail := node as ModularBuilderDetail + if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR: + var half := detail.width * 0.5 + for x in [-half, half]: + for y in [-detail.height * 0.5, detail.height * 0.5]: + for z in [-half, half]: + _add_unique_vertex(vertices, detail.to_global(Vector3(x, y, z))) + elif (detail.detail_type == ModularBuilderDetail.DetailType.FENCE or detail.detail_type == ModularBuilderDetail.DetailType.PARAPET) and detail.path_points.size() >= 2: + for point in detail.path_points: + _add_unique_vertex(vertices, detail.to_global(point)) + for local_point in detail.get_meta(CUSTOM_VERTICES_META, []): + _add_unique_vertex(vertices, detail.to_global(local_point)) + for child in node.get_children(): + _collect_detail_connection_vertices(child, vertices) + +func _collapse_merged_vertex_groups(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + if not root.has_meta(MERGED_VERTEX_GROUPS_META): + return + var groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, []) + for group_value in groups: + if not group_value is Dictionary: + continue + var group: Dictionary = group_value + var members: PackedVector3Array = group.get("members", PackedVector3Array()) + var anchor: Vector3 = group.get("position", Vector3.ZERO) + var had_member := false + for i in range(vertices.size() - 1, -1, -1): + var local_vertex := root.to_local(vertices[i]) + for member in members: + if local_vertex.distance_to(member) <= 0.025: + vertices.remove_at(i) + had_member = true + break + if had_member: + _add_unique_vertex(vertices, root.to_global(anchor)) + +func _merge_members_for_point(root: ModularBuilderPiece, world_point: Vector3) -> PackedVector3Array: + var local_point := root.to_local(world_point) + var groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, []) + for group_value in groups: + if not group_value is Dictionary: + continue + var group: Dictionary = group_value + var anchor: Vector3 = group.get("position", Vector3.ZERO) + if local_point.distance_to(anchor) <= 0.04: + return group.get("members", PackedVector3Array()) + return PackedVector3Array([local_point]) + +func _finish_merge_vertices(root: ModularBuilderPiece) -> void: + if pending_points.size() < 2: + return + var selected_members := _merge_members_for_point(root, pending_points[0]) + for member in _merge_members_for_point(root, pending_points[1]): + var found := false + for existing in selected_members: + if existing.distance_to(member) <= 0.025: + found = true + break + if not found: + selected_members.append(member) + if selected_members.size() < 2: + pending_points.clear() + status_label.text = "Choose two different vertices to merge." + update_overlays() + return + var old_groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, []).duplicate(true) + var new_groups: Array = [] + for group_value in old_groups: + if not group_value is Dictionary: + continue + var group: Dictionary = group_value + var members: PackedVector3Array = group.get("members", PackedVector3Array()) + var overlaps := false + for member in members: + for selected in selected_members: + if member.distance_to(selected) <= 0.025: + overlaps = true + break + if overlaps: + break + if not overlaps: + new_groups.append(group) + var center := Vector3.ZERO + for member in selected_members: + center += member + center /= selected_members.size() + new_groups.append({"members": selected_members, "position": center}) + var undo := get_undo_redo() + undo.create_action("Merge vertex markers") + undo.add_do_method(root, "set_meta", MERGED_VERTEX_GROUPS_META, new_groups) + undo.add_undo_method(root, "set_meta", MERGED_VERTEX_GROUPS_META, old_groups) + undo.commit_action() + pending_points.clear() + status_label.text = "Markers merged. Ctrl+Z undoes the merge." + update_overlays() + +func _add_gable_wall_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + var gable_walls: Array[Node] = [] + _collect_gable_walls(root, gable_walls) + for node in gable_walls: + _add_single_gable_wall_vertices(node as ModularBuilderPiece, vertices) + +func _add_single_gable_wall_vertices(gable: ModularBuilderPiece, vertices: Array[Vector3]) -> void: + if gable.custom_points.size() < 2: + return + var base_points: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points + var a: Vector3 = base_points[0] + var b: Vector3 = base_points[1] + var direction := (b - a).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * gable.thickness * 0.5 + var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise + for endpoint in [a, b]: + _add_unique_vertex(vertices, gable.to_global(endpoint + side)) + _add_unique_vertex(vertices, gable.to_global(endpoint - side)) + _add_unique_vertex(vertices, gable.to_global(peak + side)) + _add_unique_vertex(vertices, gable.to_global(peak - side)) + +func _collect_gable_walls(node: Node, gable_walls: Array[Node]) -> void: + if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or node.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT) and not gable_walls.has(node): + gable_walls.append(node) + for child in node.get_children(): + _collect_gable_walls(child, gable_walls) + +func _collect_walls(node: Node, walls: Array[Node]) -> void: + if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL and not walls.has(node): + walls.append(node) + for child in node.get_children(): + if child is ModularBuilderPiece and child.piece_type == ModularBuilderPiece.PieceType.WALL: + if not walls.has(child): + walls.append(child) + _collect_walls(child, walls) + +func _add_unique_vertex(vertices: Array[Vector3], point: Vector3) -> void: + for existing in vertices: + if existing.distance_to(point) < 0.02: + return + vertices.append(point) + +func _get_manual_vertices(piece: ModularBuilderPiece) -> Array[Vector3]: + var result: Array[Vector3] = [] + var owners: Array[Node3D] = [] + _collect_manual_vertex_owners(piece, owners) + for owner_node in owners: + for local_point_value in owner_node.get_meta(CUSTOM_VERTICES_META, []): + var local_point: Vector3 = local_point_value + result.append(owner_node.to_global(local_point)) + return result + +func _collect_manual_vertex_owners(node: Node, owners: Array[Node3D]) -> void: + if node is ModularBuilderPiece or node is ModularBuilderDetail: + owners.append(node as Node3D) + for child in node.get_children(): + _collect_manual_vertex_owners(child, owners) + +func _raycast_piece_mesh(root: ModularBuilderPiece, camera: Camera3D, screen_position: Vector2) -> Variant: + return MESH_PICKER.raycast(root, camera, screen_position) + +func _commit_manual_vertices(owner_node: Node3D, new_vertices: Array, action_name: String) -> void: + var old_vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate() + var undo := get_undo_redo() + undo.create_action(action_name) + undo.add_do_method(owner_node, "set_meta", CUSTOM_VERTICES_META, new_vertices) + undo.add_undo_method(owner_node, "set_meta", CUSTOM_VERTICES_META, old_vertices) + undo.commit_action() + +func _create_manual_vertex(root: ModularBuilderPiece, world_position: Vector3) -> void: + var owner_node := _get_vertex_attachment_node(root) + var local_position := owner_node.to_local(world_position) + var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate() + for existing in vertices: + var existing_position: Vector3 = existing + if existing_position.distance_to(local_position) < 0.025: + status_label.text = "A manual vertex already exists there." + return + vertices.append(local_position) + _commit_manual_vertices(owner_node, vertices, "Create connection vertex") + status_label.text = "Connection vertex created and linked to the selected object." + +func _move_manual_vertex(root: ModularBuilderPiece, old_world_position: Vector3, new_world_position: Vector3) -> void: + var owner_record := _find_manual_vertex_record(root, old_world_position) + if owner_record.is_empty(): + pending_points.clear() + status_label.text = "The selected vertex is no longer available." + return + var owner_node: Node3D = owner_record.node + var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate() + vertices[owner_record.index] = owner_node.to_local(new_world_position) + pending_points.clear() + _commit_manual_vertices(owner_node, vertices, "Move connection vertex") + status_label.text = "Vertex moved." + +func _remove_manual_vertex(root: ModularBuilderPiece, world_position: Vector3) -> void: + var owner_record := _find_manual_vertex_record(root, world_position) + if owner_record.is_empty(): + return + var owner_node: Node3D = owner_record.node + var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate() + vertices.remove_at(owner_record.index) + _commit_manual_vertices(owner_node, vertices, "Remove connection vertex") + status_label.text = "Vertex removed." + +func _get_vertex_attachment_node(root: ModularBuilderPiece) -> Node3D: + var selected := get_editor_interface().get_selection().get_selected_nodes() + if selected.size() == 1: + var selected_piece := selected[0] as ModularBuilderPiece + if selected_piece != null and (selected_piece == root or _is_descendant_of_piece(selected_piece, root)): + return selected_piece + if selected[0] is ModularBuilderDetail and _is_descendant_of_piece(selected[0], root): + return selected[0] as ModularBuilderDetail + return root + +func _find_manual_vertex_record(root: ModularBuilderPiece, world_position: Vector3) -> Dictionary: + var owners: Array[Node3D] = [] + _collect_manual_vertex_owners(root, owners) + var nearest_record := {} + var nearest_distance := 0.05 + for owner_node in owners: + var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []) + for i in range(vertices.size()): + var local_point: Vector3 = vertices[i] + var distance := owner_node.to_global(local_point).distance_to(world_position) + if distance < nearest_distance: + nearest_distance = distance + nearest_record = {"node": owner_node, "index": i} + return nearest_record diff --git a/addons/modular_builder/modular_builder_editor_vertices.gd.uid b/addons/modular_builder/modular_builder_editor_vertices.gd.uid new file mode 100644 index 0000000..891b5ab --- /dev/null +++ b/addons/modular_builder/modular_builder_editor_vertices.gd.uid @@ -0,0 +1 @@ +uid://ghgythev6vkt diff --git a/addons/modular_builder/modular_builder_plugin.gd b/addons/modular_builder/modular_builder_plugin.gd new file mode 100644 index 0000000..415e41c --- /dev/null +++ b/addons/modular_builder/modular_builder_plugin.gd @@ -0,0 +1,637 @@ +@tool +extends "res://addons/modular_builder/modular_builder_editor_vertices.gd" + +const ROOF_SUPPORT = preload("res://addons/modular_builder/modular_roof_support.gd") + +func _forward_3d_gui_input(camera: Camera3D, event: InputEvent) -> int: + if active_tool != TOOL_NONE and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + pending_points.clear() + _editor_sync_elapsed = 1.0 + status_label.text = "Current points cleared." + update_overlays() + return EditorPlugin.AFTER_GUI_INPUT_STOP + if _is_detail_tool(active_tool) and event is InputEventMouseMotion: + _preview_camera = camera + _preview_screen_position = event.position + _update_detail_preview() + return EditorPlugin.AFTER_GUI_INPUT_PASS + if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT): + return EditorPlugin.AFTER_GUI_INPUT_PASS + _editor_sync_elapsed = 1.0 + var root := _get_selected_piece() + if root == null: + return EditorPlugin.AFTER_GUI_INPUT_PASS + if active_tool == TOOL_FOUNDATION: + var side := _nearest_side_button(camera, event.position) + if side >= 0: + _add_foundation_at_side(root, side) + return EditorPlugin.AFTER_GUI_INPUT_STOP + return EditorPlugin.AFTER_GUI_INPUT_PASS + if active_tool == TOOL_NONE: + return EditorPlugin.AFTER_GUI_INPUT_PASS + if active_tool == TOOL_CREATE_VERTEX: + var create_position := _raycast_piece_mesh(root, camera, event.position) + if create_position == null: + status_label.text = "Click a structure surface to create a connection vertex." + return EditorPlugin.AFTER_GUI_INPUT_STOP + _create_manual_vertex(root, create_position) + return EditorPlugin.AFTER_GUI_INPUT_STOP + if active_tool == TOOL_MOVE_VERTEX: + if pending_points.is_empty(): + var selected_vertex := _nearest_vertex(camera, event.position, _get_manual_vertices(root)) + if selected_vertex == null: + status_label.text = "Click a blue manual vertex to select it." + return EditorPlugin.AFTER_GUI_INPUT_STOP + pending_points.append(selected_vertex) + status_label.text = "Vertex selected. Click the surface where you want to move it." + else: + var move_position := _raycast_piece_mesh(root, camera, event.position) + if move_position == null: + status_label.text = "Click a structure surface to move the vertex." + return EditorPlugin.AFTER_GUI_INPUT_STOP + _move_manual_vertex(root, pending_points[0], move_position) + return EditorPlugin.AFTER_GUI_INPUT_STOP + if active_tool == TOOL_REMOVE_VERTEX: + var remove_position := _nearest_vertex(camera, event.position, _get_manual_vertices(root)) + if remove_position == null: + status_label.text = "Click a blue manual vertex to remove it." + else: + _remove_manual_vertex(root, remove_position) + return EditorPlugin.AFTER_GUI_INPUT_STOP + if active_tool == TOOL_PILLAR: + var pillar_vertex := _nearest_vertex(camera, event.position, _get_tool_vertices(root)) + if pillar_vertex == null: + return EditorPlugin.AFTER_GUI_INPUT_PASS + if event.alt_pressed: + _create_vertical_pillar_from_vertex(root, pillar_vertex) + return EditorPlugin.AFTER_GUI_INPUT_STOP + for previous_point in pending_points: + if previous_point.distance_to(pillar_vertex) < 0.02: + status_label.text = "Choose a different second vertex." + return EditorPlugin.AFTER_GUI_INPUT_STOP + pending_points.append(pillar_vertex) + if pending_points.size() >= 2: + _create_pillar_from_vertices(root, pending_points[0], pending_points[1]) + else: + status_label.text = "First vertex marked. Click another vertex to place the pillar between them." + update_overlays() + return EditorPlugin.AFTER_GUI_INPUT_STOP + if active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET: + var fence_vertex := _nearest_vertex(camera, event.position, _get_tool_vertices(root)) + if fence_vertex == null: + return EditorPlugin.AFTER_GUI_INPUT_PASS + for previous_point in pending_points: + if previous_point.distance_to(fence_vertex) < 0.02: + status_label.text = "Choose a different second vertex for the path." + return EditorPlugin.AFTER_GUI_INPUT_STOP + pending_points.append(fence_vertex) + if pending_points.size() >= 2: + _create_fence_decoration(root, pending_points[0], pending_points[1]) + else: + status_label.text = "Start marked. Click the final fence vertex." + update_overlays() + return EditorPlugin.AFTER_GUI_INPUT_STOP + if _is_detail_tool(active_tool): + if _create_detail_from_click(camera, event.position): + return EditorPlugin.AFTER_GUI_INPUT_STOP + status_label.text = "Click a surface compatible with the selected tool." + return EditorPlugin.AFTER_GUI_INPUT_PASS + var point := _nearest_vertex(camera, event.position, _get_tool_vertices(root)) + if point == null: + return EditorPlugin.AFTER_GUI_INPUT_PASS + for previous_point in pending_points: + if previous_point.distance_to(point) < 0.02: + status_label.text = "Choose a different vertex." + return EditorPlugin.AFTER_GUI_INPUT_STOP + if active_tool == TOOL_FLOOR_SQUARE and pending_points.size() == 1: + var first_local := root.to_local(pending_points[0]) + var second_local := root.to_local(point) + if absf(first_local.x - second_local.x) < 0.02 or absf(first_local.z - second_local.z) < 0.02: + status_label.text = "To complete a square floor, choose the opposite diagonal vertex." + return EditorPlugin.AFTER_GUI_INPUT_STOP + pending_points.append(point) + var needed := _required_clicks() + if pending_points.size() >= needed: + _finish_shape(root) + elif active_tool == TOOL_ROOF_SHED: + status_label.text = "Low point marked. Click the high point on the other side of the roof." + elif active_tool == TOOL_MERGE_VERTICES: + status_label.text = "First vertex marked. Click the second to merge the markers." + else: + status_label.text = "Point %d of %d marked. Click the next vertex." % [pending_points.size(), needed] + update_overlays() + return EditorPlugin.AFTER_GUI_INPUT_STOP + +func _forward_3d_draw_over_viewport(overlay: Control) -> void: + pass + +func _required_clicks() -> int: + if active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_MERGE_VERTICES or active_tool == TOOL_FLOOR_SQUARE or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID or active_tool == TOOL_STAIRS or active_tool == TOOL_FENCE: + return 2 + return 3 + +func _finish_shape(root: ModularBuilderPiece) -> void: + if active_tool == TOOL_MERGE_VERTICES: + _finish_merge_vertices(root) + return + if active_tool == TOOL_WALL: + _finish_wall_segments(root) + return + if active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT: + _finish_gable_wall(root) + return + if active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID: + _finish_roof(root) + return + if active_tool == TOOL_STAIRS: + _finish_stairs(root) + return + if active_tool == TOOL_FENCE: + _finish_fence(root) + return + var local_points := PackedVector3Array() + for world_point in pending_points: + local_points.append(root.to_local(world_point)) + var piece := Node3D.new() + piece.set_script(PIECE_SCRIPT) + piece.set("centered_origin", true) + piece.name = "FloorTriangle" if active_tool == TOOL_FLOOR_TRIANGLE else "FloorSquare" + piece.set("piece_type", ModularBuilderPiece.PieceType.FLOOR) + piece.set("thickness", root.thickness) + if active_tool == TOOL_FLOOR_SQUARE: + var a := local_points[0] + var b := local_points[1] + var left := minf(a.x, b.x) + var right := maxf(a.x, b.x) + var near_z := minf(a.z, b.z) + var far_z := maxf(a.z, b.z) + var y := (a.y + b.y) * 0.5 + local_points = PackedVector3Array([ + Vector3(left, y, near_z), Vector3(right, y, near_z), + Vector3(right, y, far_z), Vector3(left, y, far_z) + ]) + var floor_pivot := _packed_points_bounds_center(local_points) + floor_pivot.y -= piece.thickness * 0.5 + piece.position = floor_pivot + for i in range(local_points.size()): + local_points[i] -= floor_pivot + piece.set("custom_points", local_points) + var undo := get_undo_redo() + undo.create_action("Create %s from vertices" % piece.name) + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + pending_points.clear() + status_label.text = "%s created inside ModularBuilderPiece. The tool remains active." % piece.name + update_overlays() + +func _finish_wall_segments(root: ModularBuilderPiece) -> void: + var start_world := pending_points[0] + var finish_world := pending_points[1] + var path := finish_world - start_world + var path_length_squared := path.length_squared() + if path_length_squared < 0.0001: + pending_points.clear() + status_label.text = "Wall points are too close together." + update_overlays() + return + var parameters: Array[float] = [0.0, 1.0] + for vertex in _get_tool_vertices(root): + var t := (vertex - start_world).dot(path) / path_length_squared + if t <= 0.001 or t >= 0.999: + continue + var projected := start_world + path * t + if vertex.distance_to(projected) > 0.02: + continue + var already_added := false + for existing_t in parameters: + if absf(existing_t - t) < 0.005: + already_added = true + break + if not already_added: + parameters.append(t) + parameters.sort() + var pieces: Array[ModularBuilderPiece] = [] + var centralize_wall := _gable_centralize_check.button_pressed + for i in range(parameters.size() - 1): + var a_world := start_world.lerp(finish_world, parameters[i]) + var b_world := start_world.lerp(finish_world, parameters[i + 1]) + pieces.append(_make_wall_segment(root, a_world, b_world, i + 1, centralize_wall)) + var scene_root := get_editor_interface().get_edited_scene_root() + var undo := get_undo_redo() + undo.create_action("Create wall in %d segments" % pieces.size()) + for piece in pieces: + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", scene_root) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = pieces[-1] + pending_points.clear() + status_label.text = "Wall split into %d segments at foundation vertices." % pieces.size() + update_overlays() + +func _finish_gable_wall(root: ModularBuilderPiece) -> void: + var start := root.to_local(pending_points[0]) + var finish := root.to_local(pending_points[1]) + if Vector2(start.x - finish.x, start.z - finish.z).length() < 0.02 or absf(start.y - finish.y) > 0.05: + pending_points.clear() + status_label.text = "Choose two separate vertices at the same height for the gable base." + update_overlays() + return + var piece_node := Node3D.new() + piece_node.set_script(PIECE_SCRIPT) + var piece := piece_node as ModularBuilderPiece + var is_right_triangle := active_tool == TOOL_GABLE_WALL_RIGHT + piece.name = "GableWallRight" if is_right_triangle else "GableWall" + piece.piece_type = ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT if is_right_triangle else ModularBuilderPiece.PieceType.GABLE_WALL + piece.unit_size = root.unit_size + piece.thickness = root.thickness + piece.centered_origin = true + piece.center_align = _gable_centralize_check.button_pressed + var pivot := (start + finish) * 0.5 + Vector3.UP * rise_spin.value * 0.5 + piece.position = pivot + piece.rise = rise_spin.value + piece.custom_points = PackedVector3Array([start - pivot, finish - pivot]) + var undo := get_undo_redo() + undo.create_action("Create gable wall") + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + pending_points.clear() + status_label.text = "Gable wall created. The tool remains active." + update_overlays() + +func _finish_roof(root: ModularBuilderPiece) -> void: + var first := root.to_local(pending_points[0]) + var second := root.to_local(pending_points[1]) + var is_gable := active_tool == TOOL_ROOF_GABLE + var is_pyramid := active_tool == TOOL_ROOF_PYRAMID + var is_shed := active_tool == TOOL_ROOF_SHED + if (is_gable or is_pyramid) and absf(first.y - second.y) > 0.05: + pending_points.clear() + status_label.text = "For this roof, choose two diagonal points at the same height." + update_overlays() + return + if is_shed and second.y <= first.y + 0.05: + pending_points.clear() + status_label.text = "Choose a low point first, then a higher point on the opposite side." + update_overlays() + return + if is_shed and not _shed_points_are_on_opposite_sides(root, pending_points[0], pending_points[1]): + pending_points.clear() + status_label.text = "Roof points must be on opposite, parallel walls." + update_overlays() + return + var roof_rise := rise_spin.value + var roof_width := 0.0 + var corners := PackedVector3Array() + if is_gable: + var gable_result := _find_roof_between_gables(root, pending_points[0], pending_points[1]) + if not gable_result.is_empty(): + corners = gable_result.corners + roof_rise = gable_result.rise + elif is_pyramid: + corners = _find_roof_corners(root, first, second) + else: + corners = _find_shed_roof_corners(root, first, second) + if corners.size() == 4: + roof_width = corners[0].distance_to(corners[1]) + if is_gable and corners.is_empty(): + corners = ROOF_SUPPORT.find_corners(root, pending_points[0], pending_points[1], _simplify_vertices_check.button_pressed) + if corners.is_empty(): + corners = _find_roof_corners(root, first, second) + if corners.size() != 4: + pending_points.clear() + status_label.text = "Could not find the other roof corners. Choose two diagonal vertices on the top edges of opposite walls." + update_overlays() + return + var piece_node := Node3D.new() + piece_node.set_script(PIECE_SCRIPT) + var piece := piece_node as ModularBuilderPiece + piece.name = "RoofGable" if is_gable else ("RoofPyramid" if is_pyramid else "RoofShed") + piece.piece_type = ModularBuilderPiece.PieceType.ROOF_GABLE if is_gable else (ModularBuilderPiece.PieceType.ROOF_PYRAMID if is_pyramid else ModularBuilderPiece.PieceType.ROOF_SHED) + piece.unit_size = root.unit_size + piece.thickness = 0.05 + piece.centered_origin = true + piece.rise = roof_rise + if is_shed: + piece.roof_width = roof_width + var pivot := Vector3.ZERO + for corner in corners: + pivot += corner + pivot /= corners.size() + if is_gable or is_pyramid: + pivot += Vector3.UP * roof_rise * 0.5 + piece.position = pivot + if is_gable or is_pyramid: + var local_corners := PackedVector3Array() + for corner in corners: + var local_corner: Vector3 = corner - pivot + local_corner.y = -roof_rise * 0.5 + local_corners.append(local_corner) + piece.custom_points = local_corners + else: + var local_corners := PackedVector3Array() + for corner in corners: + local_corners.append(corner - pivot) + piece.custom_points = local_corners + var undo := get_undo_redo() + var roof_title := "gable roof" if is_gable else ("pyramid roof" if is_pyramid else "shed roof") + undo.create_action("Create %s" % roof_title) + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + pending_points.clear() + status_label.text = "%s created. The tool remains active." % piece.name + update_overlays() + +func _finish_stairs(root: ModularBuilderPiece) -> void: + if pending_points.size() < 2: + return + var low := root.to_local(pending_points[0]) + var high := root.to_local(pending_points[1]) + if high.y <= low.y + 0.05: + pending_points.clear() + status_label.text = "Choose the low point first, then a point at least 5 cm higher." + update_overlays() + return + if Vector2(high.x - low.x, high.z - low.z).length() < 0.1: + pending_points.clear() + status_label.text = "Stair points need horizontal separation." + update_overlays() + return + var piece_node := Node3D.new() + piece_node.set_script(PIECE_SCRIPT) + var piece := piece_node as ModularBuilderPiece + var pivot := (low + high) * 0.5 + piece.name = "Stairs" + piece.piece_type = ModularBuilderPiece.PieceType.STAIRS + piece.position = pivot + piece.unit_size = root.unit_size + piece.thickness = 0.1 + piece.centered_origin = true + piece.stair_count = int(stair_count_spin.value) + piece.stair_width = stair_width_spin.value + piece.custom_points = PackedVector3Array([low - pivot, high - pivot]) + var undo := get_undo_redo() + undo.create_action("Create stairs") + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + pending_points.clear() + status_label.text = "Stairs created with %d steps. The tool remains active." % piece.stair_count + update_overlays() + +func _finish_fence(root: ModularBuilderPiece) -> void: + if pending_points.size() < 2: + return + var start := root.to_local(pending_points[0]) + var finish := root.to_local(pending_points[1]) + if start.distance_to(finish) < 0.1: + pending_points.clear() + status_label.text = "Choose two points far enough apart for the fence." + update_overlays() + return + var piece_node := Node3D.new() + piece_node.set_script(PIECE_SCRIPT) + var piece := piece_node as ModularBuilderPiece + var pivot := (start + finish) * 0.5 + piece.name = "Fence" + piece.piece_type = ModularBuilderPiece.PieceType.FENCE + piece.position = pivot + piece.unit_size = root.unit_size + piece.thickness = 0.08 + piece.centered_origin = true + piece.fence_divisions = int(fence_divisions_spin.value) + piece.fence_height = 1.0 + piece.custom_points = PackedVector3Array([start - pivot, finish - pivot]) + var undo := get_undo_redo() + undo.create_action("Create fence") + undo.add_do_method(root, "add_child", piece, true) + undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root()) + undo.add_undo_method(root, "remove_child", piece) + undo.commit_action() + _last_created_piece = piece + pending_points.clear() + status_label.text = "Fence created with %d intermediate post(s). The tool remains active." % piece.fence_divisions + update_overlays() + +func _find_roof_corners(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> PackedVector3Array: + var diagonal_center := (first + second) * 0.5 + var diagonal_length := first.distance_to(second) + var candidates: Array[Vector3] = [] + for world_point in _get_tool_vertices(root): + var local_point := root.to_local(world_point) + if absf(local_point.y - first.y) > 0.04: + continue + _add_unique_vertex(candidates, local_point) + for adjacent in candidates: + if adjacent.distance_to(first) < 0.02 or adjacent.distance_to(second) < 0.02: + continue + var opposite := diagonal_center * 2.0 - adjacent + for other_adjacent in candidates: + if other_adjacent.distance_to(first) < 0.02 or other_adjacent.distance_to(second) < 0.02 or other_adjacent.distance_to(adjacent) < 0.02: + continue + if other_adjacent.distance_to(opposite) > 0.04: + continue + if absf(adjacent.distance_to(other_adjacent) - diagonal_length) > 0.06: + continue + return PackedVector3Array([first, adjacent, second, other_adjacent]) + return PackedVector3Array() + +func _find_shed_roof_corners(root: ModularBuilderPiece, low: Vector3, high: Vector3) -> PackedVector3Array: + var candidates: Array[Vector3] = [] + for world_point in _get_tool_vertices(root): + _add_unique_vertex(candidates, root.to_local(world_point)) + var match_tolerance := maxf(0.08, root.thickness * 1.5) + var best := PackedVector3Array() + var best_score := INF + for low_adjacent in candidates: + if absf(low_adjacent.y - low.y) > 0.04 or low_adjacent.distance_to(low) < 0.4: + continue + var target_high_adjacent := low + high - low_adjacent + for high_adjacent in candidates: + if absf(high_adjacent.y - high.y) > 0.04 or high_adjacent.distance_to(high) < 0.4: + continue + var corner_error := high_adjacent.distance_to(target_high_adjacent) + if corner_error > match_tolerance: + continue + var low_edge := Vector2(low_adjacent.x - low.x, low_adjacent.z - low.z) + var high_edge := Vector2(high.x - high_adjacent.x, high.z - high_adjacent.z) + if low_edge.length() < 0.4 or high_edge.length() < 0.4: + continue + if absf(low_edge.normalized().dot(high_edge.normalized())) < 0.94: + continue + var edge_error := absf(low_edge.length() - high_edge.length()) + if edge_error > match_tolerance * 2.0: + continue + var score := corner_error + edge_error + if score < best_score: + best_score = score + best = PackedVector3Array([low, low_adjacent, high_adjacent, high]) + return best + +func _shed_points_are_on_opposite_sides(root: ModularBuilderPiece, low_world: Vector3, high_world: Vector3) -> bool: + var low_supports := _get_roof_supports_at_point(root, low_world) + var high_supports := _get_roof_supports_at_point(root, high_world) + var across := root.to_local(high_world) - root.to_local(low_world) + var across_horizontal := Vector2(across.x, across.z) + if across_horizontal.length() < 0.1: + return false + across_horizontal = across_horizontal.normalized() + for low_support in low_supports: + for high_support in high_supports: + if low_support.piece == high_support.piece: + continue + var low_direction: Vector2 = low_support.direction + var high_direction: Vector2 = high_support.direction + if absf(low_direction.dot(high_direction)) < 0.85: + continue + var side_axis := Vector2(-low_direction.y, low_direction.x) + var center_delta: Vector3 = high_support.center - low_support.center + var side_separation := absf(Vector2(center_delta.x, center_delta.z).dot(side_axis)) + if side_separation >= 0.4: + return true + return false + +func _get_roof_supports_at_point(root: ModularBuilderPiece, world_point: Vector3) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var walls: Array[Node] = [] + _collect_walls(root, walls) + for node in walls: + var wall := node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var world_a := wall.to_global(endpoints[0]) + var world_b := wall.to_global(endpoints[1]) + var local_direction := root.to_local(world_b) - root.to_local(world_a) + var direction := Vector2(local_direction.x, local_direction.z).normalized() + var wall_local_direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-wall_local_direction.z, 0.0, wall_local_direction.x).normalized() * wall.thickness * 0.5 + for endpoint in endpoints: + for height in [0.0, wall.unit_size]: + var center := wall.to_global(endpoint + Vector3.UP * height) + var support_points := [center + side, center - side] + if _simplify_vertices_check.button_pressed: + support_points.append(center) + for point in support_points: + if point.distance_to(world_point) <= 0.04: + result.append({"piece": wall, "direction": direction, "center": root.to_local(center)}) + var gables: Array[Node] = [] + _collect_gable_walls(root, gables) + for node in gables: + var gable := node as ModularBuilderPiece + if gable.custom_points.size() < 2: + continue + var base: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points + var a: Vector3 = base[0] + var b: Vector3 = base[1] + var local_direction := b - a + var direction := Vector2(local_direction.x, local_direction.z).normalized() + var side := Vector3(-local_direction.z, 0.0, local_direction.x).normalized() * gable.thickness * 0.5 + var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise + var support_points := [a + side, a - side, b + side, b - side, peak + side, peak - side] + if _simplify_vertices_check.button_pressed: + support_points.append_array([a, b, peak]) + for point_local in support_points: + var point := gable.to_global(point_local) + if point.distance_to(world_point) <= 0.04: + result.append({"piece": gable, "direction": direction, "center": root.to_local(gable.to_global((a + b) * 0.5))}) + if result.is_empty() and root.has_meta(MERGED_VERTEX_GROUPS_META): + for group_value in root.get_meta(MERGED_VERTEX_GROUPS_META, []): + if not group_value is Dictionary: + continue + var group: Dictionary = group_value + var anchor: Vector3 = group.get("position", Vector3.ZERO) + if root.to_global(anchor).distance_to(world_point) > 0.04: + continue + var members: PackedVector3Array = group.get("members", PackedVector3Array()) + for member in members: + result.append_array(_get_roof_supports_at_point(root, root.to_global(member))) + return result + +func _find_roof_between_gables(root: ModularBuilderPiece, first_world: Vector3, second_world: Vector3) -> Dictionary: + var gables: Array[Node] = [] + _collect_gable_walls(root, gables) + var first_gable: ModularBuilderPiece + var second_gable: ModularBuilderPiece + var first_base := PackedVector3Array() + var second_base := PackedVector3Array() + for node in gables: + var gable := node as ModularBuilderPiece + if gable.custom_points.size() < 2: + continue + var base: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points + var a: Vector3 = base[0] + var b: Vector3 = base[1] + var direction := (b - a).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * gable.thickness * 0.5 + var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise + var peak_a := gable.to_global(peak + side) + var peak_b := gable.to_global(peak - side) + var peak_center := gable.to_global(peak) + if first_gable == null and (peak_a.distance_to(first_world) < 0.12 or peak_b.distance_to(first_world) < 0.12 or (_simplify_vertices_check.button_pressed and peak_center.distance_to(first_world) < 0.12)): + first_gable = gable + first_base = base + elif second_gable == null and gable != first_gable and (peak_a.distance_to(second_world) < 0.12 or peak_b.distance_to(second_world) < 0.12 or (_simplify_vertices_check.button_pressed and peak_center.distance_to(second_world) < 0.12)): + second_gable = gable + second_base = base + if first_gable == null or second_gable == null: + return {} + var a0 := root.to_local(first_gable.to_global(first_base[0])) + var a1 := root.to_local(first_gable.to_global(first_base[1])) + var b0 := root.to_local(second_gable.to_global(second_base[0])) + var b1 := root.to_local(second_gable.to_global(second_base[1])) + var direct_score := a0.distance_to(b0) + a1.distance_to(b1) + var crossed_score := a0.distance_to(b1) + a1.distance_to(b0) + var corners := PackedVector3Array([a0, a1, b1, b0]) if direct_score <= crossed_score else PackedVector3Array([a0, a1, b0, b1]) + return {"corners": corners, "rise": (first_gable.rise + second_gable.rise) * 0.5} + +func _make_wall_segment(root: ModularBuilderPiece, start_world: Vector3, finish_world: Vector3, index: int, centralize_wall: bool) -> ModularBuilderPiece: + var centered_start := _center_wall_anchor(root, start_world) if centralize_wall else start_world + var centered_finish := _center_wall_anchor(root, finish_world) if centralize_wall else finish_world + var start := root.to_local(centered_start) + var finish := root.to_local(centered_finish) + var piece_node := Node3D.new() + piece_node.set_script(PIECE_SCRIPT) + var piece := piece_node as ModularBuilderPiece + piece.name = "Wall_%02d" % index + piece.set("piece_type", ModularBuilderPiece.PieceType.WALL) + piece.set("unit_size", root.unit_size) + piece.set("thickness", root.thickness) + piece.set("centered_origin", true) + piece.set("center_align", centralize_wall) + var pivot := (start + finish) * 0.5 + Vector3.UP * root.unit_size * 0.5 + piece.position = pivot + piece.set("custom_points", PackedVector3Array([start - pivot, finish - pivot])) + return piece + +func _center_wall_anchor(root: ModularBuilderPiece, world_point: Vector3) -> Vector3: + var walls: Array[Node] = [] + _collect_walls(root, walls) + var best_point := world_point + var best_distance := INF + for wall_node in walls: + var wall := wall_node as ModularBuilderPiece + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := (endpoints[1] - endpoints[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5 + for endpoint in endpoints: + var center := wall.to_global(endpoint + Vector3.UP * wall.unit_size) + for surface_corner in [center + side, center - side]: + var distance := world_point.distance_to(surface_corner) + if distance < best_distance and distance <= wall.thickness + 0.04: + best_distance = distance + best_point = center + return best_point diff --git a/addons/modular_builder/modular_builder_plugin.gd.uid b/addons/modular_builder/modular_builder_plugin.gd.uid new file mode 100644 index 0000000..3d32382 --- /dev/null +++ b/addons/modular_builder/modular_builder_plugin.gd.uid @@ -0,0 +1 @@ +uid://b065m6e7ey5n0 diff --git a/addons/modular_builder/modular_building_detail.gd b/addons/modular_builder/modular_building_detail.gd new file mode 100644 index 0000000..afe295d --- /dev/null +++ b/addons/modular_builder/modular_building_detail.gd @@ -0,0 +1,284 @@ +@tool +class_name ModularBuilderDetail +extends Node3D + +enum DetailType { WINDOW, DOOR, HATCH, PILLAR, FENCE, PARAPET } +enum DetailMode { OPENING, SEPARATE_OBJECT } +enum WindowStyle { SINGLE_PANE, CROSS_PANE } +enum DoorStyle { FLAT, PANELED } + +@export var detail_type: DetailType = DetailType.WINDOW: + set(value): + detail_type = value + notify_property_list_changed() + _rebuild() +@export var detail_mode: DetailMode = DetailMode.OPENING: + set(value): + detail_mode = value + notify_property_list_changed() + _rebuild() +@export var center_align: bool = false: + set(value): + center_align = value + _apply_pillar_alignment() + notify_property_list_changed() +@export_storage var pillar_direction: Vector3 = Vector3.UP +@export_storage var pillar_alignment_basis: Basis = Basis.IDENTITY +@export var window_style: WindowStyle = WindowStyle.SINGLE_PANE: + set(value): + window_style = value + _rebuild() +@export var door_style: DoorStyle = DoorStyle.FLAT: + set(value): + door_style = value + _rebuild() +@export_range(0.01, 5.0, 0.001, "suffix:m") var width: float = 1.0: + set(value): + width = value + _rebuild() +@export_range(0.2, 5.0, 0.001, "suffix:m") var height: float = 1.0: + set(value): + height = value + _rebuild() +@export_range(0.02, 0.5, 0.001, "suffix:m") var frame_depth: float = 0.08: + set(value): + frame_depth = value + _rebuild() +@export_range(0.1, 8.0, 0.001, "suffix:m") var fence_height: float = 0.7: + set(value): + fence_height = value + _rebuild() +@export_range(0, 100, 1, "suffix: posts") var fence_divisions: int = 1: + set(value): + fence_divisions = value + _rebuild() +@export_storage var path_points: PackedVector3Array = PackedVector3Array() +@export_range(0.01, 0.5, 0.001, "suffix:m") var frame_width: float = 0.08: + set(value): + frame_width = value + _rebuild() +@export var follow_host_thickness: bool = true: + set(value): + follow_host_thickness = value + _rebuild() +@export var surface_material: Material: + set(value): + surface_material = value + _rebuild() +@export var frame_material: Material: + set(value): + frame_material = value + _rebuild() +@export var panel_material: Material: + set(value): + panel_material = value + _rebuild() + +func _validate_property(property: Dictionary) -> void: + var separate_object := detail_mode == DetailMode.SEPARATE_OBJECT + var is_window := detail_type == DetailType.WINDOW + var is_door := detail_type == DetailType.DOOR + var has_frame_and_panel := separate_object and detail_type != DetailType.PILLAR and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET + var show_property := true + match StringName(property.name): + &"detail_mode": + show_property = detail_type != DetailType.PILLAR and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET + &"center_align": + show_property = detail_type == DetailType.PILLAR + &"fence_height", &"fence_divisions": + show_property = detail_type == DetailType.FENCE or detail_type == DetailType.PARAPET + &"path_points": + show_property = false + &"width": + show_property = detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET + if detail_type == DetailType.PILLAR: + property.hint = PROPERTY_HINT_RANGE + property.hint_string = "0.01,5.0,0.001,suffix:m" + &"height": + show_property = detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET + &"window_style": + show_property = is_window and separate_object + &"door_style": + show_property = is_door and separate_object + &"frame_width", &"frame_material", &"panel_material": + show_property = has_frame_and_panel + &"follow_host_thickness": + show_property = has_frame_and_panel + &"frame_depth": + show_property = has_frame_and_panel and not follow_host_thickness + &"surface_material": + show_property = separate_object + if not show_property: + property.usage = PROPERTY_USAGE_NO_EDITOR + +func _ready() -> void: + set_notify_transform(true) + _rebuild() + +func _notification(what: int) -> void: + if what == NOTIFICATION_TRANSFORM_CHANGED and is_inside_tree(): + var host := get_parent() + if host != null and host.has_method("_rebuild"): + host.call("_rebuild") + +func _on_host_thickness_changed() -> void: + _rebuild(false) + +func _apply_pillar_alignment() -> void: + if detail_type != DetailType.PILLAR: + return + var direction := pillar_direction.normalized() + if direction.is_zero_approx(): + direction = Vector3.UP + if center_align: + var object_axes := pillar_alignment_basis.orthonormalized() + var best_axis := direction + var best_dot := -1.0 + for axis in [object_axes.x, object_axes.y, object_axes.z]: + var normalized_axis: Vector3 = axis.normalized() + var alignment := absf(direction.dot(normalized_axis)) + if alignment > best_dot: + best_dot = alignment + best_axis = normalized_axis if direction.dot(normalized_axis) >= 0.0 else -normalized_axis + direction = best_axis + var reference_axis := Vector3.FORWARD if absf(direction.dot(Vector3.UP)) > 0.99 else Vector3.UP + var x_axis := reference_axis.cross(direction).normalized() + var z_axis := x_axis.cross(direction).normalized() + transform.basis = Basis(x_axis, direction, z_axis).orthonormalized() + if center_align and pillar_direction.length() > 0.0: + height = pillar_direction.length() + +func _rebuild(notify_host: bool = true) -> void: + if not is_inside_tree(): + return + for child in get_children(): + if child.name == "DetailGeometry": + remove_child(child) + child.queue_free() + var host := get_parent() + if notify_host and host != null and host.has_method("_rebuild"): + host.call("_rebuild") + if detail_mode == DetailMode.OPENING and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET: + return + var holder := Node3D.new() + holder.name = "DetailGeometry" + add_child(holder) + var scene_root: Node = null + if Engine.is_editor_hint() and not has_meta("modular_builder_preview"): + scene_root = get_tree().edited_scene_root + if scene_root: + holder.owner = scene_root + var effective_depth := _effective_frame_depth() + var frame := minf(maxf(frame_width, 0.01), minf(width, height) * 0.45) + if detail_type == DetailType.FENCE or detail_type == DetailType.PARAPET: + _build_fence_geometry(holder) + return + if detail_type == DetailType.PILLAR: + _add_box(holder, "PillarBody", Vector3(width, height, width), Vector3.ZERO, surface_material) + return + if detail_type == DetailType.HATCH: + _add_box(holder, "LeftFrame", Vector3(frame, effective_depth, height), Vector3(-width * 0.5 + frame * 0.5, 0.0, 0.0), _frame_material()) + _add_box(holder, "RightFrame", Vector3(frame, effective_depth, height), Vector3(width * 0.5 - frame * 0.5, 0.0, 0.0), _frame_material()) + _add_box(holder, "NearFrame", Vector3(width, effective_depth, frame), Vector3(0.0, 0.0, -height * 0.5 + frame * 0.5), _frame_material()) + _add_box(holder, "FarFrame", Vector3(width, effective_depth, frame), Vector3(0.0, 0.0, height * 0.5 - frame * 0.5), _frame_material()) + _add_box(holder, "HatchPanel", Vector3(width - frame * 2.0, effective_depth * 0.35, height - frame * 2.0), Vector3(0.0, effective_depth * 0.4, 0.0), _panel_material()) + return + _add_box(holder, "LeftFrame", Vector3(frame, height, effective_depth), Vector3(-width * 0.5 + frame * 0.5, 0.0, 0.0), _frame_material()) + _add_box(holder, "RightFrame", Vector3(frame, height, effective_depth), Vector3(width * 0.5 - frame * 0.5, 0.0, 0.0), _frame_material()) + _add_box(holder, "TopFrame", Vector3(width, frame, effective_depth), Vector3(0.0, height * 0.5 - frame * 0.5, 0.0), _frame_material()) + if detail_type != DetailType.DOOR: + _add_box(holder, "BottomFrame", Vector3(width, frame, effective_depth), Vector3(0.0, -height * 0.5 + frame * 0.5, 0.0), _frame_material()) + if detail_type == DetailType.WINDOW: + _add_box(holder, "WindowPanel", Vector3(maxf(width - frame * 2.0, 0.001), maxf(height - frame * 2.0, 0.001), effective_depth * 0.35), Vector3.ZERO, _panel_material()) + if window_style == WindowStyle.CROSS_PANE: + _add_box(holder, "VerticalMullion", Vector3(frame * 0.55, height - frame * 2.0, effective_depth * 0.4), Vector3.ZERO, _frame_material()) + _add_box(holder, "HorizontalMullion", Vector3(width - frame * 2.0, frame * 0.55, effective_depth * 0.4), Vector3.ZERO, _frame_material()) + elif detail_type == DetailType.DOOR: + _add_box(holder, "DoorPanel", Vector3(width - frame * 2.0, height - frame, effective_depth * 0.45), Vector3(0.0, -frame * 0.5, 0.0), _panel_material()) + if door_style == DoorStyle.PANELED: + var inset_depth := maxf(effective_depth * 0.04, 0.005) + var panel_width := (width - frame * 2.0) * 0.82 + var panel_height := maxf((height - frame) * 0.28, 0.05) + for panel_index in range(2): + var panel_y := -frame * 0.5 + (panel_index - 0.5) * (panel_height + frame * 0.25) + _add_box(holder, "DoorInset_%d" % panel_index, Vector3(panel_width, panel_height, inset_depth), Vector3(0.0, panel_y, effective_depth * 0.24), _frame_material()) + +func _effective_frame_depth() -> float: + if follow_host_thickness: + var host := get_parent() + if host != null: + var host_thickness = host.get("thickness") + if host_thickness is float or host_thickness is int: + var depth := float(host_thickness) + if detail_type == DetailType.WINDOW or detail_type == DetailType.DOOR: + depth *= 1.25 + return maxf(depth, 0.02) + return frame_depth + +func _build_fence_geometry(holder: Node3D) -> void: + if path_points.size() < 2 or fence_height <= 0.0: + return + var start := path_points[0] + var finish := path_points[1] + var path := finish - start + var path_length := path.length() + if path_length < 0.05: + return + var path_axis := path / path_length + var up_axis := Vector3.UP - path_axis * path_axis.dot(Vector3.UP) + if up_axis.length_squared() < 0.001: + up_axis = Vector3.FORWARD - path_axis * path_axis.dot(Vector3.FORWARD) + up_axis = up_axis.normalized() + var side_axis := path_axis.cross(up_axis).normalized() + var post_width := 0.16 if detail_type == DetailType.PARAPET else 0.08 + var post_count := fence_divisions + 2 + var posts: Array[Vector3] = [] + for i in range(post_count): + var base := start.lerp(finish, float(i) / float(post_count - 1)) + posts.append(base) + _add_oriented_box(holder, "Post_%d" % i, base + up_axis * fence_height * 0.5, path_axis, up_axis, side_axis, Vector3(post_width, fence_height, post_width)) + for i in range(posts.size() - 1): + var a := posts[i] + var b := posts[i + 1] + var segment := b - a + var segment_length := segment.length() + if detail_type == DetailType.PARAPET: + _add_oriented_box(holder, "Rail_%d" % i, (a + b) * 0.5 + up_axis * fence_height * 0.78, segment.normalized(), up_axis, side_axis, Vector3(segment_length, post_width * 0.8, post_width * 0.8)) + else: + for height_ratio in [0.28, 0.78]: + _add_oriented_box(holder, "Rail_%d_%d" % [i, int(height_ratio * 100.0)], (a + b) * 0.5 + up_axis * fence_height * height_ratio, segment.normalized(), up_axis, side_axis, Vector3(segment_length, post_width, post_width)) + +func _add_oriented_box(holder: Node3D, node_name: String, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3, dimensions: Vector3) -> void: + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + var box := BoxMesh.new() + box.size = dimensions + mesh_instance.mesh = box + var basis := Basis(axis_x.normalized(), axis_y.normalized(), axis_z.normalized()).orthonormalized() + mesh_instance.transform = Transform3D(basis, center) + mesh_instance.material_override = surface_material + holder.add_child(mesh_instance) + if Engine.is_editor_hint() and not has_meta("modular_builder_preview"): + var scene_root := get_tree().edited_scene_root + if scene_root: + mesh_instance.owner = scene_root + +func _frame_material() -> Material: + return frame_material if frame_material != null else surface_material + +func _panel_material() -> Material: + return panel_material if panel_material != null else surface_material + +func _add_box(holder: Node3D, node_name: String, box_size: Vector3, box_position: Vector3, material: Material = null) -> void: + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + var box := BoxMesh.new() + box.size = box_size + mesh_instance.mesh = box + mesh_instance.position = box_position + mesh_instance.material_override = material if material != null else surface_material + holder.add_child(mesh_instance) + if Engine.is_editor_hint() and not has_meta("modular_builder_preview"): + var scene_root := get_tree().edited_scene_root + if scene_root: + mesh_instance.owner = scene_root diff --git a/addons/modular_builder/modular_building_detail.gd.uid b/addons/modular_builder/modular_building_detail.gd.uid new file mode 100644 index 0000000..5611ebc --- /dev/null +++ b/addons/modular_builder/modular_building_detail.gd.uid @@ -0,0 +1 @@ +uid://dsttda167kuhi diff --git a/addons/modular_builder/modular_mesh_baker.gd b/addons/modular_builder/modular_mesh_baker.gd new file mode 100644 index 0000000..f342347 --- /dev/null +++ b/addons/modular_builder/modular_mesh_baker.gd @@ -0,0 +1,166 @@ +@tool +extends RefCounted + +func bake(building: ModularBuilderPiece) -> ArrayMesh: + var mesh_instances: Array[MeshInstance3D] = [] + _collect_mesh_instances(building, mesh_instances) + var groups: Array[Dictionary] = [] + var projected_materials: Dictionary = {} + for mesh_instance in mesh_instances: + if not is_instance_valid(mesh_instance) or not mesh_instance.is_visible_in_tree() or _is_export_excluded(mesh_instance, building): + continue + var source_mesh := mesh_instance.mesh + if source_mesh == null: + continue + var relative_transform := building.global_transform.affine_inverse() * mesh_instance.global_transform + if absf(relative_transform.basis.determinant()) < 0.000001: + continue + var normal_transform := relative_transform.basis.inverse().transposed() + var mirrored := relative_transform.basis.determinant() < 0.0 + for surface_index in range(source_mesh.get_surface_count()): + var primitive_type := Mesh.PRIMITIVE_TRIANGLES + var surface_arrays: Array + if source_mesh is PrimitiveMesh: + # PrimitiveMesh provides mesh arrays directly; surface_get_* methods + # are used for other meshes. + if surface_index > 0: + continue + surface_arrays = source_mesh.get_mesh_arrays() + else: + primitive_type = source_mesh.surface_get_primitive_type(surface_index) + surface_arrays = source_mesh.surface_get_arrays(surface_index) + if primitive_type != Mesh.PRIMITIVE_TRIANGLES: + continue + if surface_arrays.is_empty() or not surface_arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array: + continue + var source_vertices: PackedVector3Array = surface_arrays[Mesh.ARRAY_VERTEX] + if source_vertices.size() < 3: + continue + var source_normals := PackedVector3Array() + var source_uvs := PackedVector2Array() + var source_colors := PackedColorArray() + if surface_arrays[Mesh.ARRAY_NORMAL] is PackedVector3Array: + source_normals = surface_arrays[Mesh.ARRAY_NORMAL] + if surface_arrays[Mesh.ARRAY_TEX_UV] is PackedVector2Array: + source_uvs = surface_arrays[Mesh.ARRAY_TEX_UV] + if surface_arrays[Mesh.ARRAY_COLOR] is PackedColorArray: + source_colors = surface_arrays[Mesh.ARRAY_COLOR] + var source_indices := PackedInt32Array() + if surface_arrays[Mesh.ARRAY_INDEX] is PackedInt32Array: + source_indices = surface_arrays[Mesh.ARRAY_INDEX] + var material := mesh_instance.get_active_material(surface_index) + var force_projected_uvs := false + var bake_material := material + if material is StandardMaterial3D and material.uv1_triplanar: + force_projected_uvs = true + var material_id := material.get_instance_id() + if not projected_materials.has(material_id): + var material_copy := material.duplicate() as StandardMaterial3D + material_copy.uv1_triplanar = false + material_copy.uv1_world_triplanar = false + projected_materials[material_id] = material_copy + bake_material = projected_materials[material_id] as Material + var group_index := _find_or_create_group(groups, bake_material) + var group: Dictionary = groups[group_index] + var group_vertices: Array = group["vertices"] + var group_normals: Array = group["normals"] + var group_uvs: Array = group["uvs"] + var group_colors: Array = group["colors"] + var has_indices := not source_indices.is_empty() + var triangle_count := source_indices.size() if has_indices else source_vertices.size() + for triangle_start in range(0, triangle_count - 2, 3): + var index_a := source_indices[triangle_start] if has_indices else triangle_start + var index_b := source_indices[triangle_start + 1] if has_indices else triangle_start + 1 + var index_c := source_indices[triangle_start + 2] if has_indices else triangle_start + 2 + if index_a >= source_vertices.size() or index_b >= source_vertices.size() or index_c >= source_vertices.size(): + continue + if mirrored: + var swap_index := index_b + index_b = index_c + index_c = swap_index + var local_a: Vector3 = source_vertices[index_a] + var local_b: Vector3 = source_vertices[index_b] + var local_c: Vector3 = source_vertices[index_c] + var baked_a: Vector3 = relative_transform * local_a + var baked_b: Vector3 = relative_transform * local_b + var baked_c: Vector3 = relative_transform * local_c + var face_normal := (baked_b - baked_a).cross(baked_c - baked_a).normalized() + if face_normal.length_squared() < 0.5: + continue + var baked_points := [baked_a, baked_b, baked_c] + var source_point_indices := [index_a, index_b, index_c] + var source_face_normal := (local_b - local_a).cross(local_c - local_a).normalized() + for corner in range(3): + var source_index: int = source_point_indices[corner] + var baked_point: Vector3 = baked_points[corner] + var baked_normal := face_normal + if source_normals.size() == source_vertices.size(): + baked_normal = (normal_transform * source_normals[source_index]).normalized() + if baked_normal.length_squared() < 0.5: + baked_normal = face_normal + var uv: Vector2 + if not force_projected_uvs and source_uvs.size() == source_vertices.size(): + uv = source_uvs[source_index] + else: + uv = _project_uv(source_vertices[source_index], source_face_normal) + var color := source_colors[source_index] if source_colors.size() == source_vertices.size() else Color.WHITE + group_vertices.append(baked_point) + group_normals.append(baked_normal) + group_uvs.append(uv) + group_colors.append(color) + groups[group_index] = group + if groups.is_empty(): + return null + var combined := ArrayMesh.new() + combined.resource_name = "%s_Mesh" % building.name + for group in groups: + var vertices := PackedVector3Array(group["vertices"]) + if vertices.is_empty(): + continue + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(group["normals"]) + arrays[Mesh.ARRAY_TEX_UV] = PackedVector2Array(group["uvs"]) + arrays[Mesh.ARRAY_COLOR] = PackedColorArray(group["colors"]) + var surface_tool := SurfaceTool.new() + surface_tool.create_from_arrays(arrays, Mesh.PRIMITIVE_TRIANGLES) + if group["material"] is Material: + surface_tool.set_material(group["material"]) + surface_tool.generate_tangents() + var temp_mesh := surface_tool.commit() + if temp_mesh == null or temp_mesh.get_surface_count() == 0: + continue + combined.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, temp_mesh.surface_get_arrays(0)) + if group["material"] is Material: + combined.surface_set_material(combined.get_surface_count() - 1, group["material"]) + return combined if combined.get_surface_count() > 0 else null + +func _find_or_create_group(groups: Array[Dictionary], material: Material) -> int: + for i in range(groups.size()): + if groups[i]["material"] == material: + return i + groups.append({"material": material, "vertices": [], "normals": [], "uvs": [], "colors": []}) + return groups.size() - 1 + +func _project_uv(point: Vector3, normal: Vector3) -> Vector2: + var absolute_normal := normal.abs() + if absolute_normal.y >= absolute_normal.x and absolute_normal.y >= absolute_normal.z: + return Vector2(point.x, point.z) + if absolute_normal.x >= absolute_normal.z: + return Vector2(point.z, point.y) + return Vector2(point.x, point.y) + +func _is_export_excluded(mesh_instance: MeshInstance3D, building: ModularBuilderPiece) -> bool: + var current: Node = mesh_instance + while current != null and current != building: + if current.has_meta("modular_builder_preview") or current.name == "ModularBuilderMarkers": + return true + current = current.get_parent() + return false + +func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void: + if node is MeshInstance3D: + result.append(node) + for child in node.get_children(): + _collect_mesh_instances(child, result) diff --git a/addons/modular_builder/modular_mesh_baker.gd.uid b/addons/modular_builder/modular_mesh_baker.gd.uid new file mode 100644 index 0000000..80bf506 --- /dev/null +++ b/addons/modular_builder/modular_mesh_baker.gd.uid @@ -0,0 +1 @@ +uid://bd52re1amu4r2 diff --git a/addons/modular_builder/modular_mesh_picker.gd b/addons/modular_builder/modular_mesh_picker.gd new file mode 100644 index 0000000..0e2e837 --- /dev/null +++ b/addons/modular_builder/modular_mesh_picker.gd @@ -0,0 +1,55 @@ +@tool +extends RefCounted + +static func raycast(root: Node3D, camera: Camera3D, screen_position: Vector2) -> Variant: + var ray_origin := camera.project_ray_origin(screen_position) + var ray_direction := camera.project_ray_normal(screen_position).normalized() + var nearest_point: Variant = null + var nearest_distance := INF + var mesh_nodes: Array[MeshInstance3D] = [] + _collect_mesh_instances(root, mesh_nodes) + for mesh_node in mesh_nodes: + var source_mesh := mesh_node.mesh + if source_mesh == null or not mesh_node.is_visible_in_tree(): + continue + for surface_index in range(source_mesh.get_surface_count()): + var arrays: Array + if source_mesh is PrimitiveMesh: + if surface_index > 0: + continue + arrays = source_mesh.get_mesh_arrays() + else: + if source_mesh.surface_get_primitive_type(surface_index) != Mesh.PRIMITIVE_TRIANGLES: + continue + arrays = source_mesh.surface_get_arrays(surface_index) + if arrays.is_empty() or not arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array: + continue + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var indices := PackedInt32Array() + if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array: + indices = arrays[Mesh.ARRAY_INDEX] + var has_indices := not indices.is_empty() + var triangle_count := indices.size() if has_indices else vertices.size() + for triangle_start in range(0, triangle_count - 2, 3): + var index_a := indices[triangle_start] if has_indices else triangle_start + var index_b := indices[triangle_start + 1] if has_indices else triangle_start + 1 + var index_c := indices[triangle_start + 2] if has_indices else triangle_start + 2 + if index_a < 0 or index_b < 0 or index_c < 0 or index_a >= vertices.size() or index_b >= vertices.size() or index_c >= vertices.size(): + continue + var vertex_a := mesh_node.to_global(vertices[index_a]) + var vertex_b := mesh_node.to_global(vertices[index_b]) + var vertex_c := mesh_node.to_global(vertices[index_c]) + var hit: Variant = Geometry3D.ray_intersects_triangle(ray_origin, ray_direction, vertex_a, vertex_b, vertex_c) + if hit == null: + continue + var distance := ray_origin.distance_to(hit) + if distance < nearest_distance: + nearest_distance = distance + nearest_point = hit + return nearest_point + +static func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void: + if node is MeshInstance3D: + result.append(node) + for child in node.get_children(): + _collect_mesh_instances(child, result) diff --git a/addons/modular_builder/modular_mesh_picker.gd.uid b/addons/modular_builder/modular_mesh_picker.gd.uid new file mode 100644 index 0000000..22a08c5 --- /dev/null +++ b/addons/modular_builder/modular_mesh_picker.gd.uid @@ -0,0 +1 @@ +uid://cg71aq1smh1q3 diff --git a/addons/modular_builder/modular_piece.gd b/addons/modular_builder/modular_piece.gd new file mode 100644 index 0000000..9f29f34 --- /dev/null +++ b/addons/modular_builder/modular_piece.gd @@ -0,0 +1,61 @@ +@tool +class_name ModularBuilderPiece +extends "res://addons/modular_builder/modular_piece_geometry.gd" + +func _ready() -> void: + _rebuild() + +func _validate_property(property: Dictionary) -> void: + if property.name == &"symmetric_dimensions" and piece_type == PieceType.BUILDING: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"dimension_height" and (piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE or piece_type == PieceType.FLOOR): + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"dimension_depth" and piece_type == PieceType.WALL: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"dimension_width" or property.name == &"dimension_height" or property.name == &"dimension_depth": + if symmetric_dimensions: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"rise" and piece_type != PieceType.GABLE_WALL and piece_type != PieceType.GABLE_WALL_RIGHT and piece_type != PieceType.ROOF_GABLE and piece_type != PieceType.ROOF_PYRAMID: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"roof_width" and piece_type != PieceType.ROOF_SHED: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"stair_count" and piece_type != PieceType.STAIRS: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"stair_width" and piece_type != PieceType.STAIRS: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"fence_divisions" and piece_type != PieceType.FENCE: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"fence_height" and piece_type != PieceType.FENCE: + property.usage = PROPERTY_USAGE_NO_EDITOR + if property.name == &"center_align" and piece_type != PieceType.GABLE_WALL and piece_type != PieceType.GABLE_WALL_RIGHT and piece_type != PieceType.WALL: + property.usage = PROPERTY_USAGE_NO_EDITOR + +func _rebuild() -> void: + if not is_inside_tree(): + return + var old_collision := get_node_or_null("Collision") + if old_collision != null: + remove_child(old_collision) + old_collision.queue_free() + var mesh_instance := get_node_or_null("Geometry") as MeshInstance3D + if piece_type == PieceType.BUILDING: + if mesh_instance != null: + remove_child(mesh_instance) + mesh_instance.queue_free() + return + if mesh_instance == null: + mesh_instance = MeshInstance3D.new() + mesh_instance.name = "Geometry" + add_child(mesh_instance) + _set_scene_owner(mesh_instance) + mesh_instance.mesh = _make_mesh() + mesh_instance.material_override = surface_material + if mesh_instance.mesh != null and mesh_instance.mesh.get_surface_count() > 0: + _dimension_reference = mesh_instance.mesh.get_aabb().size + _apply_dimension_scale() + +func _set_scene_owner(node: Node) -> void: + if Engine.is_editor_hint(): + var scene_root := get_tree().edited_scene_root + if scene_root: + node.owner = scene_root diff --git a/addons/modular_builder/modular_piece.gd.uid b/addons/modular_builder/modular_piece.gd.uid new file mode 100644 index 0000000..9262b14 --- /dev/null +++ b/addons/modular_builder/modular_piece.gd.uid @@ -0,0 +1 @@ +uid://coqicbv0jfksq diff --git a/addons/modular_builder/modular_piece_geometry.gd b/addons/modular_builder/modular_piece_geometry.gd new file mode 100644 index 0000000..1c0950d --- /dev/null +++ b/addons/modular_builder/modular_piece_geometry.gd @@ -0,0 +1,748 @@ +@tool +class_name ModularBuilderPieceGeometry +extends Node3D + +const DETAIL_SCRIPT_PATH := "res://addons/modular_builder/modular_building_detail.gd" +const DETAIL_TYPE_HATCH := 2 +const DETAIL_TYPE_PILLAR := 3 + +enum PieceType { FOUNDATION_SQUARE, FOUNDATION_TRIANGLE, FLOOR, WALL, BUILDING, GABLE_WALL, ROOF_GABLE, ROOF_SHED, GABLE_WALL_RIGHT, ROOF_PYRAMID, STAIRS, FENCE } + +@export var piece_type: PieceType = PieceType.FOUNDATION_SQUARE: + set(value): + piece_type = value + notify_property_list_changed() + _rebuild() +@export_range(0.5, 8.0, 0.001, "suffix:m") var unit_size: float = 2.0: + set(value): + unit_size = value + _rebuild() +@export_range(0.01, 1.0, 0.001, "suffix:m") var thickness: float = 0.2: + set(value): + thickness = value + _rebuild() + for child in get_children(): + if child.has_method("_on_host_thickness_changed"): + child.call("_on_host_thickness_changed") +@export_range(0.1, 8.0, 0.001, "suffix:m") var rise: float = 1.0: + set(value): + rise = value + _rebuild() +@export_range(0.01, 32.0, 0.001, "suffix:m") var roof_width: float = 2.0: + set(value): + roof_width = value + _rebuild() +@export_range(1, 100, 1, "suffix: steps") var stair_count: int = 10: + set(value): + stair_count = value + _rebuild() +@export_range(0.01, 8.0, 0.001, "suffix:m") var stair_width: float = 1.0: + set(value): + stair_width = value + _rebuild() +@export_range(0, 100, 1, "suffix: posts") var fence_divisions: int = 1: + set(value): + fence_divisions = value + _rebuild() +@export_range(0.1, 8.0, 0.001, "suffix:m") var fence_height: float = 1.0: + set(value): + fence_height = value + _rebuild() +@export var symmetric_dimensions: bool = true: + set(value): + if symmetric_dimensions == value: + return + if not value: + _capture_mesh_dimensions() + symmetric_dimensions = value + notify_property_list_changed() + if value: + scale = Vector3.ONE + else: + _apply_dimension_scale() +@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_width: float = 2.0: + set(value): + dimension_width = value + _apply_dimension_scale() +@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_height: float = 2.0: + set(value): + dimension_height = value + _apply_dimension_scale() +@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_depth: float = 2.0: + set(value): + dimension_depth = value + _apply_dimension_scale() +@export_storage var _dimension_reference: Vector3 = Vector3.ZERO +@export var center_align: bool = false: + set(value): + center_align = value + _rebuild() +@export var surface_material: Material: + set(value): + surface_material = value + _rebuild() +@export var custom_points: PackedVector3Array = PackedVector3Array(): + set(value): + custom_points = value + _rebuild() +@export var centered_origin: bool = false: + set(value): + centered_origin = value + _rebuild() + +func _rebuild() -> void: + pass + +func _capture_mesh_dimensions() -> void: + var geometry := get_node_or_null("Geometry") as MeshInstance3D + if geometry == null or geometry.mesh == null: + return + var dimensions := geometry.mesh.get_aabb().size + if dimensions.x > 0.001: + dimension_width = dimensions.x + if dimensions.y > 0.001: + dimension_height = dimensions.y + if dimensions.z > 0.001: + dimension_depth = dimensions.z + _dimension_reference = dimensions + +func _apply_dimension_scale() -> void: + if symmetric_dimensions or _dimension_reference.x <= 0.001 or _dimension_reference.y <= 0.001 or _dimension_reference.z <= 0.001: + return + var target := Vector3(dimension_width, dimension_height, dimension_depth) + if piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE or piece_type == PieceType.FLOOR: + target.y = _dimension_reference.y + if piece_type == PieceType.WALL: + target.z = _dimension_reference.z + scale = Vector3(target.x / _dimension_reference.x, target.y / _dimension_reference.y, target.z / _dimension_reference.z) + +func _make_mesh() -> ArrayMesh: + if piece_type == PieceType.GABLE_WALL and custom_points.size() >= 2: + return _make_gable_wall_mesh() + if piece_type == PieceType.GABLE_WALL_RIGHT and custom_points.size() >= 2: + return _make_right_gable_wall_mesh() + if (piece_type == PieceType.ROOF_GABLE or piece_type == PieceType.ROOF_SHED or piece_type == PieceType.ROOF_PYRAMID) and custom_points.size() >= 2: + return _make_roof_mesh() + if piece_type == PieceType.STAIRS and custom_points.size() >= 2: + return _make_stair_mesh() + if piece_type == PieceType.FENCE and custom_points.size() >= 2: + return _make_fence_mesh() + if custom_points.size() >= 2 and piece_type == PieceType.WALL: + return _make_custom_wall_mesh() + if custom_points.size() >= 3 and piece_type == PieceType.FLOOR: + return _make_custom_floor_mesh() + if custom_points.size() >= 3 and (piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE): + return _make_custom_foundation_mesh() + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var s := unit_size + var polygon: Array[Vector2] + match piece_type: + PieceType.FOUNDATION_TRIANGLE: + polygon = [Vector2(-s * 0.5, -s * 0.5), Vector2(s * 0.5, -s * 0.5), Vector2(-s * 0.5, s * 0.5)] + _: + polygon = [Vector2(-s * 0.5, -s * 0.5), Vector2(s * 0.5, -s * 0.5), Vector2(s * 0.5, s * 0.5), Vector2(-s * 0.5, s * 0.5)] + var y0 := -thickness * 0.5 if centered_origin else 0.0 + var y1 := thickness * 0.5 if centered_origin else thickness + if piece_type == PieceType.WALL: + polygon = [Vector2(-s * 0.5, -thickness * 0.5), Vector2(s * 0.5, -thickness * 0.5), Vector2(s * 0.5, thickness * 0.5), Vector2(-s * 0.5, thickness * 0.5)] + y0 = -s * 0.5 if centered_origin else 0.0 + y1 = s * 0.5 if centered_origin else s + if piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE: + var hatch_mesh := _make_hatch_surface_mesh(polygon, y1, y0) + if hatch_mesh != null: + return hatch_mesh + for i in range(1, polygon.size() - 1): + _add_tri(vertices, normals, Vector3(polygon[0].x, y1, polygon[0].y), Vector3(polygon[i + 1].x, y1, polygon[i + 1].y), Vector3(polygon[i].x, y1, polygon[i].y)) + _add_tri(vertices, normals, Vector3(polygon[0].x, y0, polygon[0].y), Vector3(polygon[i].x, y0, polygon[i].y), Vector3(polygon[i + 1].x, y0, polygon[i + 1].y)) + for i in range(polygon.size()): + var j := (i + 1) % polygon.size() + var a := polygon[i] + var b := polygon[j] + _add_tri(vertices, normals, Vector3(a.x, y0, a.y), Vector3(b.x, y0, b.y), Vector3(b.x, y1, b.y)) + _add_tri(vertices, normals, Vector3(a.x, y0, a.y), Vector3(b.x, y1, b.y), Vector3(a.x, y1, a.y)) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + var result := ArrayMesh.new() + result.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return result + +func _make_gable_wall_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var base_points := _get_centered_gable_base() if center_align else PackedVector3Array([custom_points[0], custom_points[1]]) + var a: Vector3 = base_points[0] + var b: Vector3 = base_points[1] + var direction := (b - a).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5 + var peak := (a + b) * 0.5 + Vector3.UP * rise + var front_a := a + side + var front_b := b + side + var front_peak := peak + side + var back_a := a - side + var back_b := b - side + var back_peak := peak - side + _add_tri(vertices, normals, front_a, front_b, front_peak) + _add_tri(vertices, normals, back_b, back_a, back_peak) + _add_quad(vertices, normals, front_a, back_a, back_b, front_b) + _add_quad(vertices, normals, front_b, back_b, back_peak, front_peak) + _add_quad(vertices, normals, front_peak, back_peak, back_a, front_a) + return _array_mesh(vertices, normals) + +func _make_right_gable_wall_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var base_points := _get_centered_gable_base() if center_align else PackedVector3Array([custom_points[0], custom_points[1]]) + var a: Vector3 = base_points[0] + var b: Vector3 = base_points[1] + var direction := (b - a).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5 + var peak := b + Vector3.UP * rise + var front_a := a + side + var front_b := b + side + var front_peak := peak + side + var back_a := a - side + var back_b := b - side + var back_peak := peak - side + _add_tri(vertices, normals, front_a, front_b, front_peak) + _add_tri(vertices, normals, back_peak, back_b, back_a) + _add_quad(vertices, normals, front_a, back_a, back_b, front_b) + _add_quad(vertices, normals, front_b, back_b, back_peak, front_peak) + _add_quad(vertices, normals, front_peak, back_peak, back_a, front_a) + return _array_mesh(vertices, normals) + +func _make_roof_mesh() -> ArrayMesh: + if piece_type == PieceType.ROOF_SHED and custom_points.size() == 2: + return _make_shed_roof_mesh() + if custom_points.size() >= 4: + return _make_oriented_roof_mesh() + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var first: Vector3 = custom_points[0] + var second: Vector3 = custom_points[1] + var min_x := minf(first.x, second.x) + var max_x := maxf(first.x, second.x) + var min_z := minf(first.z, second.z) + var max_z := maxf(first.z, second.z) + var span_x := max_x - min_x + var span_z := max_z - min_z + if span_x < 0.02 or span_z < 0.02: + return _array_mesh(vertices, normals) + var panels: Array = [] + if piece_type == PieceType.ROOF_GABLE: + if span_x <= span_z: + var ridge_x := (min_x + max_x) * 0.5 + panels.append([Vector3(min_x, 0.0, min_z), Vector3(min_x, 0.0, max_z), Vector3(ridge_x, rise, max_z), Vector3(ridge_x, rise, min_z)]) + panels.append([Vector3(ridge_x, rise, min_z), Vector3(ridge_x, rise, max_z), Vector3(max_x, 0.0, max_z), Vector3(max_x, 0.0, min_z)]) + else: + var ridge_z := (min_z + max_z) * 0.5 + panels.append([Vector3(min_x, 0.0, min_z), Vector3(max_x, 0.0, min_z), Vector3(max_x, rise, ridge_z), Vector3(min_x, rise, ridge_z)]) + panels.append([Vector3(min_x, rise, ridge_z), Vector3(max_x, rise, ridge_z), Vector3(max_x, 0.0, max_z), Vector3(min_x, 0.0, max_z)]) + else: + if span_x <= span_z: + var low_x := first.x + var high_x := second.x + panels.append([Vector3(low_x, 0.0, min_z), Vector3(low_x, 0.0, max_z), Vector3(high_x, rise, max_z), Vector3(high_x, rise, min_z)]) + else: + var low_z := first.z + var high_z := second.z + panels.append([Vector3(min_x, 0.0, low_z), Vector3(max_x, 0.0, low_z), Vector3(max_x, rise, high_z), Vector3(min_x, rise, high_z)]) + for panel in panels: + _append_solid_panel(vertices, normals, panel) + return _array_mesh(vertices, normals) + +func _make_shed_roof_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var low: Vector3 = custom_points[0] + var high: Vector3 = custom_points[1] + var slope := high - low + var horizontal := Vector3(slope.x, 0.0, slope.z) + if horizontal.length_squared() < 0.0001 or roof_width <= 0.0: + return _array_mesh(vertices, normals) + var across := Vector3(horizontal.z, 0.0, -horizontal.x).normalized() * roof_width * 0.5 + var panel := [low - across, low + across, high + across, high - across] + _append_solid_panel(vertices, normals, panel) + return _array_mesh(vertices, normals) + +func _make_oriented_roof_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var p0: Vector3 = custom_points[0] + var p1: Vector3 = custom_points[1] + var p2: Vector3 = custom_points[2] + var p3: Vector3 = custom_points[3] + var edge_01 := Vector2(p1.x - p0.x, p1.z - p0.z).length() + var edge_12 := Vector2(p2.x - p1.x, p2.z - p1.z).length() + var panels: Array = [] + if piece_type == PieceType.ROOF_GABLE: + if edge_01 <= edge_12: + var ridge_01 := (p0 + p1) * 0.5 + Vector3.UP * rise + var ridge_23 := (p2 + p3) * 0.5 + Vector3.UP * rise + panels.append([p0, p3, ridge_23, ridge_01]) + panels.append([ridge_01, ridge_23, p2, p1]) + else: + var ridge_12 := (p1 + p2) * 0.5 + Vector3.UP * rise + var ridge_30 := (p3 + p0) * 0.5 + Vector3.UP * rise + panels.append([p0, p1, ridge_12, ridge_30]) + panels.append([ridge_30, ridge_12, p2, p3]) + elif piece_type == PieceType.ROOF_PYRAMID: + var peak := (p0 + p1 + p2 + p3) * 0.25 + Vector3.UP * rise + panels.append([p0, p1, peak]) + panels.append([p1, p2, peak]) + panels.append([p2, p3, peak]) + panels.append([p3, p0, peak]) + else: + # The four points define the low and high edges. Keep their centers + # fixed when adjusting both edge widths in the Inspector. + var base_width := Vector2(p1.x - p0.x, p1.z - p0.z).length() + if base_width > 0.001 and roof_width > 0.0: + var direction := Vector3(p1.x - p0.x, 0.0, p1.z - p0.z).normalized() + var low_center := (p0 + p1) * 0.5 + var high_center := (p2 + p3) * 0.5 + var half_width := roof_width * 0.5 + panels.append([ + low_center - direction * half_width, + low_center + direction * half_width, + high_center + direction * half_width, + high_center - direction * half_width, + ]) + else: + panels.append([p0, p1, p2, p3]) + for panel in panels: + _append_solid_panel(vertices, normals, panel) + return _array_mesh(vertices, normals) + +func _make_stair_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var start: Vector3 = custom_points[0] + var finish: Vector3 = custom_points[1] + var horizontal := Vector3(finish.x - start.x, 0.0, finish.z - start.z) + var run := horizontal.length() + var rise_amount := finish.y - start.y + if run < 0.05 or rise_amount <= 0.05 or stair_count < 1 or stair_width <= 0.0: + return _array_mesh(vertices, normals) + var forward := horizontal / run + var width_axis := Vector3(-forward.z, 0.0, forward.x) * stair_width * 0.5 + var profile := PackedVector2Array([Vector2(0.0, 0.0), Vector2(run, 0.0), Vector2(run, rise_amount)]) + for step_index in range(stair_count - 1, -1, -1): + var tread_start := run * float(step_index) / stair_count + var tread_end := run * float(step_index + 1) / stair_count + var tread_height := rise_amount * float(step_index + 1) / stair_count + profile.append(Vector2(tread_start, tread_height)) + if step_index > 0: + profile.append(Vector2(tread_start, rise_amount * float(step_index) / stair_count)) + var triangulation := Geometry2D.triangulate_polygon(profile) + for side_sign in [-1.0, 1.0]: + for triangle_index in range(0, triangulation.size(), 3): + var a := profile[triangulation[triangle_index]] + var b := profile[triangulation[triangle_index + 1]] + var c := profile[triangulation[triangle_index + 2]] + var point_a: Vector3 = start + forward * a.x + Vector3.UP * a.y + width_axis * side_sign + var point_b: Vector3 = start + forward * b.x + Vector3.UP * b.y + width_axis * side_sign + var point_c: Vector3 = start + forward * c.x + Vector3.UP * c.y + width_axis * side_sign + if side_sign > 0.0: + _add_tri(vertices, normals, point_a, point_b, point_c) + else: + _add_tri(vertices, normals, point_c, point_b, point_a) + for profile_index in range(profile.size()): + var next_index := (profile_index + 1) % profile.size() + var a := profile[profile_index] + var b := profile[next_index] + var point_a_left := start + forward * a.x + Vector3.UP * a.y - width_axis + var point_b_left := start + forward * b.x + Vector3.UP * b.y - width_axis + var point_b_right := start + forward * b.x + Vector3.UP * b.y + width_axis + var point_a_right := start + forward * a.x + Vector3.UP * a.y + width_axis + _add_quad(vertices, normals, point_a_left, point_b_left, point_b_right, point_a_right) + return _array_mesh(vertices, normals) + +func _make_fence_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var start: Vector3 = custom_points[0] + var finish: Vector3 = custom_points[1] + var path := finish - start + var path_length := path.length() + if path_length < 0.05 or fence_height <= 0.0: + return _array_mesh(vertices, normals) + var path_axis := path / path_length + var post_count := fence_divisions + 2 + var post_positions: Array[Vector3] = [] + for post_index in range(post_count): + var base_position := start.lerp(finish, float(post_index) / float(post_count - 1)) + post_positions.append(base_position) + var side_axis := path_axis.cross(Vector3.UP).normalized() + if side_axis.length_squared() < 0.001: + side_axis = Vector3.FORWARD + _append_oriented_box(vertices, normals, base_position + Vector3.UP * fence_height * 0.5, path_axis, Vector3.UP, side_axis, Vector3(thickness, fence_height, thickness)) + for section_index in range(post_positions.size() - 1): + var section_start := post_positions[section_index] + var section_end := post_positions[section_index + 1] + var section_axis := (section_end - section_start).normalized() + var rail_up := Vector3.UP - section_axis * section_axis.dot(Vector3.UP) + if rail_up.length_squared() < 0.001: + rail_up = Vector3.FORWARD - section_axis * section_axis.dot(Vector3.FORWARD) + rail_up = rail_up.normalized() + var rail_side := section_axis.cross(rail_up).normalized() + var section_length := section_start.distance_to(section_end) + for height_ratio in [0.28, 0.78]: + var rail_center: Vector3 = (section_start + section_end) * 0.5 + Vector3.UP * fence_height * height_ratio + _append_oriented_box(vertices, normals, rail_center, section_axis, rail_up, rail_side, Vector3(section_length, thickness, thickness)) + return _array_mesh(vertices, normals) + +func _append_oriented_box(vertices: PackedVector3Array, normals: PackedVector3Array, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3, dimensions: Vector3) -> void: + var x := axis_x.normalized() * dimensions.x * 0.5 + var y := axis_y.normalized() * dimensions.y * 0.5 + var z := axis_z.normalized() * dimensions.z * 0.5 + var nnn := center - x - y - z + var pnn := center + x - y - z + var ppn := center + x + y - z + var npn := center - x + y - z + var nnp := center - x - y + z + var pnp := center + x - y + z + var ppp := center + x + y + z + var npp := center - x + y + z + _add_quad(vertices, normals, nnp, pnp, ppp, npp) + _add_quad(vertices, normals, pnn, nnn, npn, ppn) + _add_quad(vertices, normals, npp, ppp, ppn, npn) + _add_quad(vertices, normals, nnn, pnn, pnp, nnp) + _add_quad(vertices, normals, nnn, nnp, npp, npn) + _add_quad(vertices, normals, pnp, pnn, ppn, ppp) + +func _get_centered_gable_base() -> PackedVector3Array: + var selected_a := to_global(custom_points[0]) + var selected_b := to_global(custom_points[1]) + var search_root: Node = get_parent() + while search_root != null and not search_root is ModularBuilderPieceGeometry: + search_root = search_root.get_parent() + if search_root == null: + return PackedVector3Array([custom_points[0], custom_points[1]]) + var walls: Array[ModularBuilderPieceGeometry] = [] + _collect_centering_walls(search_root, walls) + var centered_a := selected_a + var centered_b := selected_b + var nearest_a := INF + var nearest_b := INF + for wall in walls: + if wall.custom_points.size() < 2: + continue + var wall_a := wall.to_global(wall.custom_points[0] + Vector3.UP * wall.unit_size) + var wall_b := wall.to_global(wall.custom_points[1] + Vector3.UP * wall.unit_size) + var tolerance: float = wall.thickness + 0.04 + for endpoint in [wall_a, wall_b]: + var distance_a := selected_a.distance_to(endpoint) + if distance_a < nearest_a and distance_a <= tolerance: + nearest_a = distance_a + centered_a = endpoint + var distance_b := selected_b.distance_to(endpoint) + if distance_b < nearest_b and distance_b <= tolerance: + nearest_b = distance_b + centered_b = endpoint + if nearest_a < INF and nearest_b < INF and centered_a.distance_to(centered_b) >= 0.02: + return PackedVector3Array([to_local(centered_a), to_local(centered_b)]) + return PackedVector3Array([custom_points[0], custom_points[1]]) + +func _collect_centering_walls(node: Node, walls: Array[ModularBuilderPieceGeometry]) -> void: + if node is ModularBuilderPieceGeometry and node.piece_type == PieceType.WALL: + walls.append(node) + for child in node.get_children(): + _collect_centering_walls(child, walls) + +func _append_solid_panel(vertices: PackedVector3Array, normals: PackedVector3Array, panel: Array) -> void: + if panel.size() < 3: + return + var p0: Vector3 = panel[0] + var p1: Vector3 = panel[1] + var p2: Vector3 = panel[2] + var normal: Vector3 = (p1 - p0).cross(p2 - p0).normalized() + if normal.is_zero_approx(): + return + var half_depth: Vector3 = normal * thickness * 0.5 + var front: Array[Vector3] = [] + var back: Array[Vector3] = [] + for point in panel: + front.append(point + half_depth) + back.append(point - half_depth) + for i in range(1, panel.size() - 1): + _add_tri(vertices, normals, front[0], front[i], front[i + 1]) + _add_tri(vertices, normals, back[i + 1], back[i], back[0]) + for i in range(panel.size()): + var next := (i + 1) % panel.size() + _add_quad(vertices, normals, front[i], back[i], back[next], front[next]) + +func _make_custom_wall_mesh() -> ArrayMesh: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var wall_points: PackedVector3Array = _get_centered_wall_points() if center_align else custom_points + var a: Vector3 = wall_points[0] + var b: Vector3 = wall_points[1] + var direction := (b - a).normalized() + var length := a.distance_to(b) + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5 + var height := Vector3.UP * unit_size + var openings: Array[PackedVector2Array] = [] + for child in get_children(): + if _is_detail_node(child) and int(child.get("detail_type")) != DETAIL_TYPE_HATCH and int(child.get("detail_type")) != DETAIL_TYPE_PILLAR: + var detail := child as Node3D + var center := Vector2((detail.position - a).dot(direction), detail.position.y - a.y) + var axis_x := Vector2(detail.transform.basis.x.dot(direction), detail.transform.basis.x.y).normalized() + var axis_y := Vector2(detail.transform.basis.y.dot(direction), detail.transform.basis.y.y).normalized() + var opening := PackedVector2Array([ + center - axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5, + center + axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5, + center + axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5, + center - axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5 + ]) + var wall_rect := PackedVector2Array([Vector2(0.0, 0.0), Vector2(length, 0.0), Vector2(length, unit_size), Vector2(0.0, unit_size)]) + for clipped_opening in Geometry2D.intersect_polygons(opening, wall_rect): + if clipped_opening.size() >= 3: + openings.append(clipped_opening) + var xs: Array[float] = [0.0, length] + var ys: Array[float] = [0.0, unit_size] + for opening in openings: + for point in opening: + xs.append(clampf(point.x, 0.0, length)) + ys.append(clampf(point.y, 0.0, unit_size)) + xs.sort() + ys.sort() + for xi in range(xs.size() - 1): + for yi in range(ys.size() - 1): + var u0: float = xs[xi] + var u1: float = xs[xi + 1] + var v0: float = ys[yi] + var v1: float = ys[yi + 1] + var mid := Vector2((u0 + u1) * 0.5, (v0 + v1) * 0.5) + var cell := PackedVector2Array([Vector2(u0, v0), Vector2(u1, v0), Vector2(u1, v1), Vector2(u0, v1)]) + var remaining: Array[PackedVector2Array] = [cell] + for opening in openings: + var next_remaining: Array[PackedVector2Array] = [] + for polygon in remaining: + for clipped in Geometry2D.clip_polygons(polygon, opening): + if clipped.size() >= 3: + next_remaining.append(clipped) + remaining = next_remaining + for polygon in remaining: + var indices := Geometry2D.triangulate_polygon(polygon) + for ti in range(0, indices.size(), 3): + var p0: Vector2 = polygon[indices[ti]] + var p1: Vector2 = polygon[indices[ti + 1]] + var p2: Vector2 = polygon[indices[ti + 2]] + var f0 := a + direction * p0.x + Vector3.UP * p0.y + var f1 := a + direction * p1.x + Vector3.UP * p1.y + var f2 := a + direction * p2.x + Vector3.UP * p2.y + _add_tri(vertices, normals, f0 + side, f1 + side, f2 + side) + _add_tri(vertices, normals, f2 - side, f1 - side, f0 - side) + # Close the wall body at its ends, top, and base. + _add_quad(vertices, normals, a - side, a + side, a + side + height, a - side + height) + _add_quad(vertices, normals, b + side, b - side, b - side + height, b + side + height) + _add_quad(vertices, normals, a + side + height, b + side + height, b - side + height, a - side + height) + _add_quad(vertices, normals, a - side, b - side, b + side, a + side) + for opening in openings: + for edge_index in range(opening.size()): + var p0 := opening[edge_index] + var p1 := opening[(edge_index + 1) % opening.size()] + var a0 := a + direction * p0.x + Vector3.UP * p0.y + var a1 := a + direction * p1.x + Vector3.UP * p1.y + _add_quad(vertices, normals, a0 + side, a0 - side, a1 - side, a1 + side) + return _array_mesh(vertices, normals) + +func _get_centered_wall_points() -> PackedVector3Array: + if custom_points.size() < 2: + return custom_points + var selected_a := to_global(custom_points[0]) + var selected_b := to_global(custom_points[1]) + var search_root: Node = get_parent() + while search_root != null and not search_root is ModularBuilderPieceGeometry: + search_root = search_root.get_parent() + if search_root == null: + return custom_points + var walls: Array[ModularBuilderPieceGeometry] = [] + _collect_centering_walls(search_root, walls) + var centered_a := selected_a + var centered_b := selected_b + var nearest_a := INF + var nearest_b := INF + for wall in walls: + if wall == self or wall.custom_points.size() < 2: + continue + var direction := (wall.custom_points[1] - wall.custom_points[0]).normalized() + var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5 + for endpoint in wall.custom_points: + var center := wall.to_global(endpoint + Vector3.UP * wall.unit_size) + for surface_corner in [center + side, center - side]: + var distance_a := selected_a.distance_to(surface_corner) + if distance_a < nearest_a and distance_a <= wall.thickness + 0.04: + nearest_a = distance_a + centered_a = center + var distance_b := selected_b.distance_to(surface_corner) + if distance_b < nearest_b and distance_b <= wall.thickness + 0.04: + nearest_b = distance_b + centered_b = center + if nearest_a < INF and nearest_b < INF and centered_a.distance_to(centered_b) >= 0.02: + return PackedVector3Array([to_local(centered_a), to_local(centered_b)]) + return custom_points + +func _make_custom_floor_mesh() -> ArrayMesh: + var polygon: Array[Vector2] = [] + for point in custom_points: + polygon.append(Vector2(point.x, point.z)) + var hatch_mesh := _make_hatch_surface_mesh(polygon, custom_points[0].y, custom_points[0].y - thickness) + if hatch_mesh != null: + return hatch_mesh + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var points := custom_points + var bottom: Array[Vector3] = [] + for point in points: + bottom.append(point - Vector3.UP * thickness) + for i in range(1, points.size() - 1): + _add_tri(vertices, normals, points[0], points[i + 1], points[i]) + _add_tri(vertices, normals, bottom[0], bottom[i], bottom[i + 1]) + for i in range(points.size()): + var j := (i + 1) % points.size() + _add_quad(vertices, normals, bottom[i], bottom[j], points[j], points[i]) + return _array_mesh(vertices, normals) + +func _make_hatch_surface_mesh(polygon: Array[Vector2], top_y: float, bottom_y: float) -> ArrayMesh: + var openings: Array[PackedVector2Array] = [] + for child in get_children(): + if _is_detail_node(child) and int(child.get("detail_type")) == DETAIL_TYPE_HATCH: + var detail := child as Node3D + var center := Vector2(detail.position.x, detail.position.z) + var axis_x := Vector2(detail.transform.basis.x.x, detail.transform.basis.x.z).normalized() + var axis_y := Vector2(detail.transform.basis.z.x, detail.transform.basis.z.z).normalized() + var opening := PackedVector2Array([ + center - axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5, + center + axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5, + center + axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5, + center - axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5 + ]) + openings.append(opening) + if openings.is_empty() or polygon.size() < 3: + return null + var min_x := polygon[0].x + var max_x := polygon[0].x + var min_z := polygon[0].y + var max_z := polygon[0].y + var xs: Array[float] = [] + var zs: Array[float] = [] + for point in polygon: + min_x = minf(min_x, point.x) + max_x = maxf(max_x, point.x) + min_z = minf(min_z, point.y) + max_z = maxf(max_z, point.y) + xs.append(point.x) + zs.append(point.y) + for opening in openings: + for point in opening: + xs.append(clampf(point.x, min_x, max_x)) + zs.append(clampf(point.y, min_z, max_z)) + xs.sort() + zs.sort() + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var source_polygon := PackedVector2Array() + for point in polygon: + source_polygon.append(point) + for xi in range(xs.size() - 1): + for zi in range(zs.size() - 1): + var x0: float = xs[xi] + var x1: float = xs[xi + 1] + var z0: float = zs[zi] + var z1: float = zs[zi + 1] + if x1 - x0 < 0.0001 or z1 - z0 < 0.0001: + continue + var cell := PackedVector2Array([Vector2(x0, z0), Vector2(x1, z0), Vector2(x1, z1), Vector2(x0, z1)]) + var remaining: Array[PackedVector2Array] = [] + for clipped_surface in Geometry2D.intersect_polygons(source_polygon, cell): + remaining.append(clipped_surface) + for opening in openings: + var next_remaining: Array[PackedVector2Array] = [] + for surface_polygon in remaining: + for clipped_surface in Geometry2D.clip_polygons(surface_polygon, opening): + if clipped_surface.size() >= 3: + next_remaining.append(clipped_surface) + remaining = next_remaining + for clipped_surface in remaining: + var triangulated := Geometry2D.triangulate_polygon(clipped_surface) + for ti in range(0, triangulated.size(), 3): + var a: Vector2 = clipped_surface[triangulated[ti]] + var b: Vector2 = clipped_surface[triangulated[ti + 1]] + var c: Vector2 = clipped_surface[triangulated[ti + 2]] + _add_tri(vertices, normals, Vector3(a.x, top_y, a.y), Vector3(c.x, top_y, c.y), Vector3(b.x, top_y, b.y)) + _add_tri(vertices, normals, Vector3(a.x, bottom_y, a.y), Vector3(b.x, bottom_y, b.y), Vector3(c.x, bottom_y, c.y)) + for i in range(polygon.size()): + var j := (i + 1) % polygon.size() + var a := polygon[i] + var b := polygon[j] + _add_quad(vertices, normals, Vector3(a.x, bottom_y, a.y), Vector3(b.x, bottom_y, b.y), Vector3(b.x, top_y, b.y), Vector3(a.x, top_y, a.y)) + for opening in openings: + var visible_edges := Geometry2D.intersect_polygons(opening, source_polygon) + for visible_opening in visible_edges: + for edge_index in range(visible_opening.size()): + var p0 := visible_opening[edge_index] + var p1 := visible_opening[(edge_index + 1) % visible_opening.size()] + _add_quad(vertices, normals, Vector3(p0.x, bottom_y, p0.y), Vector3(p0.x, top_y, p0.y), Vector3(p1.x, top_y, p1.y), Vector3(p1.x, bottom_y, p1.y)) + return _array_mesh(vertices, normals) + +func _is_detail_node(node: Node) -> bool: + if not node is Node3D or node.get_script() == null: + return false + return node.get_script().resource_path == DETAIL_SCRIPT_PATH + +func _make_custom_foundation_mesh() -> ArrayMesh: + var polygon: Array[Vector2] = [] + for point in custom_points: + polygon.append(Vector2(point.x, point.z)) + var bottom_y := -thickness * 0.5 if centered_origin else 0.0 + var top_y := thickness * 0.5 if centered_origin else thickness + var hatch_mesh := _make_hatch_surface_mesh(polygon, top_y, bottom_y) + if hatch_mesh != null: + return hatch_mesh + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + for i in range(1, custom_points.size() - 1): + var a := custom_points[0] + var b := custom_points[i] + var c := custom_points[i + 1] + _add_tri(vertices, normals, Vector3(a.x, top_y, a.z), Vector3(c.x, top_y, c.z), Vector3(b.x, top_y, b.z)) + _add_tri(vertices, normals, Vector3(a.x, bottom_y, a.z), Vector3(b.x, bottom_y, b.z), Vector3(c.x, bottom_y, c.z)) + for i in range(custom_points.size()): + var j := (i + 1) % custom_points.size() + var a := custom_points[i] + var b := custom_points[j] + _add_quad(vertices, normals, Vector3(a.x, bottom_y, a.z), Vector3(b.x, bottom_y, b.z), Vector3(b.x, top_y, b.z), Vector3(a.x, top_y, a.z)) + return _array_mesh(vertices, normals) + +func _add_quad(v: PackedVector3Array, n: PackedVector3Array, a: Vector3, b: Vector3, c: Vector3, d: Vector3) -> void: + _add_tri(v, n, a, b, c) + _add_tri(v, n, a, c, d) + +func _array_mesh(v: PackedVector3Array, n: PackedVector3Array) -> ArrayMesh: + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = v + arrays[Mesh.ARRAY_NORMAL] = n + var result := ArrayMesh.new() + result.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return result + +func _add_tri(v: PackedVector3Array, n: PackedVector3Array, a: Vector3, b: Vector3, c: Vector3) -> void: + var normal := (b - a).cross(c - a).normalized() + v.append(a) + v.append(b) + v.append(c) + n.append(normal) + n.append(normal) + n.append(normal) + # Also generate reversed faces to show both sides. + # This keeps floors and foundations visible from below with any material. + v.append(c) + v.append(b) + v.append(a) + n.append(-normal) + n.append(-normal) + n.append(-normal) diff --git a/addons/modular_builder/modular_piece_geometry.gd.uid b/addons/modular_builder/modular_piece_geometry.gd.uid new file mode 100644 index 0000000..0e7655e --- /dev/null +++ b/addons/modular_builder/modular_piece_geometry.gd.uid @@ -0,0 +1 @@ +uid://dp1333xlylabf diff --git a/addons/modular_builder/modular_pillar_support.gd b/addons/modular_builder/modular_pillar_support.gd new file mode 100644 index 0000000..3962aaf --- /dev/null +++ b/addons/modular_builder/modular_pillar_support.gd @@ -0,0 +1,34 @@ +@tool +extends RefCounted + +static func vertical_direction_for_piece(piece: ModularBuilderPiece, world_anchor: Vector3) -> Vector3: + var local_anchor := piece.to_local(world_anchor) + var low_y := 0.0 + var high_y := piece.unit_size + match piece.piece_type: + ModularBuilderPiece.PieceType.FOUNDATION_SQUARE, ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + low_y = -piece.thickness * 0.5 if piece.centered_origin else 0.0 + high_y = piece.thickness * 0.5 if piece.centered_origin else piece.thickness + ModularBuilderPiece.PieceType.FLOOR: + high_y = piece.custom_points[0].y if not piece.custom_points.is_empty() else (piece.thickness * 0.5 if piece.centered_origin else piece.thickness) + low_y = high_y - piece.thickness + ModularBuilderPiece.PieceType.GABLE_WALL, ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT: + if piece.custom_points.size() >= 2: + var base_points: PackedVector3Array = piece.call("_get_centered_gable_base") if piece.center_align else piece.custom_points + low_y = base_points[0].y + high_y = low_y + piece.rise + _: + if piece.custom_points.size() >= 2: + var nearest_endpoint := piece.custom_points[0] + var nearest_horizontal := INF + for endpoint in piece.custom_points: + var horizontal := Vector2(endpoint.x - local_anchor.x, endpoint.z - local_anchor.z).length_squared() + if horizontal < nearest_horizontal: + nearest_horizontal = horizontal + nearest_endpoint = endpoint + low_y = nearest_endpoint.y + high_y = nearest_endpoint.y + piece.unit_size + return Vector3.DOWN if absf(local_anchor.y - low_y) < absf(local_anchor.y - high_y) else Vector3.UP + +static func vertical_direction_for_pillar(pillar: ModularBuilderDetail, world_anchor: Vector3) -> Vector3: + return Vector3.DOWN if pillar.to_local(world_anchor).y < 0.0 else Vector3.UP diff --git a/addons/modular_builder/modular_pillar_support.gd.uid b/addons/modular_builder/modular_pillar_support.gd.uid new file mode 100644 index 0000000..9318bbd --- /dev/null +++ b/addons/modular_builder/modular_pillar_support.gd.uid @@ -0,0 +1 @@ +uid://df6psamkqmyvg diff --git a/addons/modular_builder/modular_roof_support.gd b/addons/modular_builder/modular_roof_support.gd new file mode 100644 index 0000000..bd20dec --- /dev/null +++ b/addons/modular_builder/modular_roof_support.gd @@ -0,0 +1,72 @@ +@tool +extends RefCounted + +# Find the roof outline from two diagonal corners at the top +# of parallel walls, even when the structure is rotated around Y. +static func find_corners(root: ModularBuilderPiece, first_world: Vector3, second_world: Vector3, allow_center: bool = false) -> PackedVector3Array: + var first_edges := _matching_top_edges(root, first_world, allow_center) + var second_edges := _matching_top_edges(root, second_world, allow_center) + var best_corners := PackedVector3Array() + var best_score := INF + var best_length := 0.0 + for first_edge in first_edges: + for second_edge in second_edges: + if first_edge["wall"] == second_edge["wall"]: + continue + var a: Vector3 = first_edge["selected"] + var adjacent_a: Vector3 = first_edge["other"] + var c: Vector3 = second_edge["selected"] + var adjacent_c: Vector3 = second_edge["other"] + var edge_a := Vector2(adjacent_a.x - a.x, adjacent_a.z - a.z) + var edge_c := Vector2(adjacent_c.x - c.x, adjacent_c.z - c.z) + var length_a := edge_a.length() + var length_c := edge_c.length() + if length_a < 0.2 or length_c < 0.2: + continue + if edge_a.normalized().dot(edge_c.normalized()) > -0.9: + continue + var center_a := (a + adjacent_a) * 0.5 + var center_c := (c + adjacent_c) * 0.5 + if Vector2(center_c.x - center_a.x, center_c.z - center_a.z).length() < 0.2: + continue + var diagonal_error := (a + c - adjacent_a - adjacent_c).length() + var length_error := absf(length_a - length_c) + var tolerance := maxf(0.12, maxf(first_edge["thickness"], second_edge["thickness"]) * 0.5) + if diagonal_error > tolerance or length_error > tolerance: + continue + var score := diagonal_error + length_error + if score < best_score - 0.01 or (absf(score - best_score) <= 0.01 and length_a > best_length): + best_score = score + best_length = length_a + best_corners = PackedVector3Array([a, adjacent_a, c, adjacent_c]) + return best_corners + +static func _matching_top_edges(root: ModularBuilderPiece, world_point: Vector3, allow_center: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var walls: Array[ModularBuilderPiece] = [] + _collect_walls(root, walls) + for wall in walls: + var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points + if endpoints.size() < 2: + continue + var direction := endpoints[1] - endpoints[0] + var horizontal := Vector2(direction.x, direction.z) + if horizontal.length() < 0.2: + continue + var side := Vector3(-horizontal.y, 0.0, horizontal.x).normalized() * wall.thickness * 0.5 + for offset in [side, -side]: + var top_a := wall.to_global(endpoints[0] + Vector3.UP * wall.unit_size + offset) + var top_b := wall.to_global(endpoints[1] + Vector3.UP * wall.unit_size + offset) + var center_a := wall.to_global(endpoints[0] + Vector3.UP * wall.unit_size) + var center_b := wall.to_global(endpoints[1] + Vector3.UP * wall.unit_size) + if top_a.distance_to(world_point) <= 0.08 or (allow_center and center_a.distance_to(world_point) <= 0.08): + result.append({"wall": wall, "selected": root.to_local(top_a), "other": root.to_local(top_b), "thickness": wall.thickness}) + if top_b.distance_to(world_point) <= 0.08 or (allow_center and center_b.distance_to(world_point) <= 0.08): + result.append({"wall": wall, "selected": root.to_local(top_b), "other": root.to_local(top_a), "thickness": wall.thickness}) + return result + +static func _collect_walls(node: Node, result: Array[ModularBuilderPiece]) -> void: + if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL: + result.append(node) + for child in node.get_children(): + _collect_walls(child, result) diff --git a/addons/modular_builder/modular_roof_support.gd.uid b/addons/modular_builder/modular_roof_support.gd.uid new file mode 100644 index 0000000..6c1a411 --- /dev/null +++ b/addons/modular_builder/modular_roof_support.gd.uid @@ -0,0 +1 @@ +uid://c3m5254irja4m diff --git a/addons/modular_builder/modular_vertex_simplifier.gd b/addons/modular_builder/modular_vertex_simplifier.gd new file mode 100644 index 0000000..e348e26 --- /dev/null +++ b/addons/modular_builder/modular_vertex_simplifier.gd @@ -0,0 +1,32 @@ +@tool +extends RefCounted + +static func piece_vertex(piece: ModularBuilderPiece, world_point: Vector3) -> Vector3: + var local := piece.to_local(world_point) + match piece.piece_type: + ModularBuilderPiece.PieceType.FOUNDATION_SQUARE, ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE: + local.y = piece.thickness * 0.5 if piece.centered_origin else piece.thickness + ModularBuilderPiece.PieceType.FLOOR: + if not piece.custom_points.is_empty(): + local.y = piece.custom_points[0].y + ModularBuilderPiece.PieceType.WALL, ModularBuilderPiece.PieceType.GABLE_WALL, ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT: + var endpoints: PackedVector3Array + if piece.piece_type == ModularBuilderPiece.PieceType.WALL: + endpoints = piece.call("_get_centered_wall_points") if piece.center_align else piece.custom_points + else: + endpoints = piece.call("_get_centered_gable_base") if piece.center_align else piece.custom_points + if endpoints.size() >= 2: + var start := Vector2(endpoints[0].x, endpoints[0].z) + var edge := Vector2(endpoints[1].x, endpoints[1].z) - start + if edge.length_squared() > 0.000001: + var fraction := clampf((Vector2(local.x, local.z) - start).dot(edge) / edge.length_squared(), 0.0, 1.0) + var center := start + edge * fraction + local.x = center.x + local.z = center.y + return piece.to_global(local) + +static func pillar_vertex(pillar: ModularBuilderDetail, world_point: Vector3) -> Vector3: + var local := pillar.to_local(world_point) + local.x = 0.0 + local.z = 0.0 + return pillar.to_global(local) diff --git a/addons/modular_builder/modular_vertex_simplifier.gd.uid b/addons/modular_builder/modular_vertex_simplifier.gd.uid new file mode 100644 index 0000000..7da8706 --- /dev/null +++ b/addons/modular_builder/modular_vertex_simplifier.gd.uid @@ -0,0 +1 @@ +uid://dpixdrfnwnj5m diff --git a/addons/modular_builder/plugin.cfg b/addons/modular_builder/plugin.cfg new file mode 100644 index 0000000..3dd453f --- /dev/null +++ b/addons/modular_builder/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Modular Builder (English)" +description="Create snap-together modular building pieces directly in the Godot editor." +author="Michel Savadim" +version="0.2.0" +script="modular_builder_plugin.gd" diff --git a/project.godot b/project.godot index 68da78f..42f6f47 100644 --- a/project.godot +++ b/project.godot @@ -19,7 +19,7 @@ window/stretch/mode="disabled" [editor_plugins] -enabled=PackedStringArray() +enabled=PackedStringArray("res://addons/modular_builder/plugin.cfg") [rendering] diff --git a/src/editor/build_ops.gd b/src/editor/build_ops.gd new file mode 100644 index 0000000..dad6cce --- /dev/null +++ b/src/editor/build_ops.gd @@ -0,0 +1,348 @@ +extends RefCounted +class_name BuildOps +## Bauen-Modus (Karte t_97ea59c5): Modular-Builder-Pieces im App-Skelett. +## +## Architektur-Entscheidung (dokumentiert, Karte t_97ea59c5): +## 1. WRAPPER statt Plugin-UI: Das Godot-Editor-Plugin (addons/modular_builder, +## aktivierbar) bleibt unangetastet für den Godot-Editor. Die App bekommt +## einen EIGENEN „Bauen“-Modus, der dieselbe Runtime-Geometrie benutzt +## (modular_piece_geometry.gd + modular_building_detail.gd — CC0, reines +## Node3D/ArrayMesh, keine Editor-Abhängigkeit). Keine Godot-Editor-API +## leakt ins App-UI. +## 2. UNDO: EIN ActionHistory-Eintrag PRO BAUTEIL (Snapshot des Blocks +## "gebaeude" — wie Objekte platzieren). Der Block ist klein (Positionen + +## Preset-Punkte), tiefe Kopien sind billig. Kein Terrain-Stroke-Muster +## nötig, weil ein Bauteil eine atomare Klick-Aktion ist (kein Drag). +## 3. SPEICHERN: Die BAUTEILE sind die Quelle der Wahrheit und wandern als +## optionale Sektion "gebaeude" ins .svmap ( checksum-neutral: der +## Server-Loader prüft Pflichtblöcke und die Prüfsumme ÜBER alles — +## unbekannte Zusatzblöcke stören nicht). Das gebackene Mesh wird NICHT +## in der Datei abgelegt: Baken ist deterministisch aus den Teilen +## (Editor-Ansicht backt beim Laden, Match-Export in Karte 2 backt beim +## Export). Weiterbauen nach Laden bleibt damit möglich — Empfehlung +## „beim Speichern backen“ wäre nur Doppeldaten. +## 4. RASTER: 2-m-Modulraster. "ecke"-Teile (Wand/Treppe/Tür/Fenster/Giebel) +## rasten auf gerade Meter (Zellen-Ecken), "mitte"-Teile (Fundament, +## Boden, Dach) auf ungerade Meter (Zellen-Mitte) — so greifen Fundamente +## und Wände bündig ineinander. Drehen in 90°-Schritten bleibt rastergetreu. +## +## Teile-Format (im doc["gebaeude"]-Array): +## {"id": "wand", "pos": [x, y, z], "rot": 90.0, +## "punkte": [[x,y,z], ...], # lokale custom_points des Pieces +## "details": [{"typ": "tuer", "pos": [x,y,z], "w": 1.0, "h": 2.0}]} +## "punkte"/"details" kommen aus dem Preset (make_piece) — sie bleiben in der +## Datei, damit Laden → Weiterbauen → Baken identisch funktioniert. + +const GEO_SCRIPT := "res://addons/modular_builder/modular_piece_geometry.gd" +const DETAIL_SCRIPT := "res://addons/modular_builder/modular_building_detail.gd" + +# PieceType (addons/modular_builder/modular_piece_geometry.gd, Kopie der Werte — +# die Typen-Anmerkung Array[ModularBuilderPieceGeometry] im Plugin-Code braucht +# den globalen Klassen-Cache, der hier bewusst NICHT angefasst wird). +const T_FOUNDATION_SQUARE := 0 +const T_FOUNDATION_TRIANGLE := 1 +const T_FLOOR := 2 +const T_WALL := 3 +const T_GABLE_WALL := 5 +const T_ROOF_GABLE := 6 +const T_ROOF_SHED := 7 +const T_GABLE_WALL_RIGHT := 8 +const T_ROOF_PYRAMID := 9 +const T_STAIRS := 10 + +const DETAIL_WINDOW := 0 +const DETAIL_DOOR := 1 + +const GRID_M := 2.0 # Modulraster (unit_size des Plugins) +const WALL_H := 2.0 # Wandhöhe = unit_size +const SNAP_Y_M := 0.05 # Y-Snap: verhindert Float-Staub beim Stapeln + +## Katalog: id → Preset. Reihenfolge = Anzeige-Reihenfolge im Bauen-Panel. +const KINDS := { + "fundament": { + "label": "Fundament", "info": "2×2 m, Basis", "typ": T_FOUNDATION_SQUARE, "raster": "mitte", + "farbe": Color(0.62, 0.62, 0.60), + "punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]], + }, + "boden": { + "label": "Boden", "info": "2×2 m Platte", "typ": T_FLOOR, "raster": "mitte", + "farbe": Color(0.72, 0.60, 0.42), + "punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]], + }, + "wand": { + "label": "Wand", "info": "2 m lang, 2 m hoch", "typ": T_WALL, "raster": "ecke", + "farbe": Color(0.88, 0.86, 0.80), + "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + }, + "tuer": { + "label": "Wand mit Tür", "info": "2 m, Öffnung 1×2 m", "typ": T_WALL, "raster": "ecke", + "farbe": Color(0.88, 0.86, 0.80), + "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + "details": [{"typ": DETAIL_DOOR, "pos": [1.0, 1.0, 0.0], "w": 1.0, "h": 2.0}], + }, + "fenster": { + "label": "Wand mit Fenster", "info": "2 m, Fenster 1×1 m", "typ": T_WALL, "raster": "ecke", + "farbe": Color(0.88, 0.86, 0.80), + "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + "details": [{"typ": DETAIL_WINDOW, "pos": [1.0, 1.0, 0.0], "w": 1.0, "h": 1.0}], + }, + "giebelwand": { + "label": "Giebelwand", "info": "Spitze in der Mitte", "typ": T_GABLE_WALL, "raster": "ecke", + "farbe": Color(0.88, 0.86, 0.80), + "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0, + }, + "giebelwand_rechts": { + "label": "Giebel rechts", "info": "Spitze rechts", "typ": T_GABLE_WALL_RIGHT, "raster": "ecke", + "farbe": Color(0.88, 0.86, 0.80), + "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0, + }, + "dach_giebel": { + "label": "Dach (Giebel)", "info": "2×2 m, Satteldach", "typ": T_ROOF_GABLE, "raster": "mitte", + "farbe": Color(0.66, 0.28, 0.20), + "punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]], + "rise": 1.0, + }, + "dach_pyramide": { + "label": "Dach (Pyramide)", "info": "2×2 m, Spitze mitte", "typ": T_ROOF_PYRAMID, "raster": "mitte", + "farbe": Color(0.66, 0.28, 0.20), + "punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]], + "rise": 1.0, + }, + "treppe": { + "label": "Treppe", "info": "2 m lang, 1 m hoch", "typ": T_STAIRS, "raster": "ecke", + "farbe": Color(0.55, 0.42, 0.30), + "punkte": [[0.0, 0.0, 0.0], [2.0, 1.0, 0.0]], "stufen": 10, "breite": 1.2, + }, +} + + +# ------------------------------------------------------------------ Blöcke --- + +## "gebaeude"-Block garantieren (alte Maps haben ihn nicht — Snapshot/Undo +## braucht den Block NUR, wenn er existiert oder beim ersten Teil entsteht). +static func ensure_block(doc: Dictionary) -> Array: + if not doc.has("gebaeude") or not (doc["gebaeude"] is Array): + doc["gebaeude"] = [] + return doc["gebaeude"] + + +static func pieces(doc: Dictionary) -> Array: + var block = doc.get("gebaeude", []) + return block if block is Array else [] + + +static func piece_count(doc: Dictionary) -> int: + return pieces(doc).size() + + +static func kind_label(kind_id: String) -> String: + var kind: Dictionary = KINDS.get(kind_id, {}) + return str(kind.get("label", kind_id)) + + +## Gesamt-Dreiecke aller Teile (grob, für Statusleiste/Budget-Blick). +static func tri_count(doc: Dictionary) -> int: + var total := 0 + for teil in pieces(doc): + if not (teil is Dictionary): + continue + var mesh := piece_mesh(teil) + if mesh != null: + total += int(mesh.get_faces().size() / 3) + return total + + +# ------------------------------------------------------------------- Teile --- + +## Ein Bauteil-Dict aus dem Preset bauen (Punkte/Details als Kopien — +## sonst aliasen sie mit dem Katalog-Konstanten). +static func make_piece(kind_id: String, pos: Vector3, rot_deg: float) -> Dictionary: + var kind: Dictionary = KINDS.get(kind_id, {}) + if kind.is_empty(): + return {} + var punkte: Array = [] + for p in (kind.get("punkte", []) as Array): + punkte.append([float(p[0]), float(p[1]), float(p[2])]) + var teil := { + "id": kind_id, + "pos": [pos.x, pos.y, pos.z], + "rot": rot_deg, + "punkte": punkte, + } + if kind.has("details"): + var details: Array = [] + for d in (kind["details"] as Array): + details.append({ + "typ": int(d["typ"]), + "pos": [float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2])], + "w": float(d["w"]), + "h": float(d["h"]), + }) + teil["details"] = details + return teil + + +## 2-m-Raster-Snap je Teil-Typ ("ecke" = gerade Meter, "mitte" = ungerade Meter). +static func snap_xz(kind_id: String, x: float, z: float) -> Vector2: + var kind: Dictionary = KINDS.get(kind_id, {}) + var half := GRID_M * 0.5 + if str(kind.get("raster", "ecke")) == "mitte": + return Vector2(floorf(x / GRID_M) * GRID_M + half, floorf(z / GRID_M) * GRID_M + half) + return Vector2(roundf(x / GRID_M) * GRID_M, roundf(z / GRID_M) * GRID_M) + + +## Y-Snap: 5-cm-Raster gegen Float-Staub beim Stapeln. +static func snap_y(y: float) -> float: + return roundf(y / SNAP_Y_M) * SNAP_Y_M + + +## Komplett gerasterte Teil-Position aus einem Trefferpunkt (Boden oder +## Bauteil-Oberfläche): XZ aufs Modulraster, Y aufs 5-cm-Raster. +static func snapped_pos(kind_id: String, hit: Vector3) -> Vector3: + var xz := snap_xz(kind_id, hit.x, hit.z) + return Vector3(xz.x, snap_y(hit.y), xz.y) + + +## Mesh EINES Teils (lokal, Ursprung = Teil-Position). Nutzt die Plugin- +## Runtime-Geometrie — identische Meshes wie im Godot-Editor-Plugin. +static func piece_mesh(teil: Dictionary) -> ArrayMesh: + var kind_id := str(teil.get("id", "")) + var kind: Dictionary = KINDS.get(kind_id, {}) + if kind.is_empty(): + return null + var geo := Node3D.new() + geo.set_script(load(GEO_SCRIPT)) + geo.set("piece_type", int(kind["typ"])) + geo.set("unit_size", WALL_H) + geo.set("thickness", 0.2) + if kind.has("rise"): + geo.set("rise", float(kind["rise"])) + if kind.has("stufen"): + geo.set("stair_count", int(kind["stufen"])) + geo.set("stair_width", float(kind["breite"])) + # custom_points: vom Preset (Datei) oder (Rückfall) aus dem Katalog. + var pts_src: Array = (teil.get("punkte", kind.get("punkte", [])) as Array) + if pts_src.size() >= 2: + var pts := PackedVector3Array() + for p in pts_src: + pts.append(Vector3(float(p[0]), float(p[1]), float(p[2]))) + geo.set("custom_points", pts) + # Detail-Öffnungen (Tür/Fenster) als Kind-Knoten — die Wand-Geometrie + # schneidet sie aus (genau wie im Plugin). + for d in (teil.get("details", []) as Array): + var detail := Node3D.new() + detail.set_script(load(DETAIL_SCRIPT)) + detail.set("detail_type", int(d["typ"])) + detail.set("width", float(d["w"])) + detail.set("height", float(d["h"])) + detail.position = Vector3(float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2])) + geo.add_child(detail) + var mesh: ArrayMesh = geo.call("_make_mesh") + geo.free() + return mesh + + +## Material je Teil-Typ (flache Farben, eigene CC0-freie Fallbacks statt der +## nicht-CC0-Demo-Texturen — Sly-Look kommt später als echtes Texturenpaket). +static func piece_material(kind_id: String, cache: Dictionary) -> StandardMaterial3D: + if cache.has(kind_id): + return cache[kind_id] + var mat := StandardMaterial3D.new() + mat.albedo_color = KINDS.get(kind_id, {}).get("farbe", Color(0.8, 0.8, 0.8)) + mat.roughness = 0.9 + cache[kind_id] = mat + return mat + + +# ------------------------------------------------------------------- Baken --- + +## ALLE Teile in WELT-Koordinaten backen (für Kollisions-Raycast + späterer +## Match-Export in Karte 2). Rückgabe: faces (Dreiecke, Welt-Koordinaten) + +## offsets[i] = Index des ERSTEN Dreiecks von Teil i im faces-Array. +static func bake_faces(doc: Dictionary) -> Dictionary: + var faces := PackedVector3Array() + var offsets: Array = [] + for teil in pieces(doc): + if not (teil is Dictionary): + offsets.append(-1) + continue + offsets.append(faces.size() / 3) + var mesh := piece_mesh(teil) + if mesh == null: + continue + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var xf := Transform3D( + Basis(Vector3.UP, deg_to_rad(float(teil.get("rot", 0.0)))), + Vector3(float(pos[0]), float(pos[1]), float(pos[2]))) + for v in mesh.get_faces(): + faces.append(xf * v) + return {"faces": faces, "offsets": offsets} + + +## Aßerhalb-Test-loser AABB aller Teile (für Auswahl-Rahmen im Viewport). +static func piece_aabb(teil: Dictionary) -> AABB: + var mesh := piece_mesh(teil) + if mesh == null: + return AABB() + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var xf := Transform3D( + Basis(Vector3.UP, deg_to_rad(float(teil.get("rot", 0.0)))), + Vector3(float(pos[0]), float(pos[1]), float(pos[2]))) + var aabb := AABB() + var first := true + for v in mesh.get_faces(): + var wp := xf * v + if first: + aabb = AABB(wp, Vector3.ZERO) + first = false + else: + aabb = aabb.expand(wp) + return aabb + + +# ---------------------------------------------------------------- Ray-Pick --- + +## Analytischer Ray-Treffer auf den gebackenen Flächen (Möller–Trumbore) — +## kein Physik-Space nötig (SubViewport ohne Physik-Tick, headless-testbar). +## Rückgabe: {"piece": Teil-Index, "punkt": Vector3} oder {}. +static func ray_pick(baked: Dictionary, origin: Vector3, dir: Vector3, max_dist: float = 500.0) -> Dictionary: + var faces: PackedVector3Array = baked.get("faces", PackedVector3Array()) + var offsets: Array = baked.get("offsets", []) + var tri_count := int(faces.size() / 3) + var best_t := max_dist + var best_tri := -1 + for i in tri_count: + var a := faces[i * 3] + var b := faces[i * 3 + 1] + var c := faces[i * 3 + 2] + var t := _ray_tri(origin, dir, a, b, c) + if t >= 0.0 and t < best_t: + best_t = t + best_tri = i + if best_tri < 0: + return {} + var piece := -1 + for j in offsets.size(): + var off := int(offsets[j]) + if off >= 0 and best_tri >= off: + piece = j # offsets aufsteigend → letzter Treffer gewinnt + return {"piece": piece, "punkt": origin + dir * best_t} + + +static func _ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float: + var e1 := b - a + var e2 := c - a + var p := dir.cross(e2) + var det := e1.dot(p) + if absf(det) < 0.000001: + return -1.0 + var inv := 1.0 / det + var tv := origin - a + var u := tv.dot(p) * inv + if u < 0.0 or u > 1.0: + return -1.0 + var q := tv.cross(e1) + var v := dir.dot(q) * inv + if v < 0.0 or u + v > 1.0: + return -1.0 + return e2.dot(q) * inv diff --git a/src/editor/build_ops.gd.uid b/src/editor/build_ops.gd.uid new file mode 100644 index 0000000..367d849 --- /dev/null +++ b/src/editor/build_ops.gd.uid @@ -0,0 +1 @@ +uid://buoucxvl5d35c diff --git a/src/editor/build_tool.gd b/src/editor/build_tool.gd new file mode 100644 index 0000000..14b9362 --- /dev/null +++ b/src/editor/build_tool.gd @@ -0,0 +1,334 @@ +extends Node3D +## Bauen-Werkzeug (Karte t_97ea59c5): Modular-Builder-Pieces in der App setzen — +## Fundament/Wand/Dach/Treppe/Tür/Fenster auf einem 2-m-Modulraster. +## +## Bedienung (Chef-Grundsatz Sly: easy, deutsch): +## Bauteil rechts anklicken → Vorschau folgt dem Cursor (rastet aufs Raster) +## Linksklick → Bauteil setzen (auch AUF andere Teile — zum +## Stapeln: Wand oben drauf, Dach auf die Wände) +## Umschalt+Klick → gesetztes Bauteil AUSWÄHLEN (orange Rahmen) +## R → Vorschau drehen (90°) / Auswahl drehen +## Bild↑/Bild↓ → Auswahl anheben/absenken (0,2 m — Wände aufs +## Fundament setzen) +## Entf → Auswahl löschen +## Rechtsklick/ESC → Auswahl weg, nochmal = Werkzeug beenden +## +## UNDO: EIN Rückgängig-Schritt PRO Bauteil (ActionHistory, Block "gebaeude"). + +const MODE_HELP := "Linksklick: Bauteil setzen · Umschalt+Klick: Bauteil auswählen · R: drehen (90°) ·\nBild↑/↓: anheben/absenken · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück" + +var main # Hauptfenster (script-erweitert, Variant) +var camera: Camera3D +var view_builder # Welt-Bauer (script-erweitert, Variant) +var interaction # Objekt-Interaktion (für Auswahl-Entkoppelung) + +var active := false +var kind_id := "fundament" +var ghost_rot := 0.0 # 90°-Schritte +var selected_index := -1 + +var _ghost: MeshInstance3D +var _cursor_valid := false +var _cursor := Vector3.ZERO + +# Terrain-Cache für den Boden-Raycast (lesen) +var _heights := PackedFloat32Array() +var _res := 0 +var _sx := 0.0 +var _sz := 0.0 + + +func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void: + main = p_main + camera = p_camera + view_builder = p_builder + interaction = p_interaction + _ensure_ghost() + + +func _ensure_ghost() -> void: + if _ghost != null and is_instance_valid(_ghost): + return + _ghost = MeshInstance3D.new() + _ghost.visible = false + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(1.0, 0.75, 0.2, 0.45) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.emission_enabled = true + mat.emission = Color(1.0, 0.6, 0.1) * 0.3 + _ghost.material_override = mat + add_child(_ghost) + + +# ------------------------------------------------------------ Aktivierung --- + +func activate() -> bool: + active = true + selected_index = -1 + ghost_rot = 0.0 + _cursor_valid = false + _refresh_from_doc() + _rebuild_ghost_mesh() + if view_builder != null: + view_builder.call("set_gebaeude_selected", -1) + return true + + +func deactivate() -> void: + active = false + selected_index = -1 + if _ghost != null and is_instance_valid(_ghost): + _ghost.visible = false + if view_builder != null: + view_builder.call("set_gebaeude_selected", -1) + + +## Nach Undo/Redo/Laden/Neu: Cache frisch, Auswahl nur halten, wenn das Teil +## noch existiert. +func refresh_from_doc() -> void: + _refresh_from_doc() + _rebuild_ghost_mesh() + if selected_index >= 0 and selected_index >= BuildOps.piece_count(main.doc): + selected_index = -1 + if active: + view_builder.call("set_gebaeude_selected", selected_index) + + +func _refresh_from_doc() -> void: + var terrain: Dictionary = main.doc.get("terrain", {}) + _res = int(terrain.get("resolution", 0)) + _sx = float(terrain.get("size_x_m", 0.0)) + _sz = float(terrain.get("size_z_m", 0.0)) + if _res >= 2 and _sx > 0.0 and _sz > 0.0: + _heights = SvmapIO.decode_heights(terrain) + else: + _heights = PackedFloat32Array() + + +# ------------------------------------------------------------- Werkzeug-UI -- + +func set_kind(next_kind: String) -> void: + if not BuildOps.KINDS.has(next_kind): + return + kind_id = next_kind + selected_index = -1 + if view_builder != null: + view_builder.call("set_gebaeude_selected", -1) + _rebuild_ghost_mesh() + if active: + main.call("_set_status", "%s ausgewählt — Linksklick setzt es aufs Raster (auch auf andere Bauteile zum Stapeln). Umschalt+Klick wählt gesetzte Teile aus." % BuildOps.kind_label(kind_id)) + + +func _rebuild_ghost_mesh() -> void: + if _ghost == null or not is_instance_valid(_ghost): + return + var probe := BuildOps.make_piece(kind_id, Vector3.ZERO, 0.0) + _ghost.mesh = BuildOps.piece_mesh(probe) + + +# --------------------------------------------------------------- Eingaben --- + +func _unhandled_input(event: InputEvent) -> void: + if not active: + return + if event is InputEventMouseMotion: + _update_cursor((event as InputEventMouseMotion).position) + return + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + if mb.shift_pressed: + _select_at(mb.position) + else: + _place_piece() + get_viewport().set_input_as_handled() + elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: + if selected_index >= 0: + selected_index = -1 + view_builder.call("set_gebaeude_selected", -1) + main.call("_set_status", "Auswahl aufgehoben — Linksklick setzt weiter Bauteile.") + else: + main.call("_set_tool_mode", "objects") + get_viewport().set_input_as_handled() + + +func _update_cursor(viewport_pos: Vector2) -> void: + var hit: Variant = _ray_hit(viewport_pos) + if hit == null: + _cursor_valid = false + if _ghost != null and is_instance_valid(_ghost): + _ghost.visible = false + return + _cursor_valid = true + _cursor = hit as Vector3 + if _ghost != null and is_instance_valid(_ghost): + _ghost.position = BuildOps.snapped_pos(kind_id, _cursor) + _ghost.rotation.y = deg_to_rad(ghost_rot) + _ghost.visible = true + + +## Strahl → Trefferpunkt: ZUERST auf die gebackenen Bauteile (Stapeln!), +## sonst auf das Terrain. Entfernung entscheidet. +func _ray_hit(viewport_pos: Vector2) -> Variant: + var origin := camera.project_ray_origin(viewport_pos) + var dir := camera.project_ray_normal(viewport_pos) + var best: Variant = null + var best_t := INF + var baked: Dictionary = view_builder.call("gebaeude_baked") + if not baked.get("faces", PackedVector3Array()).is_empty(): + var pick := BuildOps.ray_pick(baked, origin, dir) + if not pick.is_empty(): + best_t = (origin - (pick["punkt"] as Vector3)).length() + best = pick["punkt"] + var terrain: Dictionary = main.doc.get("terrain", {}) + if _res >= 2 and _heights.size() >= _res * _res: + var ground: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, origin, dir) + if ground != null: + var t := (origin - (ground as Vector3)).length() + if t < best_t: + best = ground + return best + + +# ------------------------------------------------------------ Platzieren ---- + +func _place_piece() -> void: + if not _cursor_valid: + main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) + return + var before: Dictionary = main.history.snapshot(["gebaeude"]) + var pos: Vector3 = BuildOps.snapped_pos(kind_id, _cursor) + var teil := BuildOps.make_piece(kind_id, pos, ghost_rot) + if teil.is_empty(): + return + BuildOps.ensure_block(main.doc).append(teil) + var label := BuildOps.kind_label(kind_id) + main.history.commit("%s gesetzt" % label, before, main.history.snapshot(["gebaeude"])) + _after_build_change() + main.call("_set_status", "%s gesetzt (%d Bauteile). Weiter klicken zum Bauen — R dreht, Strg+Z nimmt das letzte Teil zurück." % [ + label, BuildOps.piece_count(main.doc)]) + + +func _select_at(viewport_pos: Vector2) -> void: + var origin := camera.project_ray_origin(viewport_pos) + var dir := camera.project_ray_normal(viewport_pos) + var baked: Dictionary = view_builder.call("gebaeude_baked") + var pick := BuildOps.ray_pick(baked, origin, dir) + if pick.is_empty(): + selected_index = -1 + view_builder.call("set_gebaeude_selected", -1) + main.call("_set_status", "Kein Bauteil getroffen — Umschalt+Klick direkt AUF ein Bauteil (Wand, Dach …).") + return + selected_index = int(pick["piece"]) + view_builder.call("set_gebaeude_selected", selected_index) + var label := _piece_label(selected_index) + main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Rechtsklick: Auswahl weg." % label) + + +func _piece_label(idx: int) -> String: + var list := BuildOps.pieces(main.doc) + if idx < 0 or idx >= list.size(): + return "Bauteil" + return "%s (Teil %d von %d)" % [BuildOps.kind_label(str((list[idx] as Dictionary).get("id", "?"))), idx + 1, list.size()] + + +func rotate_selected() -> void: + if selected_index < 0: + return + var list := BuildOps.pieces(main.doc) + if selected_index >= list.size(): + return + var teil: Dictionary = list[selected_index] + var before: Dictionary = main.history.snapshot(["gebaeude"]) + teil["rot"] = wrapf(float(teil.get("rot", 0.0)) + 90.0, 0.0, 360.0) + main.history.commit("%s gedreht (90°)" % _piece_label(selected_index), + before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index) + _after_build_change() + view_builder.call("set_gebaeude_selected", selected_index) + + +## Auswahl anheben/absenken (0,2 m — passend zur Fundament-Dicke), merge-gekoppelt. +func lift_selected(delta_m: float) -> void: + if selected_index < 0: + return + var list := BuildOps.pieces(main.doc) + if selected_index >= list.size(): + return + var teil: Dictionary = list[selected_index] + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var before: Dictionary = main.history.snapshot(["gebaeude"]) + teil["pos"] = [float(pos[0]), BuildOps.snap_y(float(pos[1]) + delta_m), float(pos[2])] + main.history.commit("%s %s" % [_piece_label(selected_index), + "angehoben" if delta_m > 0.0 else "abgesenkt"], + before, main.history.snapshot(["gebaeude"]), "bauen-lift:%d" % selected_index) + _after_build_change() + view_builder.call("set_gebaeude_selected", selected_index) + + +func delete_selected() -> void: + if selected_index < 0: + main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus (Entf löscht es dann).", true) + return + var list: Array = BuildOps.pieces(main.doc) + if selected_index >= list.size(): + selected_index = -1 + return + var label := _piece_label(selected_index) + var before: Dictionary = main.history.snapshot(["gebaeude"]) + list.remove_at(selected_index) + main.history.commit("%s gelöscht" % label, before, main.history.snapshot(["gebaeude"])) + selected_index = -1 + _after_build_change() + main.call("_set_status", "%s — Strg+Z holt es zurück." % label.replace(" (Teil", " gelöscht (Teil")) + + +func _after_build_change() -> void: + main.call("_after_structure_change") + if selected_index >= 0 and selected_index < BuildOps.piece_count(main.doc): + view_builder.call("set_gebaeude_selected", selected_index) + + +## R-Taste: Auswahl drehen — sonst die Vorschau (90°-Raster). +func rotate_input() -> void: + if selected_index >= 0: + rotate_selected() + return + ghost_rot = wrapf(ghost_rot + 90.0, 0.0, 360.0) + if _ghost != null and is_instance_valid(_ghost): + _ghost.rotation.y = deg_to_rad(ghost_rot) + main.call("_set_status", "Vorschau gedreht auf %.0f° — Linksklick setzt das Bauteil so." % ghost_rot) + + +## Teil per Baum/Index anwählen (ohne Maus — auch für Demos/Tests). +func select_index(idx: int) -> void: + if idx < 0 or idx >= BuildOps.piece_count(main.doc): + selected_index = -1 + view_builder.call("set_gebaeude_selected", -1) + return + selected_index = idx + view_builder.call("set_gebaeude_selected", idx) + main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Rechtsklick: Auswahl weg." % _piece_label(idx)) + + +# ---------------------------------------------------------- Demo/Headless --- + +## Demo-Hook: Bauteil programmatisch setzen (inkl. History) — exakt wie Klick. +func demo_place(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> Dictionary: + var before: Dictionary = main.history.snapshot(["gebaeude"]) + var teil := BuildOps.make_piece(p_kind, pos, rot_deg) + BuildOps.ensure_block(main.doc).append(teil) + main.history.commit("%s gesetzt" % BuildOps.kind_label(p_kind), + before, main.history.snapshot(["gebaeude"])) + return teil + + +## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus). +func demo_park_ghost(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> void: + set_kind(p_kind) + _cursor = pos + _cursor_valid = true + if _ghost != null and is_instance_valid(_ghost): + _ghost.position = BuildOps.snapped_pos(p_kind, pos) + _ghost.rotation.y = deg_to_rad(rot_deg) + _ghost.visible = true diff --git a/src/editor/build_tool.gd.uid b/src/editor/build_tool.gd.uid new file mode 100644 index 0000000..5cea67f --- /dev/null +++ b/src/editor/build_tool.gd.uid @@ -0,0 +1 @@ +uid://vukeqokihvko diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 7fdf1ca..9102f91 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -49,6 +49,7 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict "materials_r8_b64": Marshalls.raw_to_base64(zero_materials), }, "objects": [], + "gebaeude": [], # Bauen-Modus (Karte t_97ea59c5) — Bauteile, optional "spawn_zones": _default_spawn_zones(size), "bounds": { "min": [-half, -10.0, -half], diff --git a/src/ui/main.gd b/src/ui/main.gd index e3a079f..5f4cfd9 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -27,7 +27,8 @@ var history := ActionHistory.new() var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d) -var tool_mode := "objects" # "objects" | "terrain" | "spawns" (Spawn-Zonen) +var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5) +var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder) # UI-Referenzen var content_tree: Tree @@ -95,6 +96,11 @@ var spawn_status: Label # Live-Validierung (IMMER sichtbar, Loader-Regeln var spawn_sel_label: Label # Welche Zone ist an? var spawn_delete_btn: Button +# Bauen-Werkzeug (Karte t_97ea59c5) — großer deutscher Button + Bauteil-Panel +var build_edit_btn: Button +var build_panel: VBoxContainer +var build_kind_buttons: Dictionary = {} # kind_id → Button + # Map-Check (Karte prüfen) var check_btn: Button var check_dialog: AcceptDialog @@ -229,7 +235,7 @@ func _build_ui() -> void: status_label = Label.new() status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - status_label.clip_text = false + status_label.clip_text = true # langer Text darf das Layout NICHT sprengen status_h.add_child(status_label) stats_objects = Label.new() @@ -424,6 +430,12 @@ func _build_center_viewport() -> SubViewportContainer: world.add_child(spawn_tool) spawn_tool.setup(self, camera, view_builder, interaction) + # Bauen-Werkzeug (Karte t_97ea59c5) — ditto eigener Eingabe-Knoten. + build_tool = Node3D.new() + build_tool.set_script(load("res://src/editor/build_tool.gd")) + world.add_child(build_tool) + build_tool.setup(self, camera, view_builder, interaction) + # Bedien-Hinweis im Viewport (Overlay) help_label = Label.new() help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" @@ -751,6 +763,46 @@ func _build_right_panel() -> PanelContainer: "Klick setzt eine Zone, Klick+Ziehen verschiebt sie. Jede Zone ist " + "EIN Rückgängig-Schritt (Strg+Z). ESC beendet das Spawn-Werkzeug.")) + # --- Bauen (Karte t_97ea59c5): Modular-Builder-Pieces im App-Skelett --- + vbox.add_child(_separator()) + build_edit_btn = Button.new() + build_edit_btn.text = "🏠 Bauen (Häuser)" + build_edit_btn.custom_minimum_size = Vector2(0, 48) + build_edit_btn.toggle_mode = true + build_edit_btn.tooltip_text = "Fundamente, Wände, Dächer, Treppen und Türen bauen — im 2-Meter-Raster, wie Bauklötze" + build_edit_btn.pressed.connect(_on_build_btn_pressed) + vbox.add_child(build_edit_btn) + + build_panel = VBoxContainer.new() + build_panel.add_theme_constant_override("separation", 6) + build_panel.visible = false + vbox.add_child(build_panel) + + build_panel.add_child(_field_label("Bauteil:")) + var build_grid := GridContainer.new() + build_grid.columns = 2 + build_grid.add_theme_constant_override("h_separation", 6) + build_grid.add_theme_constant_override("v_separation", 6) + build_panel.add_child(build_grid) + var build_group := ButtonGroup.new() + for kind_id in BuildOps.KINDS.keys(): + var kind_btn := Button.new() + kind_btn.text = str(BuildOps.KINDS[kind_id]["label"]) + kind_btn.toggle_mode = true + kind_btn.button_group = build_group + kind_btn.custom_minimum_size = Vector2(148, 44) + kind_btn.clip_text = true + kind_btn.tooltip_text = "%s — %s" % [str(BuildOps.KINDS[kind_id]["label"]), str(BuildOps.KINDS[kind_id].get("info", ""))] + kind_btn.pressed.connect(_on_build_kind_pressed.bind(str(kind_id))) + build_kind_buttons[str(kind_id)] = kind_btn + build_grid.add_child(kind_btn) + (build_kind_buttons["fundament"] as Button).set_pressed_no_signal(true) + + build_panel.add_child(_muted_label( + "Linksklick setzt das Bauteil aufs 2-m-Raster — auch AUF andere Teile " + + "(Wände aufs Fundament stapeln, Dach auf die Wände). Jedes Bauteil ist " + + "EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Bauen-Modus.")) + # Signale (Änderungen → Dokument + Ansicht) prop_name.text_changed.connect(_on_prop_changed.bind("name")) prop_author.text_changed.connect(_on_prop_changed.bind("author")) @@ -1005,6 +1057,10 @@ func _unhandled_key_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() KEY_R: if not ctrl: + if tool_mode == "bauen": + build_tool.call("rotate_input") + get_viewport().set_input_as_handled() + return var delta := -15.0 if event.shift_pressed else 15.0 if interaction.placing: interaction.place_rot_deg = wrapf(interaction.place_rot_deg + delta, 0.0, 360.0) @@ -1020,6 +1076,10 @@ func _unhandled_key_input(event: InputEvent) -> void: _set_tool_mode("objects") _set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.") get_viewport().set_input_as_handled() + elif tool_mode == "bauen": + _set_tool_mode("objects") + _set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.") + get_viewport().set_input_as_handled() elif interaction.placing: interaction.cancel_placement() _set_status("Platzieren abgebrochen.") @@ -1033,6 +1093,8 @@ func _unhandled_key_input(event: InputEvent) -> void: return if tool_mode == "spawns": spawn_tool.call("delete_selected") + elif tool_mode == "bauen": + build_tool.call("delete_selected") else: interaction.delete_selected() get_viewport().set_input_as_handled() @@ -1041,15 +1103,21 @@ func _unhandled_key_input(event: InputEvent) -> void: interaction.duplicate_selected() get_viewport().set_input_as_handled() KEY_PAGEUP: - if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ - and not interaction.placing: - interaction.lift_selected(ObjectOps.LIFT_STEP_M) - get_viewport().set_input_as_handled() + if not ctrl and not interaction.placing: + if tool_mode == "bauen": + build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() + elif tool_mode == "objects" and interaction.selected_index >= 0: + interaction.lift_selected(ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() KEY_PAGEDOWN: - if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ - and not interaction.placing: - interaction.lift_selected(-ObjectOps.LIFT_STEP_M) - get_viewport().set_input_as_handled() + if not ctrl and not interaction.placing: + if tool_mode == "bauen": + build_tool.call("lift_selected", -ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() + elif tool_mode == "objects" and interaction.selected_index >= 0: + interaction.lift_selected(-ObjectOps.LIFT_STEP_M) + get_viewport().set_input_as_handled() # --------------------------------------------------------- Bibliothek ------ @@ -1137,16 +1205,24 @@ func _set_tool_mode(mode: String) -> void: if mode == tool_mode: _sync_terrain_button() _sync_spawn_button() + _sync_build_button() return if mode == "terrain": _leave_spawn_mode() + _leave_build_mode() _enter_terrain_mode() elif mode == "spawns": _leave_terrain_mode() + _leave_build_mode() _enter_spawn_mode() + elif mode == "bauen": + _leave_terrain_mode() + _leave_spawn_mode() + _enter_build_mode() else: _leave_terrain_mode() _leave_spawn_mode() + _leave_build_mode() _enter_objects_mode() @@ -1201,6 +1277,49 @@ func _enter_objects_mode() -> void: _base_tool_status() +# ------------------------------------------------------ Bauen-Werkzeug ------ +# (Karte t_97ea59c5 — Modular-Builder-Pieces direkt im App-Skelett; die +# Godot-Editor-UI des Plugins bleibt unangetastet im Addon aktivierbar.) + +func _on_build_btn_pressed() -> void: + if build_edit_btn.button_pressed: + _set_tool_mode("bauen") + else: + _set_tool_mode("objects") + + +func _on_build_kind_pressed(kind_id: String) -> void: + if tool_mode != "bauen": + _set_tool_mode("bauen") + for other in build_kind_buttons: + (build_kind_buttons[other] as Button).button_pressed = false + (build_kind_buttons[kind_id] as Button).button_pressed = true + build_tool.call("set_kind", kind_id) + + +func _enter_build_mode() -> void: + interaction.call("cancel_placement") + interaction.call("clear_selection", true) + tool_mode = "bauen" + build_panel.visible = true + help_label.text = "Linksklick: Bauteil setzen (auch auf andere Teile = stapeln) · Umschalt+Klick: Bauteil auswählen ·\nR: drehen (90°) · Bild↑/↓: anheben/absenken · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück" + build_edit_btn.button_pressed = true + build_tool.call("activate") + _set_status("Bauen-Modus an: Bauteil rechts anklicken, dann in die Karte klicken — alles rastet aufs 2-m-Raster.") + + +func _leave_build_mode() -> void: + if build_tool != null: + build_tool.call("deactivate") + if build_panel != null: + build_panel.visible = false + _sync_build_button() + + +func _sync_build_button() -> void: + build_edit_btn.button_pressed = tool_mode == "bauen" + + func _selected_terrain_mode() -> int: if terrain_mode_lower.button_pressed: return TerrainField.Mode.LOWER @@ -1383,7 +1502,12 @@ func _on_tree_selected() -> void: if _tree_select_lock: return var item := content_tree.get_selected() - if item == null or not item.has_meta("obj_index"): + if item == null: + return + if item.has_meta("gebaeude_index"): + _on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index"))) + return + if not item.has_meta("obj_index"): return var idx := int(item.get_meta("obj_index")) var objects: Array = doc.get("objects", []) @@ -1393,6 +1517,13 @@ func _on_tree_selected() -> void: _set_status("%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen." % object_label(str(objects[idx].get("id", "")))) +## Baum: Bauteil-Klick → Bauen-Modus + Teil anwählen (Karte t_97ea59c5). +func _on_tree_selected_gebaeude(gi: int) -> void: + if tool_mode != "bauen": + _set_tool_mode("bauen") + build_tool.call("select_index", gi) + + ## Auswahl (aus dem Viewport) im Baum mitmarkieren. func _mark_tree_selection(idx: int) -> void: _tree_select_lock = true @@ -1542,6 +1673,8 @@ func _after_structure_change() -> void: _recheck_terrain_mode() if tool_mode == "spawns" and spawn_tool != null: spawn_tool.call("refresh_from_doc") + if tool_mode == "bauen" and build_tool != null: + build_tool.call("refresh_from_doc") # --------------------------------------------------------------- Anzeige --- @@ -1611,13 +1744,25 @@ func _refresh_tree() -> void: point_item.set_text(0, "%s (%.0f, %.0f)" % [ str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])]) + # Gebäude (Karte t_97ea59c5): Bauteile im Baum — Klick wählt das Teil an. + var geb_list: Array = BuildOps.pieces(doc) + if not geb_list.is_empty(): + var geb_item := content_tree.create_item(root) + geb_item.set_text(0, "Gebäude (%d Bauteile)" % geb_list.size()) + for i in geb_list.size(): + var teil: Dictionary = geb_list[i] + var g_child := content_tree.create_item(geb_item) + g_child.set_text(0, "%s #%d" % [BuildOps.kind_label(str(teil.get("id", "?"))), i + 1]) + g_child.set_meta("gebaeude_index", i) + func _refresh_stats() -> void: var count := SvmapDocument.object_count(doc) var poly := SvmapDocument.poly_usage(doc, manifest) var budget := int(SvmapIO.MAX_TOTAL_POLY) - stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [ - count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget] + stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%) · Bauteile: %d" % [ + count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget, + BuildOps.piece_count(doc)] # Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4) var tris := 0 var res := 0 @@ -1674,6 +1819,9 @@ func _maybe_start_demo_mode() -> void: if args.has("--groups-demo"): _start_groups_demo(args) return + if args.has("--bauen-demo"): + _start_bauen_demo(args) + return if not args.has("--ghost-demo"): return var oid := "crate_wood" @@ -1929,4 +2077,61 @@ func _start_groups_demo(args: PackedStringArray) -> void: await _demo_shot(out_dir + "/groups_undo.png") print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir) if args.has("--groups-demo-quit"): + get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind + + +## `godot ... -- --bauen-demo [ordner]` — Desktop-Smoke (Karte t_97ea59c5): +## baut programmatisch ein kleines Haus (Fundament+Wände+Giebel+Dach+Tür-Wand +## +Treppe), zeigt Auswahl + Ghost, speichert/lädt die Map zum Beweis. Die App +## bleibt offen (Sly sieht das Ergebnis live). +func _start_bauen_demo(args: PackedStringArray) -> void: + var out_dir := "/tmp/scopedvr_bauen" + var idx := args.find("--bauen-demo") + if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): + out_dir = args[idx + 1] + DirAccess.make_dir_recursive_absolute(out_dir) + + _set_tool_mode("bauen") + var bt = build_tool # untypisiert (script-erweitert) — kein := möglich + # 1) Ghost-Vorschau am Cursor + erstes Fundament. + bt.call("demo_park_ghost", "wand", Vector3(2.0, 0.2, 2.0), 0.0) + _demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0) + _set_status("Demo 1/3: Bauen-Modus — Bauteil rechts anklicken, die gelbe Vorschau rastet aufs 2-m-Raster.") + await _demo_shot(out_dir + "/bauen_ghost.png") + # 2) Kleines Haus komplett gesetzt (jedes Teil = EIN Undo-Schritt): + # Fundament, 3 Wände, Tür-Wand, 2 Giebelwände, Dach, Treppe. + bt.call("demo_place", "fundament", Vector3(3.0, 0.0, 3.0), 0.0) + bt.call("demo_place", "wand", Vector3(2.0, 0.2, 2.0), 0.0) + bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 0.0) + bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 90.0) + bt.call("demo_place", "tuer", Vector3(4.0, 0.2, 4.0), 90.0) + bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 2.0), 0.0) + bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 4.0), 0.0) + bt.call("demo_place", "dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0) + bt.call("demo_place", "treppe", Vector3(8.0, 0.0, 7.0), 0.0) + _after_structure_change() + bt.call("select_index", 7) # Dach ausgewählt (orangener Rahmen) + _demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0) + _set_status("Demo 2/3: Kleines Haus gebaut — Fundament, Wände, Tür-Öffnung, Giebelwände, Dach, Treppe. Dach ist ausgewählt (orange).") + await _demo_shot(out_dir + "/bauen_haus.png") + # 3) Speichern → Laden → weiterbauen (Abnahme-Flow): Map roundtrippen, + # dann eine zusätzliche Wand — sie muss nach dem Laden sauber passen. + _save_to(out_dir + "/bauen_demo.svmap") + var reload := SvmapIO.load_from_file(out_dir + "/bauen_demo.svmap") + if bool(reload["ok"]): + doc = reload["data"] + history.begin(doc) + _clear_edit_state() + _refresh_all() + var before: Dictionary = history.snapshot(["gebaeude"]) + BuildOps.ensure_block(doc).append(BuildOps.make_piece("fenster", Vector3(2.0, 0.2, 4.0), 90.0)) + history.commit("Wand mit Fenster gesetzt", before, history.snapshot(["gebaeude"])) + _after_structure_change() + bt.call("demo_park_ghost", "fenster", Vector3(6.0, 0.2, 4.0), 90.0) + _demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0) + _set_status("Demo 3/3: Gespeichert, geladen und WEITERGEBAUT (Fenster-Wand dazwischen) — Strg+Z nimmt nur das neue Teil zurück.") + await _demo_shot(out_dir + "/bauen_weiter.png") + print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [ + out_dir, BuildOps.piece_count(doc)]) + if args.has("--bauen-demo-quit"): get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind \ No newline at end of file diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 2320253..f2f31ed 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -26,6 +26,11 @@ var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrf var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius) var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) +var gebaeude_markers: Array = [] # MeshInstance3D je Bauteil (Karte t_97ea59c5) + +var _gebaeude_baked: Dictionary = {} # faces/offsets für Ray-Pick (Bauen-Werkzeug) +var _gebaeude_sel: MeshInstance3D # orangener AABB-Rahmen um das gewählte Teil +var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung) var _base_mat: StandardMaterial3D var _select_mat: StandardMaterial3D @@ -41,6 +46,10 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: child.queue_free() object_markers = [] spawn_markers = [] + gebaeude_markers = [] + _gebaeude_baked = {} + _gebaeude_sel = null + _doc_ref = data _base_mat = StandardMaterial3D.new() _base_mat.albedo_color = OBJECT_COLOR _base_mat.roughness = 0.8 @@ -64,6 +73,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: _build_grid(size_x, size_z, ground_y) _build_objects(data.get("objects", []), manifest) _rebuild_spawn_markers(data) + _build_gebaeude(data) ## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1): @@ -330,4 +340,65 @@ func set_spawn_selected(team: int, pid: String) -> void: var col: Color = TEAM_COLORS[clampi(team, 0, 1)] if team >= 0 else Color.WHITE if marker.get_child_count() > 1 and marker.get_child(1) is Label3D: var label := marker.get_child(1) as Label3D - label.modulate = Color(1.0, 0.85, 0.2) if is_sel else col \ No newline at end of file + label.modulate = Color(1.0, 0.85, 0.2) if is_sel else col + + +# ------------------------------------------------------------ Bauteile ------ +# (Karte t_97ea59c5 — Modular-Builder im App-Skelett; Meshes kommen aus der +# CC0-Plugin-Runtime-Geometrie, Materialien = eigene flache Fallback-Farben.) + +## Bauteile aus dem doc backen und als MeshInstances aufbauen — plus die +## Welt-Flächen cachen, damit das Bauen-Werkzeug präzise auf Wände/Dächer +## klicken (stapeln) und Teile auswählen kann. +func _build_gebaeude(data: Dictionary) -> void: + _gebaeude_baked = BuildOps.bake_faces(data) + var mats := {} + var list: Array = BuildOps.pieces(data) + for i in list.size(): + var teil: Dictionary = list[i] + var mesh := BuildOps.piece_mesh(teil) + if mesh == null: + continue + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.material_override = BuildOps.piece_material(str(teil.get("id", "")), mats) + mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + mi.rotation.y = deg_to_rad(float(teil.get("rot", 0.0))) + add_child(mi) + gebaeude_markers.append(mi) + + +## Gebackene Welt-Flächen aller Bauteile (für das Bauen-Werkzeug: Klicken/ +## Stapeln/Auswählen). Leeres faces = noch nichts gebaut. +func gebaeude_baked() -> Dictionary: + if _gebaeude_baked.is_empty(): + return {"faces": PackedVector3Array(), "offsets": []} + return _gebaeude_baked + + +## Auswahl-Markierung: orangener, halbtransparenter Rahmen um das Bauteil. +func set_gebaeude_selected(idx: int) -> void: + if _gebaeude_sel != null and is_instance_valid(_gebaeude_sel): + _gebaeude_sel.queue_free() + _gebaeude_sel = null + var list: Array = BuildOps.pieces(_doc_ref) + if idx < 0 or idx >= list.size(): + return + var aabb := BuildOps.piece_aabb(list[idx]) + if aabb.size.length() < 0.001: + return + var box := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = aabb.size + Vector3(0.06, 0.06, 0.06) + box.mesh = bm + box.position = aabb.get_center() + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(SELECT_COLOR.r, SELECT_COLOR.g, SELECT_COLOR.b, 0.35) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.emission_enabled = true + mat.emission = SELECT_COLOR * 0.3 + box.material_override = mat + add_child(box) + _gebaeude_sel = box \ No newline at end of file diff --git a/tests/test_bauen.gd b/tests/test_bauen.gd new file mode 100644 index 0000000..a0eca56 --- /dev/null +++ b/tests/test_bauen.gd @@ -0,0 +1,225 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_97ea59c5 — Bauen-Modus (Modular-Builder). +## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd +## Exit 0 = alles grün. Läuft OHNE UI (BuildOps/SvmapIO/ActionHistory) — +## GUI-Feinheiten (Ghost, Panel) verifiziert der Desktop-Smoke (--bauen-demo). + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _cleanup() + _run_tests() + _cleanup() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _cleanup() -> void: + for path in ["user://test_bauen.svmap", "user://test_bauen_legacy.svmap"]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _run_tests() -> void: + _test_catalog() + _test_make_piece_isolation() + _test_piece_meshes() + _test_snap() + _test_bake_and_ray_pick() + _test_undo_redo() + _test_save_load_roundtrip() + _test_legacy_map_without_gebaeude() + + +# ------------------------------------------------------------------ Tests --- + +func _test_catalog() -> void: + print("Katalog") + _check(BuildOps.KINDS.size() == 10, "10 Bauteil-Typen im Katalog") + for kind_id in BuildOps.KINDS.keys(): + var kind: Dictionary = BuildOps.KINDS[kind_id] + _check(str(kind.get("label", "")).length() > 3, "Label für %s" % str(kind_id)) + _check((kind.get("punkte", []) as Array).size() >= 2, "Punkte für %s" % str(kind_id)) + _check((kind.get("raster", "") == "ecke") or (kind.get("raster", "") == "mitte"), + "Raster-Typ für %s" % str(kind_id)) + # Abnahme-Typen vorhanden: Fundament/Wand/Dach/Treppe/Tür + for need in ["fundament", "wand", "dach_giebel", "treppe", "tuer"]: + _check(BuildOps.KINDS.has(need), "Katalog enthält %s" % need) + + +func _test_make_piece_isolation() -> void: + print("make_piece") + var teil := BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0) + _check(not teil.is_empty(), "Bauteil erzeugt") + _check(str(teil["id"]) == "wand", "id übernommen") + # pos kommt aus einem Vector3 (float32!) — Vergleiche mit Toleranz. + _check(absf(float(teil["pos"][0]) - 2.0) < 0.001 and absf(float(teil["pos"][1]) - 0.2) < 0.001 \ + and absf(float(teil["pos"][2]) - 4.0) < 0.001, "Position übernommen") + _check(float(teil["rot"]) == 90.0, "Drehung übernommen") + # Punkte sind KOPIEN (kein Aliasing mit dem Katalog) + var before_x: float = (BuildOps.KINDS["wand"]["punkte"] as Array)[1][0] + (teil["punkte"] as Array)[1][0] = 99.0 + _check(float((BuildOps.KINDS["wand"]["punkte"] as Array)[1][0]) == before_x, + "Katalog-Punkte bleiben unangetastet (keine Alias-Falle)") + var tuer := BuildOps.make_piece("tuer", Vector3.ZERO, 0.0) + _check((tuer["details"] as Array).size() == 1, "Tür-Wand hat 1 Öffnung") + _check(int(((tuer["details"] as Array)[0] as Dictionary)["typ"]) == BuildOps.DETAIL_DOOR, + "Öffnung ist eine Tür") + _check(BuildOps.make_piece("gibts_nicht", Vector3.ZERO, 0.0).is_empty(), + "Unbekannte ID → leeres Teil (kein Crash)") + + +func _test_piece_meshes() -> void: + print("Meshes") + for kind_id in BuildOps.KINDS.keys(): + var mesh := BuildOps.piece_mesh(BuildOps.make_piece(str(kind_id), Vector3.ZERO, 0.0)) + _check(mesh != null and mesh.get_surface_count() > 0, "Mesh für %s" % str(kind_id)) + var wand := BuildOps.piece_mesh(BuildOps.make_piece("wand", Vector3.ZERO, 0.0)) + var tuer := BuildOps.piece_mesh(BuildOps.make_piece("tuer", Vector3.ZERO, 0.0)) + var wand_tris := int(wand.get_faces().size() / 3) + var tuer_tris := int(tuer.get_faces().size() / 3) + _check(wand_tris > 0 and tuer_tris > 0, "Wand/Tür-Meshes haben Dreiecke") + _check(wand_tris != tuer_tris, "Tür-Öffnung verändert das Wand-Mesh (%d vs %d Tris)" % [wand_tris, tuer_tris]) + # Fundament: 2×2×0,2 m — AABB prüfen (lokale Geometrie, Ursprung Fußpunkt) + var f := BuildOps.piece_mesh(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0)) + var size := f.get_aabb().size + _check(absf(size.x - 2.0) < 0.01 and absf(size.z - 2.0) < 0.01 and absf(size.y - 0.2) < 0.01, + "Fundament-AABB 2×0,2×2 m (ist %.2f×%.2f×%.2f)" % [size.x, size.y, size.z]) + + +func _test_snap() -> void: + print("Raster-Snap") + # "ecke" (Wand) → gerade Meter; "mitte" (Fundament) → ungerade Meter. + var w := BuildOps.snapped_pos("wand", Vector3(2.7, 0.11, -3.4)) + _check(w.x == 2.0 and w.z == -4.0, "Wand rastet auf Zellen-Ecken (2, −4)") + _check(absf(w.y - 0.10) < 0.001, "Y rastet auf 5 cm") + var f := BuildOps.snapped_pos("fundament", Vector3(2.7, 0.0, 3.2)) + _check(f.x == 3.0 and f.z == 3.0, "Fundament rastet auf Zellen-Mitte (3, 3)") + # Verbund: Fundament Mitte (3,3) hat Ecken bei 2 und 4 → Wand auf (2, y, 4) + # grenzt bündig. (Konvention, dokumentiert in build_ops.gd.) + + +func _test_bake_and_ray_pick() -> void: + print("Baken + Ray-Pick") + var doc := SvmapDocument.create_new("Bautest", 256.0, "mittag") + # Kleines Haus: Fundament → 3 Wände + Tür-Wand → 2 Giebelwände → Dach + Treppe + var pieces: Array = BuildOps.ensure_block(doc) + pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)) + pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 2.0), 0.0)) # Süd + pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 0.0)) # Nord + pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0)) # West + pieces.append(BuildOps.make_piece("tuer", Vector3(4.0, 0.2, 4.0), 90.0)) # Ost + Tür + pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 2.0), 0.0)) + pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 4.0), 0.0)) + pieces.append(BuildOps.make_piece("dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0)) + pieces.append(BuildOps.make_piece("treppe", Vector3(10.0, 0.0, 9.0), 0.0)) + var baked := BuildOps.bake_faces(doc) + var tris := int((baked["faces"] as PackedVector3Array).size() / 3) + _check(tris > 100, "Haus gebacken: %d Dreiecke" % tris) + _check((baked["offsets"] as Array).size() == 9, "Offsets je Teil (9)") + _check(int(baked["offsets"][0]) == 0, "erstes Teil startet bei Dreieck 0") + _check(int(baked["offsets"][8]) < tris, "letztes Teil liegt im Bereich") + # Ray von oben → trifft Dach (Teil-Index 7) + var pick := BuildOps.ray_pick(baked, Vector3(3.0, 50.0, 3.0), Vector3(0.0, -1.0, 0.0)) + _check(not pick.is_empty(), "Ray von oben trifft das Haus") + _check(int(pick["piece"]) >= 0 and int(pick["piece"]) < 9, "Treffer gehört zu einem Teil (Index %d)" % int(pick["piece"])) + # Ray von der Seite auf Wandhöhe, daneben der Türöffnung → Teil 4 (Tür-Wand) + var side := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.6), Vector3(-1.0, 0.0, 0.0)) + _check(not side.is_empty() and int(side["piece"]) == 4, + "Seiten-Ray trifft die Tür-Wand (Index %s)" % str(side.get("piece", "?"))) + # … und DURCH die Türöffnung hindurch auf die West-Wand (Teil 3) + var door := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.0), Vector3(-1.0, 0.0, 0.0)) + _check(not door.is_empty() and int(door["piece"]) == 3, + "Ray durch die Türöffnung trifft die West-Wand (Index %s)" % str(door.get("piece", "?"))) + # Leer → kein Treffer + _check(BuildOps.ray_pick({"faces": PackedVector3Array(), "offsets": []}, + Vector3.ZERO, Vector3.DOWN).is_empty(), "Leeres Baken → kein Treffer") + # AABB eines Teils liegt an der richtigen Stelle (Dach über 2,2 m) + var aabb := BuildOps.piece_aabb(pieces[7]) + _check(aabb.position.y > 2.0, "Dach-AABB liegt über den Wänden (y=%.2f)" % aabb.position.y) + + +func _test_undo_redo() -> void: + print("Undo/Redo") + var doc := SvmapDocument.create_new("Undo", 256.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + # Teil 1: erster Snapshot MUSS den (leeren) Block enthalten, damit Strg+Z + # das erste Bauteil komplett entfernen kann. + var before: Dictionary = hist.snapshot(["gebaeude"]) + _check(before.has("gebaeude"), "Snapshot enthält den leeren gebaeude-Block") + BuildOps.ensure_block(doc).append(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0)) + hist.commit("Fundament (2×2 m) gesetzt", before, hist.snapshot(["gebaeude"])) + # Teil 2 + 3 + for i in 2: + before = hist.snapshot(["gebaeude"]) + BuildOps.ensure_block(doc).append(BuildOps.make_piece("wand", Vector3(float(i) * 2.0, 0.2, 0.0), 0.0)) + hist.commit("Wand (2 m) gesetzt", before, hist.snapshot(["gebaeude"])) + _check(BuildOps.piece_count(doc) == 3, "3 Bauteile gesetzt") + hist.undo() + _check(BuildOps.piece_count(doc) == 2, "Undo → 2 Bauteile") + hist.undo() + hist.undo() + _check(BuildOps.piece_count(doc) == 0, "3× Undo → alles weg (inkl. erstes Teil)") + hist.redo() + hist.redo() + _check(BuildOps.piece_count(doc) == 2, "2× Redo → 2 Bauteile wieder da") + # Löschen + Undo (wie build_tool.delete_selected) + before = hist.snapshot(["gebaeude"]) + (BuildOps.pieces(doc) as Array).remove_at(0) + hist.commit("Bauteil gelöscht", before, hist.snapshot(["gebaeude"])) + _check(BuildOps.piece_count(doc) == 1, "Löschen → 1 Teil") + hist.undo() + _check(BuildOps.piece_count(doc) == 2, "Undo holt gelöschtes Teil zurück") + + +func _test_save_load_roundtrip() -> void: + print("Speichern/Laden") + var doc := SvmapDocument.create_new("Bauen-Save", 256.0, "mittag") + var pieces: Array = BuildOps.ensure_block(doc) + pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)) + pieces.append(BuildOps.make_piece("tuer", Vector3(2.0, 0.2, 2.0), 90.0)) + pieces.append(BuildOps.make_piece("dach_pyramide", Vector3(3.0, 2.2, 3.0), 0.0)) + var save := SvmapIO.save_to_file("user://test_bauen.svmap", doc) + _check(bool(save["ok"]), "Map mit Bauteilen speichert (ohne Fehler)") + var load := SvmapIO.load_from_file("user://test_bauen.svmap") + _check(bool(load["ok"]), "Map mit Bauteilen lädt (V1–V15 + Prüfsumme grün)") + var loaded: Dictionary = load["data"] + _check(BuildOps.piece_count(loaded) == 3, "3 Bauteile überleben .svmap") + var src: Dictionary = pieces[1] + var dst: Dictionary = (BuildOps.pieces(loaded) as Array)[1] + _check(str(dst["id"]) == "tuer" and float(dst["rot"]) == 90.0, "Bauteil-Felder identisch") + _check((dst["punkte"] as Array) == (src["punkte"] as Array), "Punkte byte-gleich über den Roundtrip") + _check(((dst["details"] as Array)[0] as Dictionary)["w"] == 1.0, "Tür-Öffnung überlebt den Roundtrip") + # Gebackenes Mesh aus GELADENEN Teilen = wie vor dem Speichern (deterministisch) + var before_faces: PackedVector3Array = BuildOps.bake_faces(doc)["faces"] + var after_faces: PackedVector3Array = BuildOps.bake_faces(loaded)["faces"] + _check(before_faces == after_faces, "Baken ist deterministisch (vor/nach Speichern identisch)") + + +func _test_legacy_map_without_gebaeude() -> void: + print("Legacy-Maps") + var doc := SvmapDocument.create_new("Legacy", 256.0, "mittag") + doc.erase("gebaeude") # alte Map ohne die Sektion + var save := SvmapIO.save_to_file("user://test_bauen_legacy.svmap", doc) + _check(bool(save["ok"]), "Map OHNE gebaeude speichert") + var load := SvmapIO.load_from_file("user://test_bauen_legacy.svmap") + _check(bool(load["ok"]), "Map OHNE gebaeude lädt") + _check(BuildOps.piece_count(load["data"]) == 0, "Legacy-Map: 0 Bauteile, kein Crash") + if FileAccess.file_exists("user://test_bauen_legacy.svmap"): + DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_bauen_legacy.svmap")) diff --git a/tests/test_bauen.gd.uid b/tests/test_bauen.gd.uid new file mode 100644 index 0000000..00362a9 --- /dev/null +++ b/tests/test_bauen.gd.uid @@ -0,0 +1 @@ +uid://bj0lkji2uax36 diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 3cb8e39..e9ffa7e 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=~/bin/godot4 rc=0 -for t in test_ux test_editor_svmap test_terrain test_library test_spawns test_testrun; do +for t in test_ux test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)