Create 3D meshes for the three building types #36

Open
opened 2026-08-24 05:40:13 +01:00 by liamjd · 0 comments
Owner

Create 3D meshes as code/resources for the Colony Rocket (3x3), Sensor Tower (1x1) and Basic Mine (2x2) building types, and the data model that carries them.

This issue covers the meshes and the building data model. The ghost/cursor behaviour that consumes them is #39; tile highlighting and validity is #37. Both depend on this.

Scope

  1. A new BuildingFootprint resource (below).
  2. Three fields on BuildingType: mesh: Mesh, footprint: BuildingFootprint, height_levels: int.
  3. One Mesh per building type, plus a footprint for each.

BuildingFootprint

Footprints are not all square. Rectangles are expected and L-shapes are planned, so an int side length cannot express the data. Shapes are authored as a small ASCII grid:

class_name BuildingFootprint
extends Resource
## The tiles a building occupies, authored as a small ASCII grid.
##
## 'X' is an occupied tile, '.' empty, '@' the anchor - the tile that sits under
## the cursor during placement, and itself occupied. Cells are stored as offsets
## from the anchor, so every consumer is `hovered_cell + offset` and rotation is
## a rotation about the cursor.

@export_multiline var shape: String = "@":
	set(value):
		shape = value
		_rebuild()

var _cells: Array[Vector2i] = []   # NOT exported - it would serialise and go stale
var _bounds: Rect2i

func cells() -> Array[Vector2i]
func bounds() -> Rect2i                        # for centring the ghost mesh
func rotated(steps: int) -> Array[Vector2i]    # pure; never mutates

Authored shapes:

XXX        @X        X          X.
X@X        XX        @          X.
XXX                             @X

rocket     mine      tower      (an L, for later)
3x3        2x2       1x1

The anchor is authored, not derived

"The centre" is undefined for an L-shape and lands on a tile corner for any even-sided rectangle. Storing cells as offsets from an explicit anchor makes the tile under the cursor be the anchor, so every consumer is hovered_cell + offset with no parity maths anywhere. It also makes rotation a rotation about the cursor tile, which is the behaviour you want.

Note the consequence for the 2x2 mine above: with @ in the top-left, the hovered tile is always the mine's top-left corner. That is now an authoring choice visible in the file, not a hidden rule.

rotated() must be pure

Rotation is Vector2i(x, y) -> Vector2i(-y, x) applied steps times, returning a new array. If footprints become shared .tres files, every BuildingType referencing one holds the same ref-counted instance — a rotation that mutated _cells would turn every building of every type using that shape for the rest of the session. Same hazard class as the shared-preset warning in TerrainGrid.next_shape().

No rotation UI in this issue; just the function and its tests.

Parse in the property setter, not _init()

Verified on 4.7.2: _init() runs before exported values are assigned, so parsing there yields an empty footprint. Property setters do fire when a resource is loaded from disk, so set(value): shape = value; _rebuild() is correct in both the editor and at runtime.

Also verified: @export_multiline serialises to literal newlines in the .tres, so the shape reads as an actual grid in a diff:

[resource]
shape = "XXX
X@X
XXX"

Validation

A hand-typed grid can be wrong in ways an array cannot, so validate: exactly one @, at least one occupied tile, ragged rows (pad or reject — decide), and 4-connectivity if disjoint blobs should be forbidden. A GUT test that walks the catalogue and asserts every footprint parses catches typos at commit time rather than at placement.


Meshes: leave them centred, offset via get_aabb()

An earlier version of this issue said to author each mesh with its origin at the base, following TerrainGrid._build_mesh_library(). That does not apply here. TerrainGrid can do it because MeshLibrary has set_item_mesh_transform(); a bare Mesh on a resource has no such hook, and the primitives have no origin offset of their own. Verified on 4.7.2:

BoxMesh      properties matching "offset": []      aabb P:(-1, -2, -1) S:(2, 4, 2)
CylinderMesh properties matching "offset": []      aabb P:(-0.5, -3, -0.5) S:(1, 6, 1)
PlaneMesh    properties matching "offset": ["center_offset"]

PlaneMesh is the only primitive with an offset. Baking origin-at-base into a box would mean hand-building an ArrayMesh with shifted vertices for every building — a lot of work to avoid one subtraction.

So: leave the meshes centred, and let #39 sit them on the ground with instance.position.y = ground_y - mesh.get_aabb().position.y. The caller never needs to know the building's height, and it keeps working unchanged if a mesh later becomes a hand-built ArrayMesh whose origin is at its base.

Simple boxes with a distinct colour/tint per type are enough for now; no art assets, consistent with how TerrainGrid generates its mesh library at runtime.

For a non-rectangular footprint, model the mesh to suit its bounding box#39 centres the ghost on bounds(), not on the occupied tiles' centre of mass.

Why Mesh rather than PackedScene

Decided 2026-08-24. A single Mesh per type keeps the ghost trivial: one MeshInstance3D with one material_override. material_override does not propagate to child MeshInstance3D nodes, so a multi-part PackedScene building would force the ghost to walk the tree tinting each part.

Revisit if and when buildings need per-instance behaviour or animated parts.

Height

height_levels: int on BuildingType, in terrain level units (x level_height), matching how the grid already quantises.

On BuildingType rather than on BuildingFootprint so a shared generic shape (a plain 2x2) can serve buildings of different heights. The mesh's real height via get_aabb().size.y is art and is allowed to disagree with height_levels, which is simulation — name that distinction in the doc comment, or someone will later "fix" the mismatch.

