M2-Karte t_97ea59c5: Modular Builder fest integriert — Bauen-Modus im App-Skelett. Addon CC0-Code unter addons/modular_builder (Demo + nicht-CC0-Texturen entfernt, ADDON_LIZENZ.txt), Plugin im Godot-Editor aktivierbar; App bekommt eigenen Bauen-Modus (build_tool.gd) auf derselben Runtime-Geometrie (modular_piece_geometry/_detail) — keine Godot-Editor-API im App-UI. 10 Bauteile (Fundament/Boden/Wand/Tür/Fenster/Giebel×2/Dach×2/Treppe), 2-m-Raster (ecke/mitte), Klicken zum Stapeln (Ray-Pick auf gebackenen Flächen), 90°-Drehen, Anheben 0,2 m. UNDO: 1 ActionHistory-Eintrag pro Bauteil (Snapshot Block gebaeude). Speichern: Bauteile als optionale Sektion gebaeude in .svmap (checksum-neutral, Legacy-Maps laden weiter); Baken deterministisch aus Teilen (Editor-Ansicht + Match-Export in Karte 2). Statuszeile clip_text (langer Text sprengte Layout → rechtes Panel weg). Tests: test_bauen.gd 87 Checks (Katalog, Meshes, Snap, Baken/Ray, Undo, Save/Laden, Legacy), Suite 421 Checks grün; GUI-Smoke --bauen-demo (Screenshots + Save/Laden-Flow).

This commit is contained in:
Hermes 2026-10-15 23:09:45 +00:00
parent 905edc021a
commit e2954a38ed
96 changed files with 7066 additions and 16 deletions

5
ADDON_LIZENZ.txt Normal file
View file

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

View file

@ -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.

View file

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

View file

@ -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.

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m5 17 15-8 15 8-15 8L5 17Z"/><path d="M5 17v16l15 8 15-8V17M20 25v16"/>
<path d="M38 13v16m-8-8h16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View file

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

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="m12 12 24 24m0-24L12 36" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m5 16 14-8 14 8-14 8L5 16Z"/><path d="M5 16v17l14 8 14-8V16M19 24v17"/>
<path d="M39 23v16m-8-8h16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="19" fill="#9b7bd1" stroke="#222" stroke-width="4"/>
<path d="M32 20v24M20 32h24" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M12 54V10h40v44"/><path d="M19 54V17l26-4v41"/></g><circle cx="39" cy="35" r="2.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View file

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

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M9 24h46M9 42h46" fill="none" stroke="#eee" stroke-width="6" stroke-linecap="round"/>
<path d="M15 13v39M32 13v39M49 13v39" fill="none" stroke="#eee" stroke-width="7" stroke-linecap="round"/>
<path d="M15 13v39M32 13v39M49 13v39" fill="none" stroke="#222" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m8 18 16-9 16 9-16 9-16-9Z"/>
<path d="M8 18v5l16 9 16-9v-5M24 27v5" opacity=".82"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m24 9 18 29H6L24 9Z"/>
<path d="M6 38v3l18 4 18-4v-3M24 38v7" opacity=".82"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M8 16 24 8l16 8-16 8-16-8Z"/>
<path d="M8 16v17l16 8 16-8V16M24 24v17"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M24 8 42 38H6L24 8Z"/>
<path d="m24 16 10 17H14l10-17Z" opacity=".72"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M8 54V29L32 9l24 20v25H8Z" fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"/>
<path d="M8 29h48M32 9v45" fill="none" stroke="#fff" stroke-width="3" opacity=".7"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M11 51V13l42 38z" fill="#eeeeee" stroke="#222222" stroke-width="5" stroke-linejoin="round"/>
<circle cx="11" cy="51" r="4" fill="#c62828"/><circle cx="11" cy="13" r="4" fill="#c62828"/><circle cx="53" cy="51" r="4" fill="#c62828"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M8 20 32 9l24 11-24 12z"/><path d="M8 20v26l24 11 24-11V20M32 32v25"/><path d="m32 32 13-6v14l-13 6z"/></g></svg>

After

