Building footprints, data model and generated placeholder meshes (#36) #42

Merged
liamjd merged 10 commits from 36-building-meshes into main 2026-08-28 22:53:10 +01:00
Owner

Delivers the building data model and the three placeholder meshes.

Closes #36

What's here

BuildingFootprint — the tiles a building occupies, authored as an ASCII grid and
stored as offsets from an explicit @ anchor, so every consumer is hovered_cell + offset
and rotation is a rotation about the cursor tile. Parsing happens in the shape setter,
not _init(), because exported values are assigned after _init() runs but setters do
fire on load from disk.

Validation rejects a shape with no anchor, more than one anchor, no occupied tiles, or
tiles that are not 4-connected (flood fill over the parsed cells, so a C-shape joined
only through its base stays legal while corner-touching tiles do not). cells() and
rotated() both return copies — a shared .tres means every BuildingType referencing
one holds the same ref-counted instance, so a mutating rotation would turn every building
of every type using that shape for the rest of the session.

BuildingType gains mesh, footprint, height_levels, plus placeholder_shape
and placeholder_color for the generated-mesh era.

BuildingMeshes.build(type, tile_size, level_height) — generates a PrimitiveMesh
sized from the footprint's bounds and height_levels, tinted via PrimitiveMesh.material.
An authored type.mesh short-circuits the whole thing and is returned untouched, which is
the migration path to Blender models later.

Resources: six footprints (1x1, 1x2, 2x2, 3x3, L, C) and three building types wired to
them, all three in the catalogue.

Decisions worth recording

Generated, not authored, meshes. tile_size is an @export on TerrainGrid, not a
constant — an authored BoxMesh of size 6.0 would bake a silent coupling to
tile_size = 2.0 into a file that never mentions terrain. Generation derives it. This also
follows TerrainGrid._build_mesh_library(), and leaves the door open to building a
non-rectangular footprint from per-cell boxes, which an authored primitive cannot do.

Meshes stay centred on their origin. A bare Mesh has no equivalent of
MeshLibrary.set_item_mesh_transform() and only PlaneMesh has a center_offset, so
#39 sits a building on the ground with
instance.position.y = ground_y - mesh.get_aabb().position.y. That works for a centred
placeholder and stays correct for an authored mesh whose origin really is at its base.

Two-layer material. PrimitiveMesh.material carries the building's real colour, a
property of the type, shared by every instance. MeshInstance3D.material_override is left
free for #39's ghost tint. Neither knows the other exists, and the material is deliberately
opaque so real buildings stay in the opaque pass — a transparent building would be depth
sorted per object, which is how you get sorting pops against a terraced GridMap on yaw.

Verification

80/80 GUT tests across 5 scripts, no orphans.

  • test_building_footprint.gd (39) — parsing, ragged rows, case, bounds, rotation purity
    and non-aliasing, round-trip from disk, all four validation rules, and a walk over every
    authored .tres.
  • test_building_meshes.gd (25) — sizing per shape, scaling against tile_size and
    level_height rather than hardcoded numbers, the grid-Y vs world-Z axis trap (using a
    non-square 1x2 footprint, the only shape that can distinguish a swap), cylinders never
    overhanging a non-square footprint, the centred-origin contract, material colour /
    matteness / opacity, both failure paths, and a walk over the real catalogue asserting
    every shipped type is exactly height_levels * level_height tall.

Height was the recurring defect — wrong twice, in two different ways, both times passing
a hand-picked example. It is now pinned by an invariant (a cylinder and a box of the same
height_levels must be the same height) rather than by a number.

Visual check: tools/screenshot_buildings.gd drops one of each type onto real plains
terrain using the placement maths #39 will own, and saves a PNG. Run headed —
gl_compatibility renders nothing headless, so no unit test in here says anything about
how this looks. Confirms all three sit exactly on their tile tops and that the three
silhouettes are distinguishable at gameplay zoom.

Known gaps and follow-ups

  • #41 — caching generated meshes. Deferred deliberately: at the time there were no
    callers, and the cache key depends on how #39 and the real placer actually call in.
  • The rocket's olive is close in value to the sand terrain. Visible in the screenshot;
    it is the one building that most needs to be findable at a glance. Worth a more saturated
    colour, but it is one field on a .tres and not worth blocking on.
  • build() does not yet document that its return may be shared. The authored path
    returns type.mesh by reference today, so callers must already treat the result as
    read-only. Worth stating on the function before #39 starts tinting anything.
  • Slope behaviour is unresolved. A multi-tile footprint straddling a terrace boundary
    will clip or float; that is #37's validity rules, not this PR.
  • Possible half-tile inconsistency in terrain's coordinate helpers. cell_to_world()
    computes (cell.x + 0.5) * tile_size, world_to_cell() floors local.x / tile_size,
    and _configure_grid() sets cell_center_x = true. Those three do not obviously agree.
    Nothing in this PR depends on it, but #39 will.

Unblocks #37 and #39.

Delivers the building data model and the three placeholder meshes. Closes #36 ## What's here **`BuildingFootprint`** — the tiles a building occupies, authored as an ASCII grid and stored as offsets from an explicit `@` anchor, so every consumer is `hovered_cell + offset` and rotation is a rotation about the cursor tile. Parsing happens in the `shape` setter, not `_init()`, because exported values are assigned after `_init()` runs but setters do fire on load from disk. Validation rejects a shape with no anchor, more than one anchor, no occupied tiles, or tiles that are not 4-connected (flood fill over the parsed cells, so a C-shape joined only through its base stays legal while corner-touching tiles do not). `cells()` and `rotated()` both return copies — a shared `.tres` means every `BuildingType` referencing one holds the same ref-counted instance, so a mutating rotation would turn every building of every type using that shape for the rest of the session. **`BuildingType`** gains `mesh`, `footprint`, `height_levels`, plus `placeholder_shape` and `placeholder_color` for the generated-mesh era. **`BuildingMeshes.build(type, tile_size, level_height)`** — generates a `PrimitiveMesh` sized from the footprint's bounds and `height_levels`, tinted via `PrimitiveMesh.material`. An authored `type.mesh` short-circuits the whole thing and is returned untouched, which is the migration path to Blender models later. **Resources**: six footprints (1x1, 1x2, 2x2, 3x3, L, C) and three building types wired to them, all three in the catalogue. ## Decisions worth recording **Generated, not authored, meshes.** `tile_size` is an `@export` on `TerrainGrid`, not a constant — an authored `BoxMesh` of size 6.0 would bake a silent coupling to `tile_size = 2.0` into a file that never mentions terrain. Generation derives it. This also follows `TerrainGrid._build_mesh_library()`, and leaves the door open to building a non-rectangular footprint from per-cell boxes, which an authored primitive cannot do. **Meshes stay centred on their origin.** A bare `Mesh` has no equivalent of `MeshLibrary.set_item_mesh_transform()` and only `PlaneMesh` has a `center_offset`, so #39 sits a building on the ground with `instance.position.y = ground_y - mesh.get_aabb().position.y`. That works for a centred placeholder and stays correct for an authored mesh whose origin really is at its base. **Two-layer material.** `PrimitiveMesh.material` carries the building's real colour, a property of the type, shared by every instance. `MeshInstance3D.material_override` is left free for #39's ghost tint. Neither knows the other exists, and the material is deliberately opaque so real buildings stay in the opaque pass — a transparent building would be depth sorted per object, which is how you get sorting pops against a terraced GridMap on yaw. ## Verification **80/80 GUT tests across 5 scripts**, no orphans. - `test_building_footprint.gd` (39) — parsing, ragged rows, case, bounds, rotation purity and non-aliasing, round-trip from disk, all four validation rules, and a walk over every authored `.tres`. - `test_building_meshes.gd` (25) — sizing per shape, scaling against `tile_size` and `level_height` rather than hardcoded numbers, the grid-Y vs world-Z axis trap (using a non-square 1x2 footprint, the only shape that can distinguish a swap), cylinders never overhanging a non-square footprint, the centred-origin contract, material colour / matteness / opacity, both failure paths, and a walk over the real catalogue asserting every shipped type is exactly `height_levels * level_height` tall. Height was the recurring defect — wrong twice, in two different ways, both times passing a hand-picked example. It is now pinned by an invariant (a cylinder and a box of the same `height_levels` must be the same height) rather than by a number. **Visual check**: `tools/screenshot_buildings.gd` drops one of each type onto real plains terrain using the placement maths #39 will own, and saves a PNG. Run headed — `gl_compatibility` renders nothing headless, so no unit test in here says anything about how this looks. Confirms all three sit exactly on their tile tops and that the three silhouettes are distinguishable at gameplay zoom. ## Known gaps and follow-ups - **#41** — caching generated meshes. Deferred deliberately: at the time there were no callers, and the cache key depends on how #39 and the real placer actually call in. - **The rocket's olive is close in value to the sand terrain.** Visible in the screenshot; it is the one building that most needs to be findable at a glance. Worth a more saturated colour, but it is one field on a `.tres` and not worth blocking on. - **`build()` does not yet document that its return may be shared.** The authored path returns `type.mesh` by reference today, so callers must already treat the result as read-only. Worth stating on the function before #39 starts tinting anything. - **Slope behaviour is unresolved.** A multi-tile footprint straddling a terrace boundary will clip or float; that is #37's validity rules, not this PR. - **Possible half-tile inconsistency in terrain's coordinate helpers.** `cell_to_world()` computes `(cell.x + 0.5) * tile_size`, `world_to_cell()` floors `local.x / tile_size`, and `_configure_grid()` sets `cell_center_x = true`. Those three do not obviously agree. Nothing in this PR depends on it, but #39 will. Unblocks #37 and #39.
liamjd merged commit 91925a7c62 into main 2026-08-28 22:53:10 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
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!42
No description provided.