Create 3D meshes as code/resources for the Colony Rocket (3x3), Sensor Tower (1x1) and Basic Mine (2x2) building types, and the data model that carries them. This issue covers **the meshes and the building data model**. The ghost/cursor behaviour that consumes them is #39; tile highlighting and validity is #37. Both depend on this. ## Scope 1. A new `BuildingFootprint` resource (below). 2. Three fields on `BuildingType`: `mesh: Mesh`, `footprint: BuildingFootprint`, `height_levels: int`. 3. One `Mesh` per building type, plus a footprint for each. --- ## `BuildingFootprint` Footprints are **not** all square. Rectangles are expected and L-shapes are planned, so an `int` side length cannot express the data. Shapes are authored as a small ASCII grid: ```gdscript class_name BuildingFootprint extends Resource ## The tiles a building occupies, authored as a small ASCII grid. ## ## 'X' is an occupied tile, '.' empty, '@' the anchor - the tile that sits under ## the cursor during placement, and itself occupied. Cells are stored as offsets ## from the anchor, so every consumer is `hovered_cell + offset` and rotation is ## a rotation about the cursor. @export_multiline var shape: String = "@": set(value): shape = value _rebuild() var _cells: Array[Vector2i] = [] # NOT exported - it would serialise and go stale var _bounds: Rect2i func cells() -> Array[Vector2i] func bounds() -> Rect2i # for centring the ghost mesh func rotated(steps: int) -> Array[Vector2i] # pure; never mutates ``` Authored shapes: ``` XXX @X X X. X@X XX @ X. XXX @X rocket mine tower (an L, for later) 3x3 2x2 1x1 ``` ### The anchor is authored, not derived "The centre" is undefined for an L-shape and lands on a tile *corner* for any even-sided rectangle. Storing cells as offsets from an explicit anchor makes the tile under the cursor **be** the anchor, so every consumer is `hovered_cell + offset` with no parity maths anywhere. It also makes rotation a rotation about the cursor tile, which is the behaviour you want. Note the consequence for the 2x2 mine above: with `@` in the top-left, the hovered tile is always the mine's top-left corner. That is now an authoring choice visible in the file, not a hidden rule. ### `rotated()` must be pure Rotation is `Vector2i(x, y) -> Vector2i(-y, x)` applied `steps` times, **returning a new array**. If footprints become shared `.tres` files, every `BuildingType` referencing one holds the same ref-counted instance — a rotation that mutated `_cells` would turn every building of every type using that shape for the rest of the session. Same hazard class as the shared-preset warning in `TerrainGrid.next_shape()`. No rotation UI in this issue; just the function and its tests. ### Parse in the property setter, not `_init()` Verified on 4.7.2: `_init()` runs **before** exported values are assigned, so parsing there yields an empty footprint. Property setters **do** fire when a resource is loaded from disk, so `set(value): shape = value; _rebuild()` is correct in both the editor and at runtime. Also verified: `@export_multiline` serialises to literal newlines in the `.tres`, so the shape reads as an actual grid in a diff: ``` [resource] shape = "XXX X@X XXX" ``` ### Validation A hand-typed grid can be wrong in ways an array cannot, so validate: exactly one `@`, at least one occupied tile, ragged rows (pad or reject — decide), and 4-connectivity if disjoint blobs should be forbidden. A GUT test that walks the catalogue and asserts every footprint parses catches typos at commit time rather than at placement. --- ## Meshes: leave them centred, offset via `get_aabb()` An earlier version of this issue said to author each mesh with its origin at the base, following `TerrainGrid._build_mesh_library()`. **That does not apply here.** `TerrainGrid` can do it because `MeshLibrary` has `set_item_mesh_transform()`; a bare `Mesh` on a resource has no such hook, and the primitives have no origin offset of their own. Verified on 4.7.2: ``` BoxMesh properties matching "offset": [] aabb P:(-1, -2, -1) S:(2, 4, 2) CylinderMesh properties matching "offset": [] aabb P:(-0.5, -3, -0.5) S:(1, 6, 1) PlaneMesh properties matching "offset": ["center_offset"] ``` `PlaneMesh` is the only primitive with an offset. Baking origin-at-base into a box would mean hand-building an `ArrayMesh` with shifted vertices for every building — a lot of work to avoid one subtraction. So: **leave the meshes centred**, and let #39 sit them on the ground with `instance.position.y = ground_y - mesh.get_aabb().position.y`. The caller never needs to know the building's height, and it keeps working unchanged if a mesh later becomes a hand-built `ArrayMesh` whose origin *is* at its base. Simple boxes with a distinct colour/tint per type are enough for now; no art assets, consistent with how `TerrainGrid` generates its mesh library at runtime. For a non-rectangular footprint, model the mesh to suit its **bounding box** — #39 centres the ghost on `bounds()`, not on the occupied tiles' centre of mass. ## Why `Mesh` rather than `PackedScene` Decided 2026-08-24. A single `Mesh` per type keeps the ghost trivial: one `MeshInstance3D` with one `material_override`. `material_override` does **not** propagate to child `MeshInstance3D` nodes, so a multi-part `PackedScene` building would force the ghost to walk the tree tinting each part. Revisit if and when buildings need per-instance behaviour or animated parts. ## Height `height_levels: int` on **`BuildingType`**, in terrain level units (x `level_height`), matching how the grid already quantises. On `BuildingType` rather than on `BuildingFootprint` so a shared generic shape (a plain `2x2`) can serve buildings of different heights. The mesh's real height via `get_aabb().size.y` is **art** and is allowed to disagree with `height_levels`, which is **simulation** — name that distinction in the doc comment, or someone will later "fix" the mismatch.
liamjd added
assets
and removed
ui
labels 2026-08-24 05:48:47 +01:00
liamjd changed title from Create 3D meshes for the two building types to Create 3D meshes for the three building types 2026-08-24 19:39:18 +01:00
Sign in to join this conversation.
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder#36
No description provided.