Width:  |  Height:  |  Size: 276 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round" d="M10 8h38l8 8v40H10zM19 8v17h25V8M19 56V35h26v21"/><path fill="none" stroke="#fff" stroke-width="4" d="M32 51V40m-6 5 6-6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 293 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M15 15L32 32 49 15M32 32v17" fill="none" stroke="#eeeeee" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="15" cy="15" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/><circle cx="49" cy="15" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/><circle cx="32" cy="49" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M32 6l-8 9h5v12h6V15h5zM58 32l-9-8v5H37v6h12v5zM32 58l8-9h-5V37h-6v12h-5zM6 32l9 8v-5h12v-6H15v-5z" fill="#eee" stroke="#222" stroke-width="2" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="8" fill="#9b7bd1" stroke="#222" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="6">
<path d="M12 50V14M52 50V14M12 25H52"/>
<path d="M8 50H16M48 50H56"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round">
<path d="M15 8h18v32H15z"/>
<path d="M12 8h18l3 3M12 8v32l3 0M12 40h18l3-3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="21" fill="#9b7bd1" stroke="#222" stroke-width="4"/>
<path d="M20 32h24" stroke="#fff" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M5 30 32 8l27 22-5 6L32 19 10 36l-5-6Z" fill="#fff"/>
<path d="M14 35v17h36V35L32 21 14 35Z" fill="none" stroke="#fff" stroke-width="4" stroke-linejoin="round" opacity=".75"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M8 45L32 13 56 45 32 53z" fill="#4d83ae" stroke="#222" stroke-width="4" stroke-linejoin="round"/>
<path d="M32 13v40M8 45l24 8 24-8" fill="none" stroke="#eee" stroke-width="3" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="m8 22 48-12v9L8 32v-10Z" fill="#fff"/>
<path d="M13 34v18h38V24L13 34Z" fill="none" stroke="#fff" stroke-width="4" stroke-linejoin="round" opacity=".75"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View file

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

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<path d="M37 20A15 15 0 0 0 11 14L7 19"/><path d="M7 10v9h9"/>
<path d="M11 28a15 15 0 0 0 26 6l4-5"/><path d="M41 38v-9h-9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round" d="M10 8h38l8 8v40H10zM19 8v17h25V8M19 56V35h26v21"/><path fill="none" stroke="#fff" stroke-width="4" d="M32 40v11m-6-6 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 293 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M8 52h12V40h12V28h12V16h12" fill="none" stroke="#eee" stroke-width="7" stroke-linejoin="round" stroke-linecap="square"/>
<path d="M8 52h12V40h12V28h12V16h12" fill="none" stroke="#222" stroke-width="2" stroke-linejoin="round" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View file

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

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M8 10h32v28H8zM8 20h32M8 29h32M19 10v10m12 0v9M19 29v9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M10 12h44v40H10z"/><path d="M32 12v40M10 32h44"/></g></svg>

After

Width:  |  Height:  |  Size: 222 B

View file

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

View file

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

View file

@ -0,0 +1 @@
uid://bem5uer4jw8ik

View file

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

View file

@ -0,0 +1 @@
uid://dd8jw301grvq8

View file

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

View file

@ -0,0 +1 @@
uid://duvk2kap6rjef

View file

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

View file

@ -0,0 +1 @@
uid://ghgythev6vkt

View file

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

View file

@ -0,0 +1 @@
uid://b065m6e7ey5n0

View file

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

View file

@ -0,0 +1 @@
uid://dsttda167kuhi

View file

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

View file

@ -0,0 +1 @@
uid://bd52re1amu4r2

View file

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

View file

@ -0,0 +1 @@
uid://cg71aq1smh1q3

View file

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

View file

@ -0,0 +1 @@
uid://coqicbv0jfksq

View file

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

View file

@ -0,0 +1 @@
uid://dp1333xlylabf

View file

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

View file

@ -0,0 +1 @@
uid://df6psamkqmyvg

View file

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

View file

@ -0,0 +1 @@
uid://c3m5254irja4m

View file

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

View file

@ -0,0 +1 @@
uid://dpixdrfnwnj5m

View file

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

View file

@ -19,7 +19,7 @@ window/stretch/mode="disabled"
[editor_plugins] [editor_plugins]
enabled=PackedStringArray() enabled=PackedStringArray("res://addons/modular_builder/plugin.cfg")
[rendering] [rendering]

348
src/editor/build_ops.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://buoucxvl5d35c

334
src/editor/build_tool.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://vukeqokihvko

View file

