Skip to content

S8.1 — Plan generation mechanism and bar typologies Implementation Plan

Status: Done (controller, D-084; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.1 · Decisions: D-009, D-026, D-061, D-065, D-079, D-082, D-083, D-084, D-085; ADR-006 (Task 2), ADR-011 (Task 3)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement S8.1 of D-084: the plan generation mechanism and the minimum wall and window geometry for every plan generator, and plan generators for the bar families of the High-Density Housing precedent study (D-082, D-083). A precedent page paraphrases the nine families HDHM-TYP-001 to HDHM-TYP-009; ADR-006 fixes one PlanGenerator subclass per family with typed parameter and preset records and records the design defaults, zone IDs, and realisation of the rules of all nine families; ADR-011 gives every generator a minimum wall length and window size below which an outdoor wall gets no window and an info diagnostic. LinearPlanGenerator is checked as HDHM-TYP-009 and kept; four new generators with Grasshopper components and icons realise HDHM-TYP-001 (Stair Bay Bar), HDHM-TYP-008 (Stair Pair), HDHM-TYP-002 (Gallery Bar, the gallery not modelled, D-083), and HDHM-TYP-006 (Terrace Row, one dwelling zone per plot and storey, D-085). Every existing plan, floor, building, and snapshot stays unchanged, and every new family validates with every plan simplifier and floor aggregator, also on a rotated and moved plan. S8.1 ends with version 0.8.0 and the local tag v0.8.0; nothing is pushed in this run (D-084).

Architecture: Lod.Core.Common.GeometryLimits (MinimumWindowWallLength 1 m, MinimumWindowWidth 0.3 m, MinimumWindowHeight 0.3 m) is the one place for these limits, next to ToleranceSettings. PlanGenerator<TParameters, TPresets> takes it through a new constructor overload (the existing one uses GeometryLimits.Default), passes it with the tolerances to LayoutMeasures.PlaceWindowsByRatio, which now returns a Result with one info WindowOmitted per omitted window, and records non-default limits in provenance. Each family is a folder Lod.Generators/<Family>/ with a parameter record (a static Default holding the ADR-006 defaults), a preset record with one slot per space type it places, and a generator whose Layout returns zone rectangles; the stair bay (dwelling | stair | dwelling) is laid out once by the internal StairBayLayout, shared by StairBayBarGenerator and StairPairGenerator. The plan simplifiers and floor aggregators are not changed. In Lod.Grasshopper a new abstract TypologyGeneratorComponent holds the shared inputs, preset reading, and output of the four new components; scripts/make_icons.py draws four new icons in the Generate green.

Tech Stack: C# 12 (LangVersion 12.0); .NET SDK ≥ 8 (global.json pins 8.0.100 with rollForward: latestMajor); netstandard2.0 core libraries; Clipper2 2.0.0; RhinoCommon and Grasshopper 8.19.25132.1001 (plugin targets net7.0); xUnit 2.9.3, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 17.14.1 (tests target net8.0); Windows PowerShell 5.1 for scripts/verify.ps1; Python 3.10 or later with Pillow for scripts/make_icons.py (the icons in this plan were drawn with Pillow 12.3.0).

Global Constraints

  • Gate: D-082 to D-085 are recorded on main (22e90fd), and this plan is committed to main together with the roadmap's S8.1 stage edits (see Roadmap edits) before Task 1 starts. ADR-006 and ADR-011 (Tasks 2 and 3) are written and accepted before any code task (roadmap S8 gate, D-084); in this run the controller accepts them under D-084, provisional pending the owner's reading. S8.1 depends on S5, which is tagged v0.5.0. Before Task 1 the working tree is clean (git status --short prints nothing).
  • Rhino 8 (8.19 or later) only (D-003); stages exchange Grasshopper objects, not JSON (D-017).
  • Lod.Core and Lod.Generators have no RhinoCommon or Grasshopper reference; their tests run with plain dotnet test.
  • Nullable reference types on; warnings are errors; .editorconfig rules are enforced in the build; every public member of Lod.Core and Lod.Generators has XML docs (CS1591 is an error).
  • Generation is deterministic; outputs have a stable order (zone IDs stairs first, then dwellings, each west to east).
  • Numerical tolerances come only from ToleranceSettings (ADR-004); S8.1 adds none and changes none. The minimum wall and window geometry is defined only in GeometryLimits (ADR-011, GLOBAL.md quality rule 4); a value counts as below a limit only when it is smaller by more than ToleranceSettings.Distance.
  • Existing outputs do not change. Every outdoor wall of the linear plan at its canonical and tested parameters exceeds the ADR-011 limits, and default limits are not recorded in provenance, so the eight existing snapshots (linear-plan-canonical, stack-two-storeys, the three simplifier-*, and the three aggregator-*) stay byte-identical, and every existing test keeps passing. The plan simplifiers, floor aggregators, validation, and Convert2BEM are not changed.
  • Report, never repair (GLOBAL.md quality rule 6). A wall or window below the minimum keeps its geometry and gets no window, reported as the info WindowOmitted; the plan is produced. A parameter value that would remove an essential relationship of a family (a count below the family minimum) or a length that is not positive and finite is the error InvalidParameter, every one reported at once.
  • Typed records per family (AGENTS.md): each family has its own parameter and preset records, even where fields coincide (001 and 008); there is no catch-all parameter class and no dictionary of parameters. Space types are only the existing DwellingUnit and Stair, so the existing default preset components serve every new generator.
  • Source contract (D-082). No book text, page images, or copied drawings enter BEMGen; the precedent page and ADR-006 paraphrase the families with their stable IDs and point to the pinned source commit. The source repository is not opened or modified while this plan is executed; every text the tasks need is given here.
  • No backward compatibility (D-064). Four components are added with new GUIDs: Stair Bay Bar 911c3204-42bb-4023-bdb7-2931ea1caad3, Stair Pair 5669d39b-45e6-47ea-9250-f2304d005335, Gallery Bar a58df462-b3c2-46b7-be3d-7ea9f4629280, Terrace Row f694e595-dba2-4b98-9d1e-f1492e3edf39. No existing component, input, output, or GUID changes; Linear Plan Generator changes its description only.
  • Grasshopper components are thin adaptors (AGENTS.md): every rule lives in Lod.Core or Lod.Generators; the components read inputs, call a generator, and report its diagnostics.
  • Research equivalence rules change only together with the research documentation (GLOBAL.md scientific rule 7): the research brief §10 states the ADR-011 exception in Task 3, before any code.
  • No simulation inside the pipeline (D-016). No CI (D-005): scripts/verify.ps1 passes locally before every merge to main.
  • Commits: type(scope): summary in lower case, no agent, model, or tool attribution and no Co-Authored-By trailers (D-001, D-002); author identity per D-050.
  • Pushes (D-084). Nothing is pushed in this run: slices are rebased onto local main, and the stage is tagged v0.8.0 locally. Task 18 (the push) is not performed in this run; it waits for the owner.
  • .gh example definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.1 adds none.
  • S8.1 changes these earlier files: DiagnosticCodes.cs (S1, last changed in S5: WindowOmitted appended), LayoutMeasures.cs, LayoutSurfaceBuilderTests.cs (S2), PlanGenerator.cs, LinearPlanGenerator.cs, LinearPlanGeneratorTests.cs (S2, last changed in S4.1), PlanGeneratorTests.cs (S4.1), PipelineComponents.cs (S2, last changed in S4.2), scripts/make_icons.py (S4.1, last changed in S4.2), and Directory.Build.props (S0, last changed in S5).

Files

Path Change Responsibility
docs/research/precedents/hdhm-typologies.md Create The nine families paraphrased with family and rule IDs, support labels, the source contract (commit and data-file hashes of D-082), and the BEMGen generator and sub-stage of each
docs/decisions/ADR-006-plan-generation-mechanism.md Create Mechanism, the stair bay, parameter and preset records, defaults with reasons, zone IDs, realisation of the rules and validation of all nine families
docs/decisions/ADR-011-minimum-wall-and-window-geometry.md Create GeometryLimits, the generator rule, WindowOmitted, rebuilt walls and windows across target walls
docs/decisions/ADR-002-geometry.md Modify (status line) "placement rule refined by ADR-011"
docs/LOD_geometric_zoning_equivalence_research_brief.md Modify §10: the ADR-011 exception to the generator rule
src/Lod.Core/Common/GeometryLimits.cs Create The minimum wall and window geometry, validated, with Default
src/Lod.Core/Common/DiagnosticCodes.cs Modify (S1 file, last changed in S5) Append WindowOmitted
src/Lod.Core/Layout/LayoutMeasures.cs Modify (S2 file) PlaceWindowsByRatio with limits and tolerances, returning a Result with WindowOmitted infos
src/Lod.Core/Plans/PlanGenerator.cs Modify (S2 file, last changed in S4.1) Constructor with GeometryLimits; window diagnostics in the result; non-default limits in provenance
tests/Lod.Core.Tests/Common/GeometryLimitsTests.cs Create Defaults, equality, rejected values
tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs Modify (S4.1 file) Short wall, wall at the minimum, window below the minimum size, provenance of limits
tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs Modify (S2 file) The new PlaceWindowsByRatio signature
src/Lod.Generators/StairBayBar/StairBayLayout.cs Create The stair bay, shared by 001 and 008
src/Lod.Generators/StairBayBar/StairBayBarGenerator.cs Create StairBayBarParameters, StairBayBarPresets, StairBayBarGenerator (001)
src/Lod.Generators/StairPair/StairPairGenerator.cs Create StairPairParameters, StairPairPresets, StairPairGenerator (008)
src/Lod.Generators/GalleryBar/GalleryBarGenerator.cs Create GalleryBarParameters, GalleryBarPresets, GalleryBarGenerator (002)
src/Lod.Generators/TerraceRow/TerraceRowGenerator.cs Create TerraceRowParameters, TerraceRowPresets, TerraceRowGenerator (006)
src/Lod.Generators/Linear/LinearPlanGenerator.cs Modify (S2 file, last changed in S4.1) Doc comment: the generator of HDHM-TYP-009
tests/Lod.Generators.Tests/PlanAssertions.cs Create Tiling, the D-026 window rule, neighbours, outdoor facings
tests/Lod.Generators.Tests/StairBayBar/StairBayBarGeneratorTests.cs, StairPair/StairPairGeneratorTests.cs, GalleryBar/GalleryBarGeneratorTests.cs, TerraceRow/TerraceRowGeneratorTests.cs Create Defaults, layout and zone IDs, tiling, windows, family relationships, invalid parameters, presets, determinism, provenance, snapshot
tests/Lod.Generators.Tests/{StairBayBar,StairPair,GalleryBar,TerraceRow}/Snapshots/*-canonical.txt Create Canonical plans of the four families
tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs Modify (S2 file, last changed in S4.1) The linear plan realises HDHM-TYP-009
tests/Lod.Integration.Tests/BarTypologyTests.cs Create Every family × simplifier × aggregator, canonical and rotated and moved; semantic merge of each family
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs Create TypologyGeneratorComponent and the four components
src/Lod.Grasshopper/Components/PipelineComponents.cs Modify (S2 file, last changed in S4.2) Linear Plan Generator description names HDHM-TYP-009
scripts/make_icons.py Modify (S4.1 file, last changed in S4.2) Motifs for the four new components
src/Lod.Grasshopper/Icons/GalleryBarComponent.png, StairBayBarComponent.png, StairPairComponent.png, TerraceRowComponent.png Create (generated) 32 icons
docs/architecture/pipeline.md, docs/architecture/domain-model.md, docs/architecture/validation.md, docs/research/program-presets.md Modify Generators, GeometryLimits, WindowOmitted, the component table through S8.1
docs/development/grasshopper-smoke-test.md Modify Append the S8.1 checklist
Directory.Build.props Modify Version 0.8.0
AGENTS.md, README.md Modify "Project state" and "Status" after S8.1; README layout line
docs/plans/2026-09-30-implementation-roadmap.md Modify Progress line, detailed-plans row, repository layout (the S8.1 stage edits are committed with this plan)
docs/decisions/decision-log.md Modify S8.1 completion entry D-086

Test counts on net8.0 at the start of S8.1: 142 core, 19 generators, 187 integration. After Task 4: 152 / 19 / 187. After Task 5: 152 / 41 / 187. After Task 6: 152 / 53 / 187. After Task 7: 152 / 71 / 187. After Task 8: 152 / 88 / 187. After Task 9: 152 / 91 / 187. After Task 10 and at the end: 152 core, 91 generators, 319 integration (core: +6 GeometryLimitsTests, +4 PlanGeneratorTests; generators: +22 StairBayBarGeneratorTests, +12 StairPairGeneratorTests, +18 GalleryBarGeneratorTests, +17 TerraceRowGeneratorTests, +3 LinearPlanGeneratorTests; integration: +64, +64, and +4 BarTypologyTests). Tests changed, none removed: LayoutSurfaceBuilderTests.WindowsAreSizedByEachZonesRatio calls PlaceWindowsByRatio with the default limits and tolerances and reads .Value; its assertions are unchanged. Grasshopper code has no unit tests; Tasks 11–13 are checked by a 0-warning build and the smoke test of Task 16.

Roadmap edits

The roadmap edits that describe S8.1 to S8.3 as the planned stages are committed together with this plan, before Task 1: the revision note v8 in the header, the S7 and S8.1–S8.3 rows of the stage table with S9 depending on S8.3, the stage diagram, the sentence below it, the S7 section (replaced by the external study, D-082), the S8 section (goal, gate, the three sub-stages, tests), the risk row on windows split between target walls, and the resolved and deferred lists of §7. The close-out (Task 17) adds the Progress line and the S8.1 row of the detailed-plans table (whose heading drops "(Checkpoint 1)") and updates the Common/ and Lod.Generators/ lines of §2 (target repository layout), which describe the implemented code. After both, the roadmap equals the reference's apart from the Progress line and the detailed-plans heading and row.

Task assignment (D-048, execution handoff §6)

Task Agent Why
1 Precedent page bemgen-bounded Document fully given by the plan
2 ADR-006 bemgen-bounded Document fully given by the plan; accepted by the controller under D-084
3 ADR-011 and the research brief bemgen-bounded Documents fully given by the plan; accepted by the controller under D-084
4 Minimum wall and window geometry bemgen-complex Changes the window rule of the generator base; every existing snapshot must stay byte-identical; a new diagnostic code
5 Stair bay bar (001) bemgen-complex New plan generator geometry and the shared bay layout; new snapshot
6 Stair pair (008) bemgen-complex New plan generator on the shared layout; new snapshot
7 Gallery bar (002) bemgen-complex New plan generator geometry; new snapshot
8 Terrace row (006) bemgen-complex New plan generator geometry, the ADR-011 limits on narrow plots; new snapshot
9 Linear plan as 009 bemgen-bounded One verification test and a doc comment, fully given by the plan
10 Bar family matrix bemgen-complex Every family with every simplifier and aggregator, rotated and moved (handoff §6: combination matrix)
11 Typology generator components bemgen-bounded Thin Grasshopper components
12 Linear Plan Generator description bemgen-bounded One description string
13 Icons bemgen-complex Generated binaries and a visual check
14 Architecture documents bemgen-bounded Documents
15 S8.1 smoke-test checklist bemgen-bounded Documents
16 Smoke test in Rhino 8 Manual (person), or the controller headless (D-056) Needs Rhino 8
17 Close-out bemgen-bounded Bookkeeping
18 Push Owner (not performed in this run, D-084) A push is never delegated, and D-084 leaves it to the owner

The end-of-stage review is done by bemgen-complex. A bounded task escalates to bemgen-complex after two failures or any deviation from this plan's expected output.


Slice A — docs/decisions-generation-mechanism

Task 1: Precedent page for the High-Density Housing families

Files: - Create: docs/research/precedents/hdhm-typologies.md

Interfaces: - Consumes: D-082 (source repository, commit 366ffbb, the three data-file hashes), D-083 (one generator per family; no book content; documentation cites family and rule IDs), D-084 (sub-stages). - Produces: the page that ADR-006 (Task 2), pipeline.md (Task 14), and the roadmap's S7 section link to; family IDs HDHM-TYP-001 to HDHM-TYP-009 and rule IDs <family>-R<nnn>; the BEMGen generator and sub-stage of each family.

  • [x] Step 1: Create the branch
git switch -c docs/decisions-generation-mechanism main
  • [x] Step 2: Write docs/research/precedents/hdhm-typologies.md with exactly this content

The page paraphrases the families in BEMGen's words; it holds no book text and no figure. The folder docs/research/precedents/ is new.

# *High-Density Housing* typology families

The precedent source of the plan generators (D-082) is the book *High-Density Housing* (DETAIL). It was studied in a separate research repository, `typology-synthesizer`, which catalogues the book's floor plans with cited claims and specifies nine typology families as topological generator rules. This page paraphrases those families in BEMGen's words, with their stable IDs, and names the BEMGen generator that realises each one (D-083). It contains no book text, no page images, and no copied drawings.

## Source contract

| Item | Value |
| --- | --- |
| Repository | `typology-synthesizer` (local path `D:\typology-synthesizer`, read-only for BEMGen) |
| Commit | `366ffbb` |
| `data/derived/generators.jsonl` | SHA-256 `29700dedd0db2898af4715df0f7e9f6dac0d4e0ab5470a3f62982d9fd3c1f061` |
| `data/derived/typologies.jsonl` | SHA-256 `5bb31c5c2bd9579df4b791e935e03edad7f6f11998cbc87fe5e2f3e573b2b22a` |
| `data/derived/s6_validation.jsonl` | SHA-256 `1ebd21d0528b4e028b84c480cd47e59e38e8812c08c4469af4498b48b04c1a02` |

- Family IDs are `HDHM-TYP-001` to `HDHM-TYP-009`; rule IDs are `<family>-R<nnn>`, in the order the source gives them. Claim, plan, and project IDs, which lead back to the book, stay in the source repository.
- **Support labels.** *Recurrent* (`recurrent_in_source`): the family occurs in several independently identified projects of the book. *Provisional*: one project only; usable as a design hypothesis without independent confirmation. Neither label says anything about housing in general.
- **Topological only.** The rules name building parts and how they connect (which part serves which, which parts adjoin, whether a court is open). The book supports no metric or count range for any family, so every dimension, count, and façade choice in a BEMGen generator is a design input with a default chosen in [ADR-006](../../decisions/ADR-006-plan-generation-mechanism.md) (D-083).
- **Generator inputs** of a family are categorical choices (an access system, a footprint form, a repetition), not ranges of variation.
- Every family has a *constraint* rule (keep all its essential relationships when other properties vary) and a *failure condition* (an organisation that lacks one of them is not a member). BEMGen realises both through parameter validation: a parameter value that would remove an essential relationship is the error `InvalidParameter`.
- BEMGen generators are not tested against the source's abstract validation records (D-083); ADR-006 states in text how each generator realises its family's rules, citing these IDs.

## Overview

| Family | Name | Support | BEMGen generator | Sub-stage |
| --- | --- | --- | --- | --- |
| `HDHM-TYP-001` | Repeated stair bays in a linear bar | recurrent (4 projects) | `StairBayBarGenerator` (*Stair Bay Bar*) | S8.1 |
| `HDHM-TYP-002` | Continuous external gallery beside a linear bar | recurrent (5 projects) | `GalleryBarGenerator` (*Gallery Bar*), gallery not modelled | S8.1 |
| `HDHM-TYP-003` | Projecting wings joined by a common band | recurrent (3 projects) | `WingedBandGenerator` (specified in ADR-006) | S8.2 |
| `HDHM-TYP-004` | Open U-shaped court with stair-served wings | recurrent (2 projects) | `OpenCourtGenerator` (specified in ADR-006) | S8.2 |
| `HDHM-TYP-005` | Enclosed courtyard residential block | provisional | `EnclosedCourtGenerator` (specified in ADR-006) | S8.3 |
| `HDHM-TYP-006` | Attached narrow terrace row | provisional | `TerraceRowGenerator` (*Terrace Row*) | S8.1 |
| `HDHM-TYP-007` | Compact point-access tower plate | provisional | `PointPlateGenerator` (specified in ADR-006) | S8.2 |
| `HDHM-TYP-008` | One stair between a dwelling pair | provisional | `StairPairGenerator` (*Stair Pair*) | S8.1 |
| `HDHM-TYP-009` | Double-sided internal corridor bar | provisional | `LinearPlanGenerator` (*Linear Plan Generator*, since S2) | S8.1 (check) |

## Families

### `HDHM-TYP-001` Repeated stair bays in a linear bar

- **Support:** recurrent. **Inputs:** access system (stairs distributed along the bar), footprint form (a straight tract).
- **Relationships.** One straight residential tract holds repeated dwelling groups (`R001`). Each group along the tract has its own local stair (`R002`). Access stays distributed among the groups; no continuous shared route such as a gallery or corridor joins them (`R003`). `R004` keeps these relationships under variation; `R005` rejects an organisation missing one of them.
- **Not supported by the source:** the number of stair bays, their spacing, how the dwellings are subdivided, and every dimension. A single stair with one dwelling pair is the boundary case that belongs to `HDHM-TYP-008` instead.

### `HDHM-TYP-002` Continuous external gallery beside a linear bar

- **Support:** recurrent. **Inputs:** access system (one continuous external gallery), footprint form (a straight tract).
- **Relationships.** Dwelling modules are arranged in one straight tract (`R001`). A continuous gallery outside the tract serves the successive modules (`R002`); the shared route runs along the modules (`R003`). `R004` and `R005` as above.
- **Not supported by the source:** which side the gallery is on, how the stairs at the ends are arranged, the number of modules, their subdivision, and every dimension.

### `HDHM-TYP-003` Projecting wings joined by a common band

- **Support:** recurrent. **Inputs:** footprint form (a band with branching wings), wing repetition (the wing-to-band relation repeats; count open).
- **Relationships.** Residential wings project from a shared connecting band (`R001`), and the wing-to-band relation repeats without a fixed wing count or shape (`R002`). `R003` keeps these relationships; `R004` rejects an organisation missing one.
- **Not supported by the source:** the number and shape of the wings, the access system, unit subdivision, and dimensions.

### `HDHM-TYP-004` Open U-shaped court with stair-served wings

- **Support:** recurrent. **Inputs:** courtyard form (an open court), footprint form (residential sides in a U).
- **Relationships.** Residential sides enclose a shared space on three sides (`R001`); the fourth side stays open, so the space is an open court (`R002`); dwellings are reached by stairs (`R003`). `R004` and `R005` as above.
- **Not supported by the source:** how the stairs are distributed, the heights of the wings, unit layouts, and dimensions.

### `HDHM-TYP-005` Enclosed courtyard residential block

- **Support:** provisional. **Input:** footprint form (a residential perimeter around an interior court).
- **Relationships.** Residential edges surround an interior courtyard (`R001`), and the court is closed on all sides rather than open like a U (`R002`). `R003` keeps these relationships; `R004` rejects an organisation missing one.
- **Not supported by the source:** the single court observed is not a bound on the number of courts, and the access variants seen (stairs, galleries) do not establish one common access rule.

### `HDHM-TYP-006` Attached narrow terrace row

- **Support:** provisional. **Inputs:** footprint form (a straight row), plot repetition (attached plots repeat; count open).
- **Relationships.** Narrow attached house plots form one straight row (`R001`), repeated along the row (`R002`), with a shared dividing wall between neighbouring plots (`R003`). `R004` and `R005` as above.
- **Not supported by the source:** the number and width of the plots, the rooms inside a house, differences between storey heights, and dimensions.

### `HDHM-TYP-007` Compact point-access tower plate

- **Support:** provisional (one reviewed floor). **Inputs:** access system (point access; number of cores open), footprint form (a compact plate).
- **Relationships.** The dwellings form a compact plate rather than a long connected bar (`R001`), and their access is gathered at one point of vertical circulation (`R002`). `R003` keeps these relationships; `R004` rejects an organisation missing one.
- **Not supported by the source:** the number of cores and units, unit subdivision, which façades the units get, and plate dimensions.

### `HDHM-TYP-008` One stair between a dwelling pair

- **Support:** provisional. **Inputs:** access system (one shared stair), unit arrangement (a dwelling pair around that stair).
- **Relationships.** One compact linear plate holds a single dwelling pair (`R001`); the two dwellings lie on opposite sides of one shared stair (`R002`), which serves both (`R003`). `R004` and `R005` as above.
- **Not supported by the source:** room partitions, balconies, which façades the dwellings get, and plate dimensions.

### `HDHM-TYP-009` Double-sided internal corridor bar

- **Support:** provisional. **Inputs:** access system (an internal corridor), footprint form (a straight bar).
- **Relationships.** A long residential bar has occupiable sides on both flanks of an internal strip (`R001`); a shared linear route runs inside the bar through the floor (`R002`); dwellings (or rooms) lie on both sides of that route (`R003`). `R004` and `R005` as above.
- **Not supported by the source:** how the dwellings are subdivided, the corridor width, façade assignment, and bar dimensions.

## Related documents

- [ADR-006 Plan generation mechanism](../../decisions/ADR-006-plan-generation-mechanism.md): parameter records, defaults, zone IDs, and how each generator realises its family.
- [ADR-011 Minimum wall and window geometry](../../decisions/ADR-011-minimum-wall-and-window-geometry.md)
- [Decision log](../../decisions/decision-log.md): D-082 (source), D-083 (one generator per family), D-084 (sub-stages).
  • [x] Step 3: Check the result
git grep -c "HDHM-TYP-00" -- docs/research/precedents/hdhm-typologies.md
git status --short

Expected: docs/research/precedents/hdhm-typologies.md:20; ?? docs/research/precedents/ and nothing else.

  • [x] Step 4: Commit
git add docs/research/precedents/hdhm-typologies.md
git commit -m "docs(research): add the high-density housing typology families"

Task 2: ADR-006 Plan generation mechanism

Files: - Create: docs/decisions/ADR-006-plan-generation-mechanism.md

Interfaces: - Consumes: D-009, D-026, D-061, D-065, D-082, D-083, D-084, D-085; the S2 base class PlanGenerator<TParameters, TPresets> (Validate, Layout, Describe; presets checked by the base, D-061) and LinearPlanGenerator; the precedent page (Task 1); ADR-002, ADR-011 (Task 3). - Produces: the accepted mechanism that Tasks 5–13 implement: one folder Lod.Generators/<Family>/ per family with <Family>Parameters : PlanParameters (static Default), <Family>Presets : PlanPresets, and <Family>Generator; the shared stair bay (StairBayLayout); the defaults, zone IDs, presets, validation, and realisation of HDHM-TYP-009, 001, 008, 002, and 006 (S8.1) and the specification of 007, 003, 004 (S8.2) and 005 (S8.3).

  • [x] Step 1: Write docs/decisions/ADR-006-plan-generation-mechanism.md with exactly this content
# ADR-006: Plan generation mechanism

Status: Accepted (by the controller under D-084; provisional pending the owner's reading)

Date: 2026-10-02

Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084; applies [ADR-002](ADR-002-geometry.md) and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)

## Context

Since S2 one plan generator exists, `LinearPlanGenerator`, built on the base class `PlanGenerator<TParameters, TPresets>`: the subclass validates its parameters and lays out zones as polygons; the base checks the presets (D-061), builds the surfaces, places the windows (D-026), and records provenance. The precedent study (D-082) gives nine typology families, `HDHM-TYP-001` to `HDHM-TYP-009`, as topological rules without dimensions or counts ([precedent page](../research/precedents/hdhm-typologies.md)). D-083 asks for one generator per family, with the dimensions and counts chosen by the controller as design defaults and recorded here, and with each generator's documentation stating how it realises its family's rules. D-084 implements the bar families (009 checked, 001, 008, 002, 006) in S8.1, the non-rectangular families without holes (007, 003, 004) in S8.2, and the enclosed courtyard (005) in S8.3. This ADR fixes the mechanism and the parameter and preset records of all nine families, so that S8.2 and S8.3 only implement them.

## Options considered

1. **One configurable generator for all families** (a family enum plus a union of parameters). One component, but a catch-all parameter record, which AGENTS.md forbids, and every family's validation in one place.
2. **A rule interpreter over the source's topological graphs.** Close to the source records, but the records carry no geometry: every dimension, count, and placement would still need a family-specific layout, and the interpreter would be a second mechanism next to `PlanGenerator`.
3. **One `PlanGenerator` subclass per family (chosen).** Extends the existing pattern (AGENTS.md working rule 2): a typed parameter record and a typed preset record per family, a layout that returns zone polygons, and the shared base for presets, surfaces, windows, and provenance. Families with the same building block (stair bays) share layout code without sharing records.

## Decision

### Mechanism

- **One generator per family.** Each family has, in `Lod.Generators/<Family>/` (namespace `Lod.Generators.<Family>`), a parameter record `<Family>Parameters : PlanParameters`, a preset record `<Family>Presets : PlanPresets` with one preset per space type it places (D-061), and a generator `<Family>Generator : PlanGenerator<<Family>Parameters, <Family>Presets>`. The family `HDHM-TYP-009` keeps the S2 names (`Lod.Generators/Linear/`, `LinearPlanParameters`, `LinearPlanPresets`, `LinearPlanGenerator`).
- **The subclass** validates its own parameters (`Validate`, every error at once, code `InvalidParameter`), lays out the storey (`Layout`: the footprint and the zones as `PlannedZone(Id, Name, SpaceType, Footprint)` polygons that tile it), and lists its parameters for provenance (`Describe`). It does not build surfaces or windows.
- **The base** (`PlanGenerator`) checks `FloorHeight` (positive) and `OrientationDegrees` (finite), checks every preset slot (`MissingPreset`, `PresetSpaceTypeMismatch`), builds the surfaces with `LayoutSurfaceBuilder` (a gap or overlap between zones is an error, ADR-002), places one centred window per outdoor wall from the zone preset's WWR (D-026) unless the wall or window is below the minimum of [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (no window, info `WindowOmitted`), and records provenance: the family parameters, `FloorHeight`, `OrientationDegrees`, and `Presets` in slot order.
- **Plan frame.** Layouts are axis-aligned in the plan frame of ADR-002, metres, origin at the south-west corner of the footprint, a bar's long axis along +x (plan east) and its depth along +y (plan north). `OrientationDegrees` turns plan north from true north, as for the linear plan; *Transform Plan* (D-070) moves or rotates the result.
- **Defaults.** Every parameter record of S8 has a static `Default` holding the defaults below; the Grasshopper component of the family takes its input defaults from it, so the defaults live in one place. The defaults are design defaults chosen by the controller (D-083): plausible residential dimensions, not values from the source, which supports no metric or count range (D-082). Floor height is 3 m and orientation 0° for every family.
- **Validation.** Lengths must be positive and finite; counts are whole numbers with a family minimum where the family needs repetition. A value that would remove one of the family's essential relationships is rejected (`InvalidParameter`), which is how a generator realises its family's constraint and failure-condition rules (`R004`/`R005`, or `R003`/`R004`). No family has an upper bound.
- **Zones.** One zone per dwelling unit and storey; dwellings are never subdivided into rooms (D-009). Non-dwelling spaces are their own zones. Space types are only `DwellingUnit`, `Stair`, and `Corridor`, so the existing default preset components serve every family; a point-access core is a `Stair` zone. Zone IDs are short, stable, and numbered from 1 west to east (along a wing's axis for wings), like the linear plan's `ST`, `CO`, `US1…`, `UN1…`; display names spell them out (`Unit 3`, `Stair 2`).
- **Windows.** The source fixes no façade or window rule, so every family uses the D-026 rule with the ADR-011 limits. Every outdoor wall above the limits gets one centred window; there is no façade without windows.
- **Not modelled by any family:** rooms (D-009), balconies, galleries (D-083), shading, and differences between storeys (every storey of a plan is the same; vertical variation comes from the floor aggregators).

### Shared building block: the stair bay

A **stair bay** is dwelling | stair | dwelling side by side along a bar, each spanning the full bar depth: the stair serves the two dwellings beside it on every storey, and every dwelling has two opposite outdoor façades. `StairBayLayout` (in `Lod.Generators/StairBayBar/`, internal) lays out *n* bays from given widths; `HDHM-TYP-001`, `HDHM-TYP-008`, and later the wings of 003, 004, and 005 use it, so the bay geometry exists once. In a bay of width `2·UnitWidth + StairWidth`, the west dwelling is `[x₀, x₀ + U]`, the stair `[x₀ + U, x₀ + U + S]`, and the east dwelling `[x₀ + U + S, x₀ + 2U + S]`.

### Families implemented in S8.1

#### `HDHM-TYP-009` Double-sided internal corridor bar: `LinearPlanGenerator` (since S2)

| Parameter | Default | Reason |
| --- | --- | --- |
| `Length` | 24 m | S2 canonical plan; long enough for two units per row behind the stair |
| `UnitDepth` | 8 m | single-aspect units off a corridor are shallower than through units |
| `CorridorWidth` | 2 m | within the usual 1.8–2 m of an internal residential corridor |
| `TargetUnitWidth` | 10 m | the row length is divided evenly (`UnitsPerRow`, rounded half away from zero) |
| `StairLength` | 4 m | a full-depth stair hall at the west end |

- Presets `LinearPlanPresets(DwellingUnit, Corridor, Stair)`; zones `ST` (stair, full depth, west end), `CO` (corridor, from the stair to the east façade), `US1…USn` and `UN1…UNn` (south and north units, west to east).
- Realisation: `R001` the two unit rows flank the corridor strip along the whole bar; `R002` the corridor runs inside the bar from the stair to the east façade; `R003` every row has at least one unit (`UnitsPerRow` ≥ 1), so dwellings lie on both sides. Validation: every length positive; `Length` > `StairLength`.
- Check (D-083): the generator realises all of the family's rules; nothing is added. The component defaults above are unchanged.

#### `HDHM-TYP-001` Repeated stair bays in a linear bar: `StairBayBarGenerator`

| Parameter | Default | Reason |
| --- | --- | --- |
| `BayCount` | 3 | the smallest count with a middle bay between two end bays; minimum 2 |
| `UnitWidth` | 7 m | within 6–8 m for a through dwelling |
| `Depth` | 12 m | a through (double-aspect) dwelling; within 10–12 m |
| `StairWidth` | 3 m | a stair hall with two flights and landings, spanning the depth |

- Presets `StairBayBarPresets(DwellingUnit, Stair)`; the bar is `BayCount` stair bays, `BayCount · (2·UnitWidth + StairWidth)` × `Depth` (51 m × 12 m by default). Zones `ST1…STn` (stairs, west to east) then `U1…U2n` (dwellings, west to east): bay *i* is `U(2i−1)`, `STi`, `U(2i)`.
- Realisation: `R001` one rectangular tract of repeated dwelling groups; `R002` each stair adjoins exactly the two dwellings of its bay, on every storey; `R003` there is no corridor or gallery zone, stairs never adjoin each other, and neighbouring bays meet only dwelling to dwelling (a party wall). `R004`/`R005`: `BayCount` < 2 is `InvalidParameter`, since one bay is `HDHM-TYP-008`.
- Validation: `BayCount` ≥ 2; `UnitWidth`, `Depth`, `StairWidth` positive.

#### `HDHM-TYP-008` One stair between a dwelling pair: `StairPairGenerator`

| Parameter | Default | Reason |
| --- | --- | --- |
| `UnitWidth` | 7 m | as `HDHM-TYP-001` |
| `Depth` | 12 m | as `HDHM-TYP-001` |
| `StairWidth` | 3 m | as `HDHM-TYP-001` |

- Presets `StairPairPresets(DwellingUnit, Stair)`; one stair bay as its own building, 17 m × 12 m by default, laid out by `StairBayLayout` with one bay. Zones `ST1`, `U1` (west), `U2` (east).
- Realisation: `R001` one compact linear plate with one dwelling pair; `R002` the two dwellings lie on opposite sides of the stair; `R003` the stair adjoins both. Validation: every length positive.

#### `HDHM-TYP-002` Continuous external gallery beside a linear bar: `GalleryBarGenerator`

| Parameter | Default | Reason |
| --- | --- | --- |
| `UnitCount` | 6 | a run of modules long enough to need a gallery; minimum 2 |
| `UnitWidth` | 6 m | gallery-access modules are narrow; within 6–8 m |
| `Depth` | 10 m | a through dwelling from the gallery side to the opposite façade |
| `StairWidth` | 4 m | an end stair with the landing that reaches the gallery |

- Presets `GalleryBarPresets(DwellingUnit, Stair)`; a row of `UnitCount` dwellings through the bar depth with a full-depth stair at each end, `UnitCount · UnitWidth + 2 · StairWidth` × `Depth` (44 m × 10 m by default). Zones `ST1` (west end), `ST2` (east end), then `U1…Un` (west to east).
- **Not modelled (D-083):** the gallery itself. There is no gallery zone and no gallery geometry (no shading); the façade on the gallery side is an ordinary outdoor façade with windows by D-026. The source does not fix the gallery side, and the generator does not choose one.
- Realisation: `R001` one straight tract of modules; `R002`, `R003` are realised only in the access they imply: the two end stairs, from which a gallery would run past every module; the gallery is omitted. Validation: `UnitCount` ≥ 2 (successive modules, `R002`); every length positive.

#### `HDHM-TYP-006` Attached narrow terrace row: `TerraceRowGenerator`

| Parameter | Default | Reason |
| --- | --- | --- |
| `PlotCount` | 6 | a short row; minimum 2 (an attached row) |
| `PlotWidth` | 5.5 m | a narrow house plot, 5–6 m |
| `Depth` | 10 m | a through house |

- Presets `TerraceRowPresets(DwellingUnit)`; a row of `PlotCount` plots, `PlotCount · PlotWidth` × `Depth` (33 m × 10 m by default). Zones `U1…Un` (named `House 1…`, west to east). No stair or corridor zones: each house's stair is inside the dwelling and is not zoned (D-009).
- **Houses span storeys.** A plan is one storey, so each plot is one dwelling zone per storey; the floor aggregators stack the storeys. A house of three storeys is therefore three zones in the detailed model (a consequence of the plan → floor → building pipeline that D-009's "one zone per dwelling unit" does not cover; *Single Zone per Floor Type* or *Single Zone Building* merge storeys).
- Realisation: `R001` one straight row of narrow attached plots; `R002` the plot repeats along the row; `R003` neighbouring plots share a dividing wall (an interzone wall pair `Ui`/`Ui+1`). Validation: `PlotCount` ≥ 2; `PlotWidth`, `Depth` positive.

### Families specified for S8.2 and S8.3

Records, defaults, and zoning are fixed here; the layouts are implemented, and may be refined, in their sub-stage, which records any change here. Each wing below is a bar of stair bays: `n = max(1, round(L / (2U + S)))` bays, rounded half away from zero, with the unit width adjusted to `(L − n·S) / (2n)` so the bays fill the wing's length `L` exactly. Validation additionally requires that adjusted width to be positive.

#### `HDHM-TYP-007` Compact point-access tower plate: `PointPlateGenerator` (S8.2)

| Parameter | Default | Reason |
| --- | --- | --- |
| `DwellingDepth` | 8 m | single-aspect corner dwellings around a core |
| `CoreWidth` | 6 m | a square core with stair, lift, and landing |

- Presets `PointPlatePresets(DwellingUnit, Stair)`. A square plate of side `CoreWidth + 2·DwellingDepth` (22 m) with the core `ST` at its centre and four dwellings `U1…U4` around it in a pinwheel, counter-clockwise from the south-west: `[0, d + c] × [0, d]`, `[d + c, s] × [0, d + c]`, `[d, s] × [d + c, s]`, `[0, d] × [d, s]` for `d` = `DwellingDepth`, `c` = `CoreWidth`, `s` = plate side. Every dwelling adjoins the core along one full side and has two façades; the core has no outdoor wall.
- Realisation: `R001` a compact plate (aspect ratio 1) instead of a bar; `R002` every dwelling's access is the one central core. Validation: both lengths positive. The unit count is fixed at four by the pinwheel; the source supports no count.

#### `HDHM-TYP-003` Projecting wings joined by a common band: `WingedBandGenerator` (S8.2)

| Parameter | Default | Reason |
| --- | --- | --- |
| `WingCount` | 3 | repetition of the wing-to-band relation; minimum 2 |
| `WingLength` | 17 m | one stair bay projecting from the band |
| `WingSpacing` | 17 m | clear distance between wings, at least the wing length, so the yards between wings are as wide as they are deep |
| `Depth` | 12 m | band and wing depth, a through dwelling |
| `UnitWidth` | 7 m | as `HDHM-TYP-001` |
| `StairWidth` | 3 m | as `HDHM-TYP-001` |

- Presets `WingedBandPresets(DwellingUnit, Stair)`. The band is a stair-bay bar along x, `y ∈ [0, Depth]`, of length `WingCount · Depth + (WingCount − 1) · WingSpacing` (70 m); wing *i* (from 1) is a stair-bay bar along y, `x ∈ [(i − 1)(Depth + WingSpacing), (i − 1)(Depth + WingSpacing) + Depth]`, `y ∈ [Depth, Depth + WingLength]`. Zones `B-ST1…`, `B-U1…` (band, west to east) and `W{i}-ST1…`, `W{i}-U1…` (wing *i*, south to north).
- Realisation: `R001` every wing adjoins the band along its whole root; `R002` `WingCount` ≥ 2 repeats the relation. The comb outline gives the band's north façade short fragments between the wings' roots and its bay boundaries; ADR-011 governs their windows.

#### `HDHM-TYP-004` Open U-shaped court with stair-served wings: `OpenCourtGenerator` (S8.2)

| Parameter | Default | Reason |
| --- | --- | --- |
| `CourtWidth` | 24 m | twice the wing depth, so the court is daylit |
| `CourtDepth` | 17 m | one stair bay along each side wing |
| `Depth` | 12 m | wing depth, a through dwelling |
| `UnitWidth` | 7 m | as `HDHM-TYP-001` |
| `StairWidth` | 3 m | as `HDHM-TYP-001` |

- Presets `OpenCourtPresets(DwellingUnit, Stair)`. Three stair-bay wings: west `[0, D] × [0, CourtDepth]`, east `[D + CourtWidth, 2D + CourtWidth] × [0, CourtDepth]`, north `[0, 2D + CourtWidth] × [CourtDepth, CourtDepth + D]` (the north wing holds the corners). The court is open to plan south. Zones `N-…`, `W-…`, `E-…` with `ST`/`U` numbering per wing.
- Realisation: `R001` dwellings on three sides of the court; `R002` the fourth (south) side stays open; `R003` every dwelling adjoins a stair. Validation: lengths positive; `CourtWidth` and `CourtDepth` positive (an open court exists).

#### `HDHM-TYP-005` Enclosed courtyard residential block: `EnclosedCourtGenerator` (S8.3)

| Parameter | Default | Reason |
| --- | --- | --- |
| `CourtWidth` | 24 m | twice the wing depth |
| `CourtDepth` | 24 m | a square court |
| `Depth` | 12 m | wing depth, a through dwelling |
| `UnitWidth` | 7 m | as `HDHM-TYP-001` |
| `StairWidth` | 3 m | as `HDHM-TYP-001` |

- Presets `EnclosedCourtPresets(DwellingUnit, Stair)`. Four stair-bay wings around one court: south and north full width, `[0, 2D + CourtWidth] × [0, D]` and `× [D + CourtDepth, 2D + CourtDepth]`; west and east between them, `[0, D]` and `[D + CourtWidth, 2D + CourtWidth]` × `[D, D + CourtDepth]`. The footprint is the outer rectangle with the court as a hole; court façades are outdoor. Zones `S-…`, `N-…`, `W-…`, `E-…`.
- Realisation: `R001` dwellings on every edge of the court; `R002` the court is closed on all four sides (the footprint has exactly one hole). Validation: lengths positive. Requires footprints with holes in every plan simplifier and floor aggregator (S8.3, D-084).

## Consequences

- A new family is a folder with three types and a component; the base keeps presets, surfaces, windows, and provenance identical across families, and every family passes the same tiling, window, determinism, and simplifier × aggregator tests.
- Families do not share parameter or preset records, even where their fields coincide (001 and 008), so each component's inputs match its family exactly (AGENTS.md: typed parameter records per typology).
- The defaults are design choices, recorded here with their reasons; changing one changes the canonical snapshot of its family and is recorded in this ADR.
- The gallery of `HDHM-TYP-002` and the internal stairs of `HDHM-TYP-006` are not in the energy model; results for those families exclude the gallery's shading and the stairs as separate zones.
- `HDHM-TYP-006` houses are one zone per storey; comparisons that count zones per dwelling must take that into account.
- The source's abstract validation records are not used as tests (D-083); a sub-stage that refines a layout updates the realisation text here.
  • [x] Step 2: Check the result
git grep -n -E "^Status:" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git grep -c "HDHM-TYP-00" -- docs/decisions/ADR-006-plan-generation-mechanism.md

Expected: docs/decisions/ADR-006-plan-generation-mechanism.md:3:Status: Accepted (by the controller under D-084; provisional pending the owner's reading) and docs/decisions/ADR-006-plan-generation-mechanism.md:24.

  • [x] Step 3: Commit
git add docs/decisions/ADR-006-plan-generation-mechanism.md
git commit -m "docs(decisions): add adr-006 plan generation mechanism"

Task 3: ADR-011 Minimum wall and window geometry

Files: - Create: docs/decisions/ADR-011-minimum-wall-and-window-geometry.md - Modify: docs/decisions/ADR-002-geometry.md (status line), docs/LOD_geometric_zoning_equivalence_research_brief.md (§10)

Interfaces: - Consumes: D-026, D-039, D-041, D-079, D-084; ADR-002 (placement rule), ADR-004 (tolerances), ADR-012 (centred windows on rebuilt walls), ADR-006 (Task 2). - Produces: the accepted rule that Task 4 implements: GeometryLimits in Lod.Core.Common with MinimumWindowWallLength 1.0 m, MinimumWindowWidth 0.3 m, MinimumWindowHeight 0.3 m (positive and finite; Default); limits passed through the PlanGenerator constructor, non-default limits recorded in provenance; the generator rule (no window and the info WindowOmitted below a limit by more than the distance tolerance); rebuilt walls not subject to the limits; WindowNotHosted and GlazingExceedsWall kept as guards.

  • [x] Step 1: Write docs/decisions/ADR-011-minimum-wall-and-window-geometry.md with exactly this content
# ADR-011: Minimum wall and window geometry

Status: Accepted (by the controller under D-084; provisional pending the owner's reading)

Date: 2026-10-02

Decisions: D-026, D-039, D-041, D-079, D-084; refines [ADR-002](ADR-002-geometry.md) (placement rule of the plan generators); applies [ADR-004](ADR-004-tolerances.md) and [ADR-012](ADR-012-centred-windows.md)

## Context

Every plan generator places one window centred on each outdoor wall, the wall rectangle scaled by `√(WWR)` (D-026, ADR-002). The linear plan's walls are all metres long, so this never produced a degenerate window. The families of S8 ([ADR-006](ADR-006-plan-generation-mechanism.md)) can give a zone short wall fragments: where a wing meets a band, where a bay boundary meets a re-entrant corner, or simply when a user sets a small width. The D-026 rule would put a window of a few centimetres on such a fragment: valid geometry, but no window anyone would build, and a needless detail for every converter. ADR-002 and the roadmap left this to ADR-011, together with how simplifiers treat windows that would straddle target walls (`WindowNotHosted`, D-079).

## Options considered

1. **Keep the D-026 rule on every wall.** Simple and conserves WWR × wall area exactly, but gives arbitrarily small windows on fragments.
2. **Reject plans with short walls.** Forces users and layouts to avoid fragments that are legitimate geometry (a wall of 0.4 m between two re-entrant corners is a real wall).
3. **Merge short fragments into a neighbouring wall's window.** Moves glazing to another wall or orientation, which the D-041/D-079 rules would then have to undo; a silent repair (GLOBAL.md quality rule 6).
4. **No window on a wall or window below a minimum, with an info diagnostic (chosen).** The wall stays as it is; the generator's glazing on that wall is zero and says so. Every later step conserves whatever glazing the generator placed.

## Decision

**Limits.** `GeometryLimits` in `Lod.Core.Common`, next to `ToleranceSettings`, is the only place where these limits are defined (GLOBAL.md quality rule 4). It is immutable; every value must be positive and finite.

| Limit | Default | Reason |
| --- | --- | --- |
| `MinimumWindowWallLength` | 1.0 m | a wall shorter than a metre is a fragment between corners, not a façade bay that would hold a window |
| `MinimumWindowWidth` | 0.3 m | below a frame-and-sash width a window has no glazed area worth modelling |
| `MinimumWindowHeight` | 0.3 m | as the width |

- `GeometryLimits.Default` holds these values. A generator receives limits through the `PlanGenerator` constructor; the one-argument constructor uses `GeometryLimits.Default`, and Grasshopper components use the defaults and expose no input. Non-default limits are recorded in the plan's provenance (`MinimumWindowWallLength`, `MinimumWindowWidth`, `MinimumWindowHeight`); the defaults are not, so existing provenance is unchanged.
- A default changes only through an update of this ADR with a decision-log entry, never to make a test pass.

**Generator rule (all plan generators, in the base `PlanGenerator`).** For every outdoor wall of a generated plan, with glazed area `A_g = WWR × A_w` from its zone's preset (D-026):

1. `A_g = 0`: no window, no diagnostic (as before).
2. The wall is shorter than `MinimumWindowWallLength`: no window.
3. Otherwise the D-026 window is computed (`Window.Centered`); if its width is below `MinimumWindowWidth` or its height below `MinimumWindowHeight`: no window.
4. Otherwise the wall gets that window.

A comparison counts a value as below a limit only when it is smaller than the limit by more than the distance tolerance, so a wall or window exactly at a limit keeps its window despite grid rounding (ADR-004). Each omitted window is the info `WindowOmitted` (subject: the wall's surface ID; the message gives the wall length or the window size, the limit, and the glazed area not placed). The plan is produced; the wall, its zone, and every other surface are unchanged; geometry is never invalid and never repaired.

**Consequences for WWR.** A plan with omitted windows has less glazing than WWR × outdoor wall area. That is the detailed model's glazing; it is reported, not corrected. Validation compares every later model with the plan's actual glazing, so nothing downstream depends on the preset's WWR.

**Simplifiers and single-zone aggregators (D-079).** The limits apply only where a generator places windows. Wherever walls are rebuilt, each target outdoor wall gets one centred window with the glazed area of the source windows it covers (ADR-012), whatever its size, because glazed area is an invariant (GLOBAL.md scientific rule 1); a source wall without a window contributes nothing. With every current zoning strategy a target wall that carries glazing contains at least one whole source wall with a window (see below), so it is never shorter than `MinimumWindowWallLength`; its window may be smaller than the minimum size when a little glazing is spread over a long wall, which is accepted and not reported.

**Windows across target walls.** A window always lies within one source wall, and windows are never split, moved, or capped. Every current zoning strategy cuts the façade only at source wall ends (semantic merge unions source zones; perimeter/core and single-zone cut at footprint vertices), so every source window lies on exactly one target wall, as D-079 requires. A future strategy that cuts a source wall inside a window fails with the error `WindowNotHosted`; it must cut at source wall ends or come with its own ADR. `WindowNotHosted` and `GlazingExceedsWall` stay guards.

## Consequences

- No existing plan changes: every outdoor wall of the linear plan at its canonical and tested parameters exceeds the limits (its shortest outdoor wall is the 2 m corridor end with a 0.89 m × 1.34 m window), so every snapshot is byte-identical.
- One new info code, `WindowOmitted`, appended to `DiagnosticCodes`. Generator components show it as a remark.
- Window and wall limits are central and injectable, like tolerances; tests that probe them construct their own `GeometryLimits`.
- The research brief §10 states the exception to the generator rule.
- ADR-002's placement rule stands, with this exception; its consequence on short fragments is settled here.
  • [x] Step 2: Point ADR-002 to ADR-011

In docs/decisions/ADR-002-geometry.md make this replacement; the "Replace" text occurs exactly once in the file.

  1. Replace
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification)

with

Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window)
  • [x] Step 3: Update the research brief

GLOBAL.md scientific rule 7: the generator rule for windows changes together with the brief. In docs/LOD_geometric_zoning_equivalence_research_brief.md make this replacement in §10; the "Replace" text occurs exactly once in the file. It appends one sentence to the paragraph on the generator rule.

  1. Replace
Rule adopted for this project (D-026, D-039, D-079): walls carry explicit windows, each with a position along the wall, a sill height, a width, and a height (ADR-002). The first plan generator uses one simple deterministic rule: one window per outdoor wall, centred on it and sized from the window-to-wall ratio of the zone's program preset, \(A_\text{glazing} = \text{WWR} \times A_\text{wall}\) (D-026); the window is the wall rectangle scaled about its centre by \(\sqrt{A_\text{glazing} / A_\text{wall}}\). This is that generator's placement rule, not a property of windows.

with

Rule adopted for this project (D-026, D-039, D-079): walls carry explicit windows, each with a position along the wall, a sill height, a width, and a height (ADR-002). The first plan generator uses one simple deterministic rule: one window per outdoor wall, centred on it and sized from the window-to-wall ratio of the zone's program preset, \(A_\text{glazing} = \text{WWR} \times A_\text{wall}\) (D-026); the window is the wall rectangle scaled about its centre by \(\sqrt{A_\text{glazing} / A_\text{wall}}\). This is that generator's placement rule, not a property of windows. Every plan generator uses it (ADR-006), except that a wall shorter than 1 m, or a window narrower or lower than 0.3 m, gets no window, reported as the info `WindowOmitted` (ADR-011); the detailed model's glazing is then what the generator placed, and every later step conserves it.
  • [x] Step 4: Check the result
git grep -n -E "^Status: .*ADR-011" -- docs/decisions
git grep -c "WindowOmitted" -- docs/LOD_geometric_zoning_equivalence_research_brief.md docs/decisions/ADR-011-minimum-wall-and-window-geometry.md

Expected: one line for the first command, the status line of ADR-002 (docs/decisions/ADR-002-geometry.md:3:Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window)); for the second, …research_brief.md:1 and …ADR-011-minimum-wall-and-window-geometry.md:2.

  • [x] Step 5: Commit
git add docs/decisions/ADR-011-minimum-wall-and-window-geometry.md docs/decisions/ADR-002-geometry.md docs/LOD_geometric_zoning_equivalence_research_brief.md
git commit -m "docs(decisions): add adr-011 minimum wall and window geometry"
  • [x] Step 6: Gate — acceptance (controller under D-084)

The roadmap's S8 gate requires ADR-006 and ADR-011 to be accepted before any S8 code. In this run the controller accepts both under D-084, after checking them against D-009, D-026, D-039, D-041, D-061, D-065, D-079, D-082 to D-085, ADR-002, ADR-004, and ADR-012; their status lines already say so ("Accepted (by the controller under D-084; provisional pending the owner's reading)"). The HDHM-TYP-006 section of ADR-006 records the choice of D-085 (one dwelling zone per plot and storey) without citing D-085, which was logged after the ADR text was settled (reviewer note 3). The owner's reading stays open; requested changes are made in a later change, with code changes if the ADRs' rules change. If the check finds a contradiction with a decision, stop and report it instead of merging. Slice B starts only after this branch is merged.

  • [x] Step 7: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/decisions-generation-mechanism
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/decisions-generation-mechanism
git branch -d docs/decisions-generation-mechanism

Expected: verify ends with VERIFY PASSED; its test lines show 142 core, 19 generators, and 187 integration tests passed on net8.0 (this slice changes no code). git pull --ff-only reports Already up to date. or fast-forwards; it fails only if origin/main has commits that local main does not, and then the controller stops and reports. Do not push (D-084).


Slice B — feature/generators-geometry-limits

Task 4: Minimum wall and window geometry in the generator base

Files: - Create: src/Lod.Core/Common/GeometryLimits.cs - Modify: src/Lod.Core/Common/DiagnosticCodes.cs (S1 file, last changed in S5), src/Lod.Core/Layout/LayoutMeasures.cs (S2 file), src/Lod.Core/Plans/PlanGenerator.cs (S2 file, last changed in S4.1) - Test: create tests/Lod.Core.Tests/Common/GeometryLimitsTests.cs; modify tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs (S4.1 file), tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs (S2 file)

Interfaces: - Consumes (S2–S5): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings tolerances), Generate(TParameters, TPresets) : Result<IGeneratedPlan>, abstract Validate, Layout, Describe); LayoutMeasures.PlaceWindowsByRatio(IEnumerable<Surface> surfaces, Func<ZoneId, double> windowToWallRatio) : IReadOnlyList<Surface> (S2); Window.Centered(double wallLength, double wallHeight, double glazedArea) : Window?; WallSurface.Length, Height, Area, WithWindows; Result.Success<T>(T value, IEnumerable<Diagnostic> diagnostics); Diagnostic.Info(string code, string message, string? subject); Provenance.Of, which sorts parameters by key, and Provenance.Format; ToleranceSettings.Distance. - Produces: - public sealed record GeometryLimits(double minimumWindowWallLength, double minimumWindowWidth, double minimumWindowHeight) in Lod.Core.Common (constructor throws ArgumentOutOfRangeException for a value that is not positive and finite), properties MinimumWindowWallLength, MinimumWindowWidth, MinimumWindowHeight, static GeometryLimits Default (1.0, 0.3, 0.3). - DiagnosticCodes.WindowOmitted = "WindowOmitted" (info). - LayoutMeasures.PlaceWindowsByRatio(IEnumerable<Surface> surfaces, Func<ZoneId, double> windowToWallRatio, GeometryLimits limits, ToleranceSettings tolerances) : Result<IReadOnlyList<Surface>>: the S2 rule, except that an outdoor wall with glazing shorter than the minimum, or whose centred window would be narrower or lower than the minimum, by more than the distance tolerance, gets no window and one info WindowOmitted (subject: the wall ID). - protected PlanGenerator(ToleranceSettings tolerances, GeometryLimits limits); the one-argument constructor uses GeometryLimits.Default; protected GeometryLimits Limits; Generate returns the window infos with the plan and records the three limits in provenance only when they differ from the default.

  • [x] Step 1: Create the branch
git switch -c feature/generators-geometry-limits main
  • [x] Step 2: Write the failing tests — create tests/Lod.Core.Tests/Common/GeometryLimitsTests.cs (full file)

Six tests: the defaults of ADR-011, record equality with the default values, and four rejected values (zero, negative, NaN, infinity).

using System;
using Lod.Core.Common;
using Xunit;

namespace Lod.Core.Tests.Common;

public sealed class GeometryLimitsTests
{
    [Fact]
    public void DefaultsMatchAdr011()
    {
        GeometryLimits limits = GeometryLimits.Default;

        Assert.Equal(1.0, limits.MinimumWindowWallLength);
        Assert.Equal(0.3, limits.MinimumWindowWidth);
        Assert.Equal(0.3, limits.MinimumWindowHeight);
    }

    [Fact]
    public void LimitsWithTheDefaultValuesEqualTheDefault()
    {
        Assert.Equal(GeometryLimits.Default, new GeometryLimits(1.0, 0.3, 0.3));
        Assert.NotEqual(GeometryLimits.Default, new GeometryLimits(1.0, 0.4, 0.3));
    }

    [Theory]
    [InlineData(0.0, 0.3, 0.3)]
    [InlineData(1.0, -0.3, 0.3)]
    [InlineData(1.0, 0.3, double.NaN)]
    [InlineData(double.PositiveInfinity, 0.3, 0.3)]
    public void RejectsNonPositiveLimits(double wallLength, double width, double height)
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new GeometryLimits(wallLength, width, height));
    }
}
  • [x] Step 3: Replace tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs (full file)

Four new tests on the test generator TwoZoneGenerator, whose corridor width is now a parameter HallWidth (default 4 m, the S4.1 plan unchanged): a 0.5 m corridor gives two 0.5 m outdoor walls without windows and two WindowOmitted infos with the message checked; a 1.0 m corridor keeps every window and has no diagnostic; limits with a 2 m minimum width omit the corridor's three windows (1.789 m wide) and keep the unit's; only non-default limits appear in provenance, in key order (Provenance.Of sorts the keys). The five S4.1 tests are unchanged.

using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Xunit;

namespace Lod.Core.Tests.Plans;

public sealed class PlanGeneratorTests
{
    private readonly TwoZoneGenerator _generator = new();

    [Fact]
    public void EachZoneGetsThePresetOfItsSlot()
    {
        IGeneratedPlan plan = _generator.Generate(new TwoZoneParameters(3.0), Presets()).Value;

        Assert.Same(ExampleResidentialPresets.DwellingUnit.Program, Assert.Single(plan.Zones, z => z.SpaceType == SpaceType.DwellingUnit).Program);
        Assert.Same(ExampleResidentialPresets.Corridor.Program, Assert.Single(plan.Zones, z => z.SpaceType == SpaceType.Corridor).Program);
        Assert.Contains(plan.Provenance.Parameters, p => p.Key == "Presets" && p.Value == "DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor");
    }

    [Fact]
    public void EveryPresetOfTheWrongSpaceTypeIsAnError()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(3.0), Presets(ExampleResidentialPresets.Stair, ExampleResidentialPresets.Stair));

        Diagnostic[] mismatches = result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).ToArray();
        Assert.False(result.IsSuccess);
        Assert.Equal(new[] { "Unit", "Hall" }, mismatches.Select(d => d.Subject));
        Assert.Equal(
            "Input Unit needs a DwellingUnit preset, got 'Example Stair' (Stair).",
            mismatches[0].Message);
    }

    [Fact]
    public void ANullPresetIsAMissingPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(3.0), new TwoZonePresets(ExampleResidentialPresets.DwellingUnit, null!));

        Assert.False(result.IsSuccess);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.MissingPreset && d.Subject == "Hall");
    }

    [Fact]
    public void ALayoutSpaceTypeWithoutASlotIsAMissingPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(3.0), new UnitOnlyPresets(ExampleResidentialPresets.DwellingUnit));

        Assert.False(result.IsSuccess);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.MissingPreset && d.Subject == "C1");
    }

    [Fact]
    public void PresetErrorsAreReportedWithParameterErrors()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(-1.0), Presets(ExampleResidentialPresets.Corridor));

        Assert.Equal(
            new[] { DiagnosticCodes.InvalidParameter, DiagnosticCodes.PresetSpaceTypeMismatch },
            result.Diagnostics.Select(d => d.Code));
    }

    [Fact]
    public void AWallShorterThanTheMinimumGetsNoWindowAndAnInfo()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(3.0, HallWidth: 0.5), Presets());

        Assert.True(result.IsSuccess);
        WallSurface[] outdoor = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
        WallSurface[] shortWalls = outdoor.Where(w => w.Length < 1.0).ToArray();
        Assert.Equal(2, shortWalls.Length);
        Assert.All(shortWalls, w => Assert.Empty(w.Windows));
        Assert.All(outdoor.Except(shortWalls), w => Assert.Single(w.Windows));
        Diagnostic[] omitted = result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).ToArray();
        Assert.Equal(shortWalls.Select(w => w.Id.Value), omitted.Select(d => d.Subject));
        Assert.All(omitted, d => Assert.Equal(DiagnosticSeverity.Info, d.Severity));
        Assert.Equal(
            "Outdoor wall C1/W1 is 0.5 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.3 m² of glazing not placed).",
            omitted[0].Message);
    }

    [Fact]
    public void AWallAtTheMinimumKeepsItsWindow()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TwoZoneParameters(3.0, HallWidth: 1.0), Presets());

        Assert.Empty(result.Diagnostics);
        Assert.All(result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors), w => Assert.Single(w.Windows));
    }

    [Fact]
    public void AWindowBelowTheMinimumSizeIsOmitted()
    {
        // The corridor's WWR 0.2 gives a 1.789 m wide window on each 4 m wall, below a 2 m minimum width; the unit's WWR 0.3 gives 2.191 m.
        var generator = new TwoZoneGenerator(new GeometryLimits(1.0, 2.0, 0.3));

        Result<IGeneratedPlan> result = generator.Generate(new TwoZoneParameters(3.0), Presets());

        WallSurface[] outdoor = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
        Assert.All(outdoor.Where(w => w.Zone.Value == "C1"), w => Assert.Empty(w.Windows));
        Assert.All(outdoor.Where(w => w.Zone.Value == "U1"), w => Assert.Single(w.Windows));
        Diagnostic[] omitted = result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).ToArray();
        Assert.Equal(3, omitted.Length);
        Assert.All(omitted, d => Assert.StartsWith("C1/", d.Subject));
        Assert.Equal(
            "The window of outdoor wall C1/W1 would be 1.789 m × 1.342 m, below the 2 m × 0.3 m minimum (ADR-011); the wall has no window (2.4 m² of glazing not placed).",
            omitted[0].Message);
    }

    [Fact]
    public void OnlyNonDefaultLimitsAreRecordedInProvenance()
    {
        IGeneratedPlan standard = _generator.Generate(new TwoZoneParameters(3.0), Presets()).Value;
        IGeneratedPlan custom = new TwoZoneGenerator(new GeometryLimits(1.5, 0.3, 0.25)).Generate(new TwoZoneParameters(3.0), Presets()).Value;

        Assert.DoesNotContain(standard.Provenance.Parameters, p => p.Key.StartsWith("Minimum"));
        Assert.Equal(
            new[] { "MinimumWindowHeight=0.25", "MinimumWindowWallLength=1.5", "MinimumWindowWidth=0.3" },
            custom.Provenance.Parameters.Where(p => p.Key.StartsWith("Minimum")).Select(p => $"{p.Key}={p.Value}"));
    }

    private static TwoZonePresets Presets(ProgramPreset? unit = null, ProgramPreset? hall = null) =>
        new(unit ?? ExampleResidentialPresets.DwellingUnit, hall ?? ExampleResidentialPresets.Corridor);

    private sealed record TwoZoneParameters(double FloorHeight, double HallWidth = 4.0)
        : PlanParameters(FloorHeight, 0.0);

    private record TwoZonePresets(ProgramPreset Unit, ProgramPreset Hall) : PlanPresets
    {
        public override IReadOnlyList<PresetSlot> Slots => new[]
        {
            new PresetSlot(nameof(Unit), SpaceType.DwellingUnit, Unit),
            new PresetSlot(nameof(Hall), SpaceType.Corridor, Hall),
        };
    }

    private sealed record UnitOnlyPresets(ProgramPreset Unit) : TwoZonePresets(Unit, Unit)
    {
        public override IReadOnlyList<PresetSlot> Slots => new[] { new PresetSlot(nameof(Unit), SpaceType.DwellingUnit, Unit) };
    }

    /// <summary>A plan 4 m deep: one 4 m dwelling unit <c>U1</c> west of one corridor <c>C1</c>, 4 m wide unless set otherwise.</summary>
    private sealed class TwoZoneGenerator : PlanGenerator<TwoZoneParameters, TwoZonePresets>
    {
        public TwoZoneGenerator()
            : base(ToleranceSettings.Default)
        {
        }

        public TwoZoneGenerator(GeometryLimits limits)
            : base(ToleranceSettings.Default, limits)
        {
        }

        public override string Name => nameof(TwoZoneGenerator);

        protected override IEnumerable<Diagnostic> Validate(TwoZoneParameters parameters) => Enumerable.Empty<Diagnostic>();

        protected override PlanLayout Layout(TwoZoneParameters parameters) => new(
            Polygon2.Rectangle(0.0, 0.0, 4.0 + parameters.HallWidth, 4.0),
            new[]
            {
                new PlannedZone(new ZoneId("U1"), "Unit", SpaceType.DwellingUnit, Polygon2.Rectangle(0.0, 0.0, 4.0, 4.0)),
                new PlannedZone(new ZoneId("C1"), "Corridor", SpaceType.Corridor, Polygon2.Rectangle(4.0, 0.0, 4.0 + parameters.HallWidth, 4.0)),
            });

        protected override IEnumerable<KeyValuePair<string, string>> Describe(TwoZoneParameters parameters) => Enumerable.Empty<KeyValuePair<string, string>>();
    }
}
  • [x] Step 4: Replace tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs (full file)

Only WindowsAreSizedByEachZonesRatio changes: it passes the default limits and tolerances to PlaceWindowsByRatio and reads .Value.

using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Xunit;

namespace Lod.Core.Tests.Layout;

public sealed class LayoutSurfaceBuilderTests
{
    private readonly LayoutSurfaceBuilder _builder = new(ToleranceSettings.Default);

    [Fact]
    public void SingleZoneHasFourOutdoorWallsAndUnresolvedFloorAndCeiling()
    {
        IReadOnlyList<Surface> surfaces = Build(Polygon2.Rectangle(0, 0, 10, 5), Zone("A", 0, 0, 10, 5)).Value;

        WallSurface[] walls = surfaces.OfType<WallSurface>().ToArray();
        Assert.Equal(4, walls.Length);
        Assert.All(walls, w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
        Assert.Equal(30.0 * 3.0, walls.Sum(w => w.Area), 9);
        HorizontalSurface[] horizontal = surfaces.OfType<HorizontalSurface>().ToArray();
        Assert.Equal(new[] { HorizontalKind.Floor, HorizontalKind.Ceiling }, horizontal.Select(h => h.Kind));
        Assert.All(horizontal, h => Assert.Equal(BoundaryCondition.Unresolved, h.Boundary));
        Assert.Equal(3.0, horizontal[1].Elevation);
    }

    [Fact]
    public void SharedEdgeBecomesAPairOfInterzoneWalls()
    {
        IReadOnlyList<Surface> surfaces = Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 10, 5), Zone("B", 10, 0, 20, 5)).Value;

        WallSurface ab = surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "A" && w.Boundary == BoundaryCondition.Interzone);
        WallSurface ba = surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "B" && w.Boundary == BoundaryCondition.Interzone);
        Assert.Equal("B", ab.AdjacentZone!.Value.Value);
        Assert.Equal("A", ba.AdjacentZone!.Value.Value);
        Assert.Equal(ab.Start, ba.End);
        Assert.Equal(ab.End, ba.Start);
    }

    [Fact]
    public void EdgesAreSplitWhereAnotherZoneTouchesThem()
    {
        // A long zone above two short ones: its south edge is shared with both.
        IReadOnlyList<Surface> surfaces = Build(
            Polygon2.Rectangle(0, 0, 20, 10),
            Zone("TOP", 0, 5, 20, 10),
            Zone("L", 0, 0, 12, 5),
            Zone("R", 12, 0, 20, 5)).Value;

        WallSurface[] shared = surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "TOP" && w.Boundary == BoundaryCondition.Interzone).ToArray();
        Assert.Equal(2, shared.Length);
        Assert.Equal(new[] { 12.0, 8.0 }.OrderBy(x => x), shared.Select(w => w.Length).OrderBy(x => x));
    }

    [Fact]
    public void CollinearPiecesOfOneEdgeMergeIntoOneWall()
    {
        // The plan boundary has an extra vertex halfway along the north edge, which splits the zone's edge; the pieces merge back.
        Polygon2 footprint = Polygon2.Create(new[] { new Point2(0, 0), new Point2(20, 0), new Point2(20, 5), new Point2(10, 5), new Point2(0, 5) }).Value;

        IReadOnlyList<Surface> surfaces = Build(footprint, Zone("A", 0, 0, 20, 5)).Value;

        WallSurface[] walls = surfaces.OfType<WallSurface>().ToArray();
        Assert.Equal(4, walls.Length);
        Assert.Contains(walls, w => w.Start == new Point2(20, 5) && w.End == new Point2(0, 5));
    }

    [Fact]
    public void GapsAreErrors()
    {
        Result<IReadOnlyList<Surface>> result = Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 9, 5), Zone("B", 10, 0, 20, 5));

        Assert.False(result.IsSuccess);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.UnmatchedEdge);
    }

    [Fact]
    public void OverlapsAreErrors()
    {
        Result<IReadOnlyList<Surface>> result = Build(Polygon2.Rectangle(0, 0, 10, 5), Zone("A", 0, 0, 10, 5), Zone("B", 0, 0, 10, 5));

        Assert.False(result.IsSuccess);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.OverlappingZones);
    }

    [Fact]
    public void WindowsAreSizedByEachZonesRatio()
    {
        IReadOnlyList<Surface> surfaces = LayoutMeasures.PlaceWindowsByRatio(
            Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 10, 5), Zone("B", 10, 0, 20, 5)).Value,
            id => id.Value == "A" ? 0.5 : 0.1,
            GeometryLimits.Default,
            ToleranceSettings.Default).Value;

        WallSurface[] outdoor = surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
        Assert.All(outdoor, w => Assert.Equal((w.Zone.Value == "A" ? 0.5 : 0.1) * w.Area, w.GlazedArea, 9));
        Assert.All(surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Interzone), w => Assert.Empty(w.Windows));
    }

    private static LayoutZone Zone(string id, double x0, double y0, double x1, double y1) => new(new ZoneId(id), Polygon2.Rectangle(x0, y0, x1, y1));

    private Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, params LayoutZone[] zones) => _builder.Build(footprint, zones, 3.0);
}
  • [x] Step 5: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Common.GeometryLimitsTests|FullyQualifiedName~Lod.Core.Tests.Plans.PlanGeneratorTests"

Expected: the build fails because GeometryLimits does not exist yet, with exactly one error for the net8.0 target (the compiler stops at the constructor declaration of the test generator before it binds the method bodies):

tests\Lod.Core.Tests\Plans\PlanGeneratorTests.cs(154,33): error CS0246: The type or namespace name 'GeometryLimits' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 6: Append the code — replace src/Lod.Core/Common/DiagnosticCodes.cs (full file)

WindowOmitted is appended at the end. No code is renamed or reused.

namespace Lod.Core.Common;

/// <summary>
/// Stable diagnostic codes. Add new codes here; never reuse or rename a published code.
/// </summary>
public static class DiagnosticCodes
{
    /// <summary>A required name is empty.</summary>
    public const string EmptyName = "EmptyName";

    /// <summary>A schedule does not have exactly <c>Schedule.HoursPerYear</c> values.</summary>
    public const string ScheduleLength = "ScheduleLength";

    /// <summary>A schedule value is not finite or outside the range allowed by its kind.</summary>
    public const string ScheduleValue = "ScheduleValue";

    /// <summary>A schedule has the wrong kind for its use (fraction vs temperature).</summary>
    public const string ScheduleKindMismatch = "ScheduleKindMismatch";

    /// <summary>A load value is negative or not finite.</summary>
    public const string LoadValue = "LoadValue";

    /// <summary>A load uses a basis that is not allowed for its type.</summary>
    public const string LoadBasisNotAllowed = "LoadBasisNotAllowed";

    /// <summary>A program contains the same load type in the same basis twice.</summary>
    public const string DuplicateLoadType = "DuplicateLoadType";

    /// <summary>A per-person load is defined without an occupancy load.</summary>
    public const string PerPersonWithoutOccupancy = "PerPersonWithoutOccupancy";

    /// <summary>A window-to-wall ratio is outside [0, 1).</summary>
    public const string WindowToWallRatio = "WindowToWallRatio";

    /// <summary>An aggregation received no source zones.</summary>
    public const string NoSources = "NoSources";

    /// <summary>A transfer fraction is outside [0, 1].</summary>
    public const string FractionOutOfRange = "FractionOutOfRange";

    /// <summary>A positive load cannot be expressed because the target's basis quantity is zero.</summary>
    public const string ZeroBasisQuantity = "ZeroBasisQuantity";

    /// <summary>An aggregated load is zero; its schedule is set to a constant zero.</summary>
    public const string ZeroLoadSchedule = "ZeroLoadSchedule";

    /// <summary>Setpoint weights of the conditioned sources sum to zero.</summary>
    public const string ZeroSetpointWeight = "ZeroSetpointWeight";

    /// <summary>Zone measures are negative or not finite.</summary>
    public const string InvalidMeasures = "InvalidMeasures";

    /// <summary>A polygon ring has fewer than three distinct points or no area.</summary>
    public const string DegeneratePolygon = "DegeneratePolygon";

    /// <summary>Two zones overlap.</summary>
    public const string OverlappingZones = "OverlappingZones";

    /// <summary>A zone edge is neither shared with another zone nor on the plan boundary.</summary>
    public const string UnmatchedEdge = "UnmatchedEdge";

    /// <summary>A generator, simplifier, or aggregator parameter is invalid.</summary>
    public const string InvalidParameter = "InvalidParameter";

    /// <summary>No program preset is given for a preset input, or for a space type the generator's layout places.</summary>
    public const string MissingPreset = "MissingPreset";

    /// <summary>Floors with different orientations cannot form one building.</summary>
    public const string OrientationMismatch = "OrientationMismatch";

    /// <summary>A floor aggregator received no floors.</summary>
    public const string NoFloors = "NoFloors";

    /// <summary>Zones grouped for merging do not form one connected polygon.</summary>
    public const string DisconnectedGroup = "DisconnectedGroup";

    /// <summary>The perimeter depth leaves no valid core.</summary>
    public const string PlanTooNarrow = "PlanTooNarrow";

    /// <summary>The input is valid but not supported by this implementation yet.</summary>
    public const string NotSupported = "NotSupported";

    /// <summary>Target outdoor walls do not cover a source outdoor wall over its full length (D-041).</summary>
    public const string FacadeNotCovered = "FacadeNotCovered";

    /// <summary>No single target wall contains a source window (D-039); a guard that cannot fail once the target façades cover the source façades (D-041, D-079).</summary>
    public const string WindowNotHosted = "WindowNotHosted";

    /// <summary>Validation failed and conversion was blocked.</summary>
    public const string ValidationFailed = "ValidationFailed";

    /// <summary>Validation failed and conversion continued because an override was given.</summary>
    public const string ValidationOverridden = "ValidationOverridden";

    /// <summary>A multiplied floor type had exposed floors or ceilings; its exposed storeys were split off as x1 storeys (D-046).</summary>
    public const string FloorTypeSplit = "FloorTypeSplit";

    /// <summary>A preset given to a generator's preset input has a different space type than the input feeds (D-061).</summary>
    public const string PresetSpaceTypeMismatch = "PresetSpaceTypeMismatch";

    /// <summary>Part of a storey's floor is not covered by the storey below; the part faces outdoors (D-074).</summary>
    public const string UnsupportedStorey = "UnsupportedStorey";

    /// <summary>The glazed area attributed to a rebuilt wall is not smaller than the wall's area, so no centred window can carry it (D-079).</summary>
    public const string GlazingExceedsWall = "GlazingExceedsWall";

    /// <summary>A generator placed no window on an outdoor wall because the wall or its window is below the minimum geometry (ADR-011).</summary>
    public const string WindowOmitted = "WindowOmitted";
}
  • [x] Step 7: Implement — create src/Lod.Core/Common/GeometryLimits.cs (full file)
using System;

namespace Lod.Core.Common;

/// <summary>
/// The single place where the minimum wall and window geometry of plan generators is defined (ADR-011; GLOBAL.md, quality rule 4). An outdoor wall
/// below <see cref="MinimumWindowWallLength"/>, or a window below <see cref="MinimumWindowWidth"/> or <see cref="MinimumWindowHeight"/>, gets no window.
/// </summary>
public sealed record GeometryLimits
{
    /// <summary>
    /// Creates limits; every value must be positive and finite.
    /// </summary>
    /// <param name="minimumWindowWallLength">Shortest outdoor wall that gets a window, m.</param>
    /// <param name="minimumWindowWidth">Narrowest window placed, m.</param>
    /// <param name="minimumWindowHeight">Lowest window placed, m.</param>
    public GeometryLimits(double minimumWindowWallLength, double minimumWindowWidth, double minimumWindowHeight)
    {
        MinimumWindowWallLength = RequirePositive(minimumWindowWallLength, nameof(minimumWindowWallLength));
        MinimumWindowWidth = RequirePositive(minimumWindowWidth, nameof(minimumWindowWidth));
        MinimumWindowHeight = RequirePositive(minimumWindowHeight, nameof(minimumWindowHeight));
    }

    /// <summary>Project defaults from ADR-011.</summary>
    public static GeometryLimits Default { get; } = new(minimumWindowWallLength: 1.0, minimumWindowWidth: 0.3, minimumWindowHeight: 0.3);

    /// <summary>Shortest outdoor wall that gets a window, m.</summary>
    public double MinimumWindowWallLength { get; }

    /// <summary>Narrowest window placed, m.</summary>
    public double MinimumWindowWidth { get; }

    /// <summary>Lowest window placed, m.</summary>
    public double MinimumWindowHeight { get; }

    private static double RequirePositive(double value, string name)
    {
        if (!(value > 0) || double.IsInfinity(value))
        {
            throw new ArgumentOutOfRangeException(name, value, "Geometry limits must be positive and finite.");
        }

        return value;
    }
}
  • [x] Step 8: Implement — replace src/Lod.Core/Layout/LayoutMeasures.cs (full file)

PlaceWindowsByRatio takes the limits and tolerances and returns a Result; the window of a wall that passes the limits is the S2 window (Window.Centered) unchanged. ExteriorWallArea now names ZoneId through the new using Lod.Core.Common;.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Aggregation;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;

namespace Lod.Core.Layout;

/// <summary>Measures and window placement derived from a zone's surfaces.</summary>
public static class LayoutMeasures
{
    /// <summary>Floor area, volume, and outdoor wall area of a zone (one instance, multiplier ignored).</summary>
    /// <param name="zone">The zone.</param>
    /// <param name="surfaces">Surfaces of the layout or building containing the zone.</param>
    /// <returns>The measures.</returns>
    public static ZoneMeasures Of(Zone zone, IEnumerable<Surface> surfaces) =>
        new(zone.FloorArea, zone.Volume, ExteriorWallArea(zone.Id, surfaces));

    /// <summary>Gross area of a zone's outdoor walls, windows included.</summary>
    /// <param name="zone">Zone ID.</param>
    /// <param name="surfaces">Surfaces containing the zone's walls.</param>
    /// <returns>Area, m².</returns>
    public static double ExteriorWallArea(ZoneId zone, IEnumerable<Surface> surfaces) =>
        surfaces.OfType<WallSurface>().Where(w => w.Zone == zone && w.Boundary == BoundaryCondition.Outdoors).Sum(w => w.Area);

    /// <summary>
    /// The plan generators' placement rule (D-039, D-026, ADR-011): one centered window on every outdoor wall, sized by its zone's window-to-wall
    /// ratio, except on a wall shorter than <see cref="GeometryLimits.MinimumWindowWallLength"/> or where the window would be narrower or lower
    /// than the minimum; such a wall gets no window and the info <see cref="DiagnosticCodes.WindowOmitted"/>. A value counts as below a limit
    /// only when it is smaller by more than the distance tolerance.
    /// </summary>
    /// <param name="surfaces">Surfaces without windows.</param>
    /// <param name="windowToWallRatio">WWR of each zone.</param>
    /// <param name="limits">Minimum wall and window geometry.</param>
    /// <param name="tolerances">Tolerances.</param>
    /// <returns>Surfaces with windows on outdoor walls, and an info for every omitted window.</returns>
    public static Result<IReadOnlyList<Surface>> PlaceWindowsByRatio(
        IEnumerable<Surface> surfaces,
        Func<ZoneId, double> windowToWallRatio,
        GeometryLimits limits,
        ToleranceSettings tolerances)
    {
        var diagnostics = new List<Diagnostic>();
        var placed = new List<Surface>();
        foreach (Surface surface in surfaces)
        {
            if (surface is not WallSurface { Boundary: BoundaryCondition.Outdoors } wall)
            {
                placed.Add(surface);
                continue;
            }

            double glazedArea = windowToWallRatio(wall.Zone) * wall.Area;
            Window? window = null;
            if (glazedArea > 0 && IsBelow(wall.Length, limits.MinimumWindowWallLength, tolerances))
            {
                diagnostics.Add(Diagnostic.Info(
                    DiagnosticCodes.WindowOmitted,
                    $"Outdoor wall {wall.Id.Value} is {Format(wall.Length)} m long, below the {Format(limits.MinimumWindowWallLength)} m minimum for a window (ADR-011); it has no window ({Format(glazedArea)} m² of glazing not placed).",
                    wall.Id.Value));
            }
            else if (Window.Centered(wall.Length, wall.Height, glazedArea) is { } centred)
            {
                if (IsBelow(centred.Width, limits.MinimumWindowWidth, tolerances) || IsBelow(centred.Height, limits.MinimumWindowHeight, tolerances))
                {
                    diagnostics.Add(Diagnostic.Info(
                        DiagnosticCodes.WindowOmitted,
                        $"The window of outdoor wall {wall.Id.Value} would be {Format(centred.Width)} m × {Format(centred.Height)} m, below the {Format(limits.MinimumWindowWidth)} m × {Format(limits.MinimumWindowHeight)} m minimum (ADR-011); the wall has no window ({Format(glazedArea)} m² of glazing not placed).",
                        wall.Id.Value));
                }
                else
                {
                    window = centred;
                }
            }

            placed.Add(wall.WithWindows(window is null ? Array.Empty<Window>() : new[] { window }));
        }

        return Result.Success<IReadOnlyList<Surface>>(placed, diagnostics);
    }

    private static bool IsBelow(double value, double limit, ToleranceSettings tolerances) => value < limit - tolerances.Distance;

    private static string Format(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
}
  • [x] Step 9: Implement — replace src/Lod.Core/Plans/PlanGenerator.cs (full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Lod.Core.Programs;

namespace Lod.Core.Plans;

/// <summary>Parameters every plan generator needs. Typology-specific parameters extend this record (spec §6).</summary>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public abstract record PlanParameters(double FloorHeight, double OrientationDegrees);

/// <summary>A zone proposed by a generator's layout step.</summary>
/// <param name="Id">Zone ID.</param>
/// <param name="Name">Display name.</param>
/// <param name="SpaceType">Space type; selects the program preset.</param>
/// <param name="Footprint">Zone polygon.</param>
public sealed record PlannedZone(ZoneId Id, string Name, SpaceType SpaceType, Polygon2 Footprint);

/// <summary>The geometric output of a generator's layout step.</summary>
/// <param name="Footprint">Plan boundary.</param>
/// <param name="Zones">Zones tiling the footprint.</param>
public sealed record PlanLayout(Polygon2 Footprint, IReadOnlyList<PlannedZone> Zones);

/// <summary>
/// Base class of plan generators (spec §5). Subclasses only validate their parameters and lay out zones; this class assigns program presets,
/// builds surfaces, places centered windows from each preset's WWR within the minimum wall and window geometry (ADR-011), and records provenance.
/// Generation is deterministic.
/// </summary>
/// <typeparam name="TParameters">The generator's parameter record.</typeparam>
/// <typeparam name="TPresets">The generator's preset record, one preset per space type it places (D-061).</typeparam>
public abstract class PlanGenerator<TParameters, TPresets>
    where TParameters : PlanParameters
    where TPresets : PlanPresets
{
    /// <summary>Initialises the generator with the default minimum wall and window geometry (ADR-011).</summary>
    /// <param name="tolerances">Tolerances.</param>
    protected PlanGenerator(ToleranceSettings tolerances)
        : this(tolerances, GeometryLimits.Default)
    {
    }

    /// <summary>Initialises the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    /// <param name="limits">Minimum wall and window geometry (ADR-011); recorded in provenance when not the default.</param>
    protected PlanGenerator(ToleranceSettings tolerances, GeometryLimits limits)
    {
        Tolerances = tolerances;
        Limits = limits;
    }

    /// <summary>Operation name recorded in provenance.</summary>
    public abstract string Name { get; }

    /// <summary>Tolerances.</summary>
    protected ToleranceSettings Tolerances { get; }

    /// <summary>Minimum wall and window geometry (ADR-011).</summary>
    protected GeometryLimits Limits { get; }

    /// <summary>Generates a detailed plan.</summary>
    /// <param name="parameters">Generator parameters.</param>
    /// <param name="presets">Program presets, one per slot; each must have its slot's space type.</param>
    /// <returns>The plan, or errors.</returns>
    public Result<IGeneratedPlan> Generate(TParameters parameters, TPresets presets)
    {
        var diagnostics = new List<Diagnostic>();
        if (!IsPositive(parameters.FloorHeight))
        {
            diagnostics.Add(Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"FloorHeight must be positive, got {parameters.FloorHeight}."));
        }

        if (double.IsNaN(parameters.OrientationDegrees) || double.IsInfinity(parameters.OrientationDegrees))
        {
            diagnostics.Add(Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"OrientationDegrees must be finite, got {parameters.OrientationDegrees}."));
        }

        diagnostics.AddRange(Validate(parameters));
        diagnostics.AddRange(CheckSlots(presets.Slots));
        if (diagnostics.Count > 0)
        {
            return Result.Failure<IGeneratedPlan>(diagnostics);
        }

        PlanLayout layout = Layout(parameters);
        var zones = new List<Zone>();
        foreach (PlannedZone planned in layout.Zones)
        {
            ProgramPreset? preset = Find(presets, planned.SpaceType);
            if (preset is null)
            {
                diagnostics.Add(Diagnostic.Error(DiagnosticCodes.MissingPreset, $"No program preset for space type {planned.SpaceType}.", planned.Id.Value));
                continue;
            }

            zones.Add(new Zone(planned.Id, planned.Name, planned.SpaceType, new[] { new ZonePart(planned.Footprint, 0.0, parameters.FloorHeight) }, preset.Program, Enumerable.Empty<ZoneId>()));
        }

        if (diagnostics.Count > 0)
        {
            return Result.Failure<IGeneratedPlan>(diagnostics);
        }

        Result<IReadOnlyList<Surface>> surfaces = new LayoutSurfaceBuilder(Tolerances)
            .Build(layout.Footprint, layout.Zones.Select(z => new LayoutZone(z.Id, z.Footprint)).ToArray(), parameters.FloorHeight);
        if (!surfaces.IsSuccess)
        {
            return Result.Failure<IGeneratedPlan>(surfaces.Diagnostics);
        }

        Dictionary<ZoneId, double> ratios = layout.Zones.ToDictionary(z => z.Id, z => Find(presets, z.SpaceType)!.WindowToWallRatio);
        Result<IReadOnlyList<Surface>> withWindows = LayoutMeasures.PlaceWindowsByRatio(surfaces.Value, id => ratios[id], Limits, Tolerances);
        IEnumerable<KeyValuePair<string, string>> recorded = Describe(parameters)
            .Concat(new[]
            {
                new KeyValuePair<string, string>(nameof(PlanParameters.FloorHeight), Provenance.Format(parameters.FloorHeight)),
                new KeyValuePair<string, string>(nameof(PlanParameters.OrientationDegrees), Provenance.Format(parameters.OrientationDegrees)),
                new KeyValuePair<string, string>("Presets", string.Join(";", presets.Slots.Select(s => $"{s.Preset.SpaceType}:{s.Preset.Name}"))),
            })
            .Concat(DescribeLimits());
        var plan = new GeneratedPlan(layout.Footprint, parameters.FloorHeight, parameters.OrientationDegrees, zones, withWindows.Value, Provenance.Of(Name, recorded));
        return Result.Success<IGeneratedPlan>(plan, withWindows.Diagnostics);
    }

    /// <summary>Checks typology-specific parameters.</summary>
    /// <param name="parameters">Parameters.</param>
    /// <returns>Errors, if any.</returns>
    protected abstract IEnumerable<Diagnostic> Validate(TParameters parameters);

    /// <summary>Lays out zones for valid parameters.</summary>
    /// <param name="parameters">Validated parameters.</param>
    /// <returns>Footprint and zones.</returns>
    protected abstract PlanLayout Layout(TParameters parameters);

    /// <summary>Typology-specific parameters for provenance, as invariant-culture text.</summary>
    /// <param name="parameters">Parameters.</param>
    /// <returns>Key-value pairs.</returns>
    protected abstract IEnumerable<KeyValuePair<string, string>> Describe(TParameters parameters);

    private IEnumerable<KeyValuePair<string, string>> DescribeLimits() => Limits == GeometryLimits.Default
        ? Enumerable.Empty<KeyValuePair<string, string>>()
        : new[]
        {
            new KeyValuePair<string, string>(nameof(GeometryLimits.MinimumWindowWallLength), Provenance.Format(Limits.MinimumWindowWallLength)),
            new KeyValuePair<string, string>(nameof(GeometryLimits.MinimumWindowWidth), Provenance.Format(Limits.MinimumWindowWidth)),
            new KeyValuePair<string, string>(nameof(GeometryLimits.MinimumWindowHeight), Provenance.Format(Limits.MinimumWindowHeight)),
        };

    private static IEnumerable<Diagnostic> CheckSlots(IEnumerable<PresetSlot> slots)
    {
        foreach (PresetSlot slot in slots)
        {
            if (slot.Preset is null)
            {
                yield return Diagnostic.Error(DiagnosticCodes.MissingPreset, $"Input {slot.Input} needs a {slot.SpaceType} preset, got none.", slot.Input);
            }
            else if (slot.Preset.SpaceType != slot.SpaceType)
            {
                yield return Diagnostic.Error(
                    DiagnosticCodes.PresetSpaceTypeMismatch,
                    $"Input {slot.Input} needs a {slot.SpaceType} preset, got '{slot.Preset.Name}' ({slot.Preset.SpaceType}).",
                    slot.Input);
            }
        }
    }

    private static ProgramPreset? Find(TPresets presets, SpaceType spaceType) =>
        presets.Slots.FirstOrDefault(s => s.SpaceType == spaceType)?.Preset;

    /// <summary>Whether a value is finite and positive.</summary>
    /// <param name="value">The value.</param>
    /// <returns><c>true</c> when positive and finite.</returns>
    protected static bool IsPositive(double value) => value > 0 && !double.IsInfinity(value);
}
  • [x] Step 10: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Common.GeometryLimitsTests|FullyQualifiedName~Lod.Core.Tests.Plans.PlanGeneratorTests"

Expected:

Passed!  - Failed:     0, Passed:    15, Skipped:     0, Total:    15, Duration: … - Lod.Core.Tests.dll (net8.0)

Then the solution:

dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short

Expected: Build succeeded., 0 Warning(s), 0 Error(s); on net8.0 (the order of the lines may differ):

Passed!  - Failed:     0, Passed:   152, Skipped:     0, Total:   152, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed!  - Failed:     0, Passed:    19, Skipped:     0, Total:    19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed!  - Failed:     0, Passed:   187, Skipped:     0, Total:   187, Duration: … - Lod.Integration.Tests.dll (net8.0)

git status --short lists exactly the seven files of this task (five modified, two new), and no *.received.txt: no existing plan has a wall or window below the limits, so every snapshot is unchanged.

  • [x] Step 11: Commit
git add src/Lod.Core/Common/GeometryLimits.cs src/Lod.Core/Common/DiagnosticCodes.cs src/Lod.Core/Layout/LayoutMeasures.cs src/Lod.Core/Plans/PlanGenerator.cs tests/Lod.Core.Tests/Common/GeometryLimitsTests.cs tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs
git commit -m "feature(generators): omit windows below the adr-011 minimum wall and window geometry"
  • [x] Step 12: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/generators-geometry-limits
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/generators-geometry-limits
git branch -d feature/generators-geometry-limits

Expected: VERIFY PASSED, with 152 core, 19 generators, and 187 integration tests passed on net8.0. Do not push (D-084).


Slice C — feature/generators-bar-typologies

Task 5: Stair-bay bar generator (HDHM-TYP-001)

Files: - Create: src/Lod.Generators/StairBayBar/StairBayLayout.cs, src/Lod.Generators/StairBayBar/StairBayBarGenerator.cs - Test: create tests/Lod.Generators.Tests/PlanAssertions.cs, tests/Lod.Generators.Tests/StairBayBar/StairBayBarGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.txt

Interfaces: - Consumes (S2–S4.1, Task 4): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings), Validate, Layout, Describe, protected static bool IsPositive(double)), PlanParameters(double FloorHeight, double OrientationDegrees), PlanPresets.Slots, PresetSlot(string Input, SpaceType SpaceType, ProgramPreset Preset), PlanLayout(Polygon2 Footprint, IReadOnlyList<PlannedZone> Zones), PlannedZone(ZoneId Id, string Name, SpaceType SpaceType, Polygon2 Footprint), Polygon2.Rectangle, Provenance.Format, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch; test support Snapshot.Match(string actual, string name) (snapshots next to the calling test file, Snapshots/<name>.txt), TextReport.Describe, ExampleResidentialPresets, PolygonOps.IntersectionArea, Orientation.Bin. - Produces: - internal static class StairBayLayout with PlanLayout Bays(int bays, double unitWidth, double depth, double stairWidth): bays west to east from the origin; zones ST1…STn (stairs), then U1…U2n (dwellings), bay i = U(2i−1), STi, U(2i), each through the full depth. - public sealed record StairBayBarParameters(int BayCount, double UnitWidth, double Depth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (3, 7, 12, 3, 3, 0); public sealed record StairBayBarPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets (slots DwellingUnit, Stair); public sealed class StairBayBarGenerator(ToleranceSettings), Name = StairBayBarGenerator; errors InvalidParameter for BayCount < 2 (message BayCount must be at least 2 (one bay is HDHM-TYP-008, Stair Pair), got {n}.) and for each length that is not positive and finite ({name} must be positive, got {value}.); provenance keys BayCount, UnitWidth, Depth, StairWidth. - Test support PlanAssertions (internal): TilesTheFootprint(IGeneratedPlan), HasOneCentredWindowPerOutdoorWall(IGeneratedPlan, PlanPresets), Neighbours(IGeneratedPlan, string zone), OutdoorFacings(IGeneratedPlan, string zone), used by Tasks 6–9.

  • [x] Step 1: Create the branch
git switch -c feature/generators-bar-typologies main
  • [x] Step 2: Write the shared test support — create tests/Lod.Generators.Tests/PlanAssertions.cs (full file)

The checks every plan generator must pass (ADR-006): zones tile the footprint without overlaps, every outdoor wall carries one window centred on it with WWR × wall area of its zone's preset (D-026) and no other wall carries one, and two adjacency queries over interzone and outdoor walls.

using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Xunit;

namespace Lod.Generators.Tests;

/// <summary>Checks every plan generator must pass (ADR-006): tiling, the D-026 window rule, and adjacency queries.</summary>
internal static class PlanAssertions
{
    /// <summary>The zones cover the footprint's area and no two zones overlap.</summary>
    public static void TilesTheFootprint(IGeneratedPlan plan)
    {
        var ops = new PolygonOps(ToleranceSettings.Default);
        Assert.True(ToleranceSettings.Default.AreaEquals(plan.Footprint.Area, plan.Zones.Sum(z => z.FloorArea)));
        for (int i = 0; i < plan.Zones.Count; i++)
        {
            for (int j = i + 1; j < plan.Zones.Count; j++)
            {
                Assert.Equal(0.0, ops.IntersectionArea(plan.Zones[i].Parts[0].Footprint, plan.Zones[j].Parts[0].Footprint), 9);
            }
        }
    }

    /// <summary>Every outdoor wall has one window centred on it with area WWR × wall area of its zone's preset (D-026); other walls have none.</summary>
    public static void HasOneCentredWindowPerOutdoorWall(IGeneratedPlan plan, PlanPresets presets)
    {
        foreach (WallSurface wall in plan.Surfaces.OfType<WallSurface>())
        {
            if (wall.Boundary != BoundaryCondition.Outdoors)
            {
                Assert.Empty(wall.Windows);
                continue;
            }

            Zone zone = plan.Zones.Single(z => z.Id == wall.Zone);
            double ratio = presets.Slots.Single(s => s.SpaceType == zone.SpaceType).Preset.WindowToWallRatio;
            Window window = Assert.Single(wall.Windows);
            Assert.Equal(ratio * wall.Area, window.Area, 9);
            Assert.Equal((wall.Length - window.Width) / 2.0, window.Offset, 12);
            Assert.Equal((wall.Height - window.Height) / 2.0, window.SillHeight, 12);
        }
    }

    /// <summary>IDs of the zones that share an interzone wall with a zone, in ordinal order.</summary>
    public static IReadOnlyList<string> Neighbours(IGeneratedPlan plan, string zone) =>
        plan.Surfaces.OfType<WallSurface>()
            .Where(w => w.Zone.Value == zone && w.Boundary == BoundaryCondition.Interzone)
            .Select(w => w.AdjacentZone!.Value.Value)
            .Distinct()
            .OrderBy(id => id, System.StringComparer.Ordinal)
            .ToArray();

    /// <summary>Plan orientation bins of a zone's outdoor walls, in ordinal order of their names.</summary>
    public static IReadOnlyList<OrientationBin> OutdoorFacings(IGeneratedPlan plan, string zone) =>
        plan.Surfaces.OfType<WallSurface>()
            .Where(w => w.Zone.Value == zone && w.Boundary == BoundaryCondition.Outdoors)
            .Select(w => Orientation.Bin(w.PlanAzimuth))
            .Distinct()
            .OrderBy(b => b.ToString(), System.StringComparer.Ordinal)
            .ToArray();
}
  • [x] Step 3: Write the failing tests — create tests/Lod.Generators.Tests/StairBayBar/StairBayBarGeneratorTests.cs (full file)

22 tests: the ADR-006 defaults; the canonical bar (zone IDs, 51 m × 12 m, stairs 36 m², dwellings 84 m², ST2 at x = 24–27 m, no source zones); tiling for 2, 3, and 5 bays; the window rule; each stair adjoining exactly the two dwellings of its bay and facing north and south; dwellings through the depth with a party wall between neighbouring bays and no corridor; six invalid parameters; the boundary case of one bay; every invalid parameter reported at once; preset slots; swapped presets; orientation; determinism; provenance; the canonical snapshot.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.StairBayBar;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.StairBayBar;

public sealed class StairBayBarGeneratorTests
{
    /// <summary>The example presets, one per slot.</summary>
    public static readonly StairBayBarPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);

    private readonly StairBayBarGenerator _generator = new(ToleranceSettings.Default);

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(new StairBayBarParameters(BayCount: 3, UnitWidth: 7.0, Depth: 12.0, StairWidth: 3.0, FloorHeight: 3.0, OrientationDegrees: 0.0), StairBayBarParameters.Default);
    }

    [Fact]
    public void CanonicalBarHasThreeStairBays()
    {
        IGeneratedPlan plan = Generate(StairBayBarParameters.Default);

        Assert.Equal(new[] { "ST1", "ST2", "ST3", "U1", "U2", "U3", "U4", "U5", "U6" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(51.0 * 12.0, plan.Footprint.Area, 9);
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.Stair), z => Assert.Equal(36.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal(84.0, z.FloorArea, 9));
        Assert.Equal(new[] { "Stair 2", "Unit 4" }, plan.Zones.Where(z => z.Id.Value is "ST2" or "U4").Select(z => z.Name));
        Assert.Equal(new[] { new Point2(24.0, 0.0), new Point2(27.0, 0.0), new Point2(27.0, 12.0), new Point2(24.0, 12.0) }, plan.Zones.Single(z => z.Id.Value == "ST2").Parts[0].Footprint.Outer);
        Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
    }

    [Theory]
    [InlineData(2)]
    [InlineData(3)]
    [InlineData(5)]
    public void ZonesTileTheFootprint(int bays)
    {
        IGeneratedPlan plan = Generate(StairBayBarParameters.Default with { BayCount = bays });

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(3 * bays, plan.Zones.Count);
    }

    [Fact]
    public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(StairBayBarParameters.Default, Examples);

        Assert.Empty(result.Diagnostics);
        PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
    }

    [Fact]
    public void EachStairServesExactlyTheTwoDwellingsOfItsBay()
    {
        IGeneratedPlan plan = Generate(StairBayBarParameters.Default);

        Assert.Equal(new[] { "U1", "U2" }, PlanAssertions.Neighbours(plan, "ST1"));
        Assert.Equal(new[] { "U3", "U4" }, PlanAssertions.Neighbours(plan, "ST2"));
        Assert.Equal(new[] { "U5", "U6" }, PlanAssertions.Neighbours(plan, "ST3"));
        Assert.All(new[] { "ST1", "ST2", "ST3" }, st => Assert.Equal(new[] { OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, st)));
    }

    [Fact]
    public void DwellingsRunThroughTheDepthAndNeighbouringBaysShareAPartyWall()
    {
        IGeneratedPlan plan = Generate(StairBayBarParameters.Default);

        Assert.DoesNotContain(plan.Zones, z => z.SpaceType == SpaceType.Corridor);
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Contains(OrientationBin.North, PlanAssertions.OutdoorFacings(plan, z.Id.Value)));
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Contains(OrientationBin.South, PlanAssertions.OutdoorFacings(plan, z.Id.Value)));
        Assert.Equal(new[] { "ST1", "U3" }, PlanAssertions.Neighbours(plan, "U2"));
        Assert.Equal(new[] { "ST2", "U2" }, PlanAssertions.Neighbours(plan, "U3"));
        Assert.Equal(new[] { OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "U1"));
        Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "U6"));
    }

    [Theory]
    [InlineData(1, 7.0, 12.0, 3.0, "BayCount")]
    [InlineData(0, 7.0, 12.0, 3.0, "BayCount")]
    [InlineData(3, 0.0, 12.0, 3.0, "UnitWidth")]
    [InlineData(3, 7.0, -1.0, 3.0, "Depth")]
    [InlineData(3, 7.0, 12.0, double.NaN, "StairWidth")]
    [InlineData(3, 7.0, double.PositiveInfinity, 3.0, "Depth")]
    public void InvalidParametersAreErrors(int bays, double unitWidth, double depth, double stairWidth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new StairBayBarParameters(bays, unitWidth, depth, stairWidth, 3.0, 0.0), Examples);

        Assert.False(result.IsSuccess);
        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.StartsWith(name, error.Message);
    }

    [Fact]
    public void ASingleBayIsTheStairPairFamily()
    {
        Result<IGeneratedPlan> result = _generator.Generate(StairBayBarParameters.Default with { BayCount = 1 }, Examples);

        Assert.Equal("BayCount must be at least 2 (one bay is HDHM-TYP-008, Stair Pair), got 1.", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void EveryInvalidParameterIsReported()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new StairBayBarParameters(0, -7.0, 0.0, -3.0, 0.0, 0.0), Examples);

        Assert.Equal(5, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
    }

    [Fact]
    public void PresetsHaveOneSlotPerSpaceTypeInOrder()
    {
        Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Stair", SpaceType.Stair) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
        Assert.Equal(new[] { Examples.DwellingUnit, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
    }

    [Fact]
    public void SwappedPresetsAreAnErrorPerWrongSlot()
    {
        Result<IGeneratedPlan> result = _generator.Generate(StairBayBarParameters.Default, new StairBayBarPresets(ExampleResidentialPresets.Stair, ExampleResidentialPresets.DwellingUnit));

        Assert.Equal(new[] { "DwellingUnit", "Stair" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
    }

    [Fact]
    public void OrientationRotatesFacadeBins()
    {
        IGeneratedPlan plan = Generate(StairBayBarParameters.Default with { OrientationDegrees = 90.0 });
        WallSurface south = plan.Surfaces.OfType<WallSurface>().First(w => w.Zone.Value == "U1" && w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.South);

        Assert.Equal(OrientationBin.West, Orientation.Bin(south.PlanAzimuth + plan.OrientationDegrees));
    }

    [Fact]
    public void GenerationIsDeterministic()
    {
        Assert.Equal(TextReport.Describe(Generate(StairBayBarParameters.Default)), TextReport.Describe(Generate(StairBayBarParameters.Default)));
    }

    [Fact]
    public void ProvenanceRecordsParametersAndPresets()
    {
        Provenance provenance = Generate(StairBayBarParameters.Default).Provenance;

        Assert.Equal(nameof(StairBayBarGenerator), provenance.Operation);
        Assert.Equal(
            new[] { "BayCount=3", "Depth=12", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair", "StairWidth=3", "UnitWidth=7" },
            provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(StairBayBarParameters.Default)), "stair-bay-bar-canonical");
    }

    private IGeneratedPlan Generate(StairBayBarParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
  • [x] Step 4: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairBayBar.StairBayBarGeneratorTests"

Expected: the build fails because the namespace and its types do not exist yet, with these four errors for the net8.0 target (in this order):

tests\Lod.Generators.Tests\StairBayBar\StairBayBarGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'StairBayBar' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\StairBayBar\StairBayBarGeneratorTests.cs(165,37): error CS0246: The type or namespace name 'StairBayBarParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\StairBayBar\StairBayBarGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'StairBayBarPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\StairBayBar\StairBayBarGeneratorTests.cs(17,22): error CS0246: The type or namespace name 'StairBayBarGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 5: Implement — create src/Lod.Generators/StairBayBar/StairBayLayout.cs (full file)

The last bay ends exactly at bays · bayWidth, so the footprint and the zones share their east edge without rounding.

using System.Collections.Generic;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Plans;
using Lod.Core.Programs;

namespace Lod.Generators.StairBayBar;

/// <summary>
/// The stair bay of ADR-006: dwelling | stair | dwelling side by side along a bar, each through the full depth, the stair serving the two dwellings
/// beside it. Shared by <see cref="StairBayBarGenerator"/> (HDHM-TYP-001) and the stair pair (HDHM-TYP-008), so the bay geometry exists once.
/// </summary>
internal static class StairBayLayout
{
    /// <summary>
    /// Lays out <paramref name="bays"/> stair bays west to east from the origin. Zones: <c>ST1…STn</c> (stairs, west to east), then <c>U1…U2n</c>
    /// (dwellings, west to east); bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
    /// </summary>
    /// <param name="bays">Number of bays, at least 1.</param>
    /// <param name="unitWidth">Dwelling width along the bar, m.</param>
    /// <param name="depth">Bar depth, m.</param>
    /// <param name="stairWidth">Stair width along the bar, m.</param>
    /// <returns>The footprint and the zones.</returns>
    public static PlanLayout Bays(int bays, double unitWidth, double depth, double stairWidth)
    {
        double bayWidth = (2.0 * unitWidth) + stairWidth;
        var stairs = new List<PlannedZone>();
        var units = new List<PlannedZone>();
        for (int i = 0; i < bays; i++)
        {
            double west = i * bayWidth;
            double stairWest = west + unitWidth;
            double stairEast = stairWest + stairWidth;
            double east = i == bays - 1 ? bays * bayWidth : west + bayWidth;
            stairs.Add(new PlannedZone(new ZoneId($"ST{i + 1}"), $"Stair {i + 1}", SpaceType.Stair, Polygon2.Rectangle(stairWest, 0.0, stairEast, depth)));
            units.Add(new PlannedZone(new ZoneId($"U{(2 * i) + 1}"), $"Unit {(2 * i) + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(west, 0.0, stairWest, depth)));
            units.Add(new PlannedZone(new ZoneId($"U{(2 * i) + 2}"), $"Unit {(2 * i) + 2}", SpaceType.DwellingUnit, Polygon2.Rectangle(stairEast, 0.0, east, depth)));
        }

        stairs.AddRange(units);
        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, bays * bayWidth, depth), stairs);
    }
}
  • [x] Step 6: Implement — create src/Lod.Generators/StairBayBar/StairBayBarGenerator.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;

namespace Lod.Generators.StairBayBar;

/// <summary>Parameters of the stair-bay bar (HDHM-TYP-001, ADR-006).</summary>
/// <param name="BayCount">Number of stair bays along the bar; at least 2.</param>
/// <param name="UnitWidth">Width of each dwelling along the bar, m.</param>
/// <param name="Depth">Bar depth, which every dwelling and stair spans, m.</param>
/// <param name="StairWidth">Width of each stair along the bar, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record StairBayBarParameters(
    int BayCount,
    double UnitWidth,
    double Depth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 3 bays of 7 m dwellings and a 3 m stair, 12 m deep, 3 m storeys, orientation 0°.</summary>
    public static StairBayBarParameters Default { get; } = new(3, 7.0, 12.0, 3.0, 3.0, 0.0);
}

/// <summary>The program presets of the stair-bay bar, one per space type it places (D-061).</summary>
/// <param name="DwellingUnit">Preset of the dwellings; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Stair">Preset of the stairs; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record StairBayBarPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets
{
    /// <inheritdoc />
    public override IReadOnlyList<PresetSlot> Slots => new[]
    {
        new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit),
        new PresetSlot(nameof(Stair), SpaceType.Stair, Stair),
    };
}

/// <summary>
/// Repeated stair bays in a linear bar (HDHM-TYP-001, ADR-006): <see cref="StairBayBarParameters.BayCount"/> bays of dwelling | stair | dwelling,
/// each through the full depth. Each stair serves the two dwellings of its bay on every storey (R002); there is no corridor or gallery, stairs never
/// adjoin each other, and neighbouring bays meet dwelling to dwelling (R003); one straight tract (R001). Fewer than two bays is rejected (R004, R005):
/// one bay is HDHM-TYP-008. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>Zone IDs: <c>ST1…STn</c> stairs west to east, then <c>U1…U2n</c> dwellings west to east; bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.</remarks>
public sealed class StairBayBarGenerator : PlanGenerator<StairBayBarParameters, StairBayBarPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public StairBayBarGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

    /// <inheritdoc />
    public override string Name => nameof(StairBayBarGenerator);

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(StairBayBarParameters parameters)
    {
        if (parameters.BayCount < 2)
        {
            yield return Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(StairBayBarParameters.BayCount)} must be at least 2 (one bay is HDHM-TYP-008, Stair Pair), got {parameters.BayCount}.");
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(StairBayBarParameters.UnitWidth), parameters.UnitWidth),
            (nameof(StairBayBarParameters.Depth), parameters.Depth),
            (nameof(StairBayBarParameters.StairWidth), parameters.StairWidth),
        };
        foreach ((string name, double value) in values.Where(v => !IsPositive(v.Value)))
        {
            yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be positive, got {value}.");
        }
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(StairBayBarParameters p) => StairBayLayout.Bays(p.BayCount, p.UnitWidth, p.Depth, p.StairWidth);

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(StairBayBarParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.BayCount), p.BayCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };
}
  • [x] Step 7: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairBayBar.StairBayBarGeneratorTests"

Expected on net8.0:

Failed!  - Failed:     1, Passed:    21, Skipped:     0, Total:    22, Duration: … - Lod.Generators.Tests.dll (net8.0)

with the only failing test and its message:

Failed Lod.Generators.Tests.StairBayBar.StairBayBarGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'stair-bay-bar-canonical' is missing or differs. Review Snapshots/stair-bay-bar-canonical.received.txt and rename it to stair-bay-bar-canonical.txt to accept.

and the file tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.received.txt (git-ignored by *.received.*).

  • [x] Step 8: Review and accept the received file

Review the received file against the content below; it must be identical: 103 lines, 9 zones, 54 surfaces (20 outdoor walls, each with one window; 16 interzone walls; 18 floors and ceilings), glazing 40.5 m² south, 40.5 m² north, 10.8 m² east, 10.8 m² west (102.6 m² in all). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.received.txt tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.txt

tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.txt:

footprint area=612.000000 floorHeight=3.000000 orientation=0.000000
zone ST1 Stair "Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone ST2 Stair "Stair 2" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone ST3 Stair "Stair 3" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone U1 DwellingUnit "Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U2 DwellingUnit "Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U3 DwellingUnit "Unit 3" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U4 DwellingUnit "Unit 4" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U5 DwellingUnit "Unit 5" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U6 DwellingUnit "Unit 6" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface ST1/W1 Outdoors area=9.000000 wall (7.000000,0.000000)-(10.000000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST1/W2 Interzone adjacent=U2 area=36.000000 wall (10.000000,0.000000)-(10.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface ST1/W3 Outdoors area=9.000000 wall (10.000000,12.000000)-(7.000000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST1/W4 Interzone adjacent=U1 area=36.000000 wall (7.000000,12.000000)-(7.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface ST1/F Unresolved area=36.000000 Floor z=0.000000
surface ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface ST2/W1 Outdoors area=9.000000 wall (24.000000,0.000000)-(27.000000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST2/W2 Interzone adjacent=U4 area=36.000000 wall (27.000000,0.000000)-(27.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface ST2/W3 Outdoors area=9.000000 wall (27.000000,12.000000)-(24.000000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST2/W4 Interzone adjacent=U3 area=36.000000 wall (24.000000,12.000000)-(24.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface ST2/F Unresolved area=36.000000 Floor z=0.000000
surface ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface ST3/W1 Outdoors area=9.000000 wall (41.000000,0.000000)-(44.000000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST3/W2 Interzone adjacent=U6 area=36.000000 wall (44.000000,0.000000)-(44.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface ST3/W3 Outdoors area=9.000000 wall (44.000000,12.000000)-(41.000000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST3/W4 Interzone adjacent=U5 area=36.000000 wall (41.000000,12.000000)-(41.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface ST3/F Unresolved area=36.000000 Floor z=0.000000
surface ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=21.000000 wall (0.000000,0.000000)-(7.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U1/W2 Interzone adjacent=ST1 area=36.000000 wall (7.000000,0.000000)-(7.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Outdoors area=21.000000 wall (7.000000,12.000000)-(0.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U1/W4 Outdoors area=36.000000 wall (0.000000,12.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface U1/F Unresolved area=84.000000 Floor z=0.000000
surface U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface U2/W1 Outdoors area=21.000000 wall (10.000000,0.000000)-(17.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U2/W2 Interzone adjacent=U3 area=36.000000 wall (17.000000,0.000000)-(17.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Outdoors area=21.000000 wall (17.000000,12.000000)-(10.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U2/W4 Interzone adjacent=ST1 area=36.000000 wall (10.000000,12.000000)-(10.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=84.000000 Floor z=0.000000
surface U2/C Unresolved area=84.000000 Ceiling z=3.000000
surface U3/W1 Outdoors area=21.000000 wall (17.000000,0.000000)-(24.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U3/W2 Interzone adjacent=ST2 area=36.000000 wall (24.000000,0.000000)-(24.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Outdoors area=21.000000 wall (24.000000,12.000000)-(17.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U3/W4 Interzone adjacent=U2 area=36.000000 wall (17.000000,12.000000)-(17.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=84.000000 Floor z=0.000000
surface U3/C Unresolved area=84.000000 Ceiling z=3.000000
surface U4/W1 Outdoors area=21.000000 wall (27.000000,0.000000)-(34.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U4/W2 Interzone adjacent=U5 area=36.000000 wall (34.000000,0.000000)-(34.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Outdoors area=21.000000 wall (34.000000,12.000000)-(27.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U4/W4 Interzone adjacent=ST2 area=36.000000 wall (27.000000,12.000000)-(27.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=84.000000 Floor z=0.000000
surface U4/C Unresolved area=84.000000 Ceiling z=3.000000
surface U5/W1 Outdoors area=21.000000 wall (34.000000,0.000000)-(41.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U5/W2 Interzone adjacent=ST3 area=36.000000 wall (41.000000,0.000000)-(41.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U5/W3 Outdoors area=21.000000 wall (41.000000,12.000000)-(34.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U5/W4 Interzone adjacent=U4 area=36.000000 wall (34.000000,12.000000)-(34.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=84.000000 Floor z=0.000000
surface U5/C Unresolved area=84.000000 Ceiling z=3.000000
surface U6/W1 Outdoors area=21.000000 wall (44.000000,0.000000)-(51.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U6/W2 Outdoors area=36.000000 wall (51.000000,0.000000)-(51.000000,12.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface U6/W3 Outdoors area=21.000000 wall (51.000000,12.000000)-(44.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U6/W4 Interzone adjacent=ST3 area=36.000000 wall (44.000000,12.000000)-(44.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U6/F Unresolved area=84.000000 Floor z=0.000000
surface U6/C Unresolved area=84.000000 Ceiling z=3.000000
  • [x] Step 9: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairBayBar.StairBayBarGeneratorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 41 generators, and 187 integration tests passed on net8.0; git status lists the five new files of this task and no *.received.txt.

  • [x] Step 10: Commit
git add src/Lod.Generators/StairBayBar/StairBayLayout.cs src/Lod.Generators/StairBayBar/StairBayBarGenerator.cs tests/Lod.Generators.Tests/PlanAssertions.cs tests/Lod.Generators.Tests/StairBayBar/StairBayBarGeneratorTests.cs tests/Lod.Generators.Tests/StairBayBar/Snapshots/stair-bay-bar-canonical.txt
git commit -m "feature(generators): add the stair-bay bar generator (hdhm-typ-001)"

Task 6: Stair pair generator (HDHM-TYP-008)

Files: - Create: src/Lod.Generators/StairPair/StairPairGenerator.cs - Test: create tests/Lod.Generators.Tests/StairPair/StairPairGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/StairPair/Snapshots/stair-pair-canonical.txt

Interfaces: - Consumes: StairBayLayout.Bays (Task 5; internal to Lod.Generators), StairBayBarGenerator and its records (in the test that compares the pair with one bay of the bar), PlanAssertions (Task 5), the PlanGenerator base. - Produces: public sealed record StairPairParameters(double UnitWidth, double Depth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (7, 12, 3, 3, 0); public sealed record StairPairPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets; public sealed class StairPairGenerator(ToleranceSettings), Name = StairPairGenerator, layout StairBayLayout.Bays(1, …) (zones ST1, U1, U2); InvalidParameter for each length that is not positive and finite; provenance keys UnitWidth, Depth, StairWidth.

  • [x] Step 1: Write the failing tests — create tests/Lod.Generators.Tests/StairPair/StairPairGeneratorTests.cs (full file)

12 tests: defaults; the canonical plate (ST1, U1, U2, 17 m × 12 m, tiling); the stair adjoining both dwellings, which adjoin only the stair; the pair equal, zone by zone, to the first bay of a two-bay stair-bay bar with the same widths; the window rule; three invalid lengths; preset slots; determinism; provenance; the canonical snapshot.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.StairPair;

public sealed class StairPairGeneratorTests
{
    /// <summary>The example presets, one per slot.</summary>
    public static readonly StairPairPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);

    private readonly StairPairGenerator _generator = new(ToleranceSettings.Default);

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(new StairPairParameters(UnitWidth: 7.0, Depth: 12.0, StairWidth: 3.0, FloorHeight: 3.0, OrientationDegrees: 0.0), StairPairParameters.Default);
    }

    [Fact]
    public void CanonicalPlanIsOneDwellingPairAroundOneStair()
    {
        IGeneratedPlan plan = Generate(StairPairParameters.Default);

        Assert.Equal(new[] { "ST1", "U1", "U2" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(17.0 * 12.0, plan.Footprint.Area, 9);
        Assert.Equal(new[] { 36.0, 84.0, 84.0 }, plan.Zones.Select(z => z.FloorArea));
        PlanAssertions.TilesTheFootprint(plan);
    }

    [Fact]
    public void TheStairServesBothDwellingsOnOppositeSides()
    {
        IGeneratedPlan plan = Generate(StairPairParameters.Default);

        Assert.Equal(new[] { "U1", "U2" }, PlanAssertions.Neighbours(plan, "ST1"));
        Assert.Equal(new[] { "ST1" }, PlanAssertions.Neighbours(plan, "U1"));
        Assert.Equal(new[] { "ST1" }, PlanAssertions.Neighbours(plan, "U2"));
        Assert.Equal(new[] { OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "U1"));
        Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "U2"));
    }

    [Fact]
    public void ThePairIsOneBayOfTheStairBayBar()
    {
        IGeneratedPlan pair = Generate(new StairPairParameters(6.5, 11.0, 3.5, 3.0, 0.0));
        IGeneratedPlan bar = new StairBayBarGenerator(ToleranceSettings.Default)
            .Generate(new StairBayBarParameters(2, 6.5, 11.0, 3.5, 3.0, 0.0), new StairBayBarPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;

        foreach (string id in new[] { "ST1", "U1", "U2" })
        {
            Assert.Equal(bar.Zones.Single(z => z.Id.Value == id).Parts[0].Footprint.Outer, pair.Zones.Single(z => z.Id.Value == id).Parts[0].Footprint.Outer);
        }
    }

    [Fact]
    public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(StairPairParameters.Default, Examples);

        Assert.Empty(result.Diagnostics);
        PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
    }

    [Theory]
    [InlineData(0.0, 12.0, 3.0, "UnitWidth")]
    [InlineData(7.0, -12.0, 3.0, "Depth")]
    [InlineData(7.0, 12.0, double.NegativeInfinity, "StairWidth")]
    public void InvalidParametersAreErrors(double unitWidth, double depth, double stairWidth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new StairPairParameters(unitWidth, depth, stairWidth, 3.0, 0.0), Examples);

        Assert.False(result.IsSuccess);
        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.StartsWith(name, error.Message);
    }

    [Fact]
    public void PresetsHaveOneSlotPerSpaceTypeInOrder()
    {
        Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Stair", SpaceType.Stair) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
        Assert.Equal(new[] { Examples.DwellingUnit, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
    }

    [Fact]
    public void GenerationIsDeterministic()
    {
        Assert.Equal(TextReport.Describe(Generate(StairPairParameters.Default)), TextReport.Describe(Generate(StairPairParameters.Default)));
    }

    [Fact]
    public void ProvenanceRecordsParametersAndPresets()
    {
        Provenance provenance = Generate(StairPairParameters.Default).Provenance;

        Assert.Equal(nameof(StairPairGenerator), provenance.Operation);
        Assert.Equal(
            new[] { "Depth=12", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair", "StairWidth=3", "UnitWidth=7" },
            provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(StairPairParameters.Default)), "stair-pair-canonical");
    }

    private IGeneratedPlan Generate(StairPairParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
  • [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairPair.StairPairGeneratorTests"

Expected: the build fails with these four errors for the net8.0 target (in this order):

tests\Lod.Generators.Tests\StairPair\StairPairGeneratorTests.cs(7,22): error CS0234: The type or namespace name 'StairPair' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\StairPair\StairPairGeneratorTests.cs(115,37): error CS0246: The type or namespace name 'StairPairParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\StairPair\StairPairGeneratorTests.cs(16,28): error CS0246: The type or namespace name 'StairPairPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\StairPair\StairPairGeneratorTests.cs(18,22): error CS0246: The type or namespace name 'StairPairGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 3: Implement — create src/Lod.Generators/StairPair/StairPairGenerator.cs (full file)

The layout is the stair bay of Task 5, not a copy of it.

using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.StairBayBar;

namespace Lod.Generators.StairPair;

/// <summary>Parameters of the stair pair (HDHM-TYP-008, ADR-006).</summary>
/// <param name="UnitWidth">Width of each dwelling, m.</param>
/// <param name="Depth">Plate depth, which both dwellings and the stair span, m.</param>
/// <param name="StairWidth">Width of the stair between the dwellings, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record StairPairParameters(
    double UnitWidth,
    double Depth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: two 7 m dwellings around a 3 m stair, 12 m deep, 3 m storeys, orientation 0°.</summary>
    public static StairPairParameters Default { get; } = new(7.0, 12.0, 3.0, 3.0, 0.0);
}

/// <summary>The program presets of the stair pair, one per space type it places (D-061).</summary>
/// <param name="DwellingUnit">Preset of the two dwellings; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Stair">Preset of the stair; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record StairPairPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets
{
    /// <inheritdoc />
    public override IReadOnlyList<PresetSlot> Slots => new[]
    {
        new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit),
        new PresetSlot(nameof(Stair), SpaceType.Stair, Stair),
    };
}

/// <summary>
/// One stair between a dwelling pair (HDHM-TYP-008, ADR-006): one stair bay of the stair-bay bar as its own building, laid out by the same code.
/// One compact linear plate with one dwelling pair (R001); the dwellings lie on opposite sides of the stair (R002), which adjoins and serves both
/// (R003). One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>Zone IDs: <c>ST1</c> stair, <c>U1</c> west dwelling, <c>U2</c> east dwelling.</remarks>
public sealed class StairPairGenerator : PlanGenerator<StairPairParameters, StairPairPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public StairPairGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

    /// <inheritdoc />
    public override string Name => nameof(StairPairGenerator);

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(StairPairParameters parameters)
    {
        var values = new (string Name, double Value)[]
        {
            (nameof(StairPairParameters.UnitWidth), parameters.UnitWidth),
            (nameof(StairPairParameters.Depth), parameters.Depth),
            (nameof(StairPairParameters.StairWidth), parameters.StairWidth),
        };
        return values
            .Where(v => !IsPositive(v.Value))
            .Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
            .ToArray();
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(StairPairParameters p) => StairBayLayout.Bays(1, p.UnitWidth, p.Depth, p.StairWidth);

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(StairPairParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };
}
  • [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairPair.StairPairGeneratorTests"

Expected on net8.0:

Failed!  - Failed:     1, Passed:    11, Skipped:     0, Total:    12, Duration: … - Lod.Generators.Tests.dll (net8.0)

with the only failing test and its message:

Failed Lod.Generators.Tests.StairPair.StairPairGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'stair-pair-canonical' is missing or differs. Review Snapshots/stair-pair-canonical.received.txt and rename it to stair-pair-canonical.txt to accept.
  • [x] Step 5: Review and accept the received file

Review the received file against the content below; it must be identical: 35 lines, 3 zones, 18 surfaces (8 outdoor walls with one window each, 4 interzone walls, 6 floors and ceilings), glazing 13.5 m² south, 13.5 m² north, 10.8 m² east, 10.8 m² west (48.6 m²). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/StairPair/Snapshots/stair-pair-canonical.received.txt tests/Lod.Generators.Tests/StairPair/Snapshots/stair-pair-canonical.txt

tests/Lod.Generators.Tests/StairPair/Snapshots/stair-pair-canonical.txt:

footprint area=204.000000 floorHeight=3.000000 orientation=0.000000
zone ST1 Stair "Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone U1 DwellingUnit "Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U2 DwellingUnit "Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface ST1/W1 Outdoors area=9.000000 wall (7.000000,0.000000)-(10.000000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST1/W2 Interzone adjacent=U2 area=36.000000 wall (10.000000,0.000000)-(10.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface ST1/W3 Outdoors area=9.000000 wall (10.000000,12.000000)-(7.000000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface ST1/W4 Interzone adjacent=U1 area=36.000000 wall (7.000000,12.000000)-(7.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface ST1/F Unresolved area=36.000000 Floor z=0.000000
surface ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=21.000000 wall (0.000000,0.000000)-(7.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U1/W2 Interzone adjacent=ST1 area=36.000000 wall (7.000000,0.000000)-(7.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Outdoors area=21.000000 wall (7.000000,12.000000)-(0.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U1/W4 Outdoors area=36.000000 wall (0.000000,12.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface U1/F Unresolved area=84.000000 Floor z=0.000000
surface U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface U2/W1 Outdoors area=21.000000 wall (10.000000,0.000000)-(17.000000,0.000000) z=0.000000 facing=South glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U2/W2 Outdoors area=36.000000 wall (17.000000,0.000000)-(17.000000,12.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface U2/W3 Outdoors area=21.000000 wall (17.000000,12.000000)-(10.000000,12.000000) z=0.000000 facing=North glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface U2/W4 Interzone adjacent=ST1 area=36.000000 wall (10.000000,12.000000)-(10.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=84.000000 Floor z=0.000000
surface U2/C Unresolved area=84.000000 Ceiling z=3.000000
  • [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairPair.StairPairGeneratorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 53 generators, and 187 integration tests passed on net8.0; git status lists the three new files of this task and no *.received.txt.

  • [x] Step 7: Commit
git add src/Lod.Generators/StairPair/StairPairGenerator.cs tests/Lod.Generators.Tests/StairPair/StairPairGeneratorTests.cs tests/Lod.Generators.Tests/StairPair/Snapshots/stair-pair-canonical.txt
git commit -m "feature(generators): add the stair pair generator (hdhm-typ-008)"

Files: - Create: src/Lod.Generators/GalleryBar/GalleryBarGenerator.cs - Test: create tests/Lod.Generators.Tests/GalleryBar/GalleryBarGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/GalleryBar/Snapshots/gallery-bar-canonical.txt

Interfaces: - Consumes: the PlanGenerator base; PlanAssertions (Task 5). - Produces: public sealed record GalleryBarParameters(int UnitCount, double UnitWidth, double Depth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (6, 6, 10, 4, 3, 0); public sealed record GalleryBarPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets; public sealed class GalleryBarGenerator(ToleranceSettings), Name = GalleryBarGenerator: zones ST1 (west stair), ST2 (east stair), U1…Un (dwellings west to east), all through the full depth, no gallery zone or geometry (D-083); InvalidParameter for UnitCount < 2 (UnitCount must be at least 2 (a gallery serves successive dwellings), got {n}.) and for each length that is not positive and finite; provenance keys UnitCount, UnitWidth, Depth, StairWidth.

  • [x] Step 1: Write the failing tests — create tests/Lod.Generators.Tests/GalleryBar/GalleryBarGeneratorTests.cs (full file)

18 tests: defaults; the canonical bar (ST1, ST2, U1–U6, 44 m × 10 m, stairs 40 m², dwellings 60 m²); tiling for 2, 6, and 9 dwellings; the window rule; no gallery or corridor zone, the footprint exactly the 44 m × 10 m bar, and both long façades of every dwelling outdoors; the end stairs adjoining the end dwellings; five invalid parameters; one module; preset slots; determinism; provenance; the canonical snapshot.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.GalleryBar;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.GalleryBar;

public sealed class GalleryBarGeneratorTests
{
    /// <summary>The example presets, one per slot.</summary>
    public static readonly GalleryBarPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);

    private readonly GalleryBarGenerator _generator = new(ToleranceSettings.Default);

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(new GalleryBarParameters(UnitCount: 6, UnitWidth: 6.0, Depth: 10.0, StairWidth: 4.0, FloorHeight: 3.0, OrientationDegrees: 0.0), GalleryBarParameters.Default);
    }

    [Fact]
    public void CanonicalBarHasARowOfDwellingsBetweenTwoEndStairs()
    {
        IGeneratedPlan plan = Generate(GalleryBarParameters.Default);

        Assert.Equal(new[] { "ST1", "ST2", "U1", "U2", "U3", "U4", "U5", "U6" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(44.0 * 10.0, plan.Footprint.Area, 9);
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.Stair), z => Assert.Equal(40.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal(60.0, z.FloorArea, 9));
        Assert.Equal(new[] { new Point2(40.0, 0.0), new Point2(44.0, 0.0), new Point2(44.0, 10.0), new Point2(40.0, 10.0) }, plan.Zones.Single(z => z.Id.Value == "ST2").Parts[0].Footprint.Outer);
    }

    [Theory]
    [InlineData(2)]
    [InlineData(6)]
    [InlineData(9)]
    public void ZonesTileTheFootprint(int units)
    {
        IGeneratedPlan plan = Generate(GalleryBarParameters.Default with { UnitCount = units });

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(units + 2, plan.Zones.Count);
    }

    [Fact]
    public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(GalleryBarParameters.Default, Examples);

        Assert.Empty(result.Diagnostics);
        PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
    }

    [Fact]
    public void TheGalleryIsNotModelledAndBothLongFacadesAreOutdoors()
    {
        IGeneratedPlan plan = Generate(GalleryBarParameters.Default);

        Assert.Equal(new[] { SpaceType.Stair, SpaceType.DwellingUnit }, plan.Zones.Select(z => z.SpaceType).Distinct());
        Assert.Equal(Polygon2.Rectangle(0.0, 0.0, 44.0, 10.0).Outer, plan.Footprint.Outer);
        Assert.All(
            plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit),
            z => Assert.Equal(new[] { OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, z.Id.Value)));
    }

    [Fact]
    public void TheEndStairsAdjoinTheEndDwellings()
    {
        IGeneratedPlan plan = Generate(GalleryBarParameters.Default);

        Assert.Equal(new[] { "U1" }, PlanAssertions.Neighbours(plan, "ST1"));
        Assert.Equal(new[] { "U6" }, PlanAssertions.Neighbours(plan, "ST2"));
        Assert.Equal(new[] { OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "ST1"));
        Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "ST2"));
        Assert.Equal(new[] { "U2", "U4" }, PlanAssertions.Neighbours(plan, "U3"));
    }

    [Theory]
    [InlineData(1, 6.0, 10.0, 4.0, "UnitCount")]
    [InlineData(-2, 6.0, 10.0, 4.0, "UnitCount")]
    [InlineData(6, -6.0, 10.0, 4.0, "UnitWidth")]
    [InlineData(6, 6.0, 0.0, 4.0, "Depth")]
    [InlineData(6, 6.0, 10.0, double.NaN, "StairWidth")]
    public void InvalidParametersAreErrors(int units, double unitWidth, double depth, double stairWidth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new GalleryBarParameters(units, unitWidth, depth, stairWidth, 3.0, 0.0), Examples);

        Assert.False(result.IsSuccess);
        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.StartsWith(name, error.Message);
    }

    [Fact]
    public void OneModuleIsNotAGalleryBar()
    {
        Result<IGeneratedPlan> result = _generator.Generate(GalleryBarParameters.Default with { UnitCount = 1 }, Examples);

        Assert.Equal("UnitCount must be at least 2 (a gallery serves successive dwellings), got 1.", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void PresetsHaveOneSlotPerSpaceTypeInOrder()
    {
        Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Stair", SpaceType.Stair) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
        Assert.Equal(new[] { Examples.DwellingUnit, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
    }

    [Fact]
    public void GenerationIsDeterministic()
    {
        Assert.Equal(TextReport.Describe(Generate(GalleryBarParameters.Default)), TextReport.Describe(Generate(GalleryBarParameters.Default)));
    }

    [Fact]
    public void ProvenanceRecordsParametersAndPresets()
    {
        Provenance provenance = Generate(GalleryBarParameters.Default).Provenance;

        Assert.Equal(nameof(GalleryBarGenerator), provenance.Operation);
        Assert.Equal(
            new[] { "Depth=10", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair", "StairWidth=4", "UnitCount=6", "UnitWidth=6" },
            provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(GalleryBarParameters.Default)), "gallery-bar-canonical");
    }

    private IGeneratedPlan Generate(GalleryBarParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
  • [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.GalleryBar.GalleryBarGeneratorTests"

Expected: the build fails with these four errors for the net8.0 target (in this order):

tests\Lod.Generators.Tests\GalleryBar\GalleryBarGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'GalleryBar' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\GalleryBar\GalleryBarGeneratorTests.cs(136,37): error CS0246: The type or namespace name 'GalleryBarParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\GalleryBar\GalleryBarGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'GalleryBarPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\GalleryBar\GalleryBarGeneratorTests.cs(17,22): error CS0246: The type or namespace name 'GalleryBarGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 3: Implement — create src/Lod.Generators/GalleryBar/GalleryBarGenerator.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;

namespace Lod.Generators.GalleryBar;

/// <summary>Parameters of the gallery bar (HDHM-TYP-002, ADR-006).</summary>
/// <param name="UnitCount">Number of dwellings along the bar; at least 2.</param>
/// <param name="UnitWidth">Width of each dwelling along the bar, m.</param>
/// <param name="Depth">Bar depth, which every dwelling and both stairs span, m.</param>
/// <param name="StairWidth">Width of each end stair along the bar, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record GalleryBarParameters(
    int UnitCount,
    double UnitWidth,
    double Depth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 6 dwellings of 6 m between two 4 m end stairs, 10 m deep, 3 m storeys, orientation 0°.</summary>
    public static GalleryBarParameters Default { get; } = new(6, 6.0, 10.0, 4.0, 3.0, 0.0);
}

/// <summary>The program presets of the gallery bar, one per space type it places (D-061).</summary>
/// <param name="DwellingUnit">Preset of the dwellings; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Stair">Preset of the two end stairs; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record GalleryBarPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets
{
    /// <inheritdoc />
    public override IReadOnlyList<PresetSlot> Slots => new[]
    {
        new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit),
        new PresetSlot(nameof(Stair), SpaceType.Stair, Stair),
    };
}

/// <summary>
/// Continuous external gallery beside a linear bar (HDHM-TYP-002, ADR-006): one straight row of dwellings through the bar depth (R001) with a
/// full-depth stair at each end, from which the gallery would run past every dwelling (R002, R003). The gallery itself is not modelled (D-083): no
/// gallery zone and no gallery geometry; the façade on the gallery side is an ordinary outdoor façade. Fewer than two dwellings is rejected (R004,
/// R005). One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>Zone IDs: <c>ST1</c> west stair, <c>ST2</c> east stair, then <c>U1…Un</c> dwellings west to east.</remarks>
public sealed class GalleryBarGenerator : PlanGenerator<GalleryBarParameters, GalleryBarPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public GalleryBarGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

    /// <inheritdoc />
    public override string Name => nameof(GalleryBarGenerator);

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(GalleryBarParameters parameters)
    {
        if (parameters.UnitCount < 2)
        {
            yield return Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(GalleryBarParameters.UnitCount)} must be at least 2 (a gallery serves successive dwellings), got {parameters.UnitCount}.");
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(GalleryBarParameters.UnitWidth), parameters.UnitWidth),
            (nameof(GalleryBarParameters.Depth), parameters.Depth),
            (nameof(GalleryBarParameters.StairWidth), parameters.StairWidth),
        };
        foreach ((string name, double value) in values.Where(v => !IsPositive(v.Value)))
        {
            yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be positive, got {value}.");
        }
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(GalleryBarParameters p)
    {
        double eastStair = p.StairWidth + (p.UnitCount * p.UnitWidth);
        double length = eastStair + p.StairWidth;
        var zones = new List<PlannedZone>
        {
            new(new ZoneId("ST1"), "Stair 1", SpaceType.Stair, Polygon2.Rectangle(0.0, 0.0, p.StairWidth, p.Depth)),
            new(new ZoneId("ST2"), "Stair 2", SpaceType.Stair, Polygon2.Rectangle(eastStair, 0.0, length, p.Depth)),
        };
        for (int i = 0; i < p.UnitCount; i++)
        {
            double west = p.StairWidth + (i * p.UnitWidth);
            double east = i == p.UnitCount - 1 ? eastStair : west + p.UnitWidth;
            zones.Add(new PlannedZone(new ZoneId($"U{i + 1}"), $"Unit {i + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(west, 0.0, east, p.Depth)));
        }

        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, length, p.Depth), zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(GalleryBarParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.UnitCount), p.UnitCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };
}
  • [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.GalleryBar.GalleryBarGeneratorTests"

Expected on net8.0:

Failed!  - Failed:     1, Passed:    17, Skipped:     0, Total:    18, Duration: … - Lod.Generators.Tests.dll (net8.0)

with the only failing test and its message:

Failed Lod.Generators.Tests.GalleryBar.GalleryBarGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'gallery-bar-canonical' is missing or differs. Review Snapshots/gallery-bar-canonical.received.txt and rename it to gallery-bar-canonical.txt to accept.
  • [x] Step 5: Review and accept the received file

Review the received file against the content below; it must be identical: 93 lines, 8 zones, 48 surfaces (18 outdoor walls with one window each, 14 interzone walls, 16 floors and ceilings), glazing 34.8 m² south, 34.8 m² north, 3 m² east, 3 m² west (75.6 m²). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/GalleryBar/Snapshots/gallery-bar-canonical.received.txt tests/Lod.Generators.Tests/GalleryBar/Snapshots/gallery-bar-canonical.txt

tests/Lod.Generators.Tests/GalleryBar/Snapshots/gallery-bar-canonical.txt:

footprint area=440.000000 floorHeight=3.000000 orientation=0.000000
zone ST1 Stair "Stair 1" area=40.000000 volume=120.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone ST2 Stair "Stair 2" area=40.000000 volume=120.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone U1 DwellingUnit "Unit 1" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U2 DwellingUnit "Unit 2" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U3 DwellingUnit "Unit 3" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U4 DwellingUnit "Unit 4" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U5 DwellingUnit "Unit 5" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U6 DwellingUnit "Unit 6" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface ST1/W1 Outdoors area=12.000000 wall (0.000000,0.000000)-(4.000000,0.000000) z=0.000000 facing=South glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST1/W2 Interzone adjacent=U1 area=30.000000 wall (4.000000,0.000000)-(4.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface ST1/W3 Outdoors area=12.000000 wall (4.000000,10.000000)-(0.000000,10.000000) z=0.000000 facing=North glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST1/W4 Outdoors area=30.000000 wall (0.000000,10.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=3.000000 window(offset=3.418861 sill=1.025658 3.162278x0.948683)
surface ST1/F Unresolved area=40.000000 Floor z=0.000000
surface ST1/C Unresolved area=40.000000 Ceiling z=3.000000
surface ST2/W1 Outdoors area=12.000000 wall (40.000000,0.000000)-(44.000000,0.000000) z=0.000000 facing=South glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST2/W2 Outdoors area=30.000000 wall (44.000000,0.000000)-(44.000000,10.000000) z=0.000000 facing=East glazing=3.000000 window(offset=3.418861 sill=1.025658 3.162278x0.948683)
surface ST2/W3 Outdoors area=12.000000 wall (44.000000,10.000000)-(40.000000,10.000000) z=0.000000 facing=North glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST2/W4 Interzone adjacent=U6 area=30.000000 wall (40.000000,10.000000)-(40.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface ST2/F Unresolved area=40.000000 Floor z=0.000000
surface ST2/C Unresolved area=40.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=18.000000 wall (4.000000,0.000000)-(10.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U1/W2 Interzone adjacent=U2 area=30.000000 wall (10.000000,0.000000)-(10.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Outdoors area=18.000000 wall (10.000000,10.000000)-(4.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U1/W4 Interzone adjacent=ST1 area=30.000000 wall (4.000000,10.000000)-(4.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U1/F Unresolved area=60.000000 Floor z=0.000000
surface U1/C Unresolved area=60.000000 Ceiling z=3.000000
surface U2/W1 Outdoors area=18.000000 wall (10.000000,0.000000)-(16.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U2/W2 Interzone adjacent=U3 area=30.000000 wall (16.000000,0.000000)-(16.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Outdoors area=18.000000 wall (16.000000,10.000000)-(10.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U2/W4 Interzone adjacent=U1 area=30.000000 wall (10.000000,10.000000)-(10.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=60.000000 Floor z=0.000000
surface U2/C Unresolved area=60.000000 Ceiling z=3.000000
surface U3/W1 Outdoors area=18.000000 wall (16.000000,0.000000)-(22.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U3/W2 Interzone adjacent=U4 area=30.000000 wall (22.000000,0.000000)-(22.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Outdoors area=18.000000 wall (22.000000,10.000000)-(16.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U3/W4 Interzone adjacent=U2 area=30.000000 wall (16.000000,10.000000)-(16.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=60.000000 Floor z=0.000000
surface U3/C Unresolved area=60.000000 Ceiling z=3.000000
surface U4/W1 Outdoors area=18.000000 wall (22.000000,0.000000)-(28.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U4/W2 Interzone adjacent=U5 area=30.000000 wall (28.000000,0.000000)-(28.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Outdoors area=18.000000 wall (28.000000,10.000000)-(22.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U4/W4 Interzone adjacent=U3 area=30.000000 wall (22.000000,10.000000)-(22.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=60.000000 Floor z=0.000000
surface U4/C Unresolved area=60.000000 Ceiling z=3.000000
surface U5/W1 Outdoors area=18.000000 wall (28.000000,0.000000)-(34.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U5/W2 Interzone adjacent=U6 area=30.000000 wall (34.000000,0.000000)-(34.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U5/W3 Outdoors area=18.000000 wall (34.000000,10.000000)-(28.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U5/W4 Interzone adjacent=U4 area=30.000000 wall (28.000000,10.000000)-(28.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=60.000000 Floor z=0.000000
surface U5/C Unresolved area=60.000000 Ceiling z=3.000000
surface U6/W1 Outdoors area=18.000000 wall (34.000000,0.000000)-(40.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U6/W2 Interzone adjacent=ST2 area=30.000000 wall (40.000000,0.000000)-(40.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U6/W3 Outdoors area=18.000000 wall (40.000000,10.000000)-(34.000000,10.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface U6/W4 Interzone adjacent=U5 area=30.000000 wall (34.000000,10.000000)-(34.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U6/F Unresolved area=60.000000 Floor z=0.000000
surface U6/C Unresolved area=60.000000 Ceiling z=3.000000
  • [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.GalleryBar.GalleryBarGeneratorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 18, Skipped: 0, Total: 18, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 71 generators, and 187 integration tests passed on net8.0; git status lists the three new files of this task and no *.received.txt.

  • [x] Step 7: Commit
git add src/Lod.Generators/GalleryBar/GalleryBarGenerator.cs tests/Lod.Generators.Tests/GalleryBar/GalleryBarGeneratorTests.cs tests/Lod.Generators.Tests/GalleryBar/Snapshots/gallery-bar-canonical.txt
git commit -m "feature(generators): add the gallery bar generator without the gallery (hdhm-typ-002)"

Task 8: Terrace row generator (HDHM-TYP-006)

Files: - Create: src/Lod.Generators/TerraceRow/TerraceRowGenerator.cs - Test: create tests/Lod.Generators.Tests/TerraceRow/TerraceRowGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/TerraceRow/Snapshots/terrace-row-canonical.txt

Interfaces: - Consumes: the PlanGenerator base and its ADR-011 window rule (Task 4: WindowOmitted); PlanAssertions (Task 5). - Produces: public sealed record TerraceRowParameters(int PlotCount, double PlotWidth, double Depth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (6, 5.5, 10, 3, 0); public sealed record TerraceRowPresets(ProgramPreset DwellingUnit) : PlanPresets (one slot); public sealed class TerraceRowGenerator(ToleranceSettings), Name = TerraceRowGenerator: zones U1…Un named House i, west to east, one dwelling zone per plot and storey (D-085), no stair or corridor zones; InvalidParameter for PlotCount < 2 (PlotCount must be at least 2 (attached plots share a dividing wall), got {n}.) and for each length that is not positive and finite; provenance keys PlotCount, PlotWidth, Depth.

  • [x] Step 1: Write the failing tests — create tests/Lod.Generators.Tests/TerraceRow/TerraceRowGeneratorTests.cs (full file)

17 tests: defaults; the canonical row (U1–U6 named House 1–House 6, 33 m × 10 m, 55 m² each, dwelling units only); tiling for 2, 6, and 11 plots; the window rule; neighbouring plots sharing a dividing wall; plots 0.8 m wide, whose 0.8 m fronts get no window and twelve WindowOmitted infos while the two 10 m end walls keep theirs; four invalid parameters; one house; the single preset slot; determinism; provenance; the canonical snapshot.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.TerraceRow;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.TerraceRow;

public sealed class TerraceRowGeneratorTests
{
    /// <summary>The example preset of the only slot.</summary>
    public static readonly TerraceRowPresets Examples = new(ExampleResidentialPresets.DwellingUnit);

    private readonly TerraceRowGenerator _generator = new(ToleranceSettings.Default);

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(new TerraceRowParameters(PlotCount: 6, PlotWidth: 5.5, Depth: 10.0, FloorHeight: 3.0, OrientationDegrees: 0.0), TerraceRowParameters.Default);
    }

    [Fact]
    public void CanonicalRowHasOneDwellingPerPlot()
    {
        IGeneratedPlan plan = Generate(TerraceRowParameters.Default);

        Assert.Equal(new[] { "U1", "U2", "U3", "U4", "U5", "U6" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(new[] { "House 1", "House 6" }, plan.Zones.Where(z => z.Id.Value is "U1" or "U6").Select(z => z.Name));
        Assert.Equal(33.0 * 10.0, plan.Footprint.Area, 9);
        Assert.All(plan.Zones, z => Assert.Equal(55.0, z.FloorArea, 9));
        Assert.All(plan.Zones, z => Assert.Equal(SpaceType.DwellingUnit, z.SpaceType));
    }

    [Theory]
    [InlineData(2)]
    [InlineData(6)]
    [InlineData(11)]
    public void ZonesTileTheFootprint(int plots)
    {
        IGeneratedPlan plan = Generate(TerraceRowParameters.Default with { PlotCount = plots });

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(plots, plan.Zones.Count);
    }

    [Fact]
    public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
    {
        Result<IGeneratedPlan> result = _generator.Generate(TerraceRowParameters.Default, Examples);

        Assert.Empty(result.Diagnostics);
        PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
    }

    [Fact]
    public void NeighbouringPlotsShareADividingWall()
    {
        IGeneratedPlan plan = Generate(TerraceRowParameters.Default);

        Assert.Equal(new[] { "U2" }, PlanAssertions.Neighbours(plan, "U1"));
        Assert.Equal(new[] { "U2", "U4" }, PlanAssertions.Neighbours(plan, "U3"));
        Assert.Equal(new[] { "U5" }, PlanAssertions.Neighbours(plan, "U6"));
        WallSurface party = plan.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "U3" && w.AdjacentZone?.Value == "U4");
        Assert.Equal(10.0, party.Length, 9);
        Assert.Equal(new[] { OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "U1"));
        Assert.Equal(new[] { OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "U3"));
    }

    [Fact]
    public void FrontsBelowTheMinimumWallLengthGetNoWindow()
    {
        // 0.8 m plots: every front and back wall is below ADR-011's 1 m minimum; the 10 m end walls keep their windows.
        Result<IGeneratedPlan> result = _generator.Generate(TerraceRowParameters.Default with { PlotWidth = 0.8 }, Examples);

        Assert.True(result.IsSuccess);
        WallSurface[] outdoor = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
        Assert.All(outdoor.Where(w => w.Length < 1.0), w => Assert.Empty(w.Windows));
        Assert.Equal(2, outdoor.Count(w => w.Windows.Count == 1));
        Assert.Equal(12, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.WindowOmitted && d.Severity == DiagnosticSeverity.Info));
    }

    [Theory]
    [InlineData(1, 5.5, 10.0, "PlotCount")]
    [InlineData(0, 5.5, 10.0, "PlotCount")]
    [InlineData(6, 0.0, 10.0, "PlotWidth")]
    [InlineData(6, 5.5, double.NaN, "Depth")]
    public void InvalidParametersAreErrors(int plots, double plotWidth, double depth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new TerraceRowParameters(plots, plotWidth, depth, 3.0, 0.0), Examples);

        Assert.False(result.IsSuccess);
        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.StartsWith(name, error.Message);
    }

    [Fact]
    public void OneHouseIsNotATerrace()
    {
        Result<IGeneratedPlan> result = _generator.Generate(TerraceRowParameters.Default with { PlotCount = 1 }, Examples);

        Assert.Equal("PlotCount must be at least 2 (attached plots share a dividing wall), got 1.", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void PresetsHaveOneDwellingUnitSlot()
    {
        PresetSlotAssert(Examples);
        Result<IGeneratedPlan> result = _generator.Generate(TerraceRowParameters.Default, new TerraceRowPresets(ExampleResidentialPresets.Stair));
        Assert.Equal(new[] { "DwellingUnit" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
    }

    [Fact]
    public void GenerationIsDeterministic()
    {
        Assert.Equal(TextReport.Describe(Generate(TerraceRowParameters.Default)), TextReport.Describe(Generate(TerraceRowParameters.Default)));
    }

    [Fact]
    public void ProvenanceRecordsParametersAndPresets()
    {
        Provenance provenance = Generate(TerraceRowParameters.Default).Provenance;

        Assert.Equal(nameof(TerraceRowGenerator), provenance.Operation);
        Assert.Equal(
            new[] { "Depth=10", "FloorHeight=3", "OrientationDegrees=0", "PlotCount=6", "PlotWidth=5.5", "Presets=DwellingUnit:Example Dwelling Unit" },
            provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(TerraceRowParameters.Default)), "terrace-row-canonical");
    }

    private static void PresetSlotAssert(TerraceRowPresets presets)
    {
        Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit) }, presets.Slots.Select(s => (s.Input, s.SpaceType)));
        Assert.Same(presets.DwellingUnit, Assert.Single(presets.Slots).Preset);
    }

    private IGeneratedPlan Generate(TerraceRowParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
  • [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.TerraceRow.TerraceRowGeneratorTests"

Expected: the build fails with these five errors for the net8.0 target (in this order; TerraceRowPresets is reported at two declarations):

tests\Lod.Generators.Tests\TerraceRow\TerraceRowGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'TerraceRow' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\TerraceRow\TerraceRowGeneratorTests.cs(139,42): error CS0246: The type or namespace name 'TerraceRowPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\TerraceRow\TerraceRowGeneratorTests.cs(145,37): error CS0246: The type or namespace name 'TerraceRowParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\TerraceRow\TerraceRowGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'TerraceRowPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\TerraceRow\TerraceRowGeneratorTests.cs(17,22): error CS0246: The type or namespace name 'TerraceRowGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 3: Implement — create src/Lod.Generators/TerraceRow/TerraceRowGenerator.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;

namespace Lod.Generators.TerraceRow;

/// <summary>Parameters of the terrace row (HDHM-TYP-006, ADR-006).</summary>
/// <param name="PlotCount">Number of attached house plots along the row; at least 2.</param>
/// <param name="PlotWidth">Width of each plot along the row, m.</param>
/// <param name="Depth">Row depth, which every house spans, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record TerraceRowParameters(
    int PlotCount,
    double PlotWidth,
    double Depth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 6 plots of 5.5 m, 10 m deep, 3 m storeys, orientation 0°.</summary>
    public static TerraceRowParameters Default { get; } = new(6, 5.5, 10.0, 3.0, 0.0);
}

/// <summary>The program preset of the terrace row, which places only dwellings (D-061).</summary>
/// <param name="DwellingUnit">Preset of the houses; space type <see cref="SpaceType.DwellingUnit"/>.</param>
public sealed record TerraceRowPresets(ProgramPreset DwellingUnit) : PlanPresets
{
    /// <inheritdoc />
    public override IReadOnlyList<PresetSlot> Slots => new[]
    {
        new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit),
    };
}

/// <summary>
/// Attached narrow terrace row (HDHM-TYP-006, ADR-006): one straight row (R001) of repeated attached house plots (R002), neighbouring plots
/// sharing a dividing wall (R003); fewer than two plots is rejected (R004, R005). Each house is one dwelling zone per storey; its own stair is inside
/// the dwelling and not zoned (D-009), so the row has no stair or corridor zones. A house of several storeys is one zone per storey, stacked by the
/// floor aggregators.
/// </summary>
/// <remarks>Zone IDs: <c>U1…Un</c> houses west to east, named <c>House i</c>.</remarks>
public sealed class TerraceRowGenerator : PlanGenerator<TerraceRowParameters, TerraceRowPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public TerraceRowGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

    /// <inheritdoc />
    public override string Name => nameof(TerraceRowGenerator);

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(TerraceRowParameters parameters)
    {
        if (parameters.PlotCount < 2)
        {
            yield return Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(TerraceRowParameters.PlotCount)} must be at least 2 (attached plots share a dividing wall), got {parameters.PlotCount}.");
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(TerraceRowParameters.PlotWidth), parameters.PlotWidth),
            (nameof(TerraceRowParameters.Depth), parameters.Depth),
        };
        foreach ((string name, double value) in values.Where(v => !IsPositive(v.Value)))
        {
            yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be positive, got {value}.");
        }
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(TerraceRowParameters p)
    {
        double length = p.PlotCount * p.PlotWidth;
        var zones = new List<PlannedZone>();
        for (int i = 0; i < p.PlotCount; i++)
        {
            double west = i * p.PlotWidth;
            double east = i == p.PlotCount - 1 ? length : west + p.PlotWidth;
            zones.Add(new PlannedZone(new ZoneId($"U{i + 1}"), $"House {i + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(west, 0.0, east, p.Depth)));
        }

        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, length, p.Depth), zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(TerraceRowParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.PlotCount), p.PlotCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.PlotWidth), Provenance.Format(p.PlotWidth)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
    };
}
  • [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.TerraceRow.TerraceRowGeneratorTests"

Expected on net8.0:

Failed!  - Failed:     1, Passed:    16, Skipped:     0, Total:    17, Duration: … - Lod.Generators.Tests.dll (net8.0)

with the only failing test and its message:

Failed Lod.Generators.Tests.TerraceRow.TerraceRowGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'terrace-row-canonical' is missing or differs. Review Snapshots/terrace-row-canonical.received.txt and rename it to terrace-row-canonical.txt to accept.
  • [x] Step 5: Review and accept the received file

Review the received file against the content below; it must be identical: 73 lines, 6 zones, 36 surfaces (14 outdoor walls with one window each, 10 interzone walls, 12 floors and ceilings), glazing 29.7 m² south, 29.7 m² north, 9 m² east, 9 m² west (77.4 m²). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/TerraceRow/Snapshots/terrace-row-canonical.received.txt tests/Lod.Generators.Tests/TerraceRow/Snapshots/terrace-row-canonical.txt

tests/Lod.Generators.Tests/TerraceRow/Snapshots/terrace-row-canonical.txt:

footprint area=330.000000 floorHeight=3.000000 orientation=0.000000
zone U1 DwellingUnit "House 1" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U2 DwellingUnit "House 2" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U3 DwellingUnit "House 3" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U4 DwellingUnit "House 4" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U5 DwellingUnit "House 5" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U6 DwellingUnit "House 6" area=55.000000 volume=165.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface U1/W1 Outdoors area=16.500000 wall (0.000000,0.000000)-(5.500000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U1/W2 Interzone adjacent=U2 area=30.000000 wall (5.500000,0.000000)-(5.500000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Outdoors area=16.500000 wall (5.500000,10.000000)-(0.000000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U1/W4 Outdoors area=30.000000 wall (0.000000,10.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface U1/F Unresolved area=55.000000 Floor z=0.000000
surface U1/C Unresolved area=55.000000 Ceiling z=3.000000
surface U2/W1 Outdoors area=16.500000 wall (5.500000,0.000000)-(11.000000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U2/W2 Interzone adjacent=U3 area=30.000000 wall (11.000000,0.000000)-(11.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Outdoors area=16.500000 wall (11.000000,10.000000)-(5.500000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U2/W4 Interzone adjacent=U1 area=30.000000 wall (5.500000,10.000000)-(5.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=55.000000 Floor z=0.000000
surface U2/C Unresolved area=55.000000 Ceiling z=3.000000
surface U3/W1 Outdoors area=16.500000 wall (11.000000,0.000000)-(16.500000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U3/W2 Interzone adjacent=U4 area=30.000000 wall (16.500000,0.000000)-(16.500000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Outdoors area=16.500000 wall (16.500000,10.000000)-(11.000000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U3/W4 Interzone adjacent=U2 area=30.000000 wall (11.000000,10.000000)-(11.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=55.000000 Floor z=0.000000
surface U3/C Unresolved area=55.000000 Ceiling z=3.000000
surface U4/W1 Outdoors area=16.500000 wall (16.500000,0.000000)-(22.000000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U4/W2 Interzone adjacent=U5 area=30.000000 wall (22.000000,0.000000)-(22.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Outdoors area=16.500000 wall (22.000000,10.000000)-(16.500000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U4/W4 Interzone adjacent=U3 area=30.000000 wall (16.500000,10.000000)-(16.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=55.000000 Floor z=0.000000
surface U4/C Unresolved area=55.000000 Ceiling z=3.000000
surface U5/W1 Outdoors area=16.500000 wall (22.000000,0.000000)-(27.500000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U5/W2 Interzone adjacent=U6 area=30.000000 wall (27.500000,0.000000)-(27.500000,10.000000) z=0.000000 facing=East glazing=0.000000
surface U5/W3 Outdoors area=16.500000 wall (27.500000,10.000000)-(22.000000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U5/W4 Interzone adjacent=U4 area=30.000000 wall (22.000000,10.000000)-(22.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=55.000000 Floor z=0.000000
surface U5/C Unresolved area=55.000000 Ceiling z=3.000000
surface U6/W1 Outdoors area=16.500000 wall (27.500000,0.000000)-(33.000000,0.000000) z=0.000000 facing=South glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U6/W2 Outdoors area=30.000000 wall (33.000000,0.000000)-(33.000000,10.000000) z=0.000000 facing=East glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface U6/W3 Outdoors area=16.500000 wall (33.000000,10.000000)-(27.500000,10.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface U6/W4 Interzone adjacent=U5 area=30.000000 wall (27.500000,10.000000)-(27.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U6/F Unresolved area=55.000000 Floor z=0.000000
surface U6/C Unresolved area=55.000000 Ceiling z=3.000000
  • [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.TerraceRow.TerraceRowGeneratorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 17, Skipped: 0, Total: 17, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 88 generators, and 187 integration tests passed on net8.0; git status lists the three new files of this task and no *.received.txt.

  • [x] Step 7: Commit
git add src/Lod.Generators/TerraceRow/TerraceRowGenerator.cs tests/Lod.Generators.Tests/TerraceRow/TerraceRowGeneratorTests.cs tests/Lod.Generators.Tests/TerraceRow/Snapshots/terrace-row-canonical.txt
git commit -m "feature(generators): add the terrace row generator (hdhm-typ-006)"

Task 9: The linear plan as the generator of HDHM-TYP-009

Files: - Modify: src/Lod.Generators/Linear/LinearPlanGenerator.cs (S2 file, last changed in S4.1; doc comment only) - Test: modify tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs (S2 file, last changed in S4.1)

Interfaces: - Consumes: LinearPlanGenerator, LinearPlanGeneratorTests.Canonical and Generate (S2, S4.1); PlanAssertions.Neighbours, OutdoorFacings (Task 5); ADR-006's realisation of HDHM-TYP-009. - Produces: the check of D-083 that the linear plan realises HDHM-TYP-009 (R001–R003) at three unit widths; no code change: the generator, its parameters, its component defaults, and linear-plan-canonical.txt are unchanged.

This task has no red phase: it checks existing behaviour against the family's rules (D-083, "checked against HDHM-TYP-009 and kept"), and the check finds no gap, so nothing is added to the generator. If the new test fails, stop and report: the generator does not realise its family, which needs a decision.

  • [x] Step 1: Add the check — replace tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs (full file)

One new theory, RealisesTheDoubleSidedCorridorBarFamily, for target unit widths 10, 6, and 100 m: both unit rows are present and equally long, every unit adjoins the corridor, the corridor adjoins the stair and has one outdoor façade (east), and the plan has only stair, corridor, and dwelling-unit zones. Every other test is unchanged.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.Linear;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.Linear;

public sealed class LinearPlanGeneratorTests
{
    /// <summary>24 m × 18 m, 4 m stair, two 10 m units per row.</summary>
    public static readonly LinearPlanParameters Canonical = new(
        Length: 24.0,
        UnitDepth: 8.0,
        CorridorWidth: 2.0,
        TargetUnitWidth: 10.0,
        StairLength: 4.0,
        FloorHeight: 3.0,
        OrientationDegrees: 0.0);

    /// <summary>The example presets, one per slot.</summary>
    public static readonly LinearPlanPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair);

    private readonly LinearPlanGenerator _generator = new(ToleranceSettings.Default);

    [Theory]
    [InlineData(10.0, 2)]
    [InlineData(8.0, 3)]
    [InlineData(6.0, 3)]
    [InlineData(100.0, 1)]
    public void UnitsPerRowRoundsHalfAwayFromZero(double targetWidth, int expected)
    {
        Assert.Equal(expected, LinearPlanGenerator.UnitsPerRow(Canonical with { TargetUnitWidth = targetWidth }));
    }

    [Fact]
    public void CanonicalPlanHasStairCorridorAndTwoUnitsPerRow()
    {
        IGeneratedPlan plan = Generate(Canonical);

        Assert.Equal(new[] { "ST", "CO", "US1", "US2", "UN1", "UN2" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(24.0 * 18.0, plan.Footprint.Area, 9);
        Assert.Equal(80.0, plan.Zones.Single(z => z.Id.Value == "US1").FloorArea, 9);
        Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
    }

    [Fact]
    public void ZonesTileTheFootprintWithoutOverlap()
    {
        IGeneratedPlan plan = Generate(Canonical with { TargetUnitWidth = 6.0 });
        var ops = new PolygonOps(ToleranceSettings.Default);

        Assert.True(ToleranceSettings.Default.AreaEquals(plan.Footprint.Area, plan.Zones.Sum(z => z.FloorArea)));
        foreach (Zone a in plan.Zones)
        {
            foreach (Zone b in plan.Zones.Where(b => b.Id.Value.CompareTo(a.Id.Value) > 0))
            {
                Assert.Equal(0.0, ops.IntersectionArea(a.Parts[0].Footprint, b.Parts[0].Footprint), 9);
            }
        }
    }

    [Fact]
    public void EveryOutdoorWallHasOneCenteredWindowSizedByItsPreset()
    {
        IGeneratedPlan plan = Generate(Canonical);

        foreach (WallSurface wall in plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors))
        {
            Zone zone = plan.Zones.Single(z => z.Id == wall.Zone);
            double ratio = Examples.Slots.Single(s => s.SpaceType == zone.SpaceType).Preset.WindowToWallRatio;
            Window window = Assert.Single(wall.Windows);
            Assert.Equal(ratio * wall.Area, window.Area, 9);
            Assert.Equal((wall.Length - window.Width) / 2.0, window.Offset, 12);
            Assert.Equal((wall.Height - window.Height) / 2.0, window.SillHeight, 12);
        }
    }

    [Fact]
    public void InterzoneWallsComeInMatchingPairs()
    {
        IGeneratedPlan plan = Generate(Canonical with { TargetUnitWidth = 6.0 });
        WallSurface[] interzone = plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Interzone).ToArray();

        Assert.NotEmpty(interzone);
        Assert.All(interzone, w => Assert.Contains(interzone, o => o.Zone == w.AdjacentZone && o.AdjacentZone == w.Zone && o.Start == w.End && o.End == w.Start));
    }

    [Fact]
    public void StairHasThreeOutdoorWallsAndThreeNeighbours()
    {
        IGeneratedPlan plan = Generate(Canonical);
        WallSurface[] stair = plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "ST").ToArray();

        Assert.Equal(3, stair.Count(w => w.Boundary == BoundaryCondition.Outdoors));
        Assert.Equal(new[] { "CO", "UN1", "US1" }, stair.Where(w => w.AdjacentZone.HasValue).Select(w => w.AdjacentZone!.Value.Value).OrderBy(x => x));
    }

    [Theory]
    [InlineData(10.0)]
    [InlineData(6.0)]
    [InlineData(100.0)]
    public void RealisesTheDoubleSidedCorridorBarFamily(double targetWidth)
    {
        // HDHM-TYP-009 (ADR-006): R001 and R003, dwellings on both flanks of the internal strip, each adjoining it; R002, the shared route runs
        // inside the bar from the stair to the far façade.
        IGeneratedPlan plan = Generate(Canonical with { TargetUnitWidth = targetWidth });
        string[] south = plan.Zones.Where(z => z.Id.Value.StartsWith("US")).Select(z => z.Id.Value).ToArray();
        string[] north = plan.Zones.Where(z => z.Id.Value.StartsWith("UN")).Select(z => z.Id.Value).ToArray();

        Assert.NotEmpty(south);
        Assert.Equal(south.Length, north.Length);
        Assert.All(south.Concat(north), id => Assert.Contains("CO", PlanAssertions.Neighbours(plan, id)));
        Assert.Contains("ST", PlanAssertions.Neighbours(plan, "CO"));
        Assert.Equal(new[] { OrientationBin.East }, PlanAssertions.OutdoorFacings(plan, "CO"));
        Assert.Equal(new[] { SpaceType.Stair, SpaceType.Corridor, SpaceType.DwellingUnit }, plan.Zones.Select(z => z.SpaceType).Distinct());
    }

    [Fact]
    public void OrientationRotatesFacadeBins()
    {
        IGeneratedPlan plan = Generate(Canonical with { OrientationDegrees = 90.0 });
        WallSurface south = plan.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "US1" && w.Boundary == BoundaryCondition.Outdoors);

        Assert.Equal(OrientationBin.West, Orientation.Bin(south.PlanAzimuth + plan.OrientationDegrees));
    }

    [Theory]
    [InlineData(0.0, 8.0, 2.0, 10.0, 4.0)]
    [InlineData(24.0, -1.0, 2.0, 10.0, 4.0)]
    [InlineData(24.0, 8.0, 2.0, 10.0, 24.0)]
    public void InvalidParametersAreErrors(double length, double unitDepth, double corridor, double unitWidth, double stair)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new LinearPlanParameters(length, unitDepth, corridor, unitWidth, stair, 3.0, 0.0), Examples);

        Assert.False(result.IsSuccess);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.InvalidParameter);
    }

    [Fact]
    public void PresetsHaveOneSlotPerSpaceTypeInOrder()
    {
        Assert.Equal(
            new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Corridor", SpaceType.Corridor), ("Stair", SpaceType.Stair) },
            Examples.Slots.Select(s => (s.Input, s.SpaceType)));
        Assert.Equal(new[] { Examples.DwellingUnit, Examples.Corridor, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
    }

    [Fact]
    public void SwappedPresetsAreAnErrorPerWrongSlot()
    {
        var swapped = new LinearPlanPresets(ExampleResidentialPresets.Corridor, ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);

        Result<IGeneratedPlan> result = _generator.Generate(Canonical, swapped);

        Assert.False(result.IsSuccess);
        Assert.Equal(
            new[] { "DwellingUnit", "Corridor" },
            result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
    }

    [Fact]
    public void MissingPresetIsAnError()
    {
        var withoutStair = new LinearPlanPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, null!);

        Result<IGeneratedPlan> result = _generator.Generate(Canonical, withoutStair);

        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.MissingPreset && d.Subject == "Stair");
    }

    [Fact]
    public void GenerationIsDeterministic()
    {
        Assert.Equal(TextReport.Describe(Generate(Canonical)), TextReport.Describe(Generate(Canonical)));
    }

    [Fact]
    public void ProvenanceRecordsParametersAndPresets()
    {
        Provenance provenance = Generate(Canonical).Provenance;

        Assert.Equal(nameof(LinearPlanGenerator), provenance.Operation);
        Assert.Contains(provenance.Parameters, p => p.Key == "TargetUnitWidth" && p.Value == "10");
        Assert.Contains(provenance.Parameters, p => p.Key == "Presets" && p.Value.Contains("DwellingUnit:Example Dwelling Unit"));
        Assert.Equal(BuildInfo.InformationalVersion, provenance.CodeVersion);
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(Canonical)), "linear-plan-canonical");
    }

    private IGeneratedPlan Generate(LinearPlanParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
  • [x] Step 2: Run the tests
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.Linear.LinearPlanGeneratorTests"

Expected: Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22, Duration: … - Lod.Generators.Tests.dll (net8.0) (19 before, 3 new cases).

  • [x] Step 3: Commit the check
git add tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs
git commit -m "test(generators): check the linear plan against hdhm-typ-009"
  • [x] Step 4: Name the family — replace src/Lod.Generators/Linear/LinearPlanGenerator.cs (full file)

Only the class summary changes: it names HDHM-TYP-009 and how the layout realises R001–R003, instead of "Refined in S8 (typologies) from the precedent study (S7)".

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;

namespace Lod.Generators.Linear;

/// <summary>Parameters of the linear block plan.</summary>
/// <param name="Length">Plan length along X, m.</param>
/// <param name="UnitDepth">Depth of each dwelling unit row, m.</param>
/// <param name="CorridorWidth">Corridor width, m.</param>
/// <param name="TargetUnitWidth">Desired unit width; the actual width divides the row evenly, m.</param>
/// <param name="StairLength">Length of the stair at the west end, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record LinearPlanParameters(
    double Length,
    double UnitDepth,
    double CorridorWidth,
    double TargetUnitWidth,
    double StairLength,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees);

/// <summary>The program presets of the linear block, one per space type it places (D-061).</summary>
/// <param name="DwellingUnit">Preset of the dwelling units; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Corridor">Preset of the corridor; space type <see cref="SpaceType.Corridor"/>.</param>
/// <param name="Stair">Preset of the stair; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record LinearPlanPresets(ProgramPreset DwellingUnit, ProgramPreset Corridor, ProgramPreset Stair) : PlanPresets
{
    /// <inheritdoc />
    public override IReadOnlyList<PresetSlot> Slots => new[]
    {
        new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit),
        new PresetSlot(nameof(Corridor), SpaceType.Corridor, Corridor),
        new PresetSlot(nameof(Stair), SpaceType.Stair, Stair),
    };
}

/// <summary>
/// The simplest linear block (S2): a rectangle with a full-depth stair at the west end and a double-loaded corridor running east to the
/// façade, with one row of equal dwelling units on each side. One zone per dwelling unit (D-009). This is the generator of the double-sided internal
/// corridor bar, HDHM-TYP-009 (ADR-006): the unit rows flank the corridor strip (R001, R003) and the corridor runs inside the bar from the stair to
/// the east façade (R002).
/// </summary>
/// <remarks>
/// Zone IDs: <c>ST</c> stair, <c>CO</c> corridor, <c>US1..USn</c> south units west to east, <c>UN1..UNn</c> north units west to east.
/// </remarks>
public sealed class LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public LinearPlanGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

    /// <inheritdoc />
    public override string Name => nameof(LinearPlanGenerator);

    /// <summary>Number of units per row: the row length divided by the target width, rounded half away from zero, at least 1.</summary>
    /// <param name="parameters">Parameters.</param>
    /// <returns>Units per row.</returns>
    public static int UnitsPerRow(LinearPlanParameters parameters) =>
        Math.Max(1, (int)Math.Round((parameters.Length - parameters.StairLength) / parameters.TargetUnitWidth, MidpointRounding.AwayFromZero));

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(LinearPlanParameters parameters)
    {
        var values = new (string Name, double Value)[]
        {
            (nameof(LinearPlanParameters.Length), parameters.Length),
            (nameof(LinearPlanParameters.UnitDepth), parameters.UnitDepth),
            (nameof(LinearPlanParameters.CorridorWidth), parameters.CorridorWidth),
            (nameof(LinearPlanParameters.TargetUnitWidth), parameters.TargetUnitWidth),
            (nameof(LinearPlanParameters.StairLength), parameters.StairLength),
        };
        foreach ((string name, double value) in values.Where(v => !IsPositive(v.Value)))
        {
            yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be positive, got {value}.");
        }

        if (parameters.Length <= parameters.StairLength)
        {
            yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"Length ({parameters.Length}) must exceed StairLength ({parameters.StairLength}).");
        }
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(LinearPlanParameters p)
    {
        int count = UnitsPerRow(p);
        double unitWidth = (p.Length - p.StairLength) / count;
        double[] xs = Enumerable.Range(0, count + 1).Select(i => i == count ? p.Length : p.StairLength + (i * unitWidth)).ToArray();
        double corridorSouth = p.UnitDepth;
        double corridorNorth = p.UnitDepth + p.CorridorWidth;
        double depth = corridorNorth + p.UnitDepth;

        var zones = new List<PlannedZone>
        {
            new(new ZoneId("ST"), "Stair", SpaceType.Stair, Polygon2.Rectangle(0.0, 0.0, p.StairLength, depth)),
            new(new ZoneId("CO"), "Corridor", SpaceType.Corridor, Polygon2.Rectangle(p.StairLength, corridorSouth, p.Length, corridorNorth)),
        };
        for (int i = 0; i < count; i++)
        {
            zones.Add(new PlannedZone(new ZoneId($"US{i + 1}"), $"Unit S{i + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(xs[i], 0.0, xs[i + 1], corridorSouth)));
        }

        for (int i = 0; i < count; i++)
        {
            zones.Add(new PlannedZone(new ZoneId($"UN{i + 1}"), $"Unit N{i + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(xs[i], corridorNorth, xs[i + 1], depth)));
        }

        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, p.Length, depth), zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(LinearPlanParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.Length), Provenance.Format(p.Length)),
        new KeyValuePair<string, string>(nameof(p.UnitDepth), Provenance.Format(p.UnitDepth)),
        new KeyValuePair<string, string>(nameof(p.CorridorWidth), Provenance.Format(p.CorridorWidth)),
        new KeyValuePair<string, string>(nameof(p.TargetUnitWidth), Provenance.Format(p.TargetUnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairLength), Provenance.Format(p.StairLength)),
    };
}
  • [x] Step 5: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short
git add src/Lod.Generators/Linear/LinearPlanGenerator.cs
git commit -m "docs(generators): name the linear plan as the hdhm-typ-009 generator"

Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 91 generators, and 187 integration tests passed on net8.0; git status --short shows M src/Lod.Generators/Linear/LinearPlanGenerator.cs only and no *.received.txt (linear-plan-canonical.txt is unchanged).

Task 10: Every bar family with every simplifier and aggregator

Files: - Test: create tests/Lod.Integration.Tests/BarTypologyTests.cs

Interfaces: - Consumes: the four generators and their Default records (Tasks 5–8); test support Pipeline.Simplifier(string name), Pipeline.Aggregator(string name), Pipeline.Tolerances (S4.2); PlanTransform.Apply(IGeneratedPlan plan, double rotationDegrees, double translationX, double translationY) : Result<IGeneratedPlan> (S4.2); SemanticMerge, FloorEntry(IFloor, int), BuildingValidator(ToleranceSettings).Validate(IGeneratedBuilding) : ValidationReport. - Produces: the S8 test of the roadmap: each of the four families × four simplifiers × four aggregators (floors 1, 3, 1) builds and validates without warnings, on the canonical plan and on the plan rotated by 131.4° and moved by (15.08, −153.9) m; Semantic Merge joins only connected zones of one space type for each family. No production code changes.

Like Task 9, this task has no red phase: it checks that the existing simplifiers and aggregators accept the new families unchanged (D-084: "Bars need no pipeline change"). If any case fails, stop and report: a pipeline change needs its own decision and test-first task.

  • [x] Step 1: Write the tests — create tests/Lod.Integration.Tests/BarTypologyTests.cs (full file)

132 cases: EveryCombinationValidates (64), EveryCombinationValidatesOnARotatedAndTranslatedPlan (64), and SemanticMergeJoinsOnlyConnectedZonesOfOneType (4: the three stairs of the stair-bay bar stay apart while the dwellings of neighbouring bays merge in pairs; the stair pair keeps three zones; the gallery bar's two end stairs stay apart; the terrace row becomes one zone).

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.GalleryBar;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.TerraceRow;
using Xunit;

namespace Lod.Integration.Tests;

/// <summary>The S8.1 bar families (ADR-006) through every plan simplifier and floor aggregator.</summary>
public sealed class BarTypologyTests
{
    private static readonly string[] Families = { "StairBayBar", "StairPair", "GalleryBar", "TerraceRow" };
    private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
    private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };

    public static IEnumerable<object[]> Matrix() =>
        from family in Families
        from simplifier in Simplifiers
        from aggregator in Aggregators
        select new object[] { family, simplifier, aggregator };

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationValidates(string family, string simplifier, string aggregator)
    {
        AssertValidates(Plan(family), simplifier, aggregator);
    }

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(string family, string simplifier, string aggregator)
    {
        AssertValidates(PlanTransform.Apply(Plan(family), 131.4, 15.08, -153.9).Value, simplifier, aggregator);
    }

    [Theory]
    [InlineData("StairBayBar", "DwellingUnit-1,DwellingUnit-2,DwellingUnit-3,DwellingUnit-4,Stair-1,Stair-2,Stair-3")]
    [InlineData("StairPair", "DwellingUnit-1,DwellingUnit-2,Stair-1")]
    [InlineData("GalleryBar", "DwellingUnit-1,Stair-1,Stair-2")]
    [InlineData("TerraceRow", "DwellingUnit-1")]
    public void SemanticMergeJoinsOnlyConnectedZonesOfOneType(string family, string zones)
    {
        // Stairs of a stair-bay bar are separated by dwellings and stay apart; the dwellings of two neighbouring bays share a party wall and merge.
        IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(family)).Value;

        Assert.Equal(zones, string.Join(",", floor.Zones.Select(z => z.Id.Value).OrderBy(id => id, StringComparer.Ordinal)));
    }

    private static void AssertValidates(IGeneratedPlan plan, string simplifier, string aggregator)
    {
        Result<IFloor> floor = Pipeline.Simplifier(simplifier).Simplify(plan);
        Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
        FloorEntry[] entries = { new(floor.Value, 1), new(floor.Value, 3), new(floor.Value, 1) };

        Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(entries);
        Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);

        Assert.True(report.Passed, report.Describe());
        Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
    }

    /// <summary>The canonical plan of a bar family: ADR-006 defaults and the example presets.</summary>
    private static IGeneratedPlan Plan(string family)
    {
        ProgramPreset unit = ExampleResidentialPresets.DwellingUnit;
        ProgramPreset stair = ExampleResidentialPresets.Stair;
        return family switch
        {
            "StairBayBar" => new StairBayBarGenerator(Pipeline.Tolerances).Generate(StairBayBarParameters.Default, new StairBayBarPresets(unit, stair)).Value,
            "StairPair" => new StairPairGenerator(Pipeline.Tolerances).Generate(StairPairParameters.Default, new StairPairPresets(unit, stair)).Value,
            "GalleryBar" => new GalleryBarGenerator(Pipeline.Tolerances).Generate(GalleryBarParameters.Default, new GalleryBarPresets(unit, stair)).Value,
            "TerraceRow" => new TerraceRowGenerator(Pipeline.Tolerances).Generate(TerraceRowParameters.Default, new TerraceRowPresets(unit)).Value,
            _ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown family."),
        };
    }
}
  • [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.BarTypologyTests"

Expected: Passed! - Failed: 0, Passed: 132, Skipped: 0, Total: 132, Duration: … - Lod.Integration.Tests.dll (net8.0).

  • [x] Step 3: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/BarTypologyTests.cs
git commit -m "test(integration): validate every bar family with every simplifier and aggregator"

Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 91 generators, and 319 integration tests passed on net8.0; git status lists only ?? tests/Lod.Integration.Tests/BarTypologyTests.cs.

  • [x] Step 4: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/generators-bar-typologies
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/generators-bar-typologies
git branch -d feature/generators-bar-typologies
git diff --name-status 22e90fd main -- "tests/*/Snapshots/*"

Expected: VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0; the last command lists exactly four added (A) files, the four canonical snapshots of Tasks 5–8, and nothing modified: the eight existing snapshots are byte-identical. Do not push (D-084).


Slice D — feature/grasshopper-bar-typologies

Task 11: Typology generator components

Grasshopper code cannot be unit-tested without Rhino: Tasks 11–13 are written, built with 0 warnings, committed, and checked in Rhino by the smoke test of Task 16.

Files: - Create: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs

Interfaces: - Consumes: the four generators and their records (Tasks 5–8); ComponentCategories.Category, ComponentCategories.Generate; ComponentSupport.Unwrap(GH_Component, Result<T>) (reports diagnostics as runtime messages; WindowOmitted as a remark), ComponentSupport.PreviewLocationParameter(), ComponentSupport.PreviewLocation(GH_Component, IGH_DataAccess) (S4.1); PlanGoo(IGeneratedPlan value, Vector3d previewOffset), ProgramPresetGoo, PlanParameter, ProgramPresetParameter; IconLibrary.For(IGH_DocumentObject) (S4.1; a missing icon gives Grasshopper's default icon until Task 13). - Produces: public abstract class TypologyGeneratorComponent : GH_Component (panel 2 Generate, one Plan output, AddPreset, TryNumbers, Preset, Output) and four components, each with its number inputs defaulting to the family's Default record, Floor Height, Orientation, one required preset input per space type it places (an unconnected one is Grasshopper's missing-input warning, D-065), and Preview Location last (D-062):

Component (nickname) Inputs → output Class GUID
Stair Bay Bar (SBar) Bay Count (BC, integer, 3), Unit Width (UW, 7), Depth (D, 12), Stair Width (SW, 3), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Stair (St), Preview Location (PL) → Plan StairBayBarComponent 911c3204-42bb-4023-bdb7-2931ea1caad3
Stair Pair (SPair) Unit Width (7), Depth (12), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan StairPairComponent 5669d39b-45e6-47ea-9250-f2304d005335
Gallery Bar (GBar) Unit Count (UC, integer, 6), Unit Width (6), Depth (10), Stair Width (4), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan GalleryBarComponent a58df462-b3c2-46b7-be3d-7ea9f4629280
Terrace Row (Terr) Plot Count (PC, integer, 6), Plot Width (PW, 5.5), Depth (10), Floor Height (3), Orientation (0), Dwelling Unit, Preview Location → Plan TerraceRowComponent f694e595-dba2-4b98-9d1e-f1492e3edf39

Linear Plan Generator (LinearPlanGeneratorComponent, S2) is not changed by this task and does not derive from the new base class.

  • [x] Step 1: Create the branch
git switch -c feature/grasshopper-bar-typologies main
  • [x] Step 2: Create src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (full file)
using System;
using System.Drawing;
using Grasshopper.Kernel;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.GalleryBar;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.TerraceRow;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

/// <summary>
/// Shared inputs and output of the typology plan generator components (ADR-006): number inputs with the family's defaults, one required preset input
/// per space type (D-061, D-065), the Preview Location last (D-062), and one Plan output.
/// </summary>
public abstract class TypologyGeneratorComponent : GH_Component
{
    protected const string FloorHeightDescription = "Floor-to-floor height, m.";

    protected const string OrientationDescription = "Clockwise rotation of plan north from true north, degrees.";

    protected const string Metres = " All lengths in metres regardless of model units.";

    protected TypologyGeneratorComponent(string name, string nickname, string description)
        : base(name, nickname, description + Metres, ComponentCategories.Category, ComponentCategories.Generate)
    {
    }

    protected override Bitmap? Icon => IconLibrary.For(this);

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The detailed plan.", GH_ParamAccess.item);
    }

    /// <summary>Adds the required preset input of one space type.</summary>
    protected static void AddPreset(GH_InputParamManager pManager, string name, string nickname, SpaceType spaceType, string role) =>
        pManager.AddParameter(new ProgramPresetParameter(), name, nickname, $"Program preset of {role}; space type {spaceType}.", GH_ParamAccess.item);

    /// <summary>Reads <paramref name="count"/> number inputs from <paramref name="first"/>; <c>false</c> when one is missing.</summary>
    protected static bool TryNumbers(IGH_DataAccess DA, int first, int count, out double[] values)
    {
        values = new double[count];
        for (int i = 0; i < count; i++)
        {
            if (!DA.GetData(first + i, ref values[i]))
            {
                return false;
            }
        }

        return true;
    }

    /// <summary>Reads a preset input; <c>null</c> when it is unconnected, which Grasshopper reports as a missing input (D-065).</summary>
    protected static ProgramPreset? Preset(IGH_DataAccess DA, int index)
    {
        ProgramPresetGoo? goo = null;
        return DA.GetData(index, ref goo) && goo is not null ? goo.Value : null;
    }

    /// <summary>Reports the generator's diagnostics and outputs the plan with this component's preview location.</summary>
    protected void Output(IGH_DataAccess DA, Result<IGeneratedPlan> result)
    {
        IGeneratedPlan? plan = ComponentSupport.Unwrap(this, result);
        if (plan is not null)
        {
            DA.SetData(0, new PlanGoo(plan, ComponentSupport.PreviewLocation(this, DA)));
        }
    }
}

public sealed class StairBayBarComponent : TypologyGeneratorComponent
{
    public StairBayBarComponent()
        : base(
            "Stair Bay Bar",
            "SBar",
            "Repeated stair bays in a linear bar (HDHM-TYP-001, ADR-006): bays of dwelling | stair | dwelling through the full depth, each stair serving the two dwellings beside it; no corridor. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("911c3204-42bb-4023-bdb7-2931ea1caad3");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        StairBayBarParameters d = StairBayBarParameters.Default;
        pManager.AddIntegerParameter("Bay Count", "BC", "Number of stair bays along the bar; at least 2.", GH_ParamAccess.item, d.BayCount);
        pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling along the bar, m.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Depth", "D", "Bar depth, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the bar, m.", GH_ParamAccess.item, d.StairWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int bays = 0;
        if (!DA.GetData(0, ref bays) || !TryNumbers(DA, 1, 5, out double[] v) || Preset(DA, 6) is not { } dwellingUnit || Preset(DA, 7) is not { } stair)
        {
            return;
        }

        var parameters = new StairBayBarParameters(bays, v[0], v[1], v[2], v[3], v[4]);
        Output(DA, new StairBayBarGenerator(ToleranceSettings.Default).Generate(parameters, new StairBayBarPresets(dwellingUnit, stair)));
    }
}

public sealed class StairPairComponent : TypologyGeneratorComponent
{
    public StairPairComponent()
        : base(
            "Stair Pair",
            "SPair",
            "One stair between a dwelling pair (HDHM-TYP-008, ADR-006): one stair bay of the Stair Bay Bar as its own building, the stair serving both dwellings. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("5669d39b-45e6-47ea-9250-f2304d005335");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        StairPairParameters d = StairPairParameters.Default;
        pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling, m.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Depth", "D", "Plate depth, which both dwellings and the stair span, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of the stair between the dwellings, m.", GH_ParamAccess.item, d.StairWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the two dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stair");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        if (!TryNumbers(DA, 0, 5, out double[] v) || Preset(DA, 5) is not { } dwellingUnit || Preset(DA, 6) is not { } stair)
        {
            return;
        }

        var parameters = new StairPairParameters(v[0], v[1], v[2], v[3], v[4]);
        Output(DA, new StairPairGenerator(ToleranceSettings.Default).Generate(parameters, new StairPairPresets(dwellingUnit, stair)));
    }
}

public sealed class GalleryBarComponent : TypologyGeneratorComponent
{
    public GalleryBarComponent()
        : base(
            "Gallery Bar",
            "GBar",
            "Continuous external gallery beside a linear bar (HDHM-TYP-002, ADR-006): a row of dwellings through the bar depth with a stair at each end. The gallery is not modelled (D-083): no gallery zone or geometry; its façade is an ordinary outdoor façade. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("a58df462-b3c2-46b7-be3d-7ea9f4629280");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        GalleryBarParameters d = GalleryBarParameters.Default;
        pManager.AddIntegerParameter("Unit Count", "UC", "Number of dwellings along the bar; at least 2.", GH_ParamAccess.item, d.UnitCount);
        pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling along the bar, m.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Depth", "D", "Bar depth, which every dwelling and both stairs span, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each end stair along the bar, m.", GH_ParamAccess.item, d.StairWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the two end stairs");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int units = 0;
        if (!DA.GetData(0, ref units) || !TryNumbers(DA, 1, 5, out double[] v) || Preset(DA, 6) is not { } dwellingUnit || Preset(DA, 7) is not { } stair)
        {
            return;
        }

        var parameters = new GalleryBarParameters(units, v[0], v[1], v[2], v[3], v[4]);
        Output(DA, new GalleryBarGenerator(ToleranceSettings.Default).Generate(parameters, new GalleryBarPresets(dwellingUnit, stair)));
    }
}

public sealed class TerraceRowComponent : TypologyGeneratorComponent
{
    public TerraceRowComponent()
        : base(
            "Terrace Row",
            "Terr",
            "Attached narrow terrace row (HDHM-TYP-006, ADR-006): a row of attached house plots sharing dividing walls, one dwelling zone per plot and storey; each house's own stair is inside the dwelling and not zoned.")
    {
    }

    public override Guid ComponentGuid => new("f694e595-dba2-4b98-9d1e-f1492e3edf39");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        TerraceRowParameters d = TerraceRowParameters.Default;
        pManager.AddIntegerParameter("Plot Count", "PC", "Number of attached house plots along the row; at least 2.", GH_ParamAccess.item, d.PlotCount);
        pManager.AddNumberParameter("Plot Width", "PW", "Width of each plot along the row, m.", GH_ParamAccess.item, d.PlotWidth);
        pManager.AddNumberParameter("Depth", "D", "Row depth, which every house spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the houses");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int plots = 0;
        if (!DA.GetData(0, ref plots) || !TryNumbers(DA, 1, 4, out double[] v) || Preset(DA, 5) is not { } dwellingUnit)
        {
            return;
        }

        var parameters = new TerraceRowParameters(plots, v[0], v[1], v[2], v[3]);
        Output(DA, new TerraceRowGenerator(ToleranceSettings.Default).Generate(parameters, new TerraceRowPresets(dwellingUnit)));
    }
}
  • [x] Step 3: Build
dotnet build BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); ?? src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs only. The four icons do not exist yet; Task 13 draws them (the build embeds whatever PNGs exist and does not need them).

  • [x] Step 4: Commit
git add src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs
git commit -m "feature(grasshopper): add the stair bay bar, stair pair, gallery bar, and terrace row components"

Task 12: Linear Plan Generator description names HDHM-TYP-009

Files: - Modify: src/Lod.Grasshopper/Components/PipelineComponents.cs (S2 file, last changed in S4.2)

Interfaces: - Consumes: ADR-006 (HDHM-TYP-009). - Produces: the Linear Plan Generator description Detailed linear block plan, the double-sided internal corridor bar (HDHM-TYP-009, ADR-006): west stair, double-loaded corridor, one zone per dwelling unit. All lengths in metres regardless of model units.; no name, nickname, GUID, input, output, exposure, or icon changes.

  • [x] Step 1: Replace src/Lod.Grasshopper/Components/PipelineComponents.cs (full file)

Only the description string of LinearPlanGeneratorComponent changes.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Generators.Linear;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

public sealed class LinearPlanGeneratorComponent : GH_Component
{
    public LinearPlanGeneratorComponent()
        : base(
            "Linear Plan Generator",
            "Linear",
            "Detailed linear block plan, the double-sided internal corridor bar (HDHM-TYP-009, ADR-006): west stair, double-loaded corridor, one zone per dwelling unit. All lengths in metres regardless of model units.",
            ComponentCategories.Category,
            ComponentCategories.Generate)
    {
    }

    public override Guid ComponentGuid => new("a4a9131e-0a0f-49e0-99a4-f26760f6810f");

    protected override Bitmap? Icon => IconLibrary.For(this);

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddNumberParameter("Length", "L", "Plan length along X, m.", GH_ParamAccess.item, 24.0);
        pManager.AddNumberParameter("Unit Depth", "UD", "Depth of each unit row, m.", GH_ParamAccess.item, 8.0);
        pManager.AddNumberParameter("Corridor Width", "CW", "Corridor width, m.", GH_ParamAccess.item, 2.0);
        pManager.AddNumberParameter("Unit Width", "UW", "Target unit width, m; the row is divided evenly.", GH_ParamAccess.item, 10.0);
        pManager.AddNumberParameter("Stair Length", "SL", "Length of the west stair, m.", GH_ParamAccess.item, 4.0);
        pManager.AddNumberParameter("Floor Height", "FH", "Floor-to-floor height, m.", GH_ParamAccess.item, 3.0);
        pManager.AddNumberParameter("Orientation", "O", "Clockwise rotation of plan north from true north, degrees.", GH_ParamAccess.item, 0.0);
        pManager.AddParameter(new ProgramPresetParameter(), "Dwelling Unit", "DU", "Program preset of the dwelling units; space type DwellingUnit.", GH_ParamAccess.item);
        pManager.AddParameter(new ProgramPresetParameter(), "Corridor", "Co", "Program preset of the corridor; space type Corridor.", GH_ParamAccess.item);
        pManager.AddParameter(new ProgramPresetParameter(), "Stair", "St", "Program preset of the stair; space type Stair.", GH_ParamAccess.item);
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The detailed plan.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        double[] values = new double[7];
        for (int i = 0; i < values.Length; i++)
        {
            if (!DA.GetData(i, ref values[i]))
            {
                return;
            }
        }

        ProgramPresetGoo? dwellingUnit = null;
        ProgramPresetGoo? corridor = null;
        ProgramPresetGoo? stair = null;
        if (!DA.GetData(7, ref dwellingUnit) || !DA.GetData(8, ref corridor) || !DA.GetData(9, ref stair)
            || dwellingUnit is null || corridor is null || stair is null)
        {
            return;
        }

        var parameters = new LinearPlanParameters(values[0], values[1], values[2], values[3], values[4], values[5], values[6]);
        var presets = new LinearPlanPresets(dwellingUnit.Value, corridor.Value, stair.Value);
        IGeneratedPlan? plan = ComponentSupport.Unwrap(this, new LinearPlanGenerator(ToleranceSettings.Default).Generate(parameters, presets));
        if (plan is not null)
        {
            DA.SetData(0, new PlanGoo(plan, ComponentSupport.PreviewLocation(this, DA)));
        }
    }
}

public sealed class NoSimplificationComponent : GH_Component
{
    public NoSimplificationComponent()
        : base("No Simplification", "Z0", "Keeps the detailed plan's zones and surfaces (Z0).", ComponentCategories.Category, ComponentCategories.Simplify)
    {
    }

    public override Guid ComponentGuid => new("83270abe-a13f-4f4c-a893-79739ad6e006");

    protected override Bitmap? Icon => IconLibrary.For(this);

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "Detailed plan.", GH_ParamAccess.item);
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new FloorParameter(), "Floor", "Floor", "The floor.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        PlanGoo? plan = null;
        if (!DA.GetData(0, ref plan) || plan is null)
        {
            return;
        }

        IFloor? floor = ComponentSupport.Unwrap(this, new NoSimplification().Simplify(plan.Value));
        if (floor is not null)
        {
            DA.SetData(0, new FloorGoo(floor, ComponentSupport.PreviewLocation(this, DA)));
        }
    }
}

/// <summary>Shared inputs and solving of the floor aggregator components: floors bottom to top and their multipliers.</summary>
public abstract class FloorAggregatorComponent : GH_Component
{
    protected FloorAggregatorComponent(string name, string nickname, string description)
        : base(name, nickname, description, ComponentCategories.Category, ComponentCategories.Aggregate)
    {
    }

    protected override Bitmap? Icon => IconLibrary.For(this);

    protected abstract string MultiplierDescription { get; }

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddParameter(new FloorParameter(), "Floors", "F", "Floors, bottom to top.", GH_ParamAccess.list);
        pManager.AddIntegerParameter("Multipliers", "N", MultiplierDescription, GH_ParamAccess.list);
        pManager[1].Optional = true;
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new BuildingParameter(), "Building", "Bldg", "The building.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        var floors = new List<FloorGoo>();
        var multipliers = new List<int>();
        if (!DA.GetDataList(0, floors) || floors.Any(f => f is null))
        {
            return;
        }

        DA.GetDataList(1, multipliers);
        if (multipliers.Count != 0 && multipliers.Count != floors.Count)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Give one multiplier per floor ({floors.Count}), or none for all 1.");
            return;
        }

        FloorEntry[] entries = floors.Select((f, i) => new FloorEntry(f.Value, multipliers.Count == 0 ? 1 : multipliers[i])).ToArray();
        IGeneratedBuilding? building = ComponentSupport.Unwrap(this, CreateAggregator().Aggregate(entries));
        if (building is not null)
        {
            DA.SetData(0, new BuildingGoo(building, ComponentSupport.PreviewLocation(this, DA), UnmodelledParts(entries)));
        }
    }

    protected abstract IFloorAggregator CreateAggregator();

    /// <summary>Parts of the storeys the building represents but does not model, for its preview; none unless the method leaves storeys out.</summary>
    protected virtual IReadOnlyList<ZonePart> UnmodelledParts(IReadOnlyList<FloorEntry> entries) => new ZonePart[0];
}

public sealed class StackFloorsComponent : FloorAggregatorComponent
{
    public StackFloorsComponent()
        : base("Stack Floors", "Stack", "All storeys explicit: each floor is repeated by its multiplier and stacked; inter-storey surfaces are interzone.")
    {
    }

    public override Guid ComponentGuid => new("6ce6524d-09b0-49fc-9b88-4c15bdcc8797");

    protected override string MultiplierDescription => "How many storeys each floor stands for (repeated explicitly). Default 1.";

    protected override IFloorAggregator CreateAggregator() => new Stack(ToleranceSettings.Default);
}

public sealed class InspectComponent : GH_Component
{
    public InspectComponent()
        : base("Inspect", "Inspect", "Text report and provenance of a plan, floor, or building.", ComponentCategories.Category, ComponentCategories.Inspect)
    {
    }

    public override Guid ComponentGuid => new("1d56a553-7a66-451f-90e2-81e744b43bdd");

    protected override Bitmap? Icon => IconLibrary.For(this);

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddGenericParameter("Object", "O", "A plan, floor, or building.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddTextParameter("Report", "R", "Zones, loads, and surfaces.", GH_ParamAccess.item);
        pManager.AddTextParameter("Provenance", "P", "How the object was produced, with code version.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        IGH_Goo? goo = null;
        if (!DA.GetData(0, ref goo) || goo is null)
        {
            return;
        }

        (string Report, Provenance Provenance)? described = goo switch
        {
            PlanGoo plan => (TextReport.Describe(plan.Value), plan.Value.Provenance),
            FloorGoo floor => (TextReport.Describe(floor.Value), floor.Value.Provenance),
            BuildingGoo building => (TextReport.Describe(building.Value), building.Value.Provenance),
            _ => null,
        };
        if (described is null)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Expected a plan, floor, or building, got {goo.TypeName}.");
            return;
        }

        DA.SetData(0, described.Value.Report);
        DA.SetData(1, described.Value.Provenance.Describe());
    }
}
  • [x] Step 2: Build and commit
dotnet build BEMGen.sln -c Release
git status --short
git add src/Lod.Grasshopper/Components/PipelineComponents.cs
git commit -m "feature(grasshopper): name hdhm-typ-009 in the linear plan generator description"

Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); M src/Lod.Grasshopper/Components/PipelineComponents.cs only.

Task 13: Icons for the four generator components

Files: - Modify: scripts/make_icons.py (S4.1 file, last changed in S4.2) - Create (generated): src/Lod.Grasshopper/Icons/StairBayBarComponent.png, StairPairComponent.png, GalleryBarComponent.png, TerraceRowComponent.png

Interfaces: - Consumes: the component classes of Task 11 (25 components, 6 parameters, 1 assembly info); IconLibrary.For and the Icons\*.png embedding of Lod.Grasshopper.csproj (S4.1, unchanged). - Produces: motifs stair_bay_bar (a long bar of two stair bays: dwellings in the accent colour, two dark stair bands, a party wall), stair_pair (a compact plate: two dwellings around one dark full-depth stair), gallery_bar (a bar of dwellings between two dark end stairs, with a dashed line along it for the gallery that is not modelled), and terrace_row (three attached gables in elevation), all in the Generate green; the icon table maps StairBayBarComponent, StairPairComponent, GalleryBarComponent, and TerraceRowComponent. 32 icons.

  • [x] Step 1: Check Python and see the script fail
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"
python scripts/make_icons.py
git status --short

Expected: True 12.3.0; the script stops with exit code 1 and the message Classes without an icon: ['GalleryBarComponent', 'StairBayBarComponent', 'StairPairComponent', 'TerraceRowComponent']; icons without a class: [], and writes nothing (git status --short prints nothing). With another Pillow version the script still runs, but the PNG bytes may differ from Step 3's hashes; then Step 4 decides.

  • [x] Step 2: Replace scripts/make_icons.py (full file)

The four motifs are added after linear_generator, and the four icon entries after Linear Plan Generator in the Generate group; every other motif and entry is unchanged.

"""Draws the BEMGen Grasshopper icons (D-063).

Every icon is drawn at 96 x 96 pixels on a transparent background and downsampled to 24 x 24 with LANCZOS, then written to
src/Lod.Grasshopper/Icons/<Name>.png, where <Name> is the class name of the component, parameter, or assembly info that shows it.
Each icon is a dark glyph plus the accent colour of its toolbar panel; a parameter uses the panel of the component that makes its object.
Shapes only, no fonts, so the output does not depend on installed fonts.

Run from the repository root:  python scripts/make_icons.py
Requires Python 3.10+ and Pillow. Generated with Pillow 12.3.0; the same Pillow version gives the same pixels.
The script stops with an error when an icon has no class in src/Lod.Grasshopper or a class has no icon.
"""

from __future__ import annotations

import re
import sys
from collections.abc import Callable
from pathlib import Path

from PIL import Image, ImageDraw

ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "src" / "Lod.Grasshopper"
OUTPUT = SOURCE / "Icons"

CANVAS = 96
SIZE = 24
LINE = 8  # 2 px after downsampling

DARK = (38, 38, 38, 255)
WHITE = (255, 255, 255, 255)

# One accent per toolbar panel (ComponentCategories).
INFO = (96, 125, 139, 255)
PROGRAM = (230, 140, 30, 255)
GENERATE = (46, 158, 79, 255)
SIMPLIFY = (47, 123, 216, 255)
AGGREGATE = (138, 79, 200, 255)
CONVERT = (208, 69, 58, 255)
INSPECT = (24, 150, 150, 255)

Colour = tuple[int, int, int, int]
Draw = ImageDraw.ImageDraw
Motif = Callable[[Draw, Colour], None]
Painter = Callable[[Image.Image, Colour], None]


def box(d: Draw, xy: tuple[float, float, float, float], fill=None, outline=DARK, width: int = LINE) -> None:
    d.rectangle(xy, fill=fill, outline=outline, width=width)


def line(d: Draw, points: list[tuple[float, float]], width: int = LINE, fill=DARK) -> None:
    d.line(points, fill=fill, width=width, joint="curve")


def arrow_head(d: Draw, tip: tuple[float, float], direction: str, size: float, fill=DARK) -> None:
    x, y = tip
    if direction == "right":
        d.polygon([(x, y), (x - size, y - size), (x - size, y + size)], fill=fill)
    elif direction == "down":
        d.polygon([(x, y), (x - size, y - size), (x + size, y - size)], fill=fill)


# --- motifs: each draws inside the 96 x 96 canvas -------------------------------------------------------------


def info(d: Draw, accent) -> None:
    d.ellipse((6, 6, 90, 90), fill=accent, outline=DARK, width=LINE)
    d.rectangle((42, 40, 54, 74), fill=WHITE)
    d.rectangle((42, 20, 54, 32), fill=WHITE)


def assembly(d: Draw, accent) -> None:
    # A stepped block: the building at three levels of detail.
    box(d, (8, 52, 88, 88), fill=accent)
    box(d, (8, 30, 60, 56), fill=accent)
    box(d, (8, 8, 36, 34), fill=accent)


def schedule(d: Draw, accent) -> None:
    # A 24-hour profile as bars on a time axis.
    heights = [16, 48, 72, 36, 60, 76, 24]
    for i, h in enumerate(heights):
        x = 12 + i * 12
        d.rectangle((x, 84 - h, x + 7, 83), fill=accent)
    d.rectangle((4, 84, 92, 91), fill=DARK)
    d.rectangle((4, 4, 11, 91), fill=DARK)


def load(d: Draw, accent) -> None:
    bolt = [(58, 4), (18, 54), (44, 54), (34, 92), (78, 38), (52, 38), (64, 4)]
    d.polygon(bolt, fill=accent, outline=DARK, width=6)


def preset_tag(d: Draw, accent) -> None:
    tag = [(8, 30), (30, 8), (88, 8), (88, 88), (30, 88), (8, 66)]
    d.polygon(tag, fill=accent, outline=DARK, width=LINE)
    d.ellipse((24, 40, 40, 56), fill=WHITE, outline=DARK, width=4)
    line(d, [(52, 34), (76, 34)], width=6)
    line(d, [(52, 48), (76, 48)], width=6)
    line(d, [(52, 62), (76, 62)], width=6)


def house(d: Draw, accent) -> None:
    d.polygon([(48, 6), (90, 44), (6, 44)], fill=accent, outline=DARK, width=LINE)
    box(d, (16, 42, 80, 90), fill=accent)
    d.rectangle((40, 60, 56, 90), fill=DARK)


def corridor(d: Draw, accent) -> None:
    box(d, (4, 30, 92, 66), fill=accent)
    line(d, [(16, 48), (60, 48)], width=8)
    arrow_head(d, (78, 48), "right", 12)


def stair(d: Draw, accent) -> None:
    steps = [(6, 90), (6, 70), (28, 70), (28, 50), (50, 50), (50, 30), (72, 30), (72, 10), (90, 10), (90, 90)]
    d.polygon(steps, fill=accent, outline=DARK, width=LINE)


def linear_generator(d: Draw, accent) -> None:
    # Footprint with a west stair, a corridor band, and dwelling-unit divisions on both sides.
    box(d, (4, 12, 91, 83))
    d.rectangle((12, 20, 23, 75), fill=DARK)
    d.rectangle((28, 40, 87, 55), fill=accent)
    for x in (48, 68):
        line(d, [(x, 12), (x, 40)])
        line(d, [(x, 55), (x, 83)])


def stair_bay_bar(d: Draw, accent) -> None:
    # A long bar of two stair bays: dwelling (accent) | stair (dark) | dwelling, repeated, with a party wall between the bays.
    box(d, (2, 30, 93, 66), fill=accent)
    for x in (20, 66):
        d.rectangle((x, 30, x + 10, 66), fill=DARK)
    line(d, [(48, 30), (48, 66)], width=4)


def stair_pair(d: Draw, accent) -> None:
    # One compact plate: two dwellings (accent) around one full-depth stair (dark).
    box(d, (14, 22, 82, 74), fill=accent)
    d.rectangle((42, 26, 54, 70), fill=DARK)


def gallery_bar(d: Draw, accent) -> None:
    # A bar of dwellings (accent) between two end stairs (dark); the gallery along it is dashed, as it is not modelled.
    box(d, (4, 34, 91, 76), fill=accent)
    d.rectangle((8, 38, 18, 72), fill=DARK)
    d.rectangle((77, 38, 87, 72), fill=DARK)
    for x in (36, 54):
        line(d, [(x, 34), (x, 76)], width=6)
    for x in range(6, 90, 16):
        line(d, [(x, 20), (x + 9, 20)], width=6)


def terrace_row(d: Draw, accent) -> None:
    # Attached houses in elevation: three gables sharing their side walls.
    houses = [(6, 34), (34, 62), (62, 90)]
    for left, right in houses:
        middle = (left + right) / 2
        d.polygon([(left, 46), (middle, 22), (right, 46), (right, 88), (left, 88)], fill=accent, outline=DARK, width=6)
    for left, right in houses:
        d.rectangle((left + 10, 68, right - 10, 88), fill=DARK)


def transform_plan(d: Draw, accent) -> None:
    # A footprint outline and the same footprint rotated and moved, joined by a turning arrow.
    box(d, (6, 52, 50, 90), width=6)
    d.polygon([(66, 12), (92, 28), (76, 56), (50, 40)], fill=accent, outline=DARK, width=6)
    d.arc((18, 20, 70, 72), start=180, end=270, fill=DARK, width=8)
    arrow_head(d, (58, 24), "right", 10)


def z0(d: Draw, accent) -> None:
    # Many cells: every unit its own zone.
    box(d, (4, 4, 91, 91), fill=accent)
    for k in (32, 60):
        line(d, [(k, 4), (k, 91)])
        line(d, [(4, k), (91, k)])


def z1(d: Draw, accent) -> None:
    # Cells of one space type merged: two unit rows and a corridor.
    box(d, (4, 10, 92, 40), fill=accent)
    box(d, (4, 56, 92, 86), fill=accent)
    box(d, (4, 36, 92, 60))


def z2(d: Draw, accent) -> None:
    # Perimeter ring split on the corner bisectors, plus a core.
    box(d, (4, 4, 92, 92))
    d.rectangle((30, 30, 66, 66), fill=accent, outline=DARK, width=LINE)
    for a, b in [((4, 4), (30, 30)), ((92, 4), (66, 30)), ((4, 92), (30, 66)), ((92, 92), (66, 66))]:
        line(d, [a, b], width=6)


def z3(d: Draw, accent) -> None:
    box(d, (6, 16, 90, 80), fill=accent)


def slab(d: Draw, accent, y: float, height: float = 16, left: float = 6, right: float = 90) -> None:
    box(d, (left, y, right, y + height), fill=accent, width=6)


def stack(d: Draw, accent) -> None:
    for y in (8, 36, 64):
        slab(d, accent, y, 24)


def multiplier(d: Draw, accent) -> None:
    slab(d, accent, 6, 24, 6, 90)
    line(d, [(18, 44), (50, 84)], width=10)
    line(d, [(50, 44), (18, 84)], width=10)
    for y in (44, 58, 72):
        d.rectangle((62, y, 90, y + 8), fill=DARK)


def single_zone_per_floor_type(d: Draw, accent) -> None:
    # One zone per floor entry: a one-storey zone below a three-storey zone.
    box(d, (24, 66, 71, 91), fill=accent)
    box(d, (24, 4, 71, 58), fill=accent)


def single_zone_building(d: Draw, accent) -> None:
    box(d, (24, 4, 71, 91), fill=accent)


def convert(d: Draw, accent) -> None:
    d.polygon([(40, 44), (90, 44), (90, 92), (40, 92)], fill=accent, outline=DARK, width=LINE)
    line(d, [(6, 14), (44, 14), (64, 34), (64, 56)], width=10)
    arrow_head(d, (64, 74), "down", 16)


def inspect(d: Draw, accent) -> None:
    d.ellipse((6, 6, 64, 64), fill=accent, outline=DARK, width=LINE)
    d.ellipse((18, 18, 38, 38), fill=WHITE)
    line(d, [(56, 56), (88, 88)], width=16)


def mini_grid(d: Draw, origin: tuple[float, float], size: float, cells: int, accent) -> None:
    x0, y0 = origin
    box(d, (x0, y0, x0 + size, y0 + size), fill=accent, width=6)
    step = size / cells
    for k in range(1, cells):
        line(d, [(x0 + k * step, y0), (x0 + k * step, y0 + size)], width=4)
        line(d, [(x0, y0 + k * step), (x0 + size, y0 + k * step)], width=4)


def map_source_target(d: Draw, accent) -> None:
    mini_grid(d, (4, 4), 40, 2, accent)
    box(d, (52, 52, 92, 92), fill=accent, width=6)
    line(d, [(32, 56), (32, 72), (40, 72)], width=8)
    arrow_head(d, (52, 72), "right", 12)
    line(d, [(56, 26), (72, 26), (72, 36)], width=8)
    arrow_head(d, (72, 50), "down", 12)


def validate(d: Draw, accent) -> None:
    d.ellipse((4, 4, 92, 92), fill=accent, outline=DARK, width=LINE)
    line(d, [(24, 50), (42, 68), (74, 30)], width=14, fill=WHITE)


# Parameter motifs: small versions of their object's motif, drawn inside the hexagon (x and y in 20..76).


def small_schedule(d: Draw, accent) -> None:
    for x, h in ((28, 16), (40, 36), (52, 24), (64, 40)):
        d.rectangle((x, 72 - h, x + 7, 71), fill=accent)
    d.rectangle((24, 72, 75, 75), fill=DARK)


def small_load(d: Draw, accent) -> None:
    d.polygon([(54, 20), (30, 52), (46, 52), (40, 76), (68, 44), (52, 44), (60, 20)], fill=accent, outline=DARK, width=4)


def small_preset(d: Draw, accent) -> None:
    d.polygon([(20, 36), (32, 24), (76, 24), (76, 72), (32, 72), (20, 60)], fill=accent, outline=DARK, width=4)
    d.ellipse((30, 42, 42, 54), fill=WHITE, outline=DARK, width=4)


def small_plan(d: Draw, accent) -> None:
    box(d, (20, 24, 75, 71), width=4)
    d.rectangle((24, 44, 71, 51), fill=accent)
    d.rectangle((24, 28, 31, 67), fill=DARK)
    for x in (48,):
        line(d, [(x, 24), (x, 44)], width=4)
        line(d, [(x, 52), (x, 71)], width=4)


def small_floor(d: Draw, accent) -> None:
    box(d, (16, 36, 79, 59), fill=accent, width=4)


def small_building(d: Draw, accent) -> None:
    box(d, (32, 16, 63, 79), fill=accent, width=4)
    for y in (36, 56):
        line(d, [(32, y), (63, y)], width=4)


# --- icons ----------------------------------------------------------------------------------------------------


def component(motif: Motif) -> Painter:
    """A component: its motif at full size."""

    def paint(image: Image.Image, accent: Colour) -> None:
        motif(ImageDraw.Draw(image), accent)

    return paint


def parameter(motif: Motif) -> Painter:
    """A parameter: a small version of its object's motif inside a hexagon (the Grasshopper parameter shape)."""

    def paint(image: Image.Image, accent: Colour) -> None:
        d = ImageDraw.Draw(image)
        d.polygon([(24, 4), (72, 4), (94, 48), (72, 92), (24, 92), (2, 48)], fill=WHITE, outline=DARK, width=4)
        motif(d, accent)

    return paint


ICONS: dict[str, tuple[Colour, Painter]] = {
    "BemGenAssemblyInfo": (INFO, component(assembly)),
    "BemGenInfoComponent": (INFO, component(info)),
    "ScheduleComponent": (PROGRAM, component(schedule)),
    "LoadComponent": (PROGRAM, component(load)),
    "ProgramPresetComponent": (PROGRAM, component(preset_tag)),
    "DwellingUnitPresetComponent": (PROGRAM, component(house)),
    "CorridorPresetComponent": (PROGRAM, component(corridor)),
    "StairPresetComponent": (PROGRAM, component(stair)),
    "LinearPlanGeneratorComponent": (GENERATE, component(linear_generator)),
    "StairBayBarComponent": (GENERATE, component(stair_bay_bar)),
    "StairPairComponent": (GENERATE, component(stair_pair)),
    "GalleryBarComponent": (GENERATE, component(gallery_bar)),
    "TerraceRowComponent": (GENERATE, component(terrace_row)),
    "TransformPlanComponent": (GENERATE, component(transform_plan)),
    "NoSimplificationComponent": (SIMPLIFY, component(z0)),
    "SemanticMergeComponent": (SIMPLIFY, component(z1)),
    "PerimeterCoreComponent": (SIMPLIFY, component(z2)),
    "SingleZonePerFloorComponent": (SIMPLIFY, component(z3)),
    "StackFloorsComponent": (AGGREGATE, component(stack)),
    "StackedFloorZoneMultiplierComponent": (AGGREGATE, component(multiplier)),
    "SingleZonePerFloorTypeComponent": (AGGREGATE, component(single_zone_per_floor_type)),
    "SingleZoneBuildingComponent": (AGGREGATE, component(single_zone_building)),
    "Convert2BemComponent": (CONVERT, component(convert)),
    "InspectComponent": (INSPECT, component(inspect)),
    "MapSourceToTargetComponent": (INSPECT, component(map_source_target)),
    "ValidateComponent": (INSPECT, component(validate)),
    "ScheduleParameter": (PROGRAM, parameter(small_schedule)),
    "LoadParameter": (PROGRAM, parameter(small_load)),
    "ProgramPresetParameter": (PROGRAM, parameter(small_preset)),
    "PlanParameter": (GENERATE, parameter(small_plan)),
    "FloorParameter": (SIMPLIFY, parameter(small_floor)),
    "BuildingParameter": (AGGREGATE, parameter(small_building)),
}


def render(name: str) -> Image.Image:
    accent, paint = ICONS[name]
    image = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
    paint(image, accent)
    return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)


def classes_with_icons() -> set[str]:
    """Concrete classes that show an icon: components, parameters, and the assembly info."""
    pattern = re.compile(r"public sealed class (\w+) : (\w+)")
    names = set()
    for path in SOURCE.rglob("*.cs"):
        if "obj" in path.parts or "bin" in path.parts:
            continue
        for name, _ in pattern.findall(path.read_text(encoding="utf-8")):
            if name.endswith(("Component", "Parameter")) or name == "BemGenAssemblyInfo":
                names.add(name)
    return names


def main() -> int:
    expected = classes_with_icons()
    missing = sorted(expected - ICONS.keys())
    unknown = sorted(ICONS.keys() - expected)
    if missing or unknown:
        print(f"Classes without an icon: {missing}; icons without a class: {unknown}", file=sys.stderr)
        return 1

    OUTPUT.mkdir(parents=True, exist_ok=True)
    stale = sorted(p.name for p in OUTPUT.glob("*.png") if p.stem not in ICONS)
    if stale:
        print(f"Icons without a class in {OUTPUT}: {stale}", file=sys.stderr)
        return 1

    for name in sorted(ICONS):
        render(name).save(OUTPUT / f"{name}.png", optimize=False)
    print(f"Wrote {len(ICONS)} icons to {OUTPUT.relative_to(ROOT)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
  • [x] Step 3: Draw the icons and check the hashes
python scripts/make_icons.py
git status --short
sha256sum src/Lod.Grasshopper/Icons/*.png

Expected: Wrote 32 icons to src\Lod.Grasshopper\Icons; git status --short shows M scripts/make_icons.py and the four new PNGs (??), and no existing PNG modified; the hashes (Pillow 12.3.0):

606a11f2bbd6ee6cb7edede403166ae0f1b39231fcf5882dff360717df990cae *src/Lod.Grasshopper/Icons/BemGenAssemblyInfo.png
e33ae284b7f017ee11851986e79e4b8449e1b5027ffedf41cad68ed2abbfb15d *src/Lod.Grasshopper/Icons/BemGenInfoComponent.png
431104585e8745167567797eefef526bbf664ba6777101e3677281cb6976bfde *src/Lod.Grasshopper/Icons/BuildingParameter.png
edc0ff9b82e6161bf8bdf3b5acebc8f4a43644d42d845827a101593d91d1a110 *src/Lod.Grasshopper/Icons/Convert2BemComponent.png
c8a815ff220dafff9f84af05411eab8c43191b4b6f4be2f0b4750385e1d95489 *src/Lod.Grasshopper/Icons/CorridorPresetComponent.png
755de6f9d4122d567040d0df98ec3232c76f4f1c0af9da934276fe33585dc528 *src/Lod.Grasshopper/Icons/DwellingUnitPresetComponent.png
324ffc55b8f3aed81902fdaea4ac6293654a194e9f58aa97c9aa088547e584be *src/Lod.Grasshopper/Icons/FloorParameter.png
edc98c6c83c0040d935633c0e8fa014ebeeeeae5ef8f596207fd385ad09a0dbe *src/Lod.Grasshopper/Icons/GalleryBarComponent.png
a7191b8f5e30874eaf7d50f0d4555b3565b77157f83ae584393ab9c3b1c3a792 *src/Lod.Grasshopper/Icons/InspectComponent.png
75b4d98c4ad7cffbec53284781d3ad544ccd8ba762c7fd5c33bfd6d6845e42d4 *src/Lod.Grasshopper/Icons/LinearPlanGeneratorComponent.png
acf5163f017f01a1ebb394861f61750e54f9ae8c730a0a6a614f2120f59feadc *src/Lod.Grasshopper/Icons/LoadComponent.png
5045ceb77bc754553aa717110fab061978ef7b51d0c05e0f7f93788a8158f1d5 *src/Lod.Grasshopper/Icons/LoadParameter.png
438a45a6b5f3071e4918cb6c0f7e6112e4d0ab9ca0ad1aacdc189b4e5de9ecb8 *src/Lod.Grasshopper/Icons/MapSourceToTargetComponent.png
4a702c6b6ae3c4773678a3594228ce4d1e06c2623bc9eeb5bf1fdaaee8e50c30 *src/Lod.Grasshopper/Icons/NoSimplificationComponent.png
ac5d421e2cded0be0f7797510b864cdfcdd019dd46caed4147251923602184b8 *src/Lod.Grasshopper/Icons/PerimeterCoreComponent.png
a4bd3109301f4fccec8fa698ceca5dcfbb22e4dc839d850dd5aa4241b5cca95b *src/Lod.Grasshopper/Icons/PlanParameter.png
13741aac4525cf0c0d991bf3b673e55cd22612324fdc3ef3a8aaaaf0e66dec45 *src/Lod.Grasshopper/Icons/ProgramPresetComponent.png
bd0a92bf623dd4b3f6b9c4279e425adc44a7287751d97e8206a26e3c26e7e400 *src/Lod.Grasshopper/Icons/ProgramPresetParameter.png
90646f46d781eca01dcdf67554987ee04fdd80c450e5f94647858b5f7e7b613d *src/Lod.Grasshopper/Icons/ScheduleComponent.png
693d5513ffe5f813ed3a77dc7b68cfce31e008b407e6410ac34c9ed374c6bfaa *src/Lod.Grasshopper/Icons/ScheduleParameter.png
8217bd3bbe904a6026ef7434606168b5d466341f356280a623f92278908a4068 *src/Lod.Grasshopper/Icons/SemanticMergeComponent.png
682ee28436ffab1bc47382ff9915e27f2c774d0ef22448422d2316d739b5727f *src/Lod.Grasshopper/Icons/SingleZoneBuildingComponent.png
380ce788eef6adc20c2f45723deae73b4b747d84ffe5e2664001e46d21157c49 *src/Lod.Grasshopper/Icons/SingleZonePerFloorComponent.png
b8bb1b2890ce6ac6273f49e863b3a25ae0c05c87dc37e33b5483913e6ac52162 *src/Lod.Grasshopper/Icons/SingleZonePerFloorTypeComponent.png
5859a90fd17bb2072b1971cd31caa0df678f29bf2ad9f279c910dc59c66ca0f9 *src/Lod.Grasshopper/Icons/StackFloorsComponent.png
aa115c4ba9c51350ff291f45c6e16db41be505bc5153b6ef8159165f3286a37f *src/Lod.Grasshopper/Icons/StackedFloorZoneMultiplierComponent.png
9ffba933d6cd515b59259979ce8ce666f1a40ed906af5d5b25875f25458115f9 *src/Lod.Grasshopper/Icons/StairBayBarComponent.png
d25e67dce5eceed6cb2379c55755d38c495b4ee227069ac54bed185ed751910c *src/Lod.Grasshopper/Icons/StairPairComponent.png
939d76b08d77d2d099bd823473f1d86eb8b16983840230cf995f8ade9d0debcc *src/Lod.Grasshopper/Icons/StairPresetComponent.png
ab59722b25ff1a3407cf7344e1c4595142b7a3bed05b7f952d58f92b51ffae34 *src/Lod.Grasshopper/Icons/TerraceRowComponent.png
3ce5e835f0921d1dee8feeaf23a45aecd36c3aedb55be5a8386fdb3e540e5246 *src/Lod.Grasshopper/Icons/TransformPlanComponent.png
20a372402d7ca8ae673db6a0a1299d7a3b6dcae9141d29de2b535cc543d950ce *src/Lod.Grasshopper/Icons/ValidateComponent.png

Run python scripts/make_icons.py a second time: the hashes do not change.

  • [x] Step 4: Look at the icons

Draw a contact sheet of all icons at 8× (nearest neighbour) into the build folder, which git ignores:

python - <<'EOF'
from pathlib import Path
from PIL import Image
icons = sorted(Path("src/Lod.Grasshopper/Icons").glob("*.png"))
cell = 24 * 8 + 16
sheet = Image.new("RGBA", (6 * cell, -(-len(icons) // 6) * cell), (235, 235, 235, 255))
for i, path in enumerate(icons):
    big = Image.open(path).convert("RGBA").resize((24 * 8, 24 * 8), Image.Resampling.NEAREST)
    sheet.alpha_composite(big, ((i % 6) * cell + 8, (i // 6) * cell + 8))
Path("src/Lod.Grasshopper/bin").mkdir(parents=True, exist_ok=True)
sheet.save("src/Lod.Grasshopper/bin/icon-sheet.png")
print(len(icons), "icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png")
EOF

Expected: 32 icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png. Open the image. In the order of sorted() on Windows (case-insensitive), six per row, the 28 icons of S4.2 look as in the S4.2 plan (Task 16, Step 6) and the four new ones are added: row 2 holds the Floor parameter, Gallery Bar (a dashed dark line above a green bar with dark end stairs and two dark dividers), Inspect, Linear Plan Generator, Load, and the Load parameter; row 5 holds Stacked Floor Zone Multiplier, Stack Floors, Stair Bay Bar (a long green bar with two dark stair bands and a dark party wall between them), Stair Pair (a green plate split by one dark stair), Stair Preset, and Terrace Row (three green gables with dark doors); row 6 holds Transform Plan and Validate. The four new icons have the green of Linear Plan Generator and are told apart at 24 × 24.

If the hashes of Step 3 differed (another Pillow version), this check decides: when every icon matches the description above, continue and record the Pillow version in the commit message body; otherwise stop and report the difference. The script is never edited to match hashes.

  • [x] Step 5: Build and check the embedded icons
dotnet build BEMGen.sln -c Release
git grep -c "IconLibrary.For(this)" -- src/Lod.Grasshopper
powershell -NoProfile -Command "([Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path 'src/Lod.Grasshopper/bin/Release/net7.0/BEMGen.gha').Path)).GetManifestResourceNames() | Sort-Object"

Expected: Build succeeded., 0 Warning(s), 0 Error(s); counts BemGenAssemblyInfo.cs:1, BemGenInfoComponent.cs:1, Convert2BemComponent.cs:1, PipelineComponents.cs:4, PresetComponents.cs:1, ProgramComponents.cs:3, SimplifierComponents.cs:3, TransformPlanComponent.cs:1, TypologyGeneratorComponents.cs:1, BemParameters.cs:1 (with their paths; the four new components inherit the icon of TypologyGeneratorComponent); and the 32 resource names Lod.Grasshopper.Icons.<name>.png, one for each PNG of Step 3, including Lod.Grasshopper.Icons.GalleryBarComponent.png, Lod.Grasshopper.Icons.StairBayBarComponent.png, Lod.Grasshopper.Icons.StairPairComponent.png, and Lod.Grasshopper.Icons.TerraceRowComponent.png.

  • [x] Step 6: Commit
git add scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): draw icons for the s8.1 generator components"

Expected before git add: scripts/make_icons.py modified and the four new PNGs; nothing under bin/.

  • [x] Step 7: Merge Slice D (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-bar-typologies
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-bar-typologies
git branch -d feature/grasshopper-bar-typologies

Expected: VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0. Do not push (D-084).


Slice E — docs/architecture-bar-typologies

Task 14: Architecture documents

Files: - Modify: docs/architecture/pipeline.md (S4, last changed in S5), docs/architecture/domain-model.md (S1, last changed in S4.1), docs/architecture/validation.md (S3, last changed in S5), docs/research/program-presets.md (S1, last changed in S5)

Interfaces: - Consumes: Tasks 1–13 (families, ADRs, types, components). - Produces: the pipeline, domain-model, and validation descriptions after S8.1; the heading ## Grasshopper components through S8.1 (anchor #grasshopper-components-through-s81), which the overview of pipeline.md and program-presets.md link to; the heading ## Not implemented yet (was ## Not in Checkpoint 1) with the S8.2 and S8.3 rows.

  • [x] Step 1: Create the branch
git switch -c docs/architecture-bar-typologies main
  • [x] Step 2: Edit docs/architecture/pipeline.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied. Replacement 3 points the Preview Location link at the renamed heading of replacement 9; replacement 13 renames the section of open items, whose S7 and S8 rows replacement 14 replaces by the S8.2 and S8.3 rows.

  1. Replace
At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan; the revision stage S4.1 (D-061 to D-064) gave the plan generator one preset input per space type and added default preset components, a *Preview Location* input, and component icons; the revision stage S4.2 (D-068 to D-075, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md)) added *Transform Plan*, made the floor aggregators designate floors and ceilings on the assembled stack, turned them into four methods, and made heat transfer between zones a conversion option; stage S5 (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)) gives every rebuilt outdoor wall one centred window with the glazed area it covers. This document describes each step, the rules it applies, where results are validated, and the Grasshopper components that expose it. The domain types are described in [domain-model.md](domain-model.md), the validation checks in [validation.md](validation.md), and the `Convert2BEM` output layout in [convert2bem.md](convert2bem.md).

with

At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan; the revision stage S4.1 (D-061 to D-064) gave the plan generator one preset input per space type and added default preset components, a *Preview Location* input, and component icons; the revision stage S4.2 (D-068 to D-075, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md)) added *Transform Plan*, made the floor aggregators designate floors and ceilings on the assembled stack, turned them into four methods, and made heat transfer between zones a conversion option; stage S5 (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)) gives every rebuilt outdoor wall one centred window with the glazed area it covers; stage S8.1 (D-082 to D-084, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md)) adds a plan generator for each bar family of the *High-Density Housing* precedent study and the minimum wall and window geometry of every generator. This document describes each step, the rules it applies, where results are validated, and the Grasshopper components that expose it. The domain types are described in [domain-model.md](domain-model.md), the validation checks in [validation.md](validation.md), and the `Convert2BEM` output layout in [convert2bem.md](convert2bem.md).
  1. Replace
  → plan generator       LinearPlanGenerator : PlanGenerator<...>           → IGeneratedPlan        one preset input per space type; Z0: one zone per dwelling unit, explicit windows

with

  → plan generator       PlanGenerator<P, Presets>: LinearPlanGenerator |   → IGeneratedPlan        one preset input per space type; Z0: one zone per dwelling unit, explicit windows (ADR-011 limits)
                         StairBayBar | StairPair | GalleryBar | TerraceRow
  1. Replace
- All logic lives in `Lod.Core` and `Lod.Generators`, which reference neither Rhino nor Grasshopper; their tests run with `dotnet test`. `Lod.Grasshopper` wraps each pipeline type in a Goo with a viewport preview and exposes one component per step (D-017, D-018). Every component that previews a plan, floor, or building has a last input *Preview Location* that moves only its preview (D-062, [below](#grasshopper-components-through-s5)).

with

- All logic lives in `Lod.Core` and `Lod.Generators`, which reference neither Rhino nor Grasshopper; their tests run with `dotnet test`. `Lod.Grasshopper` wraps each pipeline type in a Goo with a viewport preview and exposes one component per step (D-017, D-018). Every component that previews a plan, floor, or building has a last input *Preview Location* that moves only its preview (D-062, [below](#grasshopper-components-through-s81)).
  1. Replace
- Numerical tolerances come only from `ToleranceSettings` (ADR-004). The same inputs always give the same output.

with

- Numerical tolerances come only from `ToleranceSettings` (ADR-004), the minimum wall and window geometry of the generators only from `GeometryLimits` (ADR-011). The same inputs always give the same output.
  1. Replace
| Types | `LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>` → `IGeneratedPlan`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070; ADR-002, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |

with

| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070, D-082, D-083, D-084; ADR-002, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md), [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |
  1. Replace
- Linear block: a full-depth stair at the west end (`ST`), a double-loaded corridor (`CO`), and one row of equal dwelling units on each side (`US1…`, `UN1…`); one zone per dwelling unit (D-009). Lengths are metres.

with

- Mechanism (ADR-006): one generator per typology family of the precedent study ([families](../research/precedents/hdhm-typologies.md), D-082, D-083), each a `PlanGenerator` subclass in `Lod.Generators/<Family>/` whose layout returns the footprint and the zones as polygons; the base checks the presets, builds the surfaces, places the windows, and records provenance. Layouts are axis-aligned in the plan frame (origin at the south-west corner, the bar along plan east); *Orientation* turns plan north and *Transform Plan* moves the result. Parameter records of S8 have a static `Default` with the ADR-006 defaults, which the components use as their input defaults. A parameter that would remove one of the family's essential relationships (a count below the family minimum) or a length that is not positive is the error `InvalidParameter`. Lengths are metres.
- Linear block (`HDHM-TYP-009`, double-sided internal corridor bar): a full-depth stair at the west end (`ST`), a double-loaded corridor (`CO`), and one row of equal dwelling units on each side (`US1…`, `UN1…`); one zone per dwelling unit (D-009). Checked against the family's rules in S8.1 and unchanged.
- Stair bay bar (`HDHM-TYP-001`): `BayCount` (≥ 2, default 3) stair bays of dwelling | stair | dwelling through the full depth (`UnitWidth` 7 m, `StairWidth` 3 m, `Depth` 12 m): stairs `ST1…STn`, then dwellings `U1…U2n`, west to east; each stair adjoins the two dwellings of its bay; no corridor.
- Stair pair (`HDHM-TYP-008`): one stair bay as its own building, with the same layout code (`StairBayLayout`): `ST1`, `U1`, `U2`; defaults as the stair bay bar.
- Gallery bar (`HDHM-TYP-002`): `UnitCount` (≥ 2, default 6) dwellings of `UnitWidth` 6 m through `Depth` 10 m between two end stairs of `StairWidth` 4 m: `ST1` (west), `ST2` (east), `U1…Un`. The gallery is not modelled (D-083): no gallery zone or geometry, and the gallery-side façade is an ordinary outdoor façade.
- Terrace row (`HDHM-TYP-006`): `PlotCount` (≥ 2, default 6) attached houses of `PlotWidth` 5.5 m through `Depth` 10 m, `U1…Un` named *House i*, neighbours sharing a dividing wall; one dwelling zone per plot and storey, the house's own stair not zoned; only a *Dwelling Unit* preset.
  1. Replace
- Walls carry explicit windows (position along the wall, sill, width, height; any number per wall, D-039). This generator's rule: one centered window per outdoor wall with area = the zone preset's WWR × wall area (D-026).

with

- Walls carry explicit windows (position along the wall, sill, width, height; any number per wall, D-039). Every generator's rule: one centered window per outdoor wall with area = the zone preset's WWR × wall area (D-026), except on a wall shorter than 1 m or where the window would be narrower or lower than 0.3 m (`GeometryLimits`, ADR-011): that wall gets no window and the info `WindowOmitted` (subject: the wall), and the plan is produced. Every later step conserves the glazing the generator placed.
  1. Replace
| Plan generator | Parameters (`InvalidParameter`); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`); polygons (`DegeneratePolygon`) | Error; no plan |

with

| Plan generator | Parameters (`InvalidParameter`, including the family minimum counts of ADR-006); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`); polygons (`DegeneratePolygon`). A wall or window below the ADR-011 minimum gets no window (`WindowOmitted`) | Error; no plan (`WindowOmitted` is a remark; the plan is produced) |
  1. Replace
## Grasshopper components through S5

with

## Grasshopper components through S8.1
  1. Replace
- **Preview Location (D-062).** *Linear Plan Generator*, *Transform Plan*, the four plan simplifiers, and the four floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.

with

- **Preview Location (D-062).** The five plan generators, *Transform Plan*, the four plan simplifiers, and the four floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.
  1. Replace
- **Windows (S5, D-079).** No component was added or removed and no GUID changed. The descriptions of *Semantic Merge*, *Perimeter Core*, *Single Zone per Floor*, *Single Zone per Floor Type*, and *Single Zone Building* state that every rebuilt outdoor wall gets one centred window with the glazed area it covers, and the *Windows* output of *Convert2BEM* is described as one centred window per glazed outdoor wall.

with

- **Windows (S5, D-079).** No component was added or removed and no GUID changed. The descriptions of *Semantic Merge*, *Perimeter Core*, *Single Zone per Floor*, *Single Zone per Floor Type*, and *Single Zone Building* state that every rebuilt outdoor wall gets one centred window with the glazed area it covers, and the *Windows* output of *Convert2BEM* is described as one centred window per glazed outdoor wall.
- **Typology generators (S8.1, ADR-006).** *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* follow *Linear Plan Generator*: their number inputs (counts are integers) default to the family's `Default` record, then *Floor Height* (3 m) and *Orientation* (0°), then one required preset input per space type they place (*Dwelling Unit*, and *Stair* except for *Terrace Row*; an unconnected one is Grasshopper's missing-input warning, D-065), then *Preview Location*. Their descriptions name the family and what is not modelled. `WindowOmitted` shows as a remark. *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged.
  1. Replace
| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Dwelling Unit, Corridor, Stair, Preview Location → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 (inputs S4.1) |

with

| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Dwelling Unit, Corridor, Stair, Preview Location → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 (inputs S4.1) |
| 2 Generate | *Stair Bay Bar* (SBar) | Bay Count (3), Unit Width (7), Depth (12), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `StairBayBarComponent` | `911c3204-42bb-4023-bdb7-2931ea1caad3` | S8.1 |
| 2 Generate | *Stair Pair* (SPair) | Unit Width (7), Depth (12), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `StairPairComponent` | `5669d39b-45e6-47ea-9250-f2304d005335` | S8.1 |
| 2 Generate | *Gallery Bar* (GBar) | Unit Count (6), Unit Width (6), Depth (10), Stair Width (4), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `GalleryBarComponent` | `a58df462-b3c2-46b7-be3d-7ea9f4629280` | S8.1 |
| 2 Generate | *Terrace Row* (Terr) | Plot Count (6), Plot Width (5.5), Depth (10), Floor Height (3), Orientation (0), Dwelling Unit, Preview Location → Plan | `TerraceRowComponent` | `f694e595-dba2-4b98-9d1e-f1492e3edf39` | S8.1 |
  1. Replace
## Not in Checkpoint 1

with

## Not implemented yet
  1. Replace
| Precedent study (*Floor Plan Manual Housing*) and the plan generation mechanism | S7 | ADR-006 |
| Typologies: refined linear, point, and courtyard plans (footprints with holes included), minimum wall and window geometry | S8 | ADR-011 |

with

| Non-rectangular typologies without holes: *Point Plate* (`HDHM-TYP-007`), *Winged Band* (003), *Open Court* (004), specified in ADR-006 | S8.2 | ADR-006, ADR-011 |
| Enclosed courtyard *Enclosed Court* (`HDHM-TYP-005`): footprints with holes in every plan simplifier and floor aggregator | S8.3 | ADR-006 |
  1. Replace
- [Validation](validation.md)

with

- [Validation](validation.md)
- [ADR-006 Plan generation mechanism](../decisions/ADR-006-plan-generation-mechanism.md)
  1. Replace
- [ADR-009 Vertical aggregation geometry](../decisions/ADR-009-vertical-aggregation.md)

with

- [ADR-009 Vertical aggregation geometry](../decisions/ADR-009-vertical-aggregation.md)
- [ADR-011 Minimum wall and window geometry](../decisions/ADR-011-minimum-wall-and-window-geometry.md)
  1. Replace
- [ADR-013 Vertical aggregation methods](../decisions/ADR-013-vertical-aggregation-methods.md)

with

- [ADR-013 Vertical aggregation methods](../decisions/ADR-013-vertical-aggregation-methods.md)
- [*High-Density Housing* typology families](../research/precedents/hdhm-typologies.md)
  • [x] Step 3: Edit docs/architecture/domain-model.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file. The first is a line of the type overview block.

  1. Replace
 ToleranceSettings: Distance, RelativeArea, RelativeLoad, AbsoluteSchedule, Angle

with

 ToleranceSettings: Distance, RelativeArea, RelativeLoad, AbsoluteSchedule, Angle
 GeometryLimits: MinimumWindowWallLength, MinimumWindowWidth, MinimumWindowHeight (S8.1)
  1. Replace
Relative comparisons use a floor of 1, `|a − b| ≤ rel · max(1, |a|, |b|)`, so values near zero are compared absolutely.

with

Relative comparisons use a floor of 1, `|a − b| ≤ rel · max(1, |a|, |b|)`, so values near zero are compared absolutely.

**`GeometryLimits`** (S8.1, ADR-011) is the only place where the minimum wall and window geometry of the plan generators is defined: `MinimumWindowWallLength` 1 m, `MinimumWindowWidth` 0.3 m, and `MinimumWindowHeight` 0.3 m in `GeometryLimits.Default`. It is an immutable record whose constructor rejects values that are not positive and finite with an `ArgumentOutOfRangeException`. `PlanGenerator` takes it through its constructor (the default when omitted) and records non-default limits in the plan's provenance. An outdoor wall or window below a limit gets no window and the info `WindowOmitted`; the limits do not apply to rebuilt walls (ADR-012).
  • [x] Step 4: Edit docs/architecture/validation.md

Make this replacement; the "Replace" text occurs exactly once in the file.

  1. Replace
- Window positions and sizes. Where walls are rebuilt, each outdoor wall gets one window centred on it with the glazed area of the source windows it covers (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)), so individual windows are not conserved by design; the rule is covered by the unit tests of `CentredWindows` and the integration tests `CentredWindowTests`. Validation compares the glazed areas in total and per orientation, which the rule conserves per wall. Every window is checked per wall when it is built, not by validation: a source window must lie on one target wall (`WindowNotHosted`) and a wall's glazed area must be smaller than its area (`GlazingExceedsWall`); both hold by construction once the façades are covered (D-041) and are kept as guards.

with

- Window positions and sizes. Where walls are rebuilt, each outdoor wall gets one window centred on it with the glazed area of the source windows it covers (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)), so individual windows are not conserved by design; the rule is covered by the unit tests of `CentredWindows` and the integration tests `CentredWindowTests`. Validation compares the glazed areas in total and per orientation, which the rule conserves per wall. The reference glazing is the plan's: a generator's wall or window below the ADR-011 minimum has no window (`WindowOmitted`), and validation compares with the glazing the generator placed, not with WWR × wall area. Every window is checked per wall when it is built, not by validation: a source window must lie on one target wall (`WindowNotHosted`) and a wall's glazed area must be smaller than its area (`GlazingExceedsWall`); both hold by construction once the façades are covered (D-041) and are kept as guards.
  • [x] Step 5: Edit docs/research/program-presets.md

Make this replacement; the "Replace" text occurs exactly once in the file. Only the link anchor changes.

  1. Replace
S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s5).

with

S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s81).
  • [x] Step 6: Check the links and commit
git grep -n "grasshopper-components-through-s5" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s81" -- docs/architecture/pipeline.md docs/research/program-presets.md
git add docs/architecture/pipeline.md docs/architecture/domain-model.md docs/architecture/validation.md docs/research/program-presets.md
git commit -m "docs(architecture): document the s8.1 generators, geometry limits, and components"

Expected: no output from the first command; docs/architecture/pipeline.md:1 and docs/research/program-presets.md:1 from the second.

Task 15: S8.1 smoke-test checklist

Files: - Modify: docs/development/grasshopper-smoke-test.md (append)

Interfaces: - Consumes: the plugin built from Slices A–D. - Produces: the checklist that Task 16 runs.

  • [x] Step 1: Append the S8.1 checklist at the end of docs/development/grasshopper-smoke-test.md

Append this section after the last line of the file (after the S5 checklist), separated by one blank line, so that it nests under ## Stage checklists like the S2–S5 checklists:

### S8.1 checklist — bar typologies (`v0.8.0`)

S8.1 adds four plan generator components in *2 Generate* ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)) and the minimum wall and window geometry of every generator ([ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md)). No existing component, input, output, or GUID changes; the description of *Linear Plan Generator* names `HDHM-TYP-009`. Use the three default preset components (*Dwelling Unit Preset*, *Stair Preset*, and for the linear plan *Corridor Preset*) with nothing connected.

#### Toolbar and icons

- [ ] *2 Generate* shows *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, and *Transform Plan*, each with its own green icon.
- [ ] Inputs, in order, with their defaults: *Stair Bay Bar* (SBar) Bay Count 3, Unit Width 7, Depth 12, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; *Stair Pair* (SPair) Unit Width 7, Depth 12, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; *Gallery Bar* (GBar) Unit Count 6, Unit Width 6, Depth 10, Stair Width 4, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; *Terrace Row* (Terr) Plot Count 6, Plot Width 5.5, Depth 10, Floor Height 3, Orientation 0, Dwelling Unit, Preview Location. Each has one output, *Plan*.
- [ ] With a preset input unconnected, the component shows Grasshopper's missing-input warning and outputs nothing (D-065).

#### Default plans

1. Place each new generator with its presets connected and nothing else changed; connect *Inspect* to each *Plan*.
   - [ ] *Stair Bay Bar*: no runtime message; nine zones `ST1`–`ST3`, `U1`–`U6`; footprint 51 m × 12 m (612 m²), stairs 36 m², dwellings 84 m²; every stair is flanked by the two dwellings of its bay (`ST2` between `U3` and `U4`, x = 24–27 m); 20 windows, 102.6 m² of glazing (40.5 south, 40.5 north, 10.8 east, 10.8 west in *Validate* on its *No Simplification* floor).
   - [ ] *Stair Pair*: three zones `ST1`, `U1`, `U2`; footprint 17 m × 12 m (204 m²); 8 windows, 48.6 m² of glazing.
   - [ ] *Gallery Bar*: eight zones `ST1`, `ST2`, `U1`–`U6`; footprint 44 m × 10 m (440 m²), stairs 40 m² at both ends, dwellings 60 m²; no gallery in the preview; 18 windows, 75.6 m² of glazing (34.8 south, 34.8 north, 3 east, 3 west).
   - [ ] *Terrace Row*: six zones `U1`–`U6` named *House 1*–*House 6*; footprint 33 m × 10 m (330 m²), 55 m² each; 14 windows, 77.4 m² of glazing.
   - [ ] Each *Inspect* report equals the snapshot `tests/Lod.Generators.Tests/<Family>/Snapshots/<family>-canonical.txt`.

#### Combinations

2. Connect each new plan to the four plan simplifiers (*Perimeter Core* Depth 4.57) and each floor three times, Multipliers 1, 3, 1, to the four floor aggregators.
   - [ ] *Semantic Merge* gives 7 zones for *Stair Bay Bar* (the three stairs stay apart; dwellings of neighbouring bays merge), 3 for *Stair Pair*, 3 for *Gallery Bar*, and 1 for *Terrace Row*.
   - [ ] For all 16 combinations of each generator, *Validate* gives Passed = True (only `ConditionedFloorArea` may read `note`), no aggregator shows a warning, and *Convert2BEM* with Override = False converts without an error.

#### Rotated placement

3. Put each new plan through *Transform Plan* with a 30° rotation about the world z axis and a move by 5.3, −2.1, 0, then through each simplifier and each aggregator (Multipliers 1, 3, 1).
   - [ ] *Validate* gives Passed = True for all 16 combinations of each generator, with the same total glazing as the unrotated plan.

#### Parameters and the minimum geometry

4. Change inputs one at a time and set each back afterwards.
   - [ ] *Stair Bay Bar* Bay Count 1: `Error InvalidParameter: BayCount must be at least 2 (one bay is HDHM-TYP-008, Stair Pair), got 1.` and no plan. *Gallery Bar* Unit Count 1 and *Terrace Row* Plot Count 1 give the matching errors; a width or depth of 0 gives `… must be positive, got 0.`
   - [ ] A *Stair Preset* in the *Dwelling Unit* input of *Stair Bay Bar*: `Error PresetSpaceTypeMismatch [DwellingUnit]: Input DwellingUnit needs a DwellingUnit preset, got 'Example Stair' (Stair).`
   - [ ] *Terrace Row* Plot Width 0.8: the plan is produced with twelve remarks such as `Info WindowOmitted [U1/W1]: Outdoor wall U1/W1 is 0.8 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.72 m² of glazing not placed).`; only the two 10 m end walls show windows, and *Validate* on its *No Simplification* floor passes.
   - [ ] *Linear Plan Generator* with its defaults shows no remark, and its *Inspect* report equals `linear-plan-canonical.txt` (ADR-011 changes no existing plan).
  • [x] Step 2: Commit
git add docs/development/grasshopper-smoke-test.md
git commit -m "docs(development): add the s8.1 smoke-test checklist"

Task 16: Smoke test in Rhino 8 (manual, person; or the controller headless, D-056)

Files: none (the result goes into the body of the close-out commit, Task 17, Step 8)

  • [x] Step 1: Build the branch
dotnet build BEMGen.sln -c Release
git rev-parse HEAD

Expected: Build succeeded., 0 Warning(s), 0 Error(s). Note the SHA.

  • [x] Step 2: Run the S8.1 checklist (person in Rhino 8, or the controller headless)

Load the plugin as docs/development/grasshopper-smoke-test.md describes and tick every item of the S8.1 checklist. In this run (D-084, under the rules of D-066) the controller runs the checks that do not need a viewport as a scripted headless Grasshopper session (D-056: Rhino 8 /nosplash /netcore with RHINO_PACKAGE_DIRS pointing at src/Lod.Grasshopper/bin/Release/net7.0/, components placed by GUID, states, messages, outputs, data trees, bounding boxes, and the Boundaries and Windows outputs of Convert2BEM written to a file). The following values are expected; they were observed in such a session on the verified reference (Rhino 8.25.25314.11001, .NET 8.0.31; BEMGen Info version text 0.5.0+<sha>, since the bump to 0.8.0 comes in Task 17). "The presets" are the default Dwelling Unit Preset and Stair Preset with nothing connected; "1, 3, 1" means a simplified floor given three times to an aggregator with those multipliers.

  1. Registration. 25 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, all primary: Gallery Bar GBar a58df462-b3c2-46b7-be3d-7ea9f4629280, Linear Plan Generator Linear a4a9131e-0a0f-49e0-99a4-f26760f6810f, Stair Bay Bar SBar 911c3204-42bb-4023-bdb7-2931ea1caad3, Stair Pair SPair 5669d39b-45e6-47ea-9250-f2304d005335, Terrace Row Terr f694e595-dba2-4b98-9d1e-f1492e3edf39, Transform Plan TPlan 57c17b9c-04b4-4595-9150-bb923877d95e. Every other component and parameter as in S5.
  2. Inputs. In order, with their persistent defaults (model in metres): Stair Bay Bar Bay Count (BC, integer) 3, Unit Width (UW) 7, Depth (D) 12, Stair Width (SW) 3, Floor Height (FH) 3, Orientation (O) 0, Dwelling Unit (DU), Stair (St), Preview Location (PL, optional) {0,0,0}; Stair Pair Unit Width 7, Depth 12, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; Gallery Bar Unit Count (UC, integer) 6, Unit Width 6, Depth 10, Stair Width 4, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; Terrace Row Plot Count (PC, integer) 6, Plot Width (PW) 5.5, Depth 10, Floor Height 3, Orientation 0, Dwelling Unit, Preview Location. Every input except Preview Location is required. With the preset inputs unconnected each component is in the warning state with Input parameter DU failed to collect data (and Input parameter St failed to collect data except for Terrace Row) and outputs nothing.
  3. Defaults. Each new generator with the presets → Inspect, and → No Simplification → Stack Floors (one floor) → Convert2BEM: no runtime message on the generator, Inspect, No Simplification, Stack Floors, or Convert2BEM (the preset components show only their remark).
Generator Plan bounding box (m) footprint area= Provenance (first part) Boundaries Windows (Breps in branches)
Stair Bay Bar (0, 0, 0)–(51, 12, 3) 612.000000 StairBayBarGenerator BayCount=3 Depth=12 FloorHeight=3 OrientationDegrees=0 Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair StairWidth=3 UnitWidth=7 Adiabatic 16, Ground 9, Outdoors 29 20 in 9 (2, 2, 2, 3, 2, 2, 2, 2, 3)
Stair Pair (0, 0, 0)–(17, 12, 3) 204.000000 StairPairGenerator Depth=12 FloorHeight=3 OrientationDegrees=0 Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair StairWidth=3 UnitWidth=7 Adiabatic 4, Ground 3, Outdoors 11 8 in 3 (2, 3, 3)
Gallery Bar (0, 0, 0)–(44, 10, 3) 440.000000 GalleryBarGenerator Depth=10 FloorHeight=3 OrientationDegrees=0 Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair StairWidth=4 UnitCount=6 UnitWidth=6 Adiabatic 14, Ground 8, Outdoors 26 18 in 8 (3, 3, 2, 2, 2, 2, 2, 2)
Terrace Row (0, 0, 0)–(33, 10, 3) 330.000000 TerraceRowGenerator Depth=10 FloorHeight=3 OrientationDegrees=0 PlotCount=6 PlotWidth=5.5 Presets=DwellingUnit:Example Dwelling Unit Adiabatic 10, Ground 6, Outdoors 20 14 in 6 (3, 2, 2, 2, 2, 3)
  1. Every combination. Each new generator's plan through each of the four plan simplifiers (Perimeter Core with its default depth), each floor given 1, 3, 1 to each of the four aggregators, each building to Validate: no runtime message on any simplifier, aggregator, or Validate, and Validate Passed = True for all 64 buildings.
  2. Rotated plan. Each new plan through Transform Plan with the transform of a 30° rotation about the world z axis followed by a move by (5.3, −2.1, 0), then the 16 combinations of item 4: no runtime message (including Transform Plan) and Validate Passed = True for all 64.
  3. Parameters and the minimum geometry. Stair Bay Bar with Bay Count 1: error state, Error InvalidParameter: BayCount must be at least 2 (one bay is HDHM-TYP-008, Stair Pair), got 1., no plan. Stair Bay Bar with the Stair Preset in its Dwelling Unit input: error state, Error PresetSpaceTypeMismatch [DwellingUnit]: Input DwellingUnit needs a DwellingUnit preset, got 'Example Stair' (Stair). Terrace Row with Plot Width 0.8: remark state with twelve remarks, Info WindowOmitted [U<i>/W1] and [U<i>/W3] for i = 1 to 6, each Outdoor wall U<i>/W<j> is 0.8 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.72 m² of glazing not placed)., and a plan.

Items that a headless session cannot observe stay open for a person (D-056, D-058): the toolbar and canvas icons, the viewport previews (no gallery on Gallery Bar; on Terrace Row with Plot Width 0.8 only the two end walls show windows), the Inspect reports compared with the four canonical snapshots and the linear plan's snapshot (the generator tests check the snapshots), the glazing values of Validate (only Passed was recorded; the generator tests check the glazing of every wall), the Semantic Merge zone counts (BarTypologyTests.SemanticMergeJoinsOnlyConnectedZonesOfOneType checks them), Convert2BEM on the 16 combinations (the headless session converted the one-storey default plans only), Validate on the 0.8 m terrace row, and Linear Plan Generator with its defaults showing no remark. No example definition is part of S8.1; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).

Any failure stops the stage: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Write down, following the guide's "Recording the result": who tested and how (person in Rhino, or the controller headless per D-056), when, the Rhino version and runtime, the SHA, the BEMGen Info version text, pass or fail for each item, and the items not performed.

  • [x] Step 3: Merge Slice E (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/architecture-bar-typologies
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-bar-typologies
git branch -d docs/architecture-bar-typologies

Expected: VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0. Do not push (D-084).


Stage close-out

Task 17: Version 0.8.0, S8.1 records, tag v0.8.0

Files: - Modify: Directory.Build.props, AGENTS.md, README.md, docs/plans/2026-09-30-implementation-roadmap.md, docs/decisions/decision-log.md, docs/plans/2026-10-02-s8.1-bar-typologies.md (this plan: checkboxes and status, D-051)

Interfaces: - Consumes: the merged Slices A–E; the smoke-test record of Task 16; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump. - Produces: the local tag v0.8.0; the S8.1 records.

The decision-log entry in Step 7 and the roadmap Progress line of Step 6 have one field filled in when this task runs: <date>, the date the stage is closed (ISO format, YYYY-MM-DD). The entry is decided by the controller (unsupervised run, D-084) and Provisional. The commit body of Step 8 uses the values written down in Task 16. The close-out uses its own branch, as the S1–S5 close-outs do.

  • [x] Step 1: Create the branch and run the gate
git switch -c chore/repo-s8.1-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1

Expected: VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0.

  • [x] Step 2: Bump the version in Directory.Build.props

Replace

    <Version>0.5.0</Version>

with

    <Version>0.8.0</Version>

Full file after this step:

<Project>
  <PropertyGroup>
    <LangVersion>12.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <Deterministic>true</Deterministic>
    <Version>0.8.0</Version>
    <Authors>Environmental Systems Lab</Authors>
    <Product>BEMGen</Product>
  </PropertyGroup>

  <!-- Records and init accessors on netstandard2.0 (ADR-001). -->
  <ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
    <Compile Include="$(MSBuildThisFileDirectory)src\Shared\IsExternalInit.cs" Link="Polyfills\IsExternalInit.cs" />
  </ItemGroup>
</Project>
  • [x] Step 3: Check the version test
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Common.BuildInfoTests"

Expected: Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1 for net8.0.

  • [x] Step 4: Commit the version
git add Directory.Build.props
git commit -m "build(repo): set version 0.8.0"
  • [x] Step 5: Update AGENTS.md and README.md

In AGENTS.md, replace everything between the heading ## Project state and the heading ## Commands (both headings stay) with:

Checkpoint 1 (D-030) is reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), and so are the revision stages S4.1 (tag `v0.4.1`, D-064) and S4.2 (tag `v0.4.2`, D-073), stage S5 (tag `v0.5.0`, D-079), and sub-stage S8.1 (tag `v0.8.0`, D-084). The pipeline runs end to end for five plan generators: program presets → a plan generator (one `PlanGenerator` subclass per *High-Density Housing* typology family with typed parameter and preset records, [ADR-006](docs/decisions/ADR-006-plan-generation-mechanism.md): `LinearPlanGenerator`, the double-sided internal corridor bar `HDHM-TYP-009`, and the bar families `StairBayBarGenerator` 001, `StairPairGenerator` 008, `GalleryBarGenerator` 002 with its gallery not modelled, and `TerraceRowGenerator` 006 with one dwelling zone per plot and storey, D-085; one typed preset per space type, D-061; optionally rotated and moved by `PlanTransform`, D-070) → plan simplifiers (`NoSimplification`, `SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`) → floor aggregators (`Stack`, `StackedFloorZoneMultiplier`, `SingleZonePerFloorType`, `SingleZoneBuilding`, D-071; floors and ceilings designated on the assembled stack, D-068) → validation → `Convert2BEM` (heat transfer between zones an export option, off by default, D-069), as described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md). Every generator places one centred window per outdoor wall (D-026), except on a wall shorter than 1 m or where the window would be narrower or lower than 0.3 m (`GeometryLimits`, info `WindowOmitted`, [ADR-011](docs/decisions/ADR-011-minimum-wall-and-window-geometry.md)). Wherever zones are merged and walls are rebuilt (`SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`, `SingleZonePerFloorType`, `SingleZoneBuilding`), every outdoor wall carries one centred window with the glazed area of the source windows it covers (`CentredWindows`, D-079, [ADR-012](docs/decisions/ADR-012-centred-windows.md)); the other steps keep their input's windows. Vertical aggregation follows [ADR-013](docs/decisions/ADR-013-vertical-aggregation-methods.md), which supersedes [ADR-009](docs/decisions/ADR-009-vertical-aggregation.md) where it differs. ADR-006, ADR-011, ADR-012, ADR-013, and the S4.2, S5, and S8.1 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084). In Grasshopper every component has an icon drawn by `scripts/make_icons.py` (D-063), and the generator, *Transform Plan*, simplifier, and aggregator components have a *Preview Location* input that moves only their preview (D-062). Until the owner says otherwise, changes need not keep backward compatibility, but component GUIDs never change (D-064).

Not implemented yet: `Lod.Export` and Convert2IDF (S6), the non-rectangular typologies 007, 003, and 004 (S8.2), the enclosed courtyard 005 with footprints with holes (S8.3), and Grasshopper UX and release (S9). The precedent study of S7 was carried out in a separate repository (D-082); its nine families are paraphrased in [docs/research/precedents/hdhm-typologies.md](docs/research/precedents/hdhm-typologies.md). The example program presets hold illustrative values, not DOE prototype values, and the `Convert2BEM` output layout stays provisional until a ClimateStudio reference definition exists (D-023).

In README.md, replace everything between the heading ## Status and the heading ## What it does (both headings stay) with:

Checkpoint 1 reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), followed by the revision stages S4.1 (tag `v0.4.1`) and S4.2 (tag `v0.4.2`), stage S5 (tag `v0.5.0`), and sub-stage S8.1 (tag `v0.8.0`). In Rhino 8 the plugin runs the whole pipeline for five plan generators: program presets with conditioning (a default preset component per space type); the linear plan generator and four bar typologies of the *High-Density Housing* precedent study, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* (ADR-006), each with one preset input per space type and explicit windows (none on a wall shorter than 1 m or for a window below 0.3 m, ADR-011); *Transform Plan* to rotate and move a plan; four plan simplifiers (Z0–Z3); four floor aggregators (Stack, StackedFloorZoneMultiplier, SingleZonePerFloorType, SingleZoneBuilding) that designate floors and ceilings on the assembled stack and warn about unsupported storeys; invariant validation; and `Convert2BEM`, which writes heat transfer between zones only when asked (off by default). Wherever zones are merged and walls are rebuilt, every outdoor wall carries one centred window with the glazed area of the windows it covers, so glazing is conserved per wall, façade, orientation, and building (D-079). Every component has an icon, and every component with a viewport preview has a *Preview Location* input that moves only its preview. The pipeline is described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md); the example definition `examples/grasshopper/checkpoint-1-pipeline.gh`, which wires every simplifier into every aggregator, is still to be built by a person. Next: Convert2IDF (S6) and the non-rectangular typologies (S8.2). The `Convert2BEM` output stays provisional until a ClimateStudio reference definition exists (D-023), and the example program presets hold illustrative values, not DOE prototype values.

In README.md, make this replacement in the "Planned layout" block; the "Replace" text occurs exactly once in the file.

  1. Replace
  Lod.Generators/    plan generators (Linear first; Point and Courtyard later)

with

  Lod.Generators/    plan generators, one per typology family (the linear plan and the S8.1 bar families; the others in S8.2 and S8.3)
  • [x] Step 6: Update the roadmap

In docs/plans/2026-09-30-implementation-roadmap.md, insert these two lines directly below the S5 Progress line (the last > **Progress:** line), so that a > separator line stays between consecutive lines:

>
> **Progress:** <date> · S8.1 Plan generation mechanism and bar typologies complete (tag `v0.8.0`, local until the owner pushes it, D-084); next: S6 and S8.2. ADR-006 fixes one `PlanGenerator` subclass per *High-Density Housing* family, with typed parameter and preset records and design defaults for all nine families; ADR-011 adds `GeometryLimits` (no window on an outdoor wall shorter than 1 m or for a window narrower or lower than 0.3 m, info `WindowOmitted`); `LinearPlanGenerator` checked as `HDHM-TYP-009` and kept; new generators and components *Stair Bay Bar* (001), *Stair Pair* (008), *Gallery Bar* (002, gallery not modelled), and *Terrace Row* (006, one zone per plot and storey, D-085); every bar family validates with every simplifier and aggregator, also rotated and moved; existing snapshots byte-identical, four new; 152 core, 91 generator, and 319 integration tests pass on `net8.0`.

Then make these replacements, in order; each "Replace" text occurs exactly once in the file. The first two generalise the heading of the detailed-plans table and add the S8.1 row; the others update the Common/ and Lod.Generators/ lines of §2 (target repository layout).

  1. Replace
### Detailed stage plans (Checkpoint 1)

with

### Detailed stage plans
  1. Replace
| S5 | [2026-10-01-s5-centred-windows.md](2026-10-01-s5-centred-windows.md) | 142 core, 19 generators, 187 integration |

with

| S5 | [2026-10-01-s5-centred-windows.md](2026-10-01-s5-centred-windows.md) | 142 core, 19 generators, 187 integration |
| S8.1 | [2026-10-02-s8.1-bar-typologies.md](2026-10-02-s8.1-bar-typologies.md) | 152 core, 91 generators, 319 integration |
  1. Replace
    Common/        ToleranceSettings, Result<T>, Diagnostic, DiagnosticCodes, ZoneId, BuildInfo

with

    Common/        ToleranceSettings, GeometryLimits, Result<T>, Diagnostic, DiagnosticCodes, ZoneId, BuildInfo
  1. Replace
    Linear/        LinearPlanGenerator, LinearPlanParameters
    Point/ Courtyard/                      (S8)

with

    Linear/        LinearPlanGenerator, LinearPlanParameters      (HDHM-TYP-009)
    StairBayBar/ StairPair/ GalleryBar/ TerraceRow/                (S8.1: 001, 008, 002, 006)
    PointPlate/ WingedBand/ OpenCourt/                             (S8.2: 007, 003, 004)
    EnclosedCourt/                                                 (S8.3: 005)

The revision note v8, the stage table and diagram, the sentence below the diagram, the S7 and S8 sections, the risk row, and §7 were committed with this plan and stay as they are.

  • [x] Step 7: Append the decision-log entry (template; fill <date> now)

Append at the end of docs/decisions/decision-log.md, after a blank line. The log ends with D-085 when this plan is approved, so the entry is D-086; if the log ends with another number, use the next free number and say so in the commit body.

### D-086 — S8.1 complete

- **Date:** <date> · **Decided by:** controller (unsupervised run, D-084) · **Status:** Provisional
- Sub-stage S8.1 (D-084) is complete and tagged `v0.8.0` locally; pushing `main` and the tag waits for the owner (D-084). [ADR-006](ADR-006-plan-generation-mechanism.md) and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md), accepted by the controller under D-084 and pending the owner's reading, fix the plan generation mechanism (one `PlanGenerator` subclass per *High-Density Housing* family with typed parameter and preset records; design defaults, zone IDs, and realisation of the rules for all nine families, D-083) and the minimum wall and window geometry of every generator. The precedent page `docs/research/precedents/hdhm-typologies.md` paraphrases the nine families with their stable IDs and the pinned source of D-082. ADR-002's placement rule is refined by ADR-011, and the research brief §10 states the exception.
- `GeometryLimits` in `Lod.Core.Common` (`MinimumWindowWallLength` 1 m, `MinimumWindowWidth` 0.3 m, `MinimumWindowHeight` 0.3 m) is passed through the `PlanGenerator` constructor, the defaults when omitted. An outdoor wall or window below a limit by more than the distance tolerance gets no window and the new info `WindowOmitted`; only non-default limits are recorded in provenance. `LayoutMeasures.PlaceWindowsByRatio` takes the limits and tolerances and returns a `Result`. Rebuilt walls are not subject to the limits (ADR-012).
- `LinearPlanGenerator` realises `HDHM-TYP-009` and is unchanged apart from its documentation. New in `Lod.Generators`: `StairBayBarGenerator` (001, at least 2 bays), `StairPairGenerator` (008, one bay of the shared `StairBayLayout`), `GalleryBarGenerator` (002, at least 2 dwellings, the gallery not modelled, D-083), and `TerraceRowGenerator` (006, at least 2 plots, one dwelling zone per plot and storey, D-085), each with a static `Default` parameter record. New components in *2 Generate*, with icons: *Stair Bay Bar* (SBar, `911c3204-42bb-4023-bdb7-2931ea1caad3`), *Stair Pair* (SPair, `5669d39b-45e6-47ea-9250-f2304d005335`), *Gallery Bar* (GBar, `a58df462-b3c2-46b7-be3d-7ea9f4629280`), and *Terrace Row* (Terr, `f694e595-dba2-4b98-9d1e-f1492e3edf39`). *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged.
- The eight existing snapshots are byte-identical; four canonical snapshots are new. 152 core, 91 generator, and 319 integration tests pass on `net8.0`, including every bar family with every simplifier and aggregator on its canonical plan and on the plan rotated by 131.4° and moved by (15.08, −153.9) m.
- The S8.1 smoke test is recorded in the close-out commit; items it did not perform stay open for a person (D-058).
- S8.2 (families 007, 003, and 004) can start; S6 (Convert2IDF) is unaffected.

Before committing, check that no <date> is left:

git grep -n -F -e "<date>" -- docs/decisions/decision-log.md docs/plans/2026-09-30-implementation-roadmap.md

Expected: no output.

  • [x] Step 8: Tick this plan and commit the records

Tick every completed checkbox of this plan and set its status line to > **Status:** Done (controller, D-084; pending the owner's reading) · … (D-051). Leave unticked the steps that run after this commit (Steps 9 and 10 below) and every step of Task 18, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 15 are file content of grasshopper-smoke-test.md and stay unticked, so the block keeps matching the file. The second -m records the smoke test as docs/development/grasshopper-smoke-test.md prescribes ("Recording the result"); fill <tester and method>, <date>, <version>, <sha>, <version text>, and <items> with the values written down in Task 16.

git add AGENTS.md README.md docs/plans/2026-09-30-implementation-roadmap.md docs/decisions/decision-log.md docs/plans/2026-10-02-s8.1-bar-typologies.md
git commit -m "docs(repo): record s8.1 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.1 checklist) by <tester and method> on <date>: Rhino <version>, .NET Core runtime, build <sha>, Version output '<version text>', all performed items passed. Not performed, pending a person (D-058): <items>."
  • [ ] Step 9: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s8.1-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.1-close-out
git branch -d chore/repo-s8.1-close-out

Expected: VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0.

  • [ ] Step 10: Tag (local)
git tag -a v0.8.0 -m "S8.1 plan generation mechanism and bar typologies complete"
git describe --tags

Expected: v0.8.0. Nothing is pushed (D-084).

Task 18: Fresh-clone gate and push (owner; not performed in this run, D-084)

Files: none

Not performed in this run. D-084 leaves pushing to the owner: the controller does not run this task, and its steps stay unticked when the stage is closed. They are listed so that the owner, or the controller once the owner authorises it, pushes the stage the way S5 was pushed (D-080); never a force push. v0.4.2 and v0.5.0 are not part of this task.

  • [ ] Step 1: Check the state to push
git status --short
git fetch origin
git rev-parse main v0.8.0^{commit}
git merge-base --is-ancestor origin/main main && echo fast-forward
git tag --list "v0.8.0"

Expected: no output from git status --short; the two SHAs are equal (the tag points at the tip of main, unless later commits were added on purpose); fast-forward (local main contains origin/main); the tag. If origin/main has commits that local main does not, stop and report: nothing is pushed, and no rebase or force push is attempted.

  • [ ] Step 2: Verify a fresh clone of the tag
git clone --branch v0.8.0 . ../BEMGen-v0.8.0-clone
powershell -NoProfile -ExecutionPolicy Bypass -File ../BEMGen-v0.8.0-clone/scripts/verify.ps1
git -C ../BEMGen-v0.8.0-clone describe --tags

Expected: the clone reports a detached HEAD at v0.8.0; verify builds the clone with 0 Warning(s) and 0 Error(s) and ends with VERIFY PASSED, with 152 core, 91 generators, and 319 integration tests passed on net8.0; describe prints v0.8.0. Then delete the clone (rm -rf ../BEMGen-v0.8.0-clone in Git Bash). If verify fails, stop: nothing is pushed.

  • [ ] Step 3: Push (no force)
git push origin main
git push origin v0.8.0
git ls-remote --tags origin v0.8.0
git status -sb

Expected: each push succeeds without --force (main as a fast-forward, the tag as [new tag]); ls-remote lists the tag (with its peeled ^{} line); git status -sb shows ## main...origin/main with nothing ahead or behind. A rejected push is not retried with force: stop and report.

Exit criteria

  • The precedent page paraphrases the nine families with their IDs and the pinned source (D-082) and holds no book content; ADR-006 and ADR-011 exist and are accepted by the controller under D-084 (pending the owner's reading); ADR-002's status line points to ADR-011; the research brief §10 states the exception.
  • GeometryLimits (1 m, 0.3 m, 0.3 m) is the only place for the minimum wall and window geometry; a generator's outdoor wall or window below a limit gets no window and the info WindowOmitted; non-default limits are recorded in provenance (GeometryLimitsTests, six tests; PlanGeneratorTests, four new tests).
  • StairBayBarGenerator (001), StairPairGenerator (008, on the shared StairBayLayout), GalleryBarGenerator (002, no gallery), and TerraceRowGenerator (006, one zone per plot and storey) implement ADR-006 with its defaults, zone IDs, and validation; each has tiling, window, relationship, invalid-parameter, determinism, provenance, and snapshot tests; LinearPlanGenerator is checked as HDHM-TYP-009 and unchanged.
  • Every new family validates with every plan simplifier and floor aggregator, on its canonical plan and rotated and moved (BarTypologyTests, 132 cases).
  • Snapshots: the eight existing snapshots byte-identical; four new canonical snapshots.
  • Four new components in 2 Generate with the GUIDs of this plan, their ADR-006 defaults, required preset inputs, Preview Location last, and icons (32 icons); no existing component, input, output, or GUID changed; Linear Plan Generator's description names HDHM-TYP-009.
  • scripts/verify.ps1 prints VERIFY PASSED; tests: 152 core, 91 generators, 319 integration on net8.0; 0 warnings.
  • The S8.1 smoke test is recorded in the close-out commit, with the items left for a person named.
  • Version 0.8.0, local tag v0.8.0; AGENTS.md, README, the roadmap Progress line, detailed-plans heading and row, and layout, and the decision-log entry D-086 are written; this plan is ticked and marked Done.
  • Nothing is pushed in this run (D-084); Task 18 is left for the owner.

Notes for the reviewer

  1. Source. Every code block, snapshot, and new document was extracted programmatically from a verified reference implementation built on main at baddbb7 (D-082 to D-084), 16 commits; local main is 22e90fd, which adds only the D-085 entry to the decision log, so every file the reference touches is identical on both. The reference's commit order is kept with three exceptions: its roadmap commit is split into the stage edits committed with this plan and the layout lines of the close-out (Task 17), as for S5; its component commit is split into Task 11 (the components) and Task 13 (the icons and make_icons.py), as S4.2 did with Tasks 12 and 16; and Task 9 holds two reference commits (the check, then the doc comment). Every task was then replayed on a clone of 22e90fd from this plan's file contents and replacements: the red errors of Tasks 4–8 (with line and column numbers and in the compiler's order), the single failing snapshot test of Tasks 5–8 and the four received snapshots (byte-identical to the expected contents), the failure of make_icons.py before Task 13 and the 32 hashes after it, the counts after every task, a 0-warning build after every task, every documentation replacement (each "Replace" text occurs exactly once when it is applied, and the result equals the reference), the close-out edits, and VERIFY PASSED on a fresh clone of the replayed v0.8.0 are observed results. The replayed end state equals the reference apart from line endings, the version, the decision log (D-085 on main, D-086 from the close-out), and the roadmap's Progress line and detailed-plans heading and row.
  2. Slices and branches. The roadmap names no slices for S8.1; this plan uses five slices and a close-out, named after D-004: docs/decisions-generation-mechanism (the name the roadmap's original S7 slice 3 gave ADR-006), feature/generators-geometry-limits, feature/generators-bar-typologies, feature/grasshopper-bar-typologies, docs/architecture-bar-typologies, and chore/repo-s8.1-close-out. Merges rebase onto local main. Unlike S5, this run ends without a push (D-084); Task 18 is written out for the owner and stays unticked.
  3. D-085 and ADR-006. D-085 (a dwelling that spans storeys is one zone per storey) was logged on main after the reference's ADR-006 text was settled. ADR-006's HDHM-TYP-006 section records the same rule ("each plot is one dwelling zone per storey"; the house's stair inside the dwelling and not zoned) and calls it a consequence of the plan → floor → building pipeline that D-009 "does not cover", while D-085 says D-009 applies per storey; the rule is the same, the wording differs, and neither ADR-006's decision line nor TerraceRowGenerator's summary cites D-085. The plan keeps the reference text unchanged; the close-out cites D-085 in AGENTS.md, the roadmap Progress line, and D-086. Citing D-085 in ADR-006 is a one-line follow-up for the owner's reading.
  4. HDHM-TYP-007 stays in S8.2. As specified in ADR-006, the point-access plate is a square (22 m × 22 m by default) with four dwellings in a pinwheel around a central Stair core that has no outdoor wall. Its footprint is a rectangle without holes, so it could in principle be generated now, but it is the first family with an interior zone and the first that is not a bar; D-084 assigns it to S8.2 with 003 and 004, and S8.1 implements only its specification.
  5. Provenance order. Provenance.Of sorts parameters by key, so every provenance test lists keys alphabetically (BayCount, Depth, FloorHeight, …) and OnlyNonDefaultLimitsAreRecordedInProvenance expects MinimumWindowHeight, MinimumWindowWallLength, MinimumWindowWidth in that order. While the reference was built, the first version of that test expected insertion order; the expectation was corrected to the sorted order, and no check was weakened.
  6. ADR-011 does not apply to rebuilt walls. The limits apply only where a generator places windows (the base PlanGenerator). Wherever walls are rebuilt, ADR-012 still gives every target outdoor wall one centred window with the glazed area it covers, whatever its size, because glazed area is an invariant (GLOBAL.md scientific rule 1); such a window may be smaller than the minimum size and is neither omitted nor reported. With the current zoning strategies a glazed target wall contains at least one whole glazed source wall and is therefore at least 1 m long. WindowNotHosted and GlazingExceedsWall stay guards; a strategy that cuts a source wall inside a window must come with its own ADR.
  7. PlaceWindowsByRatio changes its signature. The public LayoutMeasures.PlaceWindowsByRatio now takes the limits and tolerances and returns a Result<IReadOnlyList<Surface>>; its only callers are PlanGenerator and one test (LayoutSurfaceBuilderTests.WindowsAreSizedByEachZonesRatio), whose assertions are unchanged. No backward compatibility is kept (D-064).
  8. Tasks without a red phase. Tasks 9 and 10 add verification tests only: D-083 asks that the linear plan be checked against HDHM-TYP-009 and kept, and D-084 states that bars need no pipeline change. Both checks passed at once in the reference and in the replay; each task says to stop if one fails, since a failure would need a decision, not a fix in the test. The build-error red phases of Tasks 4–8 list only declaration-level errors: the compiler stops before binding method bodies, so Task 4 reports one error and Tasks 5–8 four or five.
  9. Defaults live in one place, except for the linear plan. ADR-006 gives every S8 parameter record a static Default that the components use. LinearPlanParameters (S2) has none; its component defaults stay in LinearPlanGeneratorComponent, unchanged, as ADR-006's HDHM-TYP-009 section says.
  10. Documents changed beyond the design brief. The research brief §10 gets one sentence on the ADR-011 exception (Task 3); ADR-002's status line points to ADR-011; pipeline.md's heading becomes "Grasshopper components through S8.1" (with the link in program-presets.md) and "Not in Checkpoint 1" becomes "Not implemented yet", whose S7 and S8 rows become S8.2 and S8.3 rows; domain-model.md and validation.md describe GeometryLimits and the plan's actual glazing as the reference; the roadmap's detailed-plans heading drops "(Checkpoint 1)" at the close-out; README's layout line names the typology families. The repository spec (docs/LOD_grasshopper_plugin_repository_spec.md) is not edited.
  11. Research brief §4 unchanged. §4 still lists the linear, point, and courtyard blocks as the minimum typologies. The High-Density Housing families realise and extend that list (009 and the bars are linear blocks, 007 a point block, 004 and 005 courtyards); §4 is not an equivalence rule, so GLOBAL.md scientific rule 7 does not require a change, and the owner may align it with D-082 later.
  12. A fix on main between S5 and S8.1. 4b07283 (merging coplanar faces of zone volumes in RhinoGeometry.cs) landed on main after v0.5.0. The S8.1 reference is built on top of it and holds that file in its main version; it is not an S8.1 file and no S8.1 task changes it.
  13. Icons. The 28 existing PNGs are byte-identical after Task 13 (Pillow 12.3.0); the four new ones were compared with the reference's bytes in the replay. The contact-sheet descriptions are from the replay's sheet.
  14. Smoke-test values. The expected values of Task 16 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit: registration and inputs, the default plans (bounding boxes, footprint, provenance, Boundaries, Windows), the 64 canonical and 64 rotated combinations (states and Validate Passed), and the three parameter scenarios. Inspect texts, Validate line values, Convert2BEM on the combinations, and the viewport were not observed there.
  15. Clean working tree. The git status expectations assume a working tree with no untracked files outside .gitignore before Task 1.
  16. check_plans.py false positives. The Global Constraints line that states the no-attribution rule is flagged as a possible attribution; it states the rule. The make_icons.py docstring line that names the Pillow version of the icons is flagged, as in S4.1 and S4.2.