@ -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), "materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
}, },
"objects": [], "objects": [],
"gebaeude": [], # Bauen-Modus (Karte t_97ea59c5) — Bauteile, optional
"spawn_zones": _default_spawn_zones(size), "spawn_zones": _default_spawn_zones(size),
"bounds": { "bounds": {
"min": [-half, -10.0, -half], "min": [-half, -10.0, -half],

View file

@ -27,7 +27,8 @@ var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) 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 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 # UI-Referenzen
var content_tree: Tree 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_sel_label: Label # Welche Zone ist an?
var spawn_delete_btn: Button 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) # Map-Check (Karte prüfen)
var check_btn: Button var check_btn: Button
var check_dialog: AcceptDialog var check_dialog: AcceptDialog
@ -229,7 +235,7 @@ func _build_ui() -> void:
status_label = Label.new() status_label = Label.new()
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL 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) status_h.add_child(status_label)
stats_objects = Label.new() stats_objects = Label.new()
@ -424,6 +430,12 @@ func _build_center_viewport() -> SubViewportContainer:
world.add_child(spawn_tool) world.add_child(spawn_tool)
spawn_tool.setup(self, camera, view_builder, interaction) 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) # Bedien-Hinweis im Viewport (Overlay)
help_label = Label.new() 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" 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 " "Klick setzt eine Zone, Klick+Ziehen verschiebt sie. Jede Zone ist "
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet das Spawn-Werkzeug.")) + "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) # Signale (Änderungen → Dokument + Ansicht)
prop_name.text_changed.connect(_on_prop_changed.bind("name")) prop_name.text_changed.connect(_on_prop_changed.bind("name"))
prop_author.text_changed.connect(_on_prop_changed.bind("author")) 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() get_viewport().set_input_as_handled()
KEY_R: KEY_R:
if not ctrl: 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 var delta := -15.0 if event.shift_pressed else 15.0
if interaction.placing: if interaction.placing:
interaction.place_rot_deg = wrapf(interaction.place_rot_deg + delta, 0.0, 360.0) 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_tool_mode("objects")
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.") _set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
get_viewport().set_input_as_handled() 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: elif interaction.placing:
interaction.cancel_placement() interaction.cancel_placement()
_set_status("Platzieren abgebrochen.") _set_status("Platzieren abgebrochen.")
@ -1033,6 +1093,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
return return
if tool_mode == "spawns": if tool_mode == "spawns":
spawn_tool.call("delete_selected") spawn_tool.call("delete_selected")
elif tool_mode == "bauen":
build_tool.call("delete_selected")
else: else:
interaction.delete_selected() interaction.delete_selected()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -1041,15 +1103,21 @@ func _unhandled_key_input(event: InputEvent) -> void:
interaction.duplicate_selected() interaction.duplicate_selected()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_PAGEUP: KEY_PAGEUP:
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ if not ctrl and not interaction.placing:
and not interaction.placing: if tool_mode == "bauen":
interaction.lift_selected(ObjectOps.LIFT_STEP_M) build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled() 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: KEY_PAGEDOWN:
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \ if not ctrl and not interaction.placing:
and not interaction.placing: if tool_mode == "bauen":
interaction.lift_selected(-ObjectOps.LIFT_STEP_M) build_tool.call("lift_selected", -ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled() 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 ------ # --------------------------------------------------------- Bibliothek ------
@ -1137,16 +1205,24 @@ func _set_tool_mode(mode: String) -> void:
if mode == tool_mode: if mode == tool_mode:
_sync_terrain_button() _sync_terrain_button()
_sync_spawn_button() _sync_spawn_button()
_sync_build_button()
return return
if mode == "terrain": if mode == "terrain":
_leave_spawn_mode() _leave_spawn_mode()
_leave_build_mode()
_enter_terrain_mode() _enter_terrain_mode()
elif mode == "spawns": elif mode == "spawns":
_leave_terrain_mode() _leave_terrain_mode()
_leave_build_mode()
_enter_spawn_mode() _enter_spawn_mode()
elif mode == "bauen":
_leave_terrain_mode()
_leave_spawn_mode()
_enter_build_mode()
else: else:
_leave_terrain_mode() _leave_terrain_mode()
_leave_spawn_mode() _leave_spawn_mode()
_leave_build_mode()
_enter_objects_mode() _enter_objects_mode()
@ -1201,6 +1277,49 @@ func _enter_objects_mode() -> void:
_base_tool_status() _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: func _selected_terrain_mode() -> int:
if terrain_mode_lower.button_pressed: if terrain_mode_lower.button_pressed:
return TerrainField.Mode.LOWER return TerrainField.Mode.LOWER
@ -1383,7 +1502,12 @@ func _on_tree_selected() -> void:
if _tree_select_lock: if _tree_select_lock:
return return
var item := content_tree.get_selected() 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 return
var idx := int(item.get_meta("obj_index")) var idx := int(item.get_meta("obj_index"))
var objects: Array = doc.get("objects", []) 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", "")))) _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. ## Auswahl (aus dem Viewport) im Baum mitmarkieren.
func _mark_tree_selection(idx: int) -> void: func _mark_tree_selection(idx: int) -> void:
_tree_select_lock = true _tree_select_lock = true
@ -1542,6 +1673,8 @@ func _after_structure_change() -> void:
_recheck_terrain_mode() _recheck_terrain_mode()
if tool_mode == "spawns" and spawn_tool != null: if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc") spawn_tool.call("refresh_from_doc")
if tool_mode == "bauen" and build_tool != null:
build_tool.call("refresh_from_doc")
# --------------------------------------------------------------- Anzeige --- # --------------------------------------------------------------- Anzeige ---
@ -1611,13 +1744,25 @@ func _refresh_tree() -> void:
point_item.set_text(0, "%s (%.0f, %.0f)" % [ 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])]) 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: func _refresh_stats() -> void:
var count := SvmapDocument.object_count(doc) var count := SvmapDocument.object_count(doc)
var poly := SvmapDocument.poly_usage(doc, manifest) var poly := SvmapDocument.poly_usage(doc, manifest)
var budget := int(SvmapIO.MAX_TOTAL_POLY) var budget := int(SvmapIO.MAX_TOTAL_POLY)
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [ 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] 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) # Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4)
var tris := 0 var tris := 0
var res := 0 var res := 0
@ -1674,6 +1819,9 @@ func _maybe_start_demo_mode() -> void:
if args.has("--groups-demo"): if args.has("--groups-demo"):
_start_groups_demo(args) _start_groups_demo(args)
return return
if args.has("--bauen-demo"):
_start_bauen_demo(args)
return
if not args.has("--ghost-demo"): if not args.has("--ghost-demo"):
return return
var oid := "crate_wood" var oid := "crate_wood"
@ -1930,3 +2078,60 @@ func _start_groups_demo(args: PackedStringArray) -> void:
print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir) print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--groups-demo-quit"): if args.has("--groups-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind 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

View file

@ -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 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 spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) 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 _base_mat: StandardMaterial3D
var _select_mat: StandardMaterial3D var _select_mat: StandardMaterial3D
@ -41,6 +46,10 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
child.queue_free() child.queue_free()
object_markers = [] object_markers = []
spawn_markers = [] spawn_markers = []
gebaeude_markers = []
_gebaeude_baked = {}
_gebaeude_sel = null
_doc_ref = data
_base_mat = StandardMaterial3D.new() _base_mat = StandardMaterial3D.new()
_base_mat.albedo_color = OBJECT_COLOR _base_mat.albedo_color = OBJECT_COLOR
_base_mat.roughness = 0.8 _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_grid(size_x, size_z, ground_y)
_build_objects(data.get("objects", []), manifest) _build_objects(data.get("objects", []), manifest)
_rebuild_spawn_markers(data) _rebuild_spawn_markers(data)
_build_gebaeude(data)
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1): ## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
@ -331,3 +341,64 @@ func set_spawn_selected(team: int, pid: String) -> void:
if marker.get_child_count() > 1 and marker.get_child(1) is Label3D: if marker.get_child_count() > 1 and marker.get_child(1) is Label3D:
var label := marker.get_child(1) as Label3D var label := marker.get_child(1) as Label3D
label.modulate = Color(1.0, 0.85, 0.2) if is_sel else col 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

225
tests/test_bauen.gd Normal file
View file

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

1
tests/test_bauen.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bj0lkji2uax36

View file

@ -3,7 +3,7 @@
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
GODOT=~/bin/godot4 GODOT=~/bin/godot4
rc=0 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 "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
code=$? code=$?
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1) line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)