Skip to content

S8.7 — Plans per storey and the stepped band Implementation Plan

Status: Done (controller, D-098; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.7 (section S8, Typologies) · Decisions: D-009, D-026, D-031, D-046, D-061, D-062, D-063, D-064, D-065, D-068, D-071, D-074, D-075, D-079, D-085, D-087, D-090, D-091, D-093, D-095, D-097, D-098, D-099, D-105; ADR-015 (plans per storey, created in Task 2, so not linked here), ADR-006 (the specification of SYN-TYP-012, Task 7), ADR-013 (storeys that differ, Task 7), ADR-011, ADR-008

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.7 of D-098: plans that differ by storey (D-095), decided in a new ADR-015, and the last family of the four-source set, SYN-TYP-012 (Stepped Band, stepped dwelling bands with exposed terraces), as ADR-006 specifies it, with its Grasshopper component and icon. A characterisation test first confirms that every floor aggregator already designates the part of a lower storey's ceiling that the storey above leaves free as an outdoor roof (a terrace) and the covered part as lying between the storeys (D-068), with ground and roof areas that validate, so no aggregation or validation rule changes. A new base class MultiStoreyPlanGenerator<TParameters, TPresets> returns one ordinary plan per storey, bottom-up, sharing every rule of PlanGenerator through one internal helper, so the sixteen existing generators keep their outputs. SteppedBandGenerator is its first subclass: a west stair, a row of dwellings, and a rear corridor that every dwelling adjoins, stepping back StepDepth per storey. The band validates with every simplifier and aggregator on its canonical plans, rotated and moved by two placements, with 2 and 4 storeys, and with 4 at the second placement. In Grasshopper the generator's Plan output is a list of the storeys, wired item-wise through a simplifier into an aggregator. Every existing plan, floor, building, and snapshot stays unchanged. S8.7 ends with version 0.8.6 and the tag v0.8.6, and with it S8 is complete: 18 plan generators. After the stage gate and a fresh clone of the tag pass scripts/verify.ps1, the controller pushes main and v0.8.6 (D-098); S6 (Convert2IDF) follows.

Architecture: Lod.Core.Plans gains MultiStoreyPlanGenerator<TParameters, TPresets>, a sibling of PlanGenerator<TParameters, TPresets> with the same constructors, Name, Validate, Describe, and IsPositive, whose Layout returns one PlanLayout per storey and whose Generate returns Result<IReadOnlyList<IGeneratedPlan>>. The checks of the common parameters and presets and the build of one plan (zones, surfaces, centred windows within GeometryLimits, provenance) move from PlanGenerator into the internal static class PlanBuilder, which both bases call, so PlanGenerator's behaviour, diagnostics, and provenance are identical. A multi-storey plan records Storey (from 0) in its provenance, and a storey's diagnostics are prefixed Storey {k}:. The family is a folder Lod.Generators/SteppedBand/ with a parameter record (a static Default holding the ADR-006 defaults), a preset record with one slot per space type, and the generator. No floor aggregator, simplifier, validation rule, or pipeline type changes: the storeys are floor entries with multiplier 1. TypologyGeneratorComponent gains a list output for multi-storey families, whose storeys are previewed at their elevations; one component derives from it, and scripts/make_icons.py draws one new icon.

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: S8.6 is complete on main (a4db33e, tag v0.8.5, D-105), with D-104 and D-105 recorded; this plan is committed to main before Task 1 ("docs(plans): add the s8.7 per-storey plans and stepped band plan"); it is the only file of that commit. ADR-006 (with the S8.4 specification of SYN-TYP-012, its defaults, zone IDs, order, and validation), ADR-011, ADR-013, and ADR-014 are accepted; D-095 asks for the ADR on plans per storey that Task 2 adds. 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: a multi-storey generator returns its storeys bottom-up, from storey 0; the stepped band lists on every storey ST, CO, then U1…Un west to east.
  • Counts. The stepped band needs at least 2 storeys (one step) and has no upper bound (ADR-006). A count below its minimum and every length that is not positive and finite are the error InvalidParameter, every error reported at once; when every length is positive, a Length not greater than StairWidth and a BaseDepth that the steps use up on the top storey are errors too, the latter only when the count is valid.
  • Numerical tolerances come only from ToleranceSettings (ADR-004); S8.7 adds none and changes none, and ToleranceSettings.cs is not touched. The minimum wall and window geometry stays in GeometryLimits (ADR-011), unchanged.
  • Existing outputs do not change. The twenty-four existing snapshots (linear-plan-canonical, stack-two-storeys, the three simplifier-*, the three aggregator-*, the S8.1 snapshots stair-bay-bar-canonical, stair-pair-canonical, gallery-bar-canonical, terrace-row-canonical, the S8.2 snapshots point-plate-canonical, winged-band-canonical, open-court-canonical, the S8.3 snapshot enclosed-court-canonical, the S8.4 snapshots office-plate-canonical and cafeteria-canonical, the S8.5 snapshots radial-lobes-canonical, branching-mall-canonical, care-hub-canonical, and foyer-halls-canonical, and the S8.6 snapshots operating-suite-canonical and court-cluster-canonical) stay byte-identical, and every existing test keeps passing; no test is removed, and no existing icon PNG changes. The move of PlanGenerator's body into PlanBuilder (Task 3) changes no output: every existing generator test and snapshot is run against it.
  • Never weaken a check (AGENTS.md rules 11, 12). Tasks 3 and 4 start from tests that fail to build, the way each task says. The characterisation tests of Task 1 and the integration matrix of Task 5 pass at once, because they confirm behaviour that already exists; each task says to stop if a case fails, since a pipeline change needs its own test-first task. No tolerance is widened, no validation check is relaxed, and no test is weakened or removed.
  • Report, never repair (GLOBAL.md quality rule 6). A storey that Perimeter Core cannot zone fails with PlanTooNarrow (D-087), never repaired; the generator does not impose that precondition, and Task 5 tests it on a 7 m top storey. Zones about 1 m wide on rotated plans remain the known limit of D-090; S8.7 does not change it.
  • Typed records per family (AGENTS.md): the stepped band has its own parameter and preset records; there is no catch-all parameter class and no dictionary of parameters. The presets are DwellingUnit, Corridor, and Stair, each checked against its slot's space type once for all storeys (PresetSpaceTypeMismatch). No space type and no preset is added.
  • Housing zoning (D-009, D-085). One zone per dwelling and storey; the stair and the corridor are one zone each per storey.
  • Source contract (D-093). ADR-006 and docs/research/precedents/syn-typologies.md hold everything this plan needs; typology-synthesizer is not opened while this plan is executed.
  • No backward compatibility (D-064). One component is added with a new GUID: Stepped Band 3c685bfb-b45b-45af-98d2-8318a6314922. No existing component, input, output, or GUID changes; the other typology components keep their single Plan output.
  • Grasshopper components are thin adaptors (AGENTS.md): every rule lives in Lod.Core or Lod.Generators; a component reads inputs, calls the generator, and reports its diagnostics.
  • Research equivalence rules are not changed (GLOBAL.md scientific rule 7): S8.7 adds a generator base and a generator and changes no step of the pipeline, so the research documentation needs no edit; ADR-015 and ADR-013 record that storeys that differ need no new aggregation rule.
  • Links. No commit contains a relative link to a file that a later commit creates: ADR-015 (Task 2) is committed before any document links to it.
  • Code fences. Every Markdown file this plan touches keeps balanced code fences: every opening fence line carries a language, every closing fence line is bare, and no file ends inside a block. Text this plan appends to a file is the content of a code block, never the fence lines around it; Tasks 7 and 9 run a check that prints each file's state.
  • 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-098). Slices are rebased onto local main and nothing is pushed while the stage runs. After the close-out (Task 9), its tag, and a fresh clone of v0.8.6 that passes scripts/verify.ps1, the controller pushes main and v0.8.6 (Task 10); never a force push.
  • .gh example definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.7 adds none.
  • S8.7 changes these earlier files: PlanGenerator.cs (S2, last changed in S8.1), TypologyGeneratorComponents.cs (S8.1, last changed in S8.6), scripts/make_icons.py (S4.1, last changed in S8.6), and Directory.Build.props (S0, last changed in S8.6); and the documents AGENTS.md, README.md, ADR-006, pipeline.md, program-presets.md, syn-typologies.md, the roadmap, docs/development/grasshopper-smoke-test.md, and the decision log (all last changed in S8.6), ADR-013 (last changed in S8.3), and domain-model.md (last changed in S8.4).

Files

Path Change Responsibility
tests/Lod.Core.Tests/Buildings/TerraceTests.cs Create Terraces of storeys that step back, with every aggregator (characterisation)
docs/decisions/ADR-015-plans-per-storey.md Create Plans per storey: interface, aggregators, terraces, Grasshopper wiring, multipliers
src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs Create The base of generators with one plan per storey
src/Lod.Core/Plans/PlanBuilder.cs Create (internal) Input checks and the build of one plan, shared by both bases
src/Lod.Core/Plans/PlanGenerator.cs Modify (S2 file, last changed in S8.1) Generate calls PlanBuilder; behaviour unchanged
tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs Create Storeys bottom-up, provenance, equality with PlanGenerator, diagnostics
src/Lod.Generators/SteppedBand/SteppedBandGenerator.cs Create SteppedBandParameters, SteppedBandPresets, SteppedBandGenerator (012)
tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs, …/Snapshots/stepped-band-canonical.txt Create Generator tests and the canonical storeys
tests/Lod.Integration.Tests/SteppedBandTypologyTests.cs Create Every simplifier × aggregator, canonical, at two placements, with 2 and 4 storeys, and with 4 at 131.4°; Perimeter Core on a narrow top storey, Semantic Merge, Single Zone per Floor Type, Single Zone Building
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs Modify (S8.1 file, last changed in S8.6) The list output of multi-storey families, Stepped Band
scripts/make_icons.py Modify (S4.1 file, last changed in S8.6) One new motif
src/Lod.Grasshopper/Icons/SteppedBandComponent.png Create (generated) 60 icons
docs/decisions/ADR-006-plan-generation-mechanism.md, docs/decisions/ADR-013-vertical-aggregation-methods.md, docs/architecture/pipeline.md, docs/architecture/domain-model.md, docs/research/precedents/syn-typologies.md, docs/research/program-presets.md Modify The family as implemented, storeys that differ, the base class, the wiring, the component, the renamed components heading
docs/development/grasshopper-smoke-test.md Modify Append the S8.7 checklist (Task 7) and its result (Task 9)
Directory.Build.props Modify Version 0.8.6
AGENTS.md, README.md Modify "Project state" and "Status" after S8.7; README layout line
docs/plans/2026-09-30-implementation-roadmap.md Modify Progress line and detailed-plans row
docs/decisions/ADR-015-plans-per-storey.md Modify (close-out) Cites D-106
docs/decisions/decision-log.md Modify D-106 (plans per storey), D-107 (S8.7 complete, S8 complete)

Test counts on net8.0 at the start of S8.7: 193 core, 427 generators, 1385 integration (D-105). After Task 1 and Task 2: 204 / 427 / 1385. After Task 3: 210 / 427 / 1385. After Task 4: 210 / 463 / 1385. After Task 5 and at the end: 210 core, 463 generators, 1485 integration (core: +11 TerraceTests, +6 MultiStoreyPlanGeneratorTests; generators: +36 SteppedBandGeneratorTests; integration: +100 SteppedBandTypologyTests). No test is removed or changed. Grasshopper code has no unit tests; Task 6 is checked by a 0-warning build, the embedded icons, and the smoke test of Task 8.

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

Task Agent Why
1 Terraces of storeys that step back bemgen-complex Characterisation of the floor aggregators and validation
2 ADR-015 bemgen-bounded A document given in full
3 Base of multi-storey generators bemgen-complex Core pipeline type; a refactoring of PlanGenerator that must keep every output
4 Stepped band (012) bemgen-complex New plan generator geometry per storey; new snapshot
5 Stepped band through every simplifier and aggregator bemgen-complex Combination matrix with storeys that differ
6 Generator component, list output, and icon bemgen-complex A list output with per-storey previews; generated icon and a visual check
7 Architecture documents and smoke-test checklist bemgen-bounded Documents, exact replacements
8 Smoke test The controller headless (D-056); person for the viewport items Needs Rhino 8
9 Close-out bemgen-bounded Bookkeeping
10 Fresh-clone gate and push Controller (D-098) A push is never delegated

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 — feature/per-storey-plans

Plans per storey: the characterisation of the terraces first (it passes at once, which is the finding ADR-015 rests on), then ADR-015, then the base class, test-first.

Task 1: Terraces of storeys that step back, with every floor aggregator

Files: - Test: create tests/Lod.Core.Tests/Buildings/TerraceTests.cs (the folder is new)

Interfaces: - Consumes (S2–S8.6): PlanGenerator<TParameters, TPresets>, PlanParameters, PlanPresets.Slots, PresetSlot, PlanLayout, PlannedZone, Polygon2.Rectangle, NoSimplification, FloorEntry, the four aggregators Stack, StackedFloorZoneMultiplier, SingleZonePerFloorType, and SingleZoneBuilding (ADR-013) with the designation of floors and ceilings on the assembled stack (D-068) and the warning UnsupportedStorey (D-074), HorizontalSurface, HorizontalKind, BoundaryCondition, IGeneratedBuilding.InternalMasses (D-031), Totals.Of (GroundArea, RoofArea, ExposedFloorArea), BuildingValidator, ExampleResidentialPresets.DwellingUnit and Corridor. - Produces: 11 tests on a private test generator of one storey, a dwelling U1 over [0, 10] × [South, 8] and a corridor C1 over [0, 10] × [8, 10], run for South 0, 3, and 6 m (100, 70, and 40 m²) and stacked bottom-up. With every aggregator and multiplier 1: each lower storey's ceiling keeps a 30 m² piece facing outdoors (the terrace) and the covered part lies between the storeys (Interzone, Adiabatic in StackedFloorZoneMultiplier, or internal mass in SingleZoneBuilding); the top roof is 40 m²; no floor is exposed and none is unsupported; ground and roof area are 100 m² each and the building validates, with the checks Building.GroundArea, Building.RoofArea, and Building.ExposedFloorArea enforced and passed (4 tests). Stack pairs L0/U1 with L1/U1 over 50 m² and L0/C1 with L1/C1 over 20 m², with the 30 m² terrace outdoors; SingleZonePerFloorType gives E0, E1, E2 with interzone pairs over the covered parts and no internal mass; SingleZoneBuilding turns only the covered parts into internal mass (3 tests). Multipliers 2, 1, 2 also validate with every aggregator, the exposure never multiplied (D-046) (4 tests). No production code changes.

  • [x] Step 1: Create the branch
git switch -c feature/per-storey-plans main
  • [x] Step 2: Write the characterisation tests — create tests/Lod.Core.Tests/Buildings/TerraceTests.cs (full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;

namespace Lod.Core.Tests.Buildings;

/// <summary>
/// Storeys that step back (D-095, ADR-015): every floor aggregator designates the part of a lower storey's ceiling that the storey above does not
/// cover as an outdoor roof (a terrace) and the covered part as lying between the two storeys (D-068), with ground and roof areas that validate.
/// </summary>
public sealed class TerraceTests
{
    /// <summary>
    /// Three storeys 10 m long: a dwelling <c>U1</c> from <c>y = South</c> to 8 m and a corridor <c>C1</c> from 8 m to 10 m, with South 0, 3, and 6 m,
    /// so storey 0 is 100 m², storey 1 70 m², storey 2 40 m², and each lower storey keeps a 10 m × 3 m terrace.
    /// </summary>
    private static readonly double[] Souths = { 0.0, 3.0, 6.0 };

    public static IEnumerable<object[]> Aggregators() =>
        new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }.Select(a => new object[] { a });

    [Theory]
    [MemberData(nameof(Aggregators))]
    public void TheUncoveredPartOfALowerCeilingIsAnOutdoorRoofAndTheCoveredPartLiesBetweenStoreys(string aggregator)
    {
        Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(Floors().Select(f => new FloorEntry(f, 1)).ToArray());

        Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
        Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
        IGeneratedBuilding building = result.Value;
        HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
        Assert.Equal(30.0, Outdoor(horizontal, HorizontalKind.Ceiling, 3.0), 9);
        Assert.Equal(30.0, Outdoor(horizontal, HorizontalKind.Ceiling, 6.0), 9);
        Assert.Equal(40.0, Outdoor(horizontal, HorizontalKind.Ceiling, 9.0), 9);
        Assert.Equal(70.0, Covered(building, HorizontalKind.Ceiling, 3.0), 9);
        Assert.Equal(40.0, Covered(building, HorizontalKind.Ceiling, 6.0), 9);
        Assert.Equal(0.0, Outdoor(horizontal, HorizontalKind.Floor, 3.0) + Outdoor(horizontal, HorizontalKind.Floor, 6.0), 9);
        Totals totals = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees);
        Assert.Equal(100.0, totals.GroundArea, 9);
        Assert.Equal(100.0, totals.RoofArea, 9);
        Assert.Equal(0.0, totals.ExposedFloorArea, 9);
        ValidationReport report = new BuildingValidator(ToleranceSettings.Default).Validate(building);
        Assert.True(report.Passed, report.Describe());
        Assert.All(report.Checks.Where(c => c.Check is "Building.GroundArea" or "Building.RoofArea" or "Building.ExposedFloorArea"), c => Assert.True(c.Passed && c.Enforced, c.Check));
    }

    [Fact]
    public void StackPairsTheCoveredPiecesWithTheZonesAboveAndLeavesTheTerraceOutdoors()
    {
        IGeneratedBuilding building = new Stack(ToleranceSettings.Default).Aggregate(Floors().Select(f => new FloorEntry(f, 1)).ToArray()).Value;

        HorizontalSurface[] ceilings = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Kind == HorizontalKind.Ceiling && h.Zone.Value.StartsWith("L0/")).ToArray();
        Assert.Equal(
            new[] { ("L0/U1", "L1/U1", 50.0), ("L0/U1", "-", 30.0), ("L0/C1", "L1/C1", 20.0) },
            ceilings.Select(h => (h.Zone.Value, h.AdjacentZone?.Value ?? "-", System.Math.Round(h.Area, 9))));
        Assert.Equal(BoundaryCondition.Outdoors, ceilings.Single(h => h.AdjacentZone is null).Boundary);
        Assert.Equal(Polygon2.Rectangle(0.0, 0.0, 10.0, 3.0).Area, ceilings.Single(h => h.AdjacentZone is null).Polygon.Area, 9);
    }

    [Fact]
    public void SingleZonePerFloorTypeGivesEveryStoreyItsZoneWithInterzonePairsOverTheCoveredParts()
    {
        IGeneratedBuilding building = new SingleZonePerFloorType(ToleranceSettings.Default).Aggregate(Floors().Select(f => new FloorEntry(f, 1)).ToArray()).Value;

        Assert.Equal(new[] { "E0", "E1", "E2" }, building.Zones.Select(z => z.Id.Value));
        Assert.Empty(building.InternalMasses);
        HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
        Assert.Equal(70.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 9);
        Assert.Equal(70.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E0").Sum(h => h.Area), 9);
        Assert.Equal(40.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E2").Sum(h => h.Area), 9);
        Assert.Equal(40.0, interzone.Where(h => h.Zone.Value == "E2" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 9);
    }

    [Fact]
    public void SingleZoneBuildingTurnsOnlyTheCoveredPartsIntoInternalMass()
    {
        IGeneratedBuilding building = new SingleZoneBuilding(ToleranceSettings.Default).Aggregate(Floors().Select(f => new FloorEntry(f, 1)).ToArray()).Value;

        Zone zone = Assert.Single(building.Zones);
        Assert.Equal(new[] { 100.0, 70.0, 40.0 }, zone.Parts.Select(p => System.Math.Round(p.Footprint.Area, 9)));
        Assert.Equal(new[] { (3.0, 70.0), (6.0, 40.0) }, building.InternalMasses.Select(m => (m.Elevation, System.Math.Round(m.SlabArea, 9))));
        Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
    }

    [Theory]
    [MemberData(nameof(Aggregators))]
    public void StoreysThatDifferMayStillStandForSeveralStoreysEach(string aggregator)
    {
        // Storey 0 x2, storey 1 x1, storey 2 x2: the band steps back above the second and the third storey only. Exposure is never multiplied (D-046).
        IFloor[] floors = Floors();
        FloorEntry[] entries = { new(floors[0], 2), new(floors[1], 1), new(floors[2], 2) };

        Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(entries);

        Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
        Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
        IGeneratedBuilding building = result.Value;
        Totals totals = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees);
        Assert.Equal(100.0, totals.GroundArea, 9);
        Assert.Equal(100.0, totals.RoofArea, 9);
        HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
        Assert.Equal(30.0, Outdoor(horizontal, HorizontalKind.Ceiling, 6.0), 9);
        Assert.Equal(30.0, Outdoor(horizontal, HorizontalKind.Ceiling, 9.0), 9);
        Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
        Assert.All(horizontal.Where(h => h.Boundary is BoundaryCondition.Outdoors or BoundaryCondition.Ground), h => Assert.Equal(1, multiplier[h.Zone]));
        ValidationReport report = new BuildingValidator(ToleranceSettings.Default).Validate(building);
        Assert.True(report.Passed, report.Describe());
    }

    private static double Outdoor(IEnumerable<HorizontalSurface> horizontal, HorizontalKind kind, double elevation) =>
        horizontal.Where(h => h.Kind == kind && h.Boundary == BoundaryCondition.Outdoors && System.Math.Abs(h.Elevation - elevation) < 1e-9).Sum(h => h.Area);

    /// <summary>The covered part of a slab: pieces between two storeys (interzone, or adiabatic in the zone multiplier) and internal mass.</summary>
    private static double Covered(IGeneratedBuilding building, HorizontalKind kind, double elevation) =>
        building.Surfaces.OfType<HorizontalSurface>()
            .Where(h => h.Kind == kind && h.Boundary is BoundaryCondition.Interzone or BoundaryCondition.Adiabatic && System.Math.Abs(h.Elevation - elevation) < 1e-9)
            .Sum(h => h.Area)
        + building.InternalMasses.Where(m => System.Math.Abs(m.Elevation - elevation) < 1e-9).Sum(m => m.SlabArea);

    private static IFloorAggregator Aggregator(string name) => name switch
    {
        "Stack" => new Stack(ToleranceSettings.Default),
        "StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(ToleranceSettings.Default),
        "SingleZonePerFloorType" => new SingleZonePerFloorType(ToleranceSettings.Default),
        _ => new SingleZoneBuilding(ToleranceSettings.Default),
    };

    private static IFloor[] Floors()
    {
        var generator = new StepGenerator();
        var presets = new StepPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor);
        return Souths.Select(south => new NoSimplification().Simplify(generator.Generate(new StepParameters(south), presets).Value).Value).ToArray();
    }

    private sealed record StepParameters(double South)
        : PlanParameters(3.0, 0.0);

    private sealed record StepPresets(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),
        };
    }

    /// <summary>One storey of the stepped test band: <c>U1</c> over <c>[0, 10] × [South, 8]</c>, <c>C1</c> over <c>[0, 10] × [8, 10]</c>.</summary>
    private sealed class StepGenerator : PlanGenerator<StepParameters, StepPresets>
    {
        public StepGenerator()
            : base(ToleranceSettings.Default)
        {
        }

        public override string Name => nameof(StepGenerator);

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

        protected override PlanLayout Layout(StepParameters parameters) => new(
            Polygon2.Rectangle(0.0, parameters.South, 10.0, 10.0),
            new[]
            {
                new PlannedZone(new ZoneId("U1"), "Unit", SpaceType.DwellingUnit, Polygon2.Rectangle(0.0, parameters.South, 10.0, 8.0)),
                new PlannedZone(new ZoneId("C1"), "Corridor", SpaceType.Corridor, Polygon2.Rectangle(0.0, 8.0, 10.0, 10.0)),
            });

        protected override IEnumerable<KeyValuePair<string, string>> Describe(StepParameters parameters) => Enumerable.Empty<KeyValuePair<string, string>>();
    }
}
  • [x] Step 3: Run the tests and see them pass on the unchanged code
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Buildings.TerraceTests"

Expected: Passed! - Failed: 0, Passed: 11, Skipped: 0, Total: 11, Duration: … - Lod.Core.Tests.dll (net8.0). These tests confirm behaviour that exists since S4.2 (the designation on the assembled stack, D-068), so there is no red phase: the finding is that no aggregator and no validation rule needs a change for storeys that step back. If a test fails, stop and report: a fix in Lod.Core needs its own test-first task and an ADR-013 update.

  • [x] Step 4: Build and test
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 204 core, 427 generators, and 1385 integration tests passed on net8.0; git status lists only ?? tests/Lod.Core.Tests/Buildings/TerraceTests.cs.

  • [x] Step 5: Commit
git add tests/Lod.Core.Tests/Buildings/TerraceTests.cs
git commit -m "test(aggregators): confirm the terraces of storeys that step back"

Task 2: ADR-015, plans per storey

Files: - Create: docs/decisions/ADR-015-plans-per-storey.md

Interfaces: - Consumes: D-095 (a generator may return a different plan per storey; this ADR decides the interface, the aggregators' treatment of storeys that differ, and the terrace designation), the finding of Task 1, ADR-006, ADR-013, ADR-002. - Produces: ADR-015 (Accepted by the controller under D-098, provisional pending the owner's reading): the options for returning several storeys (every generator returning a list, a storey index parameter, a sibling base class, a new pipeline type) and for carrying them in Grasshopper (a list output wired item-wise, a new Goo), the multiplier question; the decision: MultiStoreyPlanGenerator<TParameters, TPresets> with one PlanLayout per storey, inputs checked once, each storey built as PlanGenerator builds its plan, provenance Storey from 0, storey diagnostics prefixed Storey {k}:; no aggregator change (a table of the four methods with storeys that differ); a terrace is the part of a ceiling the storey above does not cover, an outdoor roof (D-068); multipliers above 1 allowed (they repeat a storey; the generator's building is the one with every multiplier 1); in Grasshopper a Plan list, wired item-wise into an aggregator's Floors with Multipliers unconnected, each storey previewed at its elevation. Its Decisions line lists D-018 to D-098; the close-out (Task 9) adds D-106, which records it in the decision log.

  • [x] Step 1: Create docs/decisions/ADR-015-plans-per-storey.md (full file)
# ADR-015: Plans per storey

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

Date: 2026-10-02

Decisions: D-018, D-068, D-070, D-071, D-072, D-074, D-085, D-095, D-098; applies [ADR-006](ADR-006-plan-generation-mechanism.md), [ADR-013](ADR-013-vertical-aggregation-methods.md), and [ADR-002](ADR-002-geometry.md)

## Context

Through S8.6 a plan generator returns one plan, a single storey; a building with several storeys repeats that plan through the multipliers of the floor aggregators, and the only vertical variation a user can make is to stack plans of different generator runs (different lengths, or plans moved by *Transform Plan*). `SYN-TYP-012` (stepped dwelling bands whose lower roofs become terraces, [ADR-006](ADR-006-plan-generation-mechanism.md)) has a plan that changes from storey to storey: storey `k + 1` is storey `k` stepped back by one terrace depth. D-095 lets a plan generator return one plan per storey and asks for this ADR: the interface, how the floor aggregators treat storeys that differ, and how a terrace is designated (the exposed part of a lower storey's roof is an outdoor roof, the covered part an interzone ceiling, D-068).

What already works, investigated before deciding:

- **The floor aggregators take storeys that differ.** `IFloorAggregator.Aggregate` takes a list of `FloorEntry(Floor, Multiplier)`, bottom to top, and the floors of the entries need not be equal: since S4 a narrower plan may stand on a wider one (a setback) and, since S4.2, a plan may overhang or be moved off the storey below ([ADR-013](ADR-013-vertical-aggregation-methods.md)).
- **Terraces are designated on the assembled stack.** `Storeys.Assemble` splits every ceiling by overlap with the zones of the storey above (D-068): the overlap is `Interzone` with the zone above, the rest faces `Outdoors`, which is a roof by `Totals.RoofArea` (a ceiling facing outdoors). A storey whose floor the storey below covers completely is supported, with no `UnsupportedStorey` warning (D-074).
- **Every method keeps that designation.** `Stack` keeps the pieces as they are; `StackedFloorZoneMultiplier` keeps ground, roof, and exposed pieces and turns the pieces between storeys adiabatic; `SingleZonePerFloorType` and `SingleZoneBuilding` keep ground, roof, and exposed pieces, keep the pieces between two of their zones `Interzone`, and turn the pieces between two storeys of one zone into internal mass (D-031). Validation compares the ground, roof, and exposed floor area of every method with `Stack` of the same entries.

A characterisation test (`TerraceTests` in `Lod.Core.Tests`) confirms this for three storeys of a band stepping back by 3 m with every aggregator: each lower storey's ceiling has a 30 m² terrace facing outdoors and its covered part between storeys (interzone, adiabatic, or internal mass by method), the roof area equals the ground area (100 m²), no floor is exposed or unsupported, and the building validates; it passes on the code of S8.6 without a change. So `Lod.Core`'s aggregation and validation need no change: what is missing is a generator that returns the storeys, and a way to carry them through Grasshopper.

## Options considered

### How a generator returns several storeys

1. **Every generator returns a list.** `PlanGenerator.Generate` returns one plan per storey for every family, a list of one for the families with one plan. One interface, but every generator, component, and test of the sixteen single-plan families changes for the sake of one family, and a list where one plan is the rule hides that those plans repeat.
2. **A storey index on the family's parameter record.** The family stays a `PlanGenerator`, and the user or component calls it once per storey. No new type, but the storeys of one building are no longer one call: the storey count, the stepping that relates them, and the check that the top storey keeps its dwellings belong to the set, and a component would need a list of storey indices as input.
3. **A sibling base class for generators of several storeys (chosen).** `MultiStoreyPlanGenerator<TParameters, TPresets>` in `Lod.Core.Plans` returns `Result<IReadOnlyList<IGeneratedPlan>>`, the plans bottom-up, one per storey. The subclass validates its parameters once and lays out every storey (`Layout` returns one `PlanLayout` per storey); the base does for each storey exactly what `PlanGenerator` does for its one plan: preset checks (D-061), surfaces, windows (D-026, ADR-011), and provenance. The two bases share that work in one internal helper, so no rule is duplicated. Each plan is an ordinary `IGeneratedPlan`, so every simplifier, *Transform Plan*, and every aggregator takes it unchanged.
4. **A new pipeline type for a stack of storeys** (`IGeneratedStack`) between generators and aggregators. It would carry the storey structure explicitly, but every simplifier and aggregator would need a second entry point, and it duplicates `FloorEntry`, which already lists storeys bottom-up.

### How Grasshopper carries the storeys

a. **A list output, wired item-wise (chosen).** The generator's *Plan* output is a list, bottom-up. Grasshopper runs a simplifier or *Transform Plan* once per item, so their *Floor* or *Plan* outputs are lists in the same order, and a floor aggregator's *Floors* input, a list, takes them as the storeys bottom-up with *Multipliers* left empty (all 1). No new parameter type, and nothing changes in the simplifier and aggregator components.

b. **A new Goo for a set of storeys.** Explicit, but a new parameter type that every simplifier would have to accept or unpack.

### Multipliers of plans that are storeys

- **Reject a multiplier above 1** for a floor of a multi-storey generator. The aggregators do not know where a floor comes from, so this would need a storey marker on floors, and it would forbid a legitimate building (a band stepping back every second storey).
- **Allow it (chosen).** A multiplier repeats its storey, as for any floor: entries `storey 0 × 2, storey 1 × 1, storey 2 × 2` are a band that steps back above its second and third storeys. The building differs from the generator's (it has more storeys, and its terraces lie higher), so the generator's building is the one with every multiplier 1, which is what the Grasshopper wiring gives by default. Exposure is never multiplied (D-046): with `StackedFloorZoneMultiplier` every storey with a terrace is split off as × 1. `TerraceTests` covers this case with every aggregator.

## Decision

### Interface

- `MultiStoreyPlanGenerator<TParameters, TPresets>` (`Lod.Core.Plans`), with the same constructor arguments, `Name`, `Validate`, and `Describe` as `PlanGenerator<TParameters, TPresets>`, and `Layout(TParameters)` returning `IReadOnlyList<PlanLayout>`, one layout per storey, bottom-up, at least one. `Generate(parameters, presets)` checks `FloorHeight`, `OrientationDegrees`, the family's parameters, and every preset slot once, before any storey is laid out, and fails with all of those errors at once; then it builds each storey as `PlanGenerator` builds its plan, and fails if any storey fails.
- Every storey has the same `FloorHeight` and `OrientationDegrees`, the parameters' values. A storey's plan, like every plan, lies at elevation 0; the floor aggregators place storey `k` at the sum of the floor heights below it (D-068).
- **Provenance:** the operation is the generator's `Name`; the parameters are the family's (`Describe`), `FloorHeight`, `OrientationDegrees`, `Presets` as for every plan, non-default limits (ADR-011), and `Storey`, the storey index from 0 at the bottom.
- **Diagnostics:** errors of the parameters and presets are reported once. A diagnostic of one storey (`WindowOmitted`, or an error of its surfaces) keeps its code, severity, and subject, the surface or zone ID within that storey's plan, and its message starts with `Storey {k}: `, so the remarks of several storeys can be told apart.
- `PlanGenerator` is unchanged in behaviour: its plans, diagnostics, and provenance stay identical.

### Floor aggregators

No aggregator changes; each takes the storeys as floor entries with multiplier 1, bottom to top:

| Method | Storeys that differ |
| --- | --- |
| `Stack` | every storey its own, `L{k}/…`; the covered part of a ceiling is `Interzone` with the zone above, its terrace `Outdoors` |
| `StackedFloorZoneMultiplier` | every entry has multiplier 1, so every storey is its own group and modelled at its elevation; the result equals `Stack` with every piece between storeys `Adiabatic` (D-075); terraces and roofs kept |
| `SingleZonePerFloorType` | one zone `E{k}` per storey (one floor entry each), one part per storey outline, walls rebuilt per outline; the covered part of a slab lies between `E{k}` and `E{k+1}` (`Interzone` pairs), its terrace `Outdoors`; no internal mass |
| `SingleZoneBuilding` | one zone `BUILDING` with one part per storey outline, walls rebuilt per outline; the covered part of every slab is internal mass, its terrace `Outdoors` |

A terrace is the part of storey `k`'s ceiling that storey `k + 1` does not cover: an outdoor roof piece (D-068). Its walls are the façades of storey `k + 1` above it, outdoor walls with the generator's windows (D-026, ADR-011). Validation is unchanged: `Building.RoofArea` sums every ceiling facing outdoors, terraces included, and is compared with `Stack` of the same entries, as are `Building.GroundArea` and `Building.ExposedFloorArea`. For a band whose every storey stands on the storey below, the roof area (terraces and top roof) equals the ground area, and the exposed floor area is zero.

### Grasshopper

- A generator component of several storeys has the same inputs as the other generator components ([ADR-006](ADR-006-plan-generation-mechanism.md): its count first, the defaults of its `Default` record, the preset inputs in record order, *Preview Location* last) and one output *Plan* with list access, the storeys bottom-up. `TypologyGeneratorComponent` gains the list output for such components; no other component changes.
- **Wiring:** *Plan* (list) → a simplifier (item-wise, a list of floors) → a floor aggregator's *Floors*, with *Multipliers* unconnected (all 1). *Transform Plan* in between moves and rotates every storey alike. Grafting the list, or wiring one storey alone, gives buildings of single storeys, which is the user's choice.
- **Preview:** each storey is previewed at its storey's elevation (the sum of the floor heights below it, in model units) plus the *Preview Location*, so the generator's preview shows the stepped band; the plans' data stay at elevation 0. Simplifier components preview their floors at elevation 0, so the floors of a band overlap there until they are aggregated.

## Consequences

- A family whose plan differs per storey is a `MultiStoreyPlanGenerator`; the sixteen families of one plan and their components are unchanged. `SteppedBandGenerator` (`SYN-TYP-012`, S8.7) is the first such generator.
- A building of storeys that differ is a list of floor entries with multiplier 1; every simplifier and floor aggregator works on it without change, and the terrace designation is that of D-068.
- Multipliers above 1 on such storeys are allowed and repeat a storey; they give another building than the generator's, and the Grasshopper wiring gives multiplier 1 unless the user connects multipliers.
- The narrowest storey of a stepped band is its top storey, so *Perimeter Core*'s precondition (D-087) is decided by the top storey; a band too narrow there fails that simplifier on the top storey only (`PlanTooNarrow`).
- The zone IDs of a storey's plan repeat on every storey (`U1` on each); the aggregators prefix them with `L{k}/` (D-068), so they are unique in the building, as for repeated plans.
- Changing the interface, the provenance key `Storey`, the diagnostic prefix, or the multiplier rule needs a decision-log entry and an update of this ADR.
  • [x] Step 2: Check the links and commit
git add docs/decisions/ADR-015-plans-per-storey.md
git grep -c "](ADR-" -- docs/decisions/ADR-015-plans-per-storey.md
git commit -m "docs(decisions): add adr-015 on plans per storey"

Expected: docs/decisions/ADR-015-plans-per-storey.md:4 (the lines that link to another ADR; git grep sees the file once it is added); the ADRs it links to, ADR-006, ADR-013, and ADR-002, exist on main, and no document links to ADR-015 yet (Task 7 adds the links; this plan names it without a link, so that no commit links to a file a later commit creates). The file has no code block.

Task 3: The base of plan generators with one plan per storey

Files: - Create: src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs, src/Lod.Core/Plans/PlanBuilder.cs (internal) - Modify: src/Lod.Core/Plans/PlanGenerator.cs (S2 file, last changed in S8.1) - Test: create tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs

Interfaces: - Consumes: PlanGenerator<TParameters, TPresets> (S2–S8.1: Generate, Validate, Layout, Describe, IsPositive, Tolerances, Limits, GeometryLimits), PlanParameters, PlanPresets, PlanLayout, PlannedZone, Result, Diagnostic (a record, so with { Message = … }), DiagnosticCodes, Provenance, ExampleResidentialPresets. - Produces: public abstract class MultiStoreyPlanGenerator<TParameters, TPresets> where TParameters : PlanParameters where TPresets : PlanPresets with public const string StoreyKey = "Storey", the constructors (ToleranceSettings) and (ToleranceSettings, GeometryLimits), public abstract string Name, protected ToleranceSettings Tolerances, protected GeometryLimits Limits, public Result<IReadOnlyList<IGeneratedPlan>> Generate(TParameters, TPresets), protected abstract IEnumerable<Diagnostic> Validate(TParameters), protected abstract IReadOnlyList<PlanLayout> Layout(TParameters) (bottom-up, at least one), protected abstract IEnumerable<KeyValuePair<string, string>> Describe(TParameters), and protected static bool IsPositive(double). Generate checks FloorHeight, OrientationDegrees, Validate, and every preset slot once and returns all errors before Layout is called; then builds every storey with PlanBuilder.Build, appending Storey=k to the described parameters, prefixes every storey diagnostic with Storey {k}: (code, severity, and subject unchanged), and fails if any storey has an error. internal static class PlanBuilder with CheckInputs(PlanParameters, IEnumerable<Diagnostic>, PlanPresets), Build(string name, PlanLayout, PlanParameters, PlanPresets, IEnumerable<KeyValuePair<string, string>>, GeometryLimits, ToleranceSettings), and IsPositive(double): the code of S8.1's PlanGenerator.Generate (common checks, preset slots, zones, surfaces, windows, provenance with the non-default limits), moved unchanged. PlanGenerator.Generate calls PlanBuilder.CheckInputs and PlanBuilder.Build; its public and protected members and its outputs are unchanged.

  • [x] Step 1: Write the failing tests — create tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs (full file)

6 tests on a private band generator of a 4 m unit U1 and a hall C1, 1 m shallower on every storey above the first: the storeys bottom-up as ordinary plans at elevation 0 with the parameters' floor height and orientation; provenance (the generator's name, the family's parameters, FloorHeight, OrientationDegrees, Presets, Storey); storey 0 equal to the plan of a PlanGenerator of the same layout, provenance aside from Storey=0; parameter and preset errors reported once and before any layout (Layouts stays 0); with a 0.5 m hall, the four WindowOmitted remarks keep their code, severity, and subject (a wall of that storey's plan) and start with Storey 0: or Storey 1:; a storey whose zones overlap fails the generation, every error starting with Storey 1:.

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;

/// <summary>The base of generators that return one plan per storey (D-095, ADR-015).</summary>
public sealed class MultiStoreyPlanGeneratorTests
{
    private readonly BandGenerator _generator = new();

    [Fact]
    public void EveryStoreyIsOnePlanBottomUp()
    {
        IReadOnlyList<IGeneratedPlan> plans = _generator.Generate(new BandParameters(3), Presets()).Value;

        Assert.Equal(3, plans.Count);
        Assert.Equal(new[] { 32.0, 24.0, 16.0 }, plans.Select(p => p.Footprint.Area));
        Assert.All(plans, p => Assert.Equal(new[] { "U1", "C1" }, p.Zones.Select(z => z.Id.Value)));
        Assert.Equal(new[] { 16.0, 12.0, 8.0 }, plans.Select(p => p.Zones[0].FloorArea));
        Assert.All(plans, p => Assert.Equal(3.0, p.FloorHeight));
        Assert.All(plans, p => Assert.Equal(12.5, p.OrientationDegrees));
        Assert.All(plans, p => Assert.All(p.Zones, z => Assert.Equal(0.0, z.Parts[0].Elevation)));
    }

    [Fact]
    public void ProvenanceRecordsTheGeneratorAndTheStoreyIndex()
    {
        IReadOnlyList<IGeneratedPlan> plans = _generator.Generate(new BandParameters(2), Presets()).Value;

        Assert.All(plans, p => Assert.Equal(nameof(BandGenerator), p.Provenance.Operation));
        Assert.Equal(
            new[] { "FloorHeight=3", "HallWidth=4", "OrientationDegrees=12.5", "Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor", "Storey=0", "Storeys=2" },
            plans[0].Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
        Assert.Equal("1", plans[1].Provenance.Parameters.Single(p => p.Key == "Storey").Value);
    }

    [Fact]
    public void AStoreyIsBuiltExactlyAsAPlanGeneratorBuildsItsPlan()
    {
        IGeneratedPlan storey = _generator.Generate(new BandParameters(2), Presets()).Value[0];
        IGeneratedPlan plan = new GroundStoreyGenerator().Generate(new BandParameters(2), Presets()).Value;

        Assert.Equal(TextReport.Describe(plan), TextReport.Describe(storey));
        Assert.Equal(
            plan.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}").Append("Storey=0").OrderBy(p => p, System.StringComparer.Ordinal),
            storey.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void ParameterAndPresetErrorsAreReportedOnceBeforeAnyStoreyIsLaidOut()
    {
        var generator = new BandGenerator();

        Result<IReadOnlyList<IGeneratedPlan>> result = generator.Generate(new BandParameters(0, FloorHeight: -1.0), Presets(ExampleResidentialPresets.Stair));

        Assert.False(result.IsSuccess);
        Assert.Equal(
            new[] { DiagnosticCodes.InvalidParameter, DiagnosticCodes.InvalidParameter, DiagnosticCodes.PresetSpaceTypeMismatch },
            result.Diagnostics.Select(d => d.Code));
        Assert.StartsWith("FloorHeight must be positive", result.Diagnostics[0].Message);
        Assert.Equal("Storeys must be at least 1, got 0.", result.Diagnostics[1].Message);
        Assert.Equal(0, generator.Layouts);
    }

    [Fact]
    public void ADiagnosticOfOneStoreyNamesItsStorey()
    {
        // A 0.5 m hall: the hall's south and north walls of every storey are below ADR-011's 1 m minimum and get no window.
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(new BandParameters(2, HallWidth: 0.5), Presets());

        Assert.True(result.IsSuccess);
        Diagnostic[] omitted = result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).ToArray();
        Assert.Equal(4, omitted.Length);
        Assert.All(omitted, d => Assert.Equal(DiagnosticSeverity.Info, d.Severity));
        Assert.Equal(new[] { "Storey 0: ", "Storey 0: ", "Storey 1: ", "Storey 1: " }, omitted.Select(d => d.Message.Substring(0, 10)));
        IGeneratedPlan[] plans = result.Value.ToArray();
        Assert.All(omitted.Take(2), d => Assert.Contains(plans[0].Surfaces, s => s.Id.Value == d.Subject));
        Assert.Equal(
            "Storey 1: 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[2].Message);
    }

    [Fact]
    public void AStoreyWhoseZonesDoNotTileFailsTheGenerationAndIsNamed()
    {
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(new BandParameters(3, OverlapStorey: 1), Presets());

        Assert.False(result.IsSuccess);
        Assert.NotEmpty(result.Diagnostics);
        Assert.All(result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), d => Assert.StartsWith("Storey 1: ", d.Message));
    }

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

    /// <param name="Storeys">Number of storeys, at least 1.</param>
    /// <param name="FloorHeight">Floor-to-floor height, m.</param>
    /// <param name="HallWidth">Width of the hall along x, m.</param>
    /// <param name="OverlapStorey">A storey whose hall overlaps its unit by 0.5 m, or −1 for none.</param>
    private sealed record BandParameters(int Storeys, double FloorHeight = 3.0, double HallWidth = 4.0, int OverlapStorey = -1)
        : PlanParameters(FloorHeight, 12.5);

    private sealed record BandPresets(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 static PlanLayout Storey(BandParameters parameters, int k)
    {
        double south = k;
        double hallWest = k == parameters.OverlapStorey ? 3.5 : 4.0;
        return new PlanLayout(
            Polygon2.Rectangle(0.0, south, 4.0 + parameters.HallWidth, 4.0),
            new[]
            {
                new PlannedZone(new ZoneId("U1"), "Unit", SpaceType.DwellingUnit, Polygon2.Rectangle(0.0, south, 4.0, 4.0)),
                new PlannedZone(new ZoneId("C1"), "Hall", SpaceType.Corridor, Polygon2.Rectangle(hallWest, south, 4.0 + parameters.HallWidth, 4.0)),
            });
    }

    private static IEnumerable<KeyValuePair<string, string>> Described(BandParameters parameters) => new[]
    {
        new KeyValuePair<string, string>(nameof(BandParameters.Storeys), parameters.Storeys.ToString(System.Globalization.CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(BandParameters.HallWidth), Provenance.Format(parameters.HallWidth)),
    };

    /// <summary>A band of a 4 m unit <c>U1</c> and a hall <c>C1</c> east of it, 4 m deep on storey 0 and 1 m shallower on every storey above.</summary>
    private sealed class BandGenerator : MultiStoreyPlanGenerator<BandParameters, BandPresets>
    {
        public BandGenerator()
            : base(ToleranceSettings.Default)
        {
        }

        public int Layouts { get; private set; }

        public override string Name => nameof(BandGenerator);

        protected override IEnumerable<Diagnostic> Validate(BandParameters parameters)
        {
            if (parameters.Storeys < 1)
            {
                yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"Storeys must be at least 1, got {parameters.Storeys}.");
            }
        }

        protected override IReadOnlyList<PlanLayout> Layout(BandParameters parameters)
        {
            Layouts++;
            return Enumerable.Range(0, parameters.Storeys).Select(k => Storey(parameters, k)).ToArray();
        }

        protected override IEnumerable<KeyValuePair<string, string>> Describe(BandParameters parameters) => Described(parameters);
    }

    /// <summary>Storey 0 of <see cref="BandGenerator"/> as a plan generator of one plan.</summary>
    private sealed class GroundStoreyGenerator : PlanGenerator<BandParameters, BandPresets>
    {
        public GroundStoreyGenerator()
            : base(ToleranceSettings.Default)
        {
        }

        public override string Name => nameof(BandGenerator);

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

        protected override PlanLayout Layout(BandParameters parameters) => Storey(parameters, 0);

        protected override IEnumerable<KeyValuePair<string, string>> Describe(BandParameters parameters) => Described(parameters);
    }
}
  • [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"

Expected: the build fails because the base class does not exist yet, with these errors for the net8.0 target (in this order):

tests\Lod.Core.Tests\Plans\MultiStoreyPlanGeneratorTests.cs(138,42): error CS0246: The type or namespace name 'MultiStoreyPlanGenerator<,>' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 3: Implement — create src/Lod.Core/Plans/PlanBuilder.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>
/// The work shared by <see cref="PlanGenerator{TParameters, TPresets}"/> and <see cref="MultiStoreyPlanGenerator{TParameters, TPresets}"/>: the
/// checks of the common parameters and of every preset slot (D-061), and building one plan from a layout: zones with their presets, surfaces,
/// centred windows within the minimum wall and window geometry (D-026, ADR-011), and provenance.
/// </summary>
internal static class PlanBuilder
{
    /// <summary>Checks <c>FloorHeight</c> and <c>OrientationDegrees</c>, then adds the family's errors and the preset slot errors, in that order.</summary>
    /// <param name="parameters">Parameters.</param>
    /// <param name="familyErrors">Errors of the family's own parameters.</param>
    /// <param name="presets">Presets.</param>
    /// <returns>Every error, empty when the inputs are valid.</returns>
    public static List<Diagnostic> CheckInputs(PlanParameters parameters, IEnumerable<Diagnostic> familyErrors, PlanPresets 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(familyErrors);
        diagnostics.AddRange(CheckSlots(presets.Slots));
        return diagnostics;
    }

    /// <summary>Builds one plan from a layout of valid parameters.</summary>
    /// <param name="name">Operation name recorded in provenance.</param>
    /// <param name="layout">Footprint and zones.</param>
    /// <param name="parameters">Validated parameters.</param>
    /// <param name="presets">Checked presets.</param>
    /// <param name="described">The family's parameters for provenance, and any further keys of the caller.</param>
    /// <param name="limits">Minimum wall and window geometry (ADR-011); recorded in provenance when not the default.</param>
    /// <param name="tolerances">Tolerances.</param>
    /// <returns>The plan, or errors.</returns>
    public static Result<IGeneratedPlan> Build(
        string name,
        PlanLayout layout,
        PlanParameters parameters,
        PlanPresets presets,
        IEnumerable<KeyValuePair<string, string>> described,
        GeometryLimits limits,
        ToleranceSettings tolerances)
    {
        var diagnostics = new List<Diagnostic>();
        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 = described
            .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(limits));
        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>Whether a value is finite and positive.</summary>
    /// <param name="value">The value.</param>
    /// <returns><c>true</c> when positive and finite.</returns>
    public static bool IsPositive(double value) => value > 0 && !double.IsInfinity(value);

    private static IEnumerable<KeyValuePair<string, string>> DescribeLimits(GeometryLimits limits) => 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(PlanPresets presets, SpaceType spaceType) =>
        presets.Slots.FirstOrDefault(s => s.SpaceType == spaceType)?.Preset;
}
  • [x] Step 4: Implement — replace src/Lod.Core/Plans/PlanGenerator.cs (full file)

Generate now calls PlanBuilder; the private helpers that moved there are removed, and the class's members, documentation, and behaviour are otherwise unchanged.

using System.Collections.Generic;
using Lod.Core.Common;
using Lod.Core.Geometry;
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)
    {
        List<Diagnostic> errors = PlanBuilder.CheckInputs(parameters, Validate(parameters), presets);
        if (errors.Count > 0)
        {
            return Result.Failure<IGeneratedPlan>(errors);
        }

        return PlanBuilder.Build(Name, Layout(parameters), parameters, presets, Describe(parameters), Limits, Tolerances);
    }

    /// <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);

    /// <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) => PlanBuilder.IsPositive(value);
}
  • [x] Step 5: Implement — create src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;

namespace Lod.Core.Plans;

/// <summary>
/// Base class of plan generators whose plan differs per storey (D-095, ADR-015). The subclass validates its parameters once and lays out every
/// storey; this class checks the common parameters and the presets once, then builds every storey exactly as
/// <see cref="PlanGenerator{TParameters, TPresets}"/> builds its plan (presets, surfaces, centred windows within the minimum wall and window geometry
/// of ADR-011, provenance) and returns the plans bottom-up. Each plan records its storey index as the provenance parameter <c>Storey</c>, from 0 at
/// the bottom. The floor aggregators stack the plans as floor entries with multiplier 1 (ADR-015). 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 MultiStoreyPlanGenerator<TParameters, TPresets>
    where TParameters : PlanParameters
    where TPresets : PlanPresets
{
    /// <summary>Provenance key of the storey index.</summary>
    public const string StoreyKey = "Storey";

    /// <summary>Initialises the generator with the default minimum wall and window geometry (ADR-011).</summary>
    /// <param name="tolerances">Tolerances.</param>
    protected MultiStoreyPlanGenerator(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 MultiStoreyPlanGenerator(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 the detailed plan of every storey.</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>One plan per storey, bottom-up, or errors. A diagnostic of one storey has a message starting with <c>Storey {k}: </c>.</returns>
    public Result<IReadOnlyList<IGeneratedPlan>> Generate(TParameters parameters, TPresets presets)
    {
        List<Diagnostic> errors = PlanBuilder.CheckInputs(parameters, Validate(parameters), presets);
        if (errors.Count > 0)
        {
            return Result.Failure<IReadOnlyList<IGeneratedPlan>>(errors);
        }

        var plans = new List<IGeneratedPlan>();
        var diagnostics = new List<Diagnostic>();
        IReadOnlyList<PlanLayout> layouts = Layout(parameters);
        for (int k = 0; k < layouts.Count; k++)
        {
            IEnumerable<KeyValuePair<string, string>> described = Describe(parameters)
                .Append(new KeyValuePair<string, string>(StoreyKey, k.ToString(CultureInfo.InvariantCulture)));
            Result<IGeneratedPlan> storey = PlanBuilder.Build(Name, layouts[k], parameters, presets, described, Limits, Tolerances);
            string prefix = $"Storey {k}: ";
            diagnostics.AddRange(storey.Diagnostics.Select(d => d with { Message = prefix + d.Message }));
            if (storey.IsSuccess)
            {
                plans.Add(storey.Value);
            }
        }

        return diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error)
            ? Result.Failure<IReadOnlyList<IGeneratedPlan>>(diagnostics)
            : Result.Success<IReadOnlyList<IGeneratedPlan>>(plans.AsReadOnly(), 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 every storey for valid parameters.</summary>
    /// <param name="parameters">Validated parameters.</param>
    /// <returns>Footprint and zones of every storey, bottom-up; at least one.</returns>
    protected abstract IReadOnlyList<PlanLayout> Layout(TParameters parameters);

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

    /// <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) => PlanBuilder.IsPositive(value);
}
  • [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"

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

  • [x] Step 7: Build, test, and check that no snapshot changed
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git diff --name-status v0.8.5 -- "tests/*/Snapshots/*"
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 210 core, 427 generators, and 1385 integration tests passed on net8.0: every generator test and snapshot of the sixteen existing families passes on the moved code; git diff prints nothing (the twenty-four snapshots are byte-identical); git status lists M src/Lod.Core/Plans/PlanGenerator.cs and the three new files of this task, and no *.received.txt.

  • [x] Step 8: Commit
git add src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs src/Lod.Core/Plans/PlanBuilder.cs src/Lod.Core/Plans/PlanGenerator.cs tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs
git commit -m "feature(core): add the base of plan generators with one plan per storey"
  • [x] Step 9: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/per-storey-plans
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/per-storey-plans
git branch -d feature/per-storey-plans

Expected: VERIFY PASSED, with 210 core, 427 generators, and 1385 integration tests passed on net8.0. Do not push (D-098).


Slice B — feature/stepped-band

The family, test-first: the generator tests first (they fail to build), the generator, the snapshot of the three canonical storeys reviewed and accepted; then the integration matrix, which passes at once; if a case of the matrix fails, stop and report: a pipeline change needs its own test-first task.

Task 4: Stepped dwelling bands with exposed terraces (SYN-TYP-012)

Files: - Create: src/Lod.Generators/SteppedBand/SteppedBandGenerator.cs - Test: create tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt

Interfaces: - Consumes: MultiStoreyPlanGenerator<TParameters, TPresets> (Task 3: Generate, Validate, Layout, Describe, IsPositive, StoreyKey), PlanParameters, PlanPresets.Slots, PresetSlot, PlanLayout, PlannedZone(ZoneId, string, SpaceType, Polygon2), Polygon2.Rectangle, Provenance.Format, Diagnostic.Error, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch, WindowOmitted; SpaceType.DwellingUnit, Corridor, and Stair, ExampleResidentialPresets.DwellingUnit, Corridor, and Stair; test support PlanAssertions (TilesTheFootprint, HasOneCentredWindowPerOutdoorWall, Neighbours), Snapshot.Match, TextReport.Describe, PolygonOps(ToleranceSettings.Default).IntersectionArea, Orientation.Bin. - Produces: public sealed record SteppedBandParameters(int StoreyCount, double Length, double BaseDepth, double StepDepth, double CorridorWidth, double UnitWidth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (3, 36, 14, 3, 2, 6, 4, 3, 0); public sealed record SteppedBandPresets(ProgramPreset DwellingUnit, ProgramPreset Corridor, ProgramPreset Stair) : PlanPresets (slots in that order); public sealed class SteppedBandGenerator(ToleranceSettings) : MultiStoreyPlanGenerator<SteppedBandParameters, SteppedBandPresets>, Name = SteppedBandGenerator, public const int MinimumStoreys = 2, public static int DwellingsPerStorey(SteppedBandParameters) = max(1, round half away from zero of (Length − StairWidth) / UnitWidth). Storey k (from 0, bottom-up): footprint [0, Length] × [k·StepDepth, BaseDepth + CorridorWidth]; ST (Stair, Stair) [0, StairWidth] × [k·StepDepth, BaseDepth]; CO (Corridor, Corridor) [0, Length] × [BaseDepth, BaseDepth + CorridorWidth]; U1…Un (Unit i, DwellingUnit) dividing [StairWidth, Length] × [k·StepDepth, BaseDepth] evenly, west to east, the last ending exactly at Length. Errors InvalidParameter, all at once: fewer than 2 storeys (StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.), each length that is not positive and finite ({name} must be positive, got {value}.); and, only when every length is positive, Length not above StairWidth (Length 4 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002)., a check ADR-006 does not list, recorded in Task 7 and D-106) and, when the count is valid, BaseDepth − (StoreyCount − 1)·StepDepth ≤ 0 (BaseDepth 12 m leaves no dwelling depth on the top storey: its 4 steps of StepDepth 3 m take 12 m; SYN-TYP-012 needs dwellings on every storey, so reduce StoreyCount or StepDepth, or increase BaseDepth.); no upper bound; provenance keys StoreyCount, Length, BaseDepth, StepDepth, CorridorWidth, UnitWidth, StairWidth, and Storey (Task 3). By default three storeys of 576, 468, and 360 m² (1404 m²), 7 zones each: ST 56, 44, and 32 m², CO 72 m², five dwellings of 6.4 m × 14, 11, and 8 m (89.6, 70.4, and 51.2 m²); every storey stands on the one below and leaves it a 36 m × 3 m terrace (108 m²); 11 windows per storey (33 in all) with 70.8, 67.2, and 63.6 m² of glazing (201.6 m²), including the south walls of ST and U1 to U5 that face the terraces; no diagnostic.

  • [x] Step 1: Create the branch
git switch -c feature/stepped-band main
  • [x] Step 2: Write the failing tests — create tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs (full file)

36 tests: the ADR-006 defaults; the canonical band (three storeys, zone IDs, names, space types, rectangles, areas); storey k stepped back k times (storeys 0 to 2); tiling of every storey for 2, 3, and 5 storeys; the dwellings dividing the band evenly for four lengths; every dwelling adjoining the corridor along the rear over 6.4 m, on every storey; every storey covered by the storey below, which keeps a 108 m² terrace (2 to 4 storeys); the windows (11 per storey, the walls facing the terraces included) and no diagnostic; a 0.8 m corridor giving six WindowOmitted remarks, two per storey, named by their storey; eleven invalid parameter sets, each a single error; a storey count of 1; the top storey without dwelling depth; the stair without room for dwellings; preset slots and swapped presets reported once; determinism; provenance with the storey; the canonical snapshot.

using System.Collections.Generic;
using System.Linq;
using System.Text;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.SteppedBand;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Generators.Tests.SteppedBand;

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

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

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(
            new SteppedBandParameters(
                StoreyCount: 3,
                Length: 36.0,
                BaseDepth: 14.0,
                StepDepth: 3.0,
                CorridorWidth: 2.0,
                UnitWidth: 6.0,
                StairWidth: 4.0,
                FloorHeight: 3.0,
                OrientationDegrees: 0.0),
            SteppedBandParameters.Default);
    }

    [Fact]
    public void CanonicalBandHasThreeStoreysOfAStairACorridorAndFiveDwellings()
    {
        IReadOnlyList<IGeneratedPlan> plans = Generate(SteppedBandParameters.Default);

        Assert.Equal(3, plans.Count);
        Assert.All(plans, p => Assert.Equal(new[] { "ST", "CO", "U1", "U2", "U3", "U4", "U5" }, p.Zones.Select(z => z.Id.Value)));
        Assert.All(plans, p => Assert.Equal(new[] { "Stair", "Corridor", "Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5" }, p.Zones.Select(z => z.Name)));
        Assert.All(plans, p => Assert.Equal(
            new[] { SpaceType.Stair, SpaceType.Corridor, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit },
            p.Zones.Select(z => z.SpaceType)));
        Assert.Equal(new[] { 576.0, 468.0, 360.0 }, plans.Select(p => System.Math.Round(p.Footprint.Area, 9)));
        Assert.Equal(new[] { 89.6, 70.4, 51.2 }, plans.Select(p => System.Math.Round(Zone(p, "U3").FloorArea, 9)));
        Assert.Equal(new[] { 56.0, 44.0, 32.0 }, plans.Select(p => System.Math.Round(Zone(p, "ST").FloorArea, 9)));
        Assert.All(plans, p => Assert.Equal(72.0, Zone(p, "CO").FloorArea, 9));
        Assert.All(plans, p => Assert.All(p.Zones, z => Assert.Equal(3.0, z.Parts[0].Height)));
        Assert.All(plans, p => Assert.All(p.Zones, z => Assert.Equal(0.0, z.Parts[0].Elevation)));
        Assert.All(plans, p => Assert.Empty(p.Footprint.Holes));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(2)]
    public void StoreyKIsTheBandSteppedBackKTimes(int k)
    {
        IGeneratedPlan plan = Generate(SteppedBandParameters.Default)[k];
        double south = 3.0 * k;

        AssertRectangle(0.0, south, 36.0, 16.0, plan.Footprint.Outer.ToArray());
        AssertRectangle(0.0, south, 4.0, 14.0, Outer(plan, "ST"));
        AssertRectangle(0.0, 14.0, 36.0, 16.0, Outer(plan, "CO"));
        for (int i = 0; i < 5; i++)
        {
            AssertRectangle(4.0 + (6.4 * i), south, i == 4 ? 36.0 : 4.0 + (6.4 * (i + 1)), 14.0, Outer(plan, $"U{i + 1}"));
        }
    }

    [Theory]
    [InlineData(2)]
    [InlineData(3)]
    [InlineData(5)]
    public void EveryStoreyIsTiledByItsZones(int storeys)
    {
        IReadOnlyList<IGeneratedPlan> plans = Generate(SteppedBandParameters.Default with { StoreyCount = storeys, BaseDepth = 16.0 });

        Assert.Equal(storeys, plans.Count);
        Assert.All(plans, PlanAssertions.TilesTheFootprint);
    }

    [Theory]
    [InlineData(10.0, 1)]
    [InlineData(36.0, 5)]
    [InlineData(37.0, 6)]
    [InlineData(64.0, 10)]
    public void TheDwellingsDivideTheBandEvenly(double length, int dwellings)
    {
        // (Length − StairWidth) / UnitWidth rounded half away from zero, at least 1: 6/6 → 1, 32/6 → 5, 33/6 = 5.5 → 6, 60/6 → 10.
        SteppedBandParameters parameters = SteppedBandParameters.Default with { Length = length };

        IReadOnlyList<IGeneratedPlan> plans = Generate(parameters);

        Assert.Equal(dwellings, SteppedBandGenerator.DwellingsPerStorey(parameters));
        Assert.All(plans, p => Assert.Equal(dwellings, p.Zones.Count(z => z.SpaceType == SpaceType.DwellingUnit)));
        Assert.All(plans, p => Assert.All(p.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal((length - 4.0) / dwellings, z.Parts[0].Footprint.Outer.Max(v => v.X) - z.Parts[0].Footprint.Outer.Min(v => v.X), 9)));
    }

    [Fact]
    public void EveryDwellingAdjoinsTheCorridorAlongTheRearOnEveryStorey()
    {
        IReadOnlyList<IGeneratedPlan> plans = Generate(SteppedBandParameters.Default);

        foreach (IGeneratedPlan plan in plans)
        {
            Assert.Equal(new[] { "CO", "U1" }, PlanAssertions.Neighbours(plan, "ST"));
            Assert.Equal(new[] { "CO", "ST", "U2" }, PlanAssertions.Neighbours(plan, "U1"));
            Assert.Equal(new[] { "CO", "U2", "U4" }, PlanAssertions.Neighbours(plan, "U3"));
            Assert.Equal(new[] { "CO", "U4" }, PlanAssertions.Neighbours(plan, "U5"));
            Assert.Equal(new[] { "ST", "U1", "U2", "U3", "U4", "U5" }, PlanAssertions.Neighbours(plan, "CO"));
            foreach (string unit in new[] { "U1", "U2", "U3", "U4", "U5" })
            {
                WallSurface rear = plan.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == unit && w.AdjacentZone?.Value == "CO");
                Assert.Equal(6.4, rear.Length, 9);
            }
        }
    }

    [Theory]
    [InlineData(2)]
    [InlineData(3)]
    [InlineData(4)]
    public void EveryStoreyIsCoveredByTheStoreyBelowWhichKeepsATerrace(int storeys)
    {
        // Length × StepDepth of every lower storey is not covered by the storey above: the terrace of 36 m × 3 m.
        IReadOnlyList<IGeneratedPlan> plans = Generate(SteppedBandParameters.Default with { StoreyCount = storeys, BaseDepth = 14.0 });
        var ops = new PolygonOps(ToleranceSettings.Default);

        for (int k = 1; k < plans.Count; k++)
        {
            Polygon2 upper = plans[k].Footprint;
            Polygon2 lower = plans[k - 1].Footprint;
            Assert.Equal(upper.Area, ops.IntersectionArea(upper, lower), 9);
            Assert.Equal(36.0 * 3.0, lower.Area - upper.Area, 9);
        }
    }

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

        Assert.Empty(result.Diagnostics);
        Assert.All(result.Value, p => PlanAssertions.HasOneCentredWindowPerOutdoorWall(p, Examples));
        Assert.All(result.Value, p => Assert.Equal(11, p.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count)));
        for (int k = 1; k < 3; k++)
        {
            // The south façade of storey k stands on the terrace of storey k − 1 at y = 3k: the stair's and every dwelling's south wall.
            WallSurface[] terraceFacing = result.Value[k].Surfaces.OfType<WallSurface>()
                .Where(w => w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.South)
                .ToArray();
            Assert.Equal(new[] { "ST", "U1", "U2", "U3", "U4", "U5" }, terraceFacing.Select(w => w.Zone.Value).OrderBy(z => z, System.StringComparer.Ordinal));
            Assert.All(terraceFacing, w => Assert.True(w.Start.Y == 3.0 * k && w.End.Y == 3.0 * k));
            Assert.All(terraceFacing, w => Assert.Single(w.Windows));
        }
    }

    [Fact]
    public void FacadesBelowTheMinimumWallLengthGetNoWindowAndNameTheirStorey()
    {
        // 0.8 m corridor: its 0.8 m west and east ends are below ADR-011's 1 m minimum on every storey.
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(SteppedBandParameters.Default with { CorridorWidth = 0.8 }, Examples);

        Assert.True(result.IsSuccess);
        Diagnostic[] omitted = result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).ToArray();
        Assert.Equal(6, omitted.Length);
        Assert.All(omitted, d => Assert.StartsWith("CO/", d.Subject));
        Assert.Equal(new[] { 0, 0, 1, 1, 2, 2 }, omitted.Select(d => int.Parse(d.Message.Substring(7, 1), System.Globalization.CultureInfo.InvariantCulture)));
    }

    [Theory]
    [InlineData(1, 36.0, 14.0, 3.0, 2.0, 6.0, 4.0, "StoreyCount")]
    [InlineData(0, 36.0, 14.0, 3.0, 2.0, 6.0, 4.0, "StoreyCount")]
    [InlineData(3, 0.0, 14.0, 3.0, 2.0, 6.0, 4.0, "Length")]
    [InlineData(3, 36.0, -1.0, 3.0, 2.0, 6.0, 4.0, "BaseDepth")]
    [InlineData(3, 36.0, 14.0, 0.0, 2.0, 6.0, 4.0, "StepDepth")]
    [InlineData(3, 36.0, 14.0, 3.0, double.NaN, 6.0, 4.0, "CorridorWidth")]
    [InlineData(3, 36.0, 14.0, 3.0, 2.0, 0.0, 4.0, "UnitWidth")]
    [InlineData(3, 36.0, 14.0, 3.0, 2.0, 6.0, double.PositiveInfinity, "StairWidth")]
    [InlineData(3, 4.0, 14.0, 3.0, 2.0, 6.0, 4.0, "Length")]
    [InlineData(5, 36.0, 12.0, 3.0, 2.0, 6.0, 4.0, "BaseDepth")]
    [InlineData(3, 36.0, 5.0, 3.0, 2.0, 6.0, 4.0, "BaseDepth")]
    public void InvalidParametersAreErrors(int storeys, double length, double baseDepth, double stepDepth, double corridorWidth, double unitWidth, double stairWidth, string name)
    {
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(
            new SteppedBandParameters(storeys, length, baseDepth, stepDepth, corridorWidth, unitWidth, 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 OneStoreyIsNotAStep()
    {
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(SteppedBandParameters.Default with { StoreyCount = 1 }, Examples);

        Assert.Equal("StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void TheTopStoreyMustKeepItsDwellings()
    {
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(SteppedBandParameters.Default with { StoreyCount = 5, BaseDepth = 12.0 }, Examples);

        Assert.Equal(
            "BaseDepth 12 m leaves no dwelling depth on the top storey: its 4 steps of StepDepth 3 m take 12 m; SYN-TYP-012 needs dwellings on every storey, so reduce StoreyCount or StepDepth, or increase BaseDepth.",
            Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void TheStairMustLeaveRoomForDwellings()
    {
        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(SteppedBandParameters.Default with { Length = 4.0 }, Examples);

        Assert.Equal("Length 4 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002).", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void PresetsHaveOneSlotPerSpaceTypeAndAreCheckedOnce()
    {
        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));
        var swapped = new SteppedBandPresets(ExampleResidentialPresets.Stair, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.DwellingUnit);

        Result<IReadOnlyList<IGeneratedPlan>> result = _generator.Generate(SteppedBandParameters.Default, swapped);

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

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

    [Fact]
    public void ProvenanceRecordsParametersPresetsAndTheStorey()
    {
        IReadOnlyList<IGeneratedPlan> plans = Generate(SteppedBandParameters.Default);

        Assert.All(plans, p => Assert.Equal(nameof(SteppedBandGenerator), p.Provenance.Operation));
        Assert.Equal(
            new[]
            {
                "BaseDepth=14", "CorridorWidth=2", "FloorHeight=3", "Length=36", "OrientationDegrees=0",
                "Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor;Stair:Example Stair", "StairWidth=4", "StepDepth=3", "Storey=0",
                "StoreyCount=3", "UnitWidth=6",
            },
            plans[0].Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
        Assert.Equal(new[] { "0", "1", "2" }, plans.Select(p => p.Provenance.Parameters.Single(q => q.Key == "Storey").Value));
    }

    [Fact]
    public void CanonicalStoreysSnapshot()
    {
        Snapshot.Match(Describe(Generate(SteppedBandParameters.Default)), "stepped-band-canonical");
    }

    /// <summary>The text report of every storey, bottom-up, each after a line <c>storey k</c>.</summary>
    private static string Describe(IReadOnlyList<IGeneratedPlan> plans)
    {
        var builder = new StringBuilder();
        for (int k = 0; k < plans.Count; k++)
        {
            builder.Append("storey ").Append(k).Append('\n').Append(TextReport.Describe(plans[k]));
        }

        return builder.ToString();
    }

    private static void AssertRectangle(double minX, double minY, double maxX, double maxY, Point2[] actual)
    {
        Point2[] expected = Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
        Assert.Equal(expected.Length, actual.Length);
        for (int i = 0; i < expected.Length; i++)
        {
            Assert.Equal(expected[i].X, actual[i].X, 9);
            Assert.Equal(expected[i].Y, actual[i].Y, 9);
        }
    }

    private static Zone Zone(IGeneratedPlan plan, string id) => plan.Zones.Single(z => z.Id.Value == id);

    private static Point2[] Outer(IGeneratedPlan plan, string zone) => Zone(plan, zone).Parts[0].Footprint.Outer.ToArray();

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

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

tests\Lod.Generators.Tests\SteppedBand\SteppedBandGeneratorTests.cs(8,22): error CS0234: The type or namespace name 'SteppedBand' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\SteppedBand\SteppedBandGeneratorTests.cs(297,52): error CS0246: The type or namespace name 'SteppedBandParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\SteppedBand\SteppedBandGeneratorTests.cs(17,28): error CS0246: The type or namespace name 'SteppedBandPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\SteppedBand\SteppedBandGeneratorTests.cs(19,22): error CS0246: The type or namespace name 'SteppedBandGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 4: Implement — create src/Lod.Generators/SteppedBand/SteppedBandGenerator.cs (full file)
using System;
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.SteppedBand;

/// <summary>Parameters of the stepped dwelling band with exposed terraces (SYN-TYP-012, ADR-006).</summary>
/// <param name="StoreyCount">Number of storeys; at least 2.</param>
/// <param name="Length">Band length along plan x, m.</param>
/// <param name="BaseDepth">Dwelling depth on the ground storey, from the south façade to the corridor, m.</param>
/// <param name="StepDepth">Depth by which every storey steps back from the one below, the terrace depth, m.</param>
/// <param name="CorridorWidth">Width of the corridor along the band's rear (plan north), m.</param>
/// <param name="UnitWidth">Target dwelling width along the band, m; adjusted so the dwellings fill the band.</param>
/// <param name="StairWidth">Width 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 SteppedBandParameters(
    int StoreyCount,
    double Length,
    double BaseDepth,
    double StepDepth,
    double CorridorWidth,
    double UnitWidth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>
    /// The ADR-006 defaults: 3 storeys of a 36 m band, 14 m of dwelling depth on the ground storey stepping back 3 m per storey, a 2 m rear
    /// corridor, 6 m target dwellings, a 4 m stair, 3 m storeys, orientation 0°.
    /// </summary>
    public static SteppedBandParameters Default { get; } = new(3, 36.0, 14.0, 3.0, 2.0, 6.0, 4.0, 3.0, 0.0);
}

/// <summary>The program presets of the stepped band, 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="Corridor">Preset of the rear 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 SteppedBandPresets(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>
/// Stepped dwelling bands with exposed terraces (SYN-TYP-012, ADR-006): the band steps back storey by storey so that the part of each lower roof
/// that the storey above leaves free is a terrace, an outdoor roof (R001, D-068), and every dwelling adjoins the shared corridor along the band's
/// rear on every storey (R002). One plan per storey (D-095, ADR-015), bottom-up; one zone per dwelling and storey (D-085).
/// </summary>
/// <remarks>
/// On storey <c>k</c> (from 0) the dwellings span <c>y ∈ [k·StepDepth, BaseDepth]</c> and <c>x ∈ [StairWidth, Length]</c>, divided evenly into
/// <see cref="DwellingsPerStorey"/> dwellings; the corridor <c>CO</c> spans <c>[0, Length] × [BaseDepth, BaseDepth + CorridorWidth]</c> and the
/// stair <c>ST</c> <c>[0, StairWidth] × [k·StepDepth, BaseDepth]</c>. Zone IDs per storey: <c>ST</c>, <c>CO</c>, then <c>U1…Un</c> west to east;
/// the floor aggregators prefix them with the storey (<c>L{k}/</c>).
/// </remarks>
public sealed class SteppedBandGenerator : MultiStoreyPlanGenerator<SteppedBandParameters, SteppedBandPresets>
{
    /// <summary>The fewest storeys: one step (ADR-006).</summary>
    public const int MinimumStoreys = 2;

    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public SteppedBandGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <summary>Number of dwellings per storey: the band length beside the stair divided by the target width, rounded half away from zero, at least 1.</summary>
    /// <param name="parameters">Parameters.</param>
    /// <returns>Dwellings per storey.</returns>
    public static int DwellingsPerStorey(SteppedBandParameters parameters) =>
        Math.Max(1, (int)Math.Round((parameters.Length - parameters.StairWidth) / parameters.UnitWidth, MidpointRounding.AwayFromZero));

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(SteppedBandParameters parameters)
    {
        var errors = new List<Diagnostic>();
        if (parameters.StoreyCount < MinimumStoreys)
        {
            errors.Add(Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(SteppedBandParameters.StoreyCount)} must be at least {MinimumStoreys} (the band steps back from storey to storey, R001), got {parameters.StoreyCount}."));
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(SteppedBandParameters.Length), parameters.Length),
            (nameof(SteppedBandParameters.BaseDepth), parameters.BaseDepth),
            (nameof(SteppedBandParameters.StepDepth), parameters.StepDepth),
            (nameof(SteppedBandParameters.CorridorWidth), parameters.CorridorWidth),
            (nameof(SteppedBandParameters.UnitWidth), parameters.UnitWidth),
            (nameof(SteppedBandParameters.StairWidth), parameters.StairWidth),
        };
        Diagnostic[] lengths = values
            .Where(v => !IsPositive(v.Value))
            .Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
            .ToArray();
        errors.AddRange(lengths);
        if (lengths.Length > 0)
        {
            return errors;
        }

        if (parameters.Length <= parameters.StairWidth)
        {
            errors.Add(Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"Length {Provenance.Format(parameters.Length)} m must exceed StairWidth {Provenance.Format(parameters.StairWidth)} m, so that the band has dwellings beside the stair (R002)."));
        }

        int steps = parameters.StoreyCount - 1;
        if (parameters.StoreyCount >= MinimumStoreys && parameters.BaseDepth - (steps * parameters.StepDepth) <= 0.0)
        {
            errors.Add(Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"BaseDepth {Provenance.Format(parameters.BaseDepth)} m leaves no dwelling depth on the top storey: its {steps} steps of StepDepth "
                + $"{Provenance.Format(parameters.StepDepth)} m take {Provenance.Format(steps * parameters.StepDepth)} m; SYN-TYP-012 needs dwellings on every storey, "
                + "so reduce StoreyCount or StepDepth, or increase BaseDepth."));
        }

        return errors;
    }

    /// <inheritdoc />
    protected override IReadOnlyList<PlanLayout> Layout(SteppedBandParameters p) =>
        Enumerable.Range(0, p.StoreyCount).Select(k => Storey(p, k)).ToArray();

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(SteppedBandParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.StoreyCount), p.StoreyCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.Length), Provenance.Format(p.Length)),
        new KeyValuePair<string, string>(nameof(p.BaseDepth), Provenance.Format(p.BaseDepth)),
        new KeyValuePair<string, string>(nameof(p.StepDepth), Provenance.Format(p.StepDepth)),
        new KeyValuePair<string, string>(nameof(p.CorridorWidth), Provenance.Format(p.CorridorWidth)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };

    /// <summary>The plan of storey <paramref name="k"/>: the band stepped back <paramref name="k"/> times from the south.</summary>
    private static PlanLayout Storey(SteppedBandParameters p, int k)
    {
        double south = k * p.StepDepth;
        double north = p.BaseDepth + p.CorridorWidth;
        int count = DwellingsPerStorey(p);
        double width = (p.Length - p.StairWidth) / count;
        var zones = new List<PlannedZone>
        {
            new(new ZoneId("ST"), "Stair", SpaceType.Stair, Polygon2.Rectangle(0.0, south, p.StairWidth, p.BaseDepth)),
            new(new ZoneId("CO"), "Corridor", SpaceType.Corridor, Polygon2.Rectangle(0.0, p.BaseDepth, p.Length, north)),
        };
        for (int i = 0; i < count; i++)
        {
            double west = p.StairWidth + (i * width);
            double east = i == count - 1 ? p.Length : p.StairWidth + ((i + 1) * width);
            zones.Add(new PlannedZone(new ZoneId($"U{i + 1}"), $"Unit {i + 1}", SpaceType.DwellingUnit, Polygon2.Rectangle(west, south, east, p.BaseDepth)));
        }

        return new PlanLayout(Polygon2.Rectangle(0.0, south, p.Length, north), zones);
    }
}
  • [x] Step 5: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.SteppedBand.SteppedBandGeneratorTests"

Expected on net8.0:

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

with the only failing test and its message:

Failed Lod.Generators.Tests.SteppedBand.SteppedBandGeneratorTests.CanonicalStoreysSnapshot
  Snapshot 'stepped-band-canonical' is missing or differs. Review Snapshots/stepped-band-canonical.received.txt and rename it to stepped-band-canonical.txt to accept.

and the file tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.received.txt (git-ignored by *.received.*).

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

Review the received file against the content below; it must be identical: 261 lines in 3 sections; storey 0: footprint 576 m², 7 zones, 47 surfaces (11 outdoor walls, each with one window; 22 interzone walls; 14 floors and ceilings), glazing 70.8 m²; storey 1: footprint 468 m², 7 zones, 47 surfaces (11 outdoor walls, each with one window; 22 interzone walls; 14 floors and ceilings), glazing 67.2 m²; storey 2: footprint 360 m², 7 zones, 47 surfaces (11 outdoor walls, each with one window; 22 interzone walls; 14 floors and ceilings), glazing 63.6 m²; 33 windows and 201.6 m² of glazing in all; on storeys 1 and 2 the south walls of ST and U1 to U5 stand on the terrace below, at y = 3 and 6 m, each with its window. Accept it:

Move-Item -Force tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.received.txt tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt

tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt:

storey 0
footprint area=576.000000 floorHeight=3.000000 orientation=0.000000
zone ST Stair "Stair" area=56.000000 volume=168.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 CO Corridor "Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=5.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U1 DwellingUnit "Unit 1" area=89.600000 volume=268.800000 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=89.600000 volume=268.800000 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=89.600000 volume=268.800000 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=89.600000 volume=268.800000 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=89.600000 volume=268.800000 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 ST/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 ST/W2 Interzone adjacent=U1 area=42.000000 wall (4.000000,0.000000)-(4.000000,14.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W3 Interzone adjacent=CO area=12.000000 wall (4.000000,14.000000)-(0.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W4 Outdoors area=42.000000 wall (0.000000,14.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=4.200000 window(offset=4.786406 sill=1.025658 4.427189x0.948683)
surface ST/F Unresolved area=56.000000 Floor z=0.000000
surface ST/C Unresolved area=56.000000 Ceiling z=3.000000
surface CO/W1 Interzone adjacent=ST area=12.000000 wall (0.000000,14.000000)-(4.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W2 Interzone adjacent=U1 area=19.200000 wall (4.000000,14.000000)-(10.400000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W3 Interzone adjacent=U2 area=19.200000 wall (10.400000,14.000000)-(16.800000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W4 Interzone adjacent=U3 area=19.200000 wall (16.800000,14.000000)-(23.200000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W5 Interzone adjacent=U4 area=19.200000 wall (23.200000,14.000000)-(29.600000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W6 Interzone adjacent=U5 area=19.200000 wall (29.600000,14.000000)-(36.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W7 Outdoors area=6.000000 wall (36.000000,14.000000)-(36.000000,16.000000) z=0.000000 facing=East glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/W8 Outdoors area=108.000000 wall (36.000000,16.000000)-(0.000000,16.000000) z=0.000000 facing=North glazing=21.600000 window(offset=9.950155 sill=0.829180 16.099689x1.341641)
surface CO/W9 Outdoors area=6.000000 wall (0.000000,16.000000)-(0.000000,14.000000) z=0.000000 facing=West glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/F Unresolved area=72.000000 Floor z=0.000000
surface CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=19.200000 wall (4.000000,0.000000)-(10.400000,0.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U1/W2 Interzone adjacent=U2 area=42.000000 wall (10.400000,0.000000)-(10.400000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Interzone adjacent=CO area=19.200000 wall (10.400000,14.000000)-(4.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U1/W4 Interzone adjacent=ST area=42.000000 wall (4.000000,14.000000)-(4.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U1/F Unresolved area=89.600000 Floor z=0.000000
surface U1/C Unresolved area=89.600000 Ceiling z=3.000000
surface U2/W1 Outdoors area=19.200000 wall (10.400000,0.000000)-(16.800000,0.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U2/W2 Interzone adjacent=U3 area=42.000000 wall (16.800000,0.000000)-(16.800000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Interzone adjacent=CO area=19.200000 wall (16.800000,14.000000)-(10.400000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U2/W4 Interzone adjacent=U1 area=42.000000 wall (10.400000,14.000000)-(10.400000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=89.600000 Floor z=0.000000
surface U2/C Unresolved area=89.600000 Ceiling z=3.000000
surface U3/W1 Outdoors area=19.200000 wall (16.800000,0.000000)-(23.200000,0.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U3/W2 Interzone adjacent=U4 area=42.000000 wall (23.200000,0.000000)-(23.200000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Interzone adjacent=CO area=19.200000 wall (23.200000,14.000000)-(16.800000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U3/W4 Interzone adjacent=U2 area=42.000000 wall (16.800000,14.000000)-(16.800000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=89.600000 Floor z=0.000000
surface U3/C Unresolved area=89.600000 Ceiling z=3.000000
surface U4/W1 Outdoors area=19.200000 wall (23.200000,0.000000)-(29.600000,0.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U4/W2 Interzone adjacent=U5 area=42.000000 wall (29.600000,0.000000)-(29.600000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Interzone adjacent=CO area=19.200000 wall (29.600000,14.000000)-(23.200000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U4/W4 Interzone adjacent=U3 area=42.000000 wall (23.200000,14.000000)-(23.200000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=89.600000 Floor z=0.000000
surface U4/C Unresolved area=89.600000 Ceiling z=3.000000
surface U5/W1 Outdoors area=19.200000 wall (29.600000,0.000000)-(36.000000,0.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U5/W2 Outdoors area=42.000000 wall (36.000000,0.000000)-(36.000000,14.000000) z=0.000000 facing=East glazing=12.600000 window(offset=3.165942 sill=0.678416 7.668116x1.643168)
surface U5/W3 Interzone adjacent=CO area=19.200000 wall (36.000000,14.000000)-(29.600000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U5/W4 Interzone adjacent=U4 area=42.000000 wall (29.600000,14.000000)-(29.600000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=89.600000 Floor z=0.000000
surface U5/C Unresolved area=89.600000 Ceiling z=3.000000
storey 1
footprint area=468.000000 floorHeight=3.000000 orientation=0.000000
zone ST Stair "Stair" area=44.000000 volume=132.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 CO Corridor "Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=5.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U1 DwellingUnit "Unit 1" area=70.400000 volume=211.200000 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=70.400000 volume=211.200000 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=70.400000 volume=211.200000 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=70.400000 volume=211.200000 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=70.400000 volume=211.200000 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 ST/W1 Outdoors area=12.000000 wall (0.000000,3.000000)-(4.000000,3.000000) z=0.000000 facing=South glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST/W2 Interzone adjacent=U1 area=33.000000 wall (4.000000,3.000000)-(4.000000,14.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W3 Interzone adjacent=CO area=12.000000 wall (4.000000,14.000000)-(0.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W4 Outdoors area=33.000000 wall (0.000000,14.000000)-(0.000000,3.000000) z=0.000000 facing=West glazing=3.300000 window(offset=3.760747 sill=1.025658 3.478505x0.948683)
surface ST/F Unresolved area=44.000000 Floor z=0.000000
surface ST/C Unresolved area=44.000000 Ceiling z=3.000000
surface CO/W1 Interzone adjacent=ST area=12.000000 wall (0.000000,14.000000)-(4.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W2 Interzone adjacent=U1 area=19.200000 wall (4.000000,14.000000)-(10.400000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W3 Interzone adjacent=U2 area=19.200000 wall (10.400000,14.000000)-(16.800000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W4 Interzone adjacent=U3 area=19.200000 wall (16.800000,14.000000)-(23.200000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W5 Interzone adjacent=U4 area=19.200000 wall (23.200000,14.000000)-(29.600000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W6 Interzone adjacent=U5 area=19.200000 wall (29.600000,14.000000)-(36.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W7 Outdoors area=6.000000 wall (36.000000,14.000000)-(36.000000,16.000000) z=0.000000 facing=East glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/W8 Outdoors area=108.000000 wall (36.000000,16.000000)-(0.000000,16.000000) z=0.000000 facing=North glazing=21.600000 window(offset=9.950155 sill=0.829180 16.099689x1.341641)
surface CO/W9 Outdoors area=6.000000 wall (0.000000,16.000000)-(0.000000,14.000000) z=0.000000 facing=West glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/F Unresolved area=72.000000 Floor z=0.000000
surface CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=19.200000 wall (4.000000,3.000000)-(10.400000,3.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U1/W2 Interzone adjacent=U2 area=33.000000 wall (10.400000,3.000000)-(10.400000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Interzone adjacent=CO area=19.200000 wall (10.400000,14.000000)-(4.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U1/W4 Interzone adjacent=ST area=33.000000 wall (4.000000,14.000000)-(4.000000,3.000000) z=0.000000 facing=West glazing=0.000000
surface U1/F Unresolved area=70.400000 Floor z=0.000000
surface U1/C Unresolved area=70.400000 Ceiling z=3.000000
surface U2/W1 Outdoors area=19.200000 wall (10.400000,3.000000)-(16.800000,3.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U2/W2 Interzone adjacent=U3 area=33.000000 wall (16.800000,3.000000)-(16.800000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Interzone adjacent=CO area=19.200000 wall (16.800000,14.000000)-(10.400000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U2/W4 Interzone adjacent=U1 area=33.000000 wall (10.400000,14.000000)-(10.400000,3.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=70.400000 Floor z=0.000000
surface U2/C Unresolved area=70.400000 Ceiling z=3.000000
surface U3/W1 Outdoors area=19.200000 wall (16.800000,3.000000)-(23.200000,3.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U3/W2 Interzone adjacent=U4 area=33.000000 wall (23.200000,3.000000)-(23.200000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Interzone adjacent=CO area=19.200000 wall (23.200000,14.000000)-(16.800000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U3/W4 Interzone adjacent=U2 area=33.000000 wall (16.800000,14.000000)-(16.800000,3.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=70.400000 Floor z=0.000000
surface U3/C Unresolved area=70.400000 Ceiling z=3.000000
surface U4/W1 Outdoors area=19.200000 wall (23.200000,3.000000)-(29.600000,3.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U4/W2 Interzone adjacent=U5 area=33.000000 wall (29.600000,3.000000)-(29.600000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Interzone adjacent=CO area=19.200000 wall (29.600000,14.000000)-(23.200000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U4/W4 Interzone adjacent=U3 area=33.000000 wall (23.200000,14.000000)-(23.200000,3.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=70.400000 Floor z=0.000000
surface U4/C Unresolved area=70.400000 Ceiling z=3.000000
surface U5/W1 Outdoors area=19.200000 wall (29.600000,3.000000)-(36.000000,3.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U5/W2 Outdoors area=33.000000 wall (36.000000,3.000000)-(36.000000,14.000000) z=0.000000 facing=East glazing=9.900000 window(offset=2.487526 sill=0.678416 6.024948x1.643168)
surface U5/W3 Interzone adjacent=CO area=19.200000 wall (36.000000,14.000000)-(29.600000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U5/W4 Interzone adjacent=U4 area=33.000000 wall (29.600000,14.000000)-(29.600000,3.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=70.400000 Floor z=0.000000
surface U5/C Unresolved area=70.400000 Ceiling z=3.000000
storey 2
footprint area=360.000000 floorHeight=3.000000 orientation=0.000000
zone ST Stair "Stair" area=32.000000 volume=96.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 CO Corridor "Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=5.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U1 DwellingUnit "Unit 1" area=51.200000 volume=153.600000 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=51.200000 volume=153.600000 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=51.200000 volume=153.600000 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=51.200000 volume=153.600000 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=51.200000 volume=153.600000 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 ST/W1 Outdoors area=12.000000 wall (0.000000,6.000000)-(4.000000,6.000000) z=0.000000 facing=South glazing=1.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683)
surface ST/W2 Interzone adjacent=U1 area=24.000000 wall (4.000000,6.000000)-(4.000000,14.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W3 Interzone adjacent=CO area=12.000000 wall (4.000000,14.000000)-(0.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W4 Outdoors area=24.000000 wall (0.000000,14.000000)-(0.000000,6.000000) z=0.000000 facing=West glazing=2.400000 window(offset=2.735089 sill=1.025658 2.529822x0.948683)
surface ST/F Unresolved area=32.000000 Floor z=0.000000
surface ST/C Unresolved area=32.000000 Ceiling z=3.000000
surface CO/W1 Interzone adjacent=ST area=12.000000 wall (0.000000,14.000000)-(4.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W2 Interzone adjacent=U1 area=19.200000 wall (4.000000,14.000000)-(10.400000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W3 Interzone adjacent=U2 area=19.200000 wall (10.400000,14.000000)-(16.800000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W4 Interzone adjacent=U3 area=19.200000 wall (16.800000,14.000000)-(23.200000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W5 Interzone adjacent=U4 area=19.200000 wall (23.200000,14.000000)-(29.600000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W6 Interzone adjacent=U5 area=19.200000 wall (29.600000,14.000000)-(36.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface CO/W7 Outdoors area=6.000000 wall (36.000000,14.000000)-(36.000000,16.000000) z=0.000000 facing=East glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/W8 Outdoors area=108.000000 wall (36.000000,16.000000)-(0.000000,16.000000) z=0.000000 facing=North glazing=21.600000 window(offset=9.950155 sill=0.829180 16.099689x1.341641)
surface CO/W9 Outdoors area=6.000000 wall (0.000000,16.000000)-(0.000000,14.000000) z=0.000000 facing=West glazing=1.200000 window(offset=0.552786 sill=0.829180 0.894427x1.341641)
surface CO/F Unresolved area=72.000000 Floor z=0.000000
surface CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=19.200000 wall (4.000000,6.000000)-(10.400000,6.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U1/W2 Interzone adjacent=U2 area=24.000000 wall (10.400000,6.000000)-(10.400000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Interzone adjacent=CO area=19.200000 wall (10.400000,14.000000)-(4.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U1/W4 Interzone adjacent=ST area=24.000000 wall (4.000000,14.000000)-(4.000000,6.000000) z=0.000000 facing=West glazing=0.000000
surface U1/F Unresolved area=51.200000 Floor z=0.000000
surface U1/C Unresolved area=51.200000 Ceiling z=3.000000
surface U2/W1 Outdoors area=19.200000 wall (10.400000,6.000000)-(16.800000,6.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U2/W2 Interzone adjacent=U3 area=24.000000 wall (16.800000,6.000000)-(16.800000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U2/W3 Interzone adjacent=CO area=19.200000 wall (16.800000,14.000000)-(10.400000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U2/W4 Interzone adjacent=U1 area=24.000000 wall (10.400000,14.000000)-(10.400000,6.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=51.200000 Floor z=0.000000
surface U2/C Unresolved area=51.200000 Ceiling z=3.000000
surface U3/W1 Outdoors area=19.200000 wall (16.800000,6.000000)-(23.200000,6.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U3/W2 Interzone adjacent=U4 area=24.000000 wall (23.200000,6.000000)-(23.200000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U3/W3 Interzone adjacent=CO area=19.200000 wall (23.200000,14.000000)-(16.800000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U3/W4 Interzone adjacent=U2 area=24.000000 wall (16.800000,14.000000)-(16.800000,6.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=51.200000 Floor z=0.000000
surface U3/C Unresolved area=51.200000 Ceiling z=3.000000
surface U4/W1 Outdoors area=19.200000 wall (23.200000,6.000000)-(29.600000,6.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U4/W2 Interzone adjacent=U5 area=24.000000 wall (29.600000,6.000000)-(29.600000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Interzone adjacent=CO area=19.200000 wall (29.600000,14.000000)-(23.200000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U4/W4 Interzone adjacent=U3 area=24.000000 wall (23.200000,14.000000)-(23.200000,6.000000) z=0.000000 facing=West glazing=0.000000
surface U4/F Unresolved area=51.200000 Floor z=0.000000
surface U4/C Unresolved area=51.200000 Ceiling z=3.000000
surface U5/W1 Outdoors area=19.200000 wall (29.600000,6.000000)-(36.000000,6.000000) z=0.000000 facing=South glazing=5.760000 window(offset=1.447288 sill=0.678416 3.505424x1.643168)
surface U5/W2 Outdoors area=24.000000 wall (36.000000,6.000000)-(36.000000,14.000000) z=0.000000 facing=East glazing=7.200000 window(offset=1.809110 sill=0.678416 4.381780x1.643168)
surface U5/W3 Interzone adjacent=CO area=19.200000 wall (36.000000,14.000000)-(29.600000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U5/W4 Interzone adjacent=U4 area=24.000000 wall (29.600000,14.000000)-(29.600000,6.000000) z=0.000000 facing=West glazing=0.000000
surface U5/F Unresolved area=51.200000 Floor z=0.000000
surface U5/C Unresolved area=51.200000 Ceiling z=3.000000
  • [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.SteppedBand.SteppedBandGeneratorTests"

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

  • [x] Step 8: Build and test
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 210 core, 463 generators, and 1385 integration tests passed on net8.0; git status lists the three new files of this task, and no *.received.txt.

  • [x] Step 9: Commit
git add src/Lod.Generators/SteppedBand/SteppedBandGenerator.cs tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt
git commit -m "feature(generators): add the stepped band with one plan per storey"

Task 5: The stepped band through every simplifier and aggregator

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

Interfaces: - Consumes: SteppedBandGenerator and its records (Task 4); Pipeline.Simplifier, Pipeline.Aggregator, Pipeline.Tolerances, PlanTransform.Apply, SemanticMerge, PerimeterCore(ToleranceSettings, double), FloorEntry, BuildingValidator, Totals.Of, ToleranceSettings.AreaEquals. - Produces: 100 cases, each storey simplified on its own (after the same placement for every storey) and the storeys stacked bottom-up with multiplier 1: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedBand (32, rotated by 30° and moved by (5.3, −2.1) m, and rotated by 131.4° and moved by (15.08, −153.9) m), EveryCombinationValidatesWithTheFewestAndTheMostStoreys (32, 2 storeys, and 4 storeys with BaseDepth 17 m), and EveryCombinationValidatesWithTheMostStoreysOnARotatedAndTranslatedBand (16, 4 storeys of 17 m at 131.4°); each validates with no warning, every lower storey keeps a terrace of Length × StepDepth facing outdoors, the top roof is the top storey's outline, ground and roof area equal the ground storey's footprint, and no floor is exposed. PerimeterCoreRejectsATopStoreyNarrowerThanTwiceItsDepth: 4 storeys of the default 14 m leave a 7 m deep top storey, which Perimeter Core (4.57 m) rejects with PlanTooNarrow (D-087) while storeys 0 to 2 pass, and the other three simplifiers validate with Stack. SemanticMergeGivesEveryStoreyOneDwellingZoneBesideTheStairAndTheCorridor (dwelling zones of 448, 352, and 256 m²), SingleZonePerFloorTypeGivesEveryStoreyItsZone (E0, E1, E2 of 576, 468, and 360 m², interzone pairs of 468 and 360 m², no internal mass), and SingleZoneBuildingHasOnePartPerStoreyOutlineAndTheCoveredSlabsAsInternalMass (parts of 576, 468, and 360 m², internal mass of 468 m² at 3 m and 360 m² at 6 m, the 36 m walls facing the terraces at y = 3 and 6 m with one window each). No production code changes.

  • [x] Step 1: Write the integration tests — create tests/Lod.Integration.Tests/SteppedBandTypologyTests.cs (full file)
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.SteppedBand;
using Xunit;

namespace Lod.Integration.Tests;

/// <summary>
/// The S8.7 stepped band (SYN-TYP-012, ADR-006): one plan per storey (ADR-015), each storey simplified on its own and the storeys stacked bottom-up
/// with multiplier 1, through every simplifier and aggregator, canonical and placed, with the fewest and the most tested storeys. Every lower storey
/// keeps a terrace of Length × StepDepth, an outdoor roof (D-068), and the roofs and terraces together cover the ground storey.
/// </summary>
public sealed class SteppedBandTypologyTests
{
    private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
    private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };

    /// <summary>Rotations (degrees) and translations (m) applied by <see cref="PlanTransform"/> to every storey alike.</summary>
    private static readonly double[][] Placements = { new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };

    /// <summary>
    /// The fewest storeys (ADR-006) and the most tested, with the base depth that keeps the top storey wider than twice the perimeter depth: four
    /// storeys of the default 14 m leave a 7 m deep top storey, which <see cref="PerimeterCore"/> rejects (D-087,
    /// <see cref="PerimeterCoreRejectsATopStoreyNarrowerThanTwiceItsDepth"/>).
    /// </summary>
    private static readonly (int Storeys, double BaseDepth)[] Counts = { (SteppedBandGenerator.MinimumStoreys, 14.0), (4, 17.0) };

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

    public static IEnumerable<object[]> PlacedMatrix() =>
        from combination in Matrix()
        from placement in Enumerable.Range(0, Placements.Length)
        select combination.Append(placement).ToArray();

    public static IEnumerable<object[]> CountMatrix() =>
        from combination in Matrix()
        from count in Enumerable.Range(0, Counts.Length)
        select combination.Append(count).ToArray();

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationValidates(string simplifier, string aggregator)
    {
        AssertValidates(SteppedBandParameters.Default, null, simplifier, aggregator);
    }

    [Theory]
    [MemberData(nameof(PlacedMatrix))]
    public void EveryCombinationValidatesOnARotatedAndTranslatedBand(string simplifier, string aggregator, int placement)
    {
        AssertValidates(SteppedBandParameters.Default, Placements[placement], simplifier, aggregator);
    }

    [Theory]
    [MemberData(nameof(CountMatrix))]
    public void EveryCombinationValidatesWithTheFewestAndTheMostStoreys(string simplifier, string aggregator, int count)
    {
        AssertValidates(Parameters(count), null, simplifier, aggregator);
    }

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationValidatesWithTheMostStoreysOnARotatedAndTranslatedBand(string simplifier, string aggregator)
    {
        AssertValidates(Parameters(1), Placements[1], simplifier, aggregator);
    }

    [Fact]
    public void PerimeterCoreRejectsATopStoreyNarrowerThanTwiceItsDepth()
    {
        // Four storeys of the default 14 m: the top storey is 14 − 3 · 3 + 2 = 7 m deep, not wider than 2 × 4.57 m (D-087); the storeys below pass.
        IReadOnlyList<IGeneratedPlan> plans = Plans(SteppedBandParameters.Default with { StoreyCount = 4 });

        Result<IFloor>[] floors = plans.Select(p => new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(p)).ToArray();

        Assert.Equal(new[] { true, true, true, false }, floors.Select(f => f.IsSuccess));
        Assert.Contains(floors[3].Diagnostics, d => d.Code == DiagnosticCodes.PlanTooNarrow);
        Assert.All(new[] { "NoSimplification", "SemanticMerge", "SingleZonePerFloor" }, s => AssertValidates(SteppedBandParameters.Default with { StoreyCount = 4 }, null, s, "Stack"));
    }

    [Fact]
    public void SemanticMergeGivesEveryStoreyOneDwellingZoneBesideTheStairAndTheCorridor()
    {
        IReadOnlyList<IGeneratedPlan> plans = Plans(SteppedBandParameters.Default);

        IFloor[] floors = plans.Select(p => new SemanticMerge(Pipeline.Tolerances).Simplify(p).Value).ToArray();

        Assert.All(floors, f => Assert.Equal(new[] { SpaceType.DwellingUnit, SpaceType.Corridor, SpaceType.Stair }.OrderBy(t => t), f.Zones.Select(z => z.SpaceType).OrderBy(t => t)));
        Assert.Equal(new[] { 448.0, 352.0, 256.0 }, floors.Select(f => Math.Round(f.Zones.Single(z => z.SpaceType == SpaceType.DwellingUnit).FloorArea, 9)));
    }

    [Fact]
    public void SingleZonePerFloorTypeGivesEveryStoreyItsZone()
    {
        IGeneratedBuilding building = Build(SteppedBandParameters.Default, null, "NoSimplification", "SingleZonePerFloorType").Value;

        Assert.Equal(new[] { "E0", "E1", "E2" }, building.Zones.Select(z => z.Id.Value));
        Assert.Equal(new[] { 576.0, 468.0, 360.0 }, building.Zones.Select(z => Math.Round(z.FloorArea, 9)));
        Assert.Empty(building.InternalMasses);
        HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
        Assert.Equal(468.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling).Sum(h => h.Area), 6);
        Assert.Equal(360.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Ceiling).Sum(h => h.Area), 6);
    }

    [Fact]
    public void SingleZoneBuildingHasOnePartPerStoreyOutlineAndTheCoveredSlabsAsInternalMass()
    {
        IGeneratedBuilding building = Build(SteppedBandParameters.Default, null, "SemanticMerge", "SingleZoneBuilding").Value;

        Zone zone = Assert.Single(building.Zones);
        Assert.Equal(new[] { 576.0, 468.0, 360.0 }, zone.Parts.Select(p => Math.Round(p.Footprint.Area, 9)));
        Assert.Equal(new[] { (3.0, 468.0), (6.0, 360.0) }, building.InternalMasses.Select(m => (m.Elevation, Math.Round(m.SlabArea, 9))));
        WallSurface[] terraceFacing = building.Surfaces.OfType<WallSurface>().Where(w => w.Elevation > 0.0 && w.Start.Y == w.End.Y && w.Start.Y < 14.0).ToArray();
        Assert.Equal(new[] { 3.0, 6.0 }, terraceFacing.Select(w => w.Start.Y));
        Assert.All(terraceFacing, w => Assert.Equal(36.0, w.Length, 9));
        Assert.All(terraceFacing, w => Assert.Single(w.Windows));
    }

    private static SteppedBandParameters Parameters(int count) =>
        SteppedBandParameters.Default with { StoreyCount = Counts[count].Storeys, BaseDepth = Counts[count].BaseDepth };

    private static void AssertValidates(SteppedBandParameters parameters, double[]? placement, string simplifier, string aggregator)
    {
        Result<IGeneratedBuilding> building = Build(parameters, placement, simplifier, aggregator);
        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);

        // Every lower storey keeps a terrace of Length × StepDepth; the top storey's roof is its whole outline; together they cover the ground storey.
        IGeneratedBuilding value = building.Value;
        HorizontalSurface[] roofs = value.Surfaces.OfType<HorizontalSurface>()
            .Where(h => h.Kind == HorizontalKind.Ceiling && h.Boundary == BoundaryCondition.Outdoors)
            .ToArray();
        int n = parameters.StoreyCount;
        double depth = parameters.BaseDepth + parameters.CorridorWidth;
        for (int k = 0; k < n; k++)
        {
            double elevation = (k + 1) * parameters.FloorHeight;
            double expected = k < n - 1 ? parameters.Length * parameters.StepDepth : parameters.Length * (depth - ((n - 1) * parameters.StepDepth));
            double actual = roofs.Where(h => Math.Abs(h.Elevation - elevation) <= Pipeline.Tolerances.Distance).Sum(h => h.Area);
            Assert.True(Pipeline.Tolerances.AreaEquals(expected, actual), $"storey {k}: terrace or roof {actual} m², expected {expected} m²");
        }

        Totals totals = Totals.Of(value.Zones, value.Surfaces, value.OrientationDegrees);
        double ground = parameters.Length * depth;
        Assert.True(Pipeline.Tolerances.AreaEquals(ground, totals.GroundArea), $"ground {totals.GroundArea} m², expected {ground} m²");
        Assert.True(Pipeline.Tolerances.AreaEquals(ground, totals.RoofArea), $"roof {totals.RoofArea} m², expected {ground} m²");
        Assert.True(Pipeline.Tolerances.AreaEquals(0.0, totals.ExposedFloorArea), $"exposed floor {totals.ExposedFloorArea} m²");
    }

    private static Result<IGeneratedBuilding> Build(SteppedBandParameters parameters, double[]? placement, string simplifier, string aggregator)
    {
        IReadOnlyList<IGeneratedPlan> plans = Plans(parameters);
        var entries = new List<FloorEntry>();
        foreach (IGeneratedPlan storey in plans)
        {
            IGeneratedPlan plan = placement is null ? storey : PlanTransform.Apply(storey, placement[0], placement[1], placement[2]).Value;
            Result<IFloor> floor = Pipeline.Simplifier(simplifier).Simplify(plan);
            Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
            entries.Add(new FloorEntry(floor.Value, 1));
        }

        return Pipeline.Aggregator(aggregator).Aggregate(entries);
    }

    private static SteppedBandPresets Presets() =>
        new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair);

    /// <summary>The storeys of a band, bottom-up: ADR-006 defaults and the example presets, or other parameters.</summary>
    private static IReadOnlyList<IGeneratedPlan> Plans(SteppedBandParameters parameters) =>
        new SteppedBandGenerator(Pipeline.Tolerances).Generate(parameters, Presets()).Value;
}
  • [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.SteppedBandTypologyTests"

Expected: Passed! - Failed: 0, Passed: 100, Skipped: 0, Total: 100, Duration: … - Lod.Integration.Tests.dll (net8.0). The matrix passes at once: the simplifiers take each storey as an ordinary plan, and the aggregators designate the terraces on the assembled stack (Task 1). If a case fails, stop and report.

  • [x] Step 3: Build and test
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 210 core, 463 generators, and 1485 integration tests passed on net8.0; git status lists only ?? tests/Lod.Integration.Tests/SteppedBandTypologyTests.cs.

  • [x] Step 4: Commit
git add tests/Lod.Integration.Tests/SteppedBandTypologyTests.cs
git commit -m "test(integration): run the stepped band through every simplifier and aggregator"
  • [x] Step 5: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/stepped-band
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/stepped-band
git branch -d feature/stepped-band
git diff --name-status v0.8.5 main -- "tests/*/Snapshots/*"

Expected: VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0; the last command lists only

A   tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt

the twenty-four existing snapshots are byte-identical. Do not push (D-098).


Slice C — feature/stepped-band-component

Task 6: Stepped Band component with one plan per storey, and its icon

Files: - Modify: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file, last changed in S8.6), scripts/make_icons.py (S4.1 file, last changed in S8.6) - Create (generated): src/Lod.Grasshopper/Icons/SteppedBandComponent.png

Interfaces: - Consumes: SteppedBandGenerator and its records (Task 4), Result<IReadOnlyList<IGeneratedPlan>> (Task 3); the S8.1 base TypologyGeneratorComponent (panel 2 Generate, AddPreset, TryNumbers, Preset, Output, FloorHeightDescription, OrientationDescription); ComponentSupport.PreviewLocationParameter(), ComponentSupport.PreviewLocation, and ComponentSupport.Unwrap (S4.1); PlanGoo(IGeneratedPlan, Vector3d); RhinoGeometry.UnitScale; IconLibrary.For. - Produces: TypologyGeneratorComponent gains protected virtual bool PlanPerStorey => false; when a component overrides it with true, its Plan output has list access ("The detailed plan of every storey, bottom-up (ADR-015). …"); and protected void Output(IGH_DataAccess DA, Result<IReadOnlyList<IGeneratedPlan>> result), which reports the diagnostics as the single-plan Output does and sets the list of PlanGoo, storey k previewed at the Preview Location plus (0, 0, the sum of the floor heights below it × RhinoGeometry.UnitScale), while the plans keep elevation 0 (ADR-015). Every other typology component keeps its item output. One component after Court Cluster in the file, with its count as an integer input first, its number inputs defaulting to the ADR-006 defaults, Floor Height, Orientation, one required preset input per space type in the preset record's order (an unconnected one is Grasshopper's missing-input warning, D-065), and Preview Location last (D-062):

Component (nickname) Inputs → output Class GUID
Stepped Band (SBand) Storey Count (SC, 3), Length (L, 36), Base Depth (BD, 14), Step Depth (SD, 3), Corridor Width (CW, 2), Unit Width (UW, 6), Stair Width (SW, 4), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Corridor (Co), Stair (St), Preview Location (PL) → Plan (list) SteppedBandComponent 3c685bfb-b45b-45af-98d2-8318a6314922

The motif stepped_band (a section of three storeys stepping back from the left, each lower roof a terrace, with the rear corridor dark on the right), in the Generate green, mapped after CourtClusterComponent. 53 components, 6 parameters, and 60 icons. The seventeen earlier typology components are unchanged.

  • [x] Step 1: Create the branch
git switch -c feature/stepped-band-component main
  • [x] Step 2: Replace src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (full file)

Four usings, the list output and its Output overload in the base class, and the component class at the end are added; nothing else changes.

using System;
using System.Collections.Generic;
using System.Drawing;
using Grasshopper.Kernel;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.BranchingMall;
using Lod.Generators.Cafeteria;
using Lod.Generators.CareHub;
using Lod.Generators.CourtCluster;
using Lod.Generators.EnclosedCourt;
using Lod.Generators.FoyerHalls;
using Lod.Generators.GalleryBar;
using Lod.Generators.OfficePlate;
using Lod.Generators.OpenCourt;
using Lod.Generators.OperatingSuite;
using Lod.Generators.PointPlate;
using Lod.Generators.RadialLobes;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.SteppedBand;
using Lod.Generators.TerraceRow;
using Lod.Generators.WingedBand;
using Lod.Grasshopper.Convert;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
using Rhino.Geometry;

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: one plan, or, for a family whose plan differs per storey,
/// the list of storey plans bottom-up (ADR-015).
/// </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);

    /// <summary>Whether the Plan output is the list of storey plans of a multi-storey family (ADR-015) rather than one plan.</summary>
    protected virtual bool PlanPerStorey => false;

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        if (PlanPerStorey)
        {
            pManager.AddParameter(
                new PlanParameter(),
                "Plan",
                "Plan",
                "The detailed plan of every storey, bottom-up (ADR-015). Wire it through a plan simplifier into a floor aggregator's Floors, with Multipliers unconnected (one storey each).",
                GH_ParamAccess.list);
        }
        else
        {
            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)));
        }
    }

    /// <summary>
    /// Reports the generator's diagnostics and outputs the storey plans, bottom-up; each storey's preview is raised to its elevation (the floor
    /// heights below it, in model units) on top of this component's preview location, while the plans keep elevation 0 (ADR-015).
    /// </summary>
    protected void Output(IGH_DataAccess DA, Result<IReadOnlyList<IGeneratedPlan>> result)
    {
        IReadOnlyList<IGeneratedPlan>? plans = ComponentSupport.Unwrap(this, result);
        if (plans is null)
        {
            return;
        }

        Vector3d location = ComponentSupport.PreviewLocation(this, DA);
        double elevation = 0.0;
        var goos = new List<PlanGoo>();
        foreach (IGeneratedPlan plan in plans)
        {
            goos.Add(new PlanGoo(plan, location + new Vector3d(0.0, 0.0, elevation * RhinoGeometry.UnitScale)));
            elevation += plan.FloorHeight;
        }

        DA.SetDataList(0, goos);
    }
}

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)));
    }
}

public sealed class PointPlateComponent : TypologyGeneratorComponent
{
    public PointPlateComponent()
        : base(
            "Point Plate",
            "PPlate",
            "Compact point-access tower plate (HDHM-TYP-007, ADR-006): a square plate with one central core, a Stair zone without outdoor walls, and four dwellings in a pinwheel around it, each adjoining the core and with two façades. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("8ac9044a-8fbe-4964-a9de-2ef8be35094a");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        PointPlateParameters d = PointPlateParameters.Default;
        pManager.AddNumberParameter("Dwelling Depth", "DD", "Depth of each dwelling from its façade to the core, m; the plate side is Core Width + 2 × Dwelling Depth.", GH_ParamAccess.item, d.DwellingDepth);
        pManager.AddNumberParameter("Core Width", "CW", "Side of the square core, m.", GH_ParamAccess.item, d.CoreWidth);
        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 four dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the core");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new PointPlateParameters(v[0], v[1], v[2], v[3]);
        Output(DA, new PointPlateGenerator(ToleranceSettings.Default).Generate(parameters, new PointPlatePresets(dwellingUnit, stair)));
    }
}

public sealed class WingedBandComponent : TypologyGeneratorComponent
{
    public WingedBandComponent()
        : base(
            "Winged Band",
            "WBand",
            "Projecting wings joined by a common band (HDHM-TYP-003, ADR-006): a band of stair bays with wings of stair bays projecting to plan north, each joined to the band along its whole root; the comb outline gives the band short façade fragments between the wings. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("e285811d-2fdb-4d31-8b02-62c270b609cf");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        WingedBandParameters d = WingedBandParameters.Default;
        pManager.AddIntegerParameter("Wing Count", "WC", "Number of wings projecting from the band; at least 2.", GH_ParamAccess.item, d.WingCount);
        pManager.AddNumberParameter("Wing Length", "WL", "Length of each wing from the band to its tip, m.", GH_ParamAccess.item, d.WingLength);
        pManager.AddNumberParameter("Wing Spacing", "WS", "Clear distance between neighbouring wings along the band, m.", GH_ParamAccess.item, d.WingSpacing);
        pManager.AddNumberParameter("Depth", "D", "Depth of the band and of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill the band and each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the band and the wings, 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 wings = 0;
        if (!DA.GetData(0, ref wings) || !TryNumbers(DA, 1, 7, out double[] v) || Preset(DA, 8) is not { } dwellingUnit || Preset(DA, 9) is not { } stair)
        {
            return;
        }

        var parameters = new WingedBandParameters(wings, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new WingedBandGenerator(ToleranceSettings.Default).Generate(parameters, new WingedBandPresets(dwellingUnit, stair)));
    }
}

public sealed class OpenCourtComponent : TypologyGeneratorComponent
{
    public OpenCourtComponent()
        : base(
            "Open Court",
            "OCourt",
            "Open U-shaped court with stair-served wings (HDHM-TYP-004, ADR-006): west, east, and north wings of stair bays around a court open to plan south, every dwelling adjoining a stair. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("223014ea-3c01-4d2b-b8ba-92ebdc4a7327");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        OpenCourtParameters d = OpenCourtParameters.Default;
        pManager.AddNumberParameter("Court Width", "CW", "Width of the court between the side wings, m.", GH_ParamAccess.item, d.CourtWidth);
        pManager.AddNumberParameter("Court Depth", "CD", "Depth of the court from its open side to the north wing, which is also the side wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
        pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, 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)
    {
        if (!TryNumbers(DA, 0, 7, out double[] v) || Preset(DA, 7) is not { } dwellingUnit || Preset(DA, 8) is not { } stair)
        {
            return;
        }

        var parameters = new OpenCourtParameters(v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new OpenCourtGenerator(ToleranceSettings.Default).Generate(parameters, new OpenCourtPresets(dwellingUnit, stair)));
    }
}

public sealed class EnclosedCourtComponent : TypologyGeneratorComponent
{
    public EnclosedCourtComponent()
        : base(
            "Enclosed Court",
            "ECourt",
            "Enclosed courtyard residential block (HDHM-TYP-005, ADR-006): south, north, west, and east wings of stair bays around one court closed on all four sides. The footprint has the court as a hole; the court façades are outdoor walls with windows. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("b9765cd0-a62b-483d-9470-86d5fda287e8");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        EnclosedCourtParameters d = EnclosedCourtParameters.Default;
        pManager.AddNumberParameter("Court Width", "CW", "Width of the court between the west and east wings, m.", GH_ParamAccess.item, d.CourtWidth);
        pManager.AddNumberParameter("Court Depth", "CD", "Depth of the court between the south and north wings, which is also the west and east wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
        pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, 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)
    {
        if (!TryNumbers(DA, 0, 7, out double[] v) || Preset(DA, 7) is not { } dwellingUnit || Preset(DA, 8) is not { } stair)
        {
            return;
        }

        var parameters = new EnclosedCourtParameters(v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new EnclosedCourtGenerator(ToleranceSettings.Default).Generate(parameters, new EnclosedCourtPresets(dwellingUnit, stair)));
    }
}

public sealed class OfficePlateComponent : TypologyGeneratorComponent
{
    public OfficePlateComponent()
        : base(
            "Office Plate",
            "OPlate",
            "Central service core within an office plate (SYN-TYP-017, ADR-006): a rectangular plate whose office work area is one zone, a ring around a central Core zone without outdoor walls; the core leaves at least 3 m of work area on every side. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("783cefaa-b836-4dbc-b16d-8e74820ed361");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        OfficePlateParameters d = OfficePlateParameters.Default;
        pManager.AddNumberParameter("Plate Length", "L", "Plate length along plan x, m.", GH_ParamAccess.item, d.PlateLength);
        pManager.AddNumberParameter("Plate Depth", "D", "Plate depth along plan y, m.", GH_ParamAccess.item, d.PlateDepth);
        pManager.AddNumberParameter("Core Length", "CL", "Length of the central core along plan x, m.", GH_ParamAccess.item, d.CoreLength);
        pManager.AddNumberParameter("Core Depth", "CD", "Depth of the central core along plan y, m.", GH_ParamAccess.item, d.CoreDepth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Office", "Of", SpaceType.Office, "the office work area");
        AddPreset(pManager, "Core", "Co", SpaceType.Core, "the service core");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new OfficePlateParameters(v[0], v[1], v[2], v[3], v[4], v[5]);
        Output(DA, new OfficePlateGenerator(ToleranceSettings.Default).Generate(parameters, new OfficePlatePresets(office, core)));
    }
}

public sealed class CafeteriaComponent : TypologyGeneratorComponent
{
    public CafeteriaComponent()
        : base(
            "Cafeteria",
            "Cafe",
            "Cafeteria service edge beside a dining field (SYN-TYP-016, ADR-006): the kitchen and servery, one zone, along the whole north edge of a larger dining field; the servery counter and the customers' route along it are not zones of their own. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("95956724-ddbc-48ce-9874-0ec6c244c8fe");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        CafeteriaParameters d = CafeteriaParameters.Default;
        pManager.AddNumberParameter("Length", "L", "Length of the service edge and the dining field along plan x, m.", GH_ParamAccess.item, d.Length);
        pManager.AddNumberParameter("Dining Depth", "DD", "Depth of the dining field along plan y, m.", GH_ParamAccess.item, d.DiningDepth);
        pManager.AddNumberParameter("Kitchen Depth", "KD", "Depth of the kitchen and servery along plan y, m; smaller than Dining Depth.", GH_ParamAccess.item, d.KitchenDepth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Kitchen", "Ki", SpaceType.Kitchen, "the kitchen and servery");
        AddPreset(pManager, "Dining", "Di", SpaceType.Dining, "the dining field");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new CafeteriaParameters(v[0], v[1], v[2], v[3], v[4]);
        Output(DA, new CafeteriaGenerator(ToleranceSettings.Default).Generate(parameters, new CafeteriaPresets(kitchen, dining)));
    }
}

public sealed class RadialLobesComponent : TypologyGeneratorComponent
{
    public RadialLobesComponent()
        : base(
            "Radial Lobes",
            "RLobes",
            "Radial lobes around a point tower core (SYN-TYP-010, ADR-006): three or four lobes on the sides of a square core, each holding two dwellings side by side that both adjoin the core; no other circulation. With three lobes the core's south side is a façade. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        RadialLobesParameters d = RadialLobesParameters.Default;
        pManager.AddIntegerParameter("Lobe Count", "LC", "Number of lobes, east, north, west, then south of the core; 3 or 4.", GH_ParamAccess.item, d.LobeCount);
        pManager.AddNumberParameter("Lobe Length", "LL", "Length of each lobe from the core to its end, which every dwelling spans, m.", GH_ParamAccess.item, d.LobeLength);
        pManager.AddNumberParameter("Lobe Width", "LW", "Width of each lobe along the core's side, two dwellings side by side, m; at most Core Width.", GH_ParamAccess.item, d.LobeWidth);
        pManager.AddNumberParameter("Core Width", "CW", "Side of the square core, m.", GH_ParamAccess.item, d.CoreWidth);
        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 core");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new RadialLobesParameters(lobes, v[0], v[1], v[2], v[3], v[4]);
        Output(DA, new RadialLobesGenerator(ToleranceSettings.Default).Generate(parameters, new RadialLobesPresets(dwellingUnit, stair)));
    }
}

public sealed class BranchingMallComponent : TypologyGeneratorComponent
{
    public BranchingMallComponent()
        : base(
            "Branching Mall",
            "BMall",
            "Branching public mall between retail wings (SYN-TYP-014, ADR-006): one mall zone through a central court and three or four branches, each with a shop wing on either side of the mall and an anchor store at its end; every shop wing and anchor adjoins the mall. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("d886b33f-7b51-41f9-b160-a550e6aadd2b");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        BranchingMallParameters d = BranchingMallParameters.Default;
        pManager.AddIntegerParameter("Branch Count", "BC", "Number of branches, east, north, west, then south of the central court; 3 or 4.", GH_ParamAccess.item, d.BranchCount);
        pManager.AddNumberParameter("Branch Length", "BL", "Length of the shop wings along each branch, m.", GH_ParamAccess.item, d.BranchLength);
        pManager.AddNumberParameter("Mall Width", "MW", "Width of the public mall in each branch, m.", GH_ParamAccess.item, d.MallWidth);
        pManager.AddNumberParameter("Shop Depth", "SD", "Depth of the shop wing on each side of the mall, m.", GH_ParamAccess.item, d.ShopDepth);
        pManager.AddNumberParameter("Anchor Depth", "AD", "Depth of the anchor store at the end of each branch, m.", GH_ParamAccess.item, d.AnchorDepth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Mall", "Ma", SpaceType.Mall, "the public mall");
        AddPreset(pManager, "Retail", "Re", SpaceType.Retail, "the shop wings and anchors");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new BranchingMallParameters(branches, v[0], v[1], v[2], v[3], v[4], v[5]);
        Output(DA, new BranchingMallGenerator(ToleranceSettings.Default).Generate(parameters, new BranchingMallPresets(mall, retail)));
    }
}

public sealed class CareHubComponent : TypologyGeneratorComponent
{
    public CareHubComponent()
        : base(
            "Care Hub",
            "CHub",
            "Communal hub serving elder-care bedroom groups (SYN-TYP-018, ADR-006): two to four wings around a communal hub, each a corridor from the hub to the wing's end with a group of bedrooms on each side; every wing corridor adjoins the hub. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("7d3d1002-c695-47df-afd9-48f50c5b3e90");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        CareHubParameters d = CareHubParameters.Default;
        pManager.AddIntegerParameter("Wing Count", "WC", "Number of bedroom wings, east, north, west, then south of the hub; 2 to 4.", GH_ParamAccess.item, d.WingCount);
        pManager.AddNumberParameter("Wing Length", "WL", "Length of each wing from the hub to its end, m.", GH_ParamAccess.item, d.WingLength);
        pManager.AddNumberParameter("Bedroom Depth", "BD", "Depth of the bedrooms on each side of a wing corridor, m.", GH_ParamAccess.item, d.BedroomDepth);
        pManager.AddNumberParameter("Corridor Width", "CoW", "Width of each wing corridor, m.", GH_ParamAccess.item, d.CorridorWidth);
        pManager.AddNumberParameter("Hub Width", "HW", "Side of the square communal hub, m; at least the wing width, twice Bedroom Depth plus Corridor Width.", GH_ParamAccess.item, d.HubWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Care Bedroom", "CB", SpaceType.CareBedroom, "the bedroom groups");
        AddPreset(pManager, "Care Communal", "CC", SpaceType.CareCommunal, "the communal hub");
        AddPreset(pManager, "Corridor", "Co", SpaceType.Corridor, "the wing corridors");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int wings = 0;
        if (!DA.GetData(0, ref wings)
            || !TryNumbers(DA, 1, 6, out double[] v)
            || Preset(DA, 7) is not { } careBedroom
            || Preset(DA, 8) is not { } careCommunal
            || Preset(DA, 9) is not { } corridor)
        {
            return;
        }

        var parameters = new CareHubParameters(wings, v[0], v[1], v[2], v[3], v[4], v[5]);
        Output(DA, new CareHubGenerator(ToleranceSettings.Default).Generate(parameters, new CareHubPresets(careBedroom, careCommunal, corridor)));
    }
}

public sealed class FoyerHallsComponent : TypologyGeneratorComponent
{
    public FoyerHallsComponent()
        : base(
            "Foyer Halls",
            "FHalls",
            "Public foyer branching to distinct activity spaces (SYN-TYP-019, ADR-006): two or three activity halls and a support wing around a common foyer (hall 1 east, support north, hall 2 west, hall 3 south); every hall and the support wing adjoins the foyer. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("6638efe7-c0e8-45b6-89b1-1e9e3f725c15");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        FoyerHallsParameters d = FoyerHallsParameters.Default;
        pManager.AddIntegerParameter("Hall Count", "HC", "Number of activity halls; 2 or 3 (the support wing takes the foyer's north side).", GH_ParamAccess.item, d.HallCount);
        pManager.AddNumberParameter("Hall Length", "HL", "Length of each hall from the foyer to its end, m.", GH_ParamAccess.item, d.HallLength);
        pManager.AddNumberParameter("Hall Width", "HW", "Width of each hall and of the support wing along the foyer's side, m; at most Foyer Width.", GH_ParamAccess.item, d.HallWidth);
        pManager.AddNumberParameter("Support Length", "SL", "Length of the support wing from the foyer to its end, m.", GH_ParamAccess.item, d.SupportLength);
        pManager.AddNumberParameter("Foyer Width", "FW", "Side of the square foyer, m.", GH_ParamAccess.item, d.FoyerWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Lobby", "Lo", SpaceType.Lobby, "the foyer");
        AddPreset(pManager, "Activity Hall", "AH", SpaceType.ActivityHall, "the activity halls");
        AddPreset(pManager, "Service", "Sv", SpaceType.Service, "the support wing");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int halls = 0;
        if (!DA.GetData(0, ref halls)
            || !TryNumbers(DA, 1, 6, out double[] v)
            || Preset(DA, 7) is not { } lobby
            || Preset(DA, 8) is not { } activityHall
            || Preset(DA, 9) is not { } service)
        {
            return;
        }

        var parameters = new FoyerHallsParameters(halls, v[0], v[1], v[2], v[3], v[4], v[5]);
        Output(DA, new FoyerHallsGenerator(ToleranceSettings.Default).Generate(parameters, new FoyerHallsPresets(lobby, activityHall, service)));
    }
}

public sealed class OperatingSuiteComponent : TypologyGeneratorComponent
{
    public OperatingSuiteComponent()
        : base(
            "Operating Suite",
            "OSuite",
            "Operating suite with separated clean and dirty routes (SYN-TYP-015, ADR-006): support rooms across the west end, then bands from south to north of a dirty corridor and theatre banks, each followed by a corridor, alternating clean and dirty; every bank lies between one clean and one dirty corridor, and every bank and corridor adjoins the support rooms. One zone per department (D-097).")
    {
    }

    public override Guid ComponentGuid => new("33323afd-abf1-4ecc-bf54-5d67c411c962");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        OperatingSuiteParameters d = OperatingSuiteParameters.Default;
        pManager.AddIntegerParameter("Bank Count", "BC", "Number of theatre banks; at least 2.", GH_ParamAccess.item, d.BankCount);
        pManager.AddNumberParameter("Bank Length", "BL", "Length of every bank and corridor from the support rooms to the east façade, m.", GH_ParamAccess.item, d.BankLength);
        pManager.AddNumberParameter("Theatre Depth", "TD", "Depth of every theatre bank across the bands, m.", GH_ParamAccess.item, d.TheatreDepth);
        pManager.AddNumberParameter("Clean Corridor Width", "CW", "Width of every clean corridor, m.", GH_ParamAccess.item, d.CleanCorridorWidth);
        pManager.AddNumberParameter("Dirty Corridor Width", "DW", "Width of every dirty corridor, m.", GH_ParamAccess.item, d.DirtyCorridorWidth);
        pManager.AddNumberParameter("Support Length", "SL", "Length of the support rooms across the suite's west end, m.", GH_ParamAccess.item, d.SupportLength);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Operating Theatre", "OT", SpaceType.OperatingTheatre, "the theatre banks");
        AddPreset(pManager, "Clean Corridor", "Clean", SpaceType.CleanCorridor, "the clean corridors");
        AddPreset(pManager, "Dirty Corridor", "Dirty", SpaceType.DirtyCorridor, "the dirty corridors");
        AddPreset(pManager, "Clinical Support", "CS", SpaceType.ClinicalSupport, "the support rooms");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int banks = 0;
        if (!DA.GetData(0, ref banks)
            || !TryNumbers(DA, 1, 7, out double[] v)
            || Preset(DA, 8) is not { } theatre
            || Preset(DA, 9) is not { } clean
            || Preset(DA, 10) is not { } dirty
            || Preset(DA, 11) is not { } support)
        {
            return;
        }

        var parameters = new OperatingSuiteParameters(banks, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new OperatingSuiteGenerator(ToleranceSettings.Default).Generate(parameters, new OperatingSuitePresets(theatre, clean, dirty, support)));
    }
}

public sealed class CourtClusterComponent : TypologyGeneratorComponent
{
    public CourtClusterComponent()
        : base(
            "Court Cluster",
            "CCluster",
            "Linked housing clusters around several courts (SYN-TYP-011, ADR-006): a row of courts closed by wings of stair bays, south and north wings across the full width and a west, middle, and east wing between them, each middle wing bordering two courts. The footprint has one hole per court; the court façades are outdoor walls with windows. One zone per dwelling unit.")
    {
    }

    public override Guid ComponentGuid => new("01fc2ca9-3ec0-4142-b827-2519a4523d36");

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        CourtClusterParameters d = CourtClusterParameters.Default;
        pManager.AddIntegerParameter("Court Count", "CC", "Number of courts in a row along plan x; at least 2.", GH_ParamAccess.item, d.CourtCount);
        pManager.AddNumberParameter("Court Width", "CW", "Width of every court between the wings on its west and east sides, m.", GH_ParamAccess.item, d.CourtWidth);
        pManager.AddNumberParameter("Court Depth", "CD", "Depth of every court between the south and north wings, which is also the west, middle, and east wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
        pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, 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 courts = 0;
        if (!DA.GetData(0, ref courts) || !TryNumbers(DA, 1, 7, out double[] v) || Preset(DA, 8) is not { } dwellingUnit || Preset(DA, 9) is not { } stair)
        {
            return;
        }

        var parameters = new CourtClusterParameters(courts, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new CourtClusterGenerator(ToleranceSettings.Default).Generate(parameters, new CourtClusterPresets(dwellingUnit, stair)));
    }
}

public sealed class SteppedBandComponent : TypologyGeneratorComponent
{
    public SteppedBandComponent()
        : base(
            "Stepped Band",
            "SBand",
            "Stepped dwelling bands with exposed terraces (SYN-TYP-012, ADR-006): a band of dwellings between a west stair and a rear corridor that steps back storey by storey, so each lower roof keeps a terrace (an outdoor roof, D-068); every dwelling adjoins the corridor. One plan per storey, bottom-up (ADR-015); one zone per dwelling and storey.")
    {
    }

    public override Guid ComponentGuid => new("3c685bfb-b45b-45af-98d2-8318a6314922");

    protected override bool PlanPerStorey => true;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        SteppedBandParameters d = SteppedBandParameters.Default;
        pManager.AddIntegerParameter("Storey Count", "SC", "Number of storeys; at least 2. Each storey steps back by Step Depth from the one below.", GH_ParamAccess.item, d.StoreyCount);
        pManager.AddNumberParameter("Length", "L", "Band length along plan x, m.", GH_ParamAccess.item, d.Length);
        pManager.AddNumberParameter("Base Depth", "BD", "Dwelling depth on the ground storey, from the south façade to the corridor, m; must exceed (Storey Count - 1) x Step Depth.", GH_ParamAccess.item, d.BaseDepth);
        pManager.AddNumberParameter("Step Depth", "SD", "Depth by which every storey steps back from the one below, the terrace depth, m.", GH_ParamAccess.item, d.StepDepth);
        pManager.AddNumberParameter("Corridor Width", "CW", "Width of the corridor along the band's rear, m.", GH_ParamAccess.item, d.CorridorWidth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; the band beside the stair is divided evenly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of the stair at the west end, 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, "Corridor", "Co", SpaceType.Corridor, "the rear corridor");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stair");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        int storeys = 0;
        if (!DA.GetData(0, ref storeys)
            || !TryNumbers(DA, 1, 8, out double[] v)
            || Preset(DA, 9) is not { } dwellingUnit
            || Preset(DA, 10) is not { } corridor
            || Preset(DA, 11) is not { } stair)
        {
            return;
        }

        var parameters = new SteppedBandParameters(storeys, v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]);
        Output(DA, new SteppedBandGenerator(ToleranceSettings.Default).Generate(parameters, new SteppedBandPresets(dwellingUnit, corridor, stair)));
    }
}
  • [x] Step 3: Build, check Python, and see the icon script fail
dotnet build BEMGen.sln -c Release
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"
python scripts/make_icons.py
git status --short

Expected: Build succeeded., 0 Warning(s), 0 Error(s); True 12.3.0; the script stops with exit code 1 and the message Classes without an icon: ['SteppedBandComponent']; icons without a class: [], and writes nothing (git status --short lists only M src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs). With another Pillow version the script still runs, but the PNG bytes may differ from Step 5's hashes; then Step 6 decides.

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

The motif is added after court_cluster, and its entry after Court Cluster 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 office(d: Draw, accent) -> None:
    # An office block in elevation with a grid of windows.
    box(d, (16, 6, 80, 90), fill=accent)
    for y in (18, 38, 58):
        for x in (26, 52):
            d.rectangle((x, y, x + 16, y + 12), fill=DARK)
    d.rectangle((40, 76, 56, 90), fill=DARK)


def core(d: Draw, accent) -> None:
    # A service core: a box with two lift doors and an up and down arrow.
    box(d, (8, 8, 88, 88), fill=accent)
    d.rectangle((20, 34, 42, 80), fill=DARK)
    d.rectangle((54, 34, 76, 80), fill=DARK)
    d.polygon([(48, 12), (38, 26), (58, 26)], fill=DARK)


def retail(d: Draw, accent) -> None:
    # A shopping bag with its handle.
    d.arc((30, 6, 66, 46), start=180, end=360, fill=DARK, width=LINE)
    d.polygon([(14, 30), (82, 30), (90, 90), (6, 90)], fill=accent, outline=DARK, width=LINE)


def mall(d: Draw, accent) -> None:
    # A branching mall in plan: shop blocks (accent) along a T of public routes (white).
    box(d, (4, 4, 92, 92), fill=accent)
    d.rectangle((40, 8, 56, 88), fill=WHITE)
    d.rectangle((8, 40, 40, 56), fill=WHITE)
    for a, b in [((40, 4), (40, 40)), ((56, 4), (56, 92)), ((40, 56), (40, 92)), ((4, 40), (40, 40)), ((4, 56), (40, 56))]:
        line(d, [a, b], width=4)


def kitchen(d: Draw, accent) -> None:
    # A cooking pot with a lid and two handles.
    box(d, (16, 40, 80, 86), fill=accent)
    line(d, [(4, 50), (16, 50)])
    line(d, [(80, 50), (92, 50)])
    d.rectangle((12, 28, 84, 38), fill=DARK)
    d.rectangle((42, 16, 54, 28), fill=DARK)


def dining(d: Draw, accent) -> None:
    # A plate between a fork and a knife.
    d.ellipse((26, 22, 74, 70), fill=accent, outline=DARK, width=LINE)
    d.ellipse((38, 34, 62, 58), outline=DARK, width=4)
    line(d, [(12, 14), (12, 86)], width=6)
    line(d, [(84, 14), (84, 86)], width=6)
    d.rectangle((80, 14, 88, 46), fill=DARK)


def operating_theatre(d: Draw, accent) -> None:
    # A medical cross.
    d.polygon(
        [(34, 6), (62, 6), (62, 34), (90, 34), (90, 62), (62, 62), (62, 90), (34, 90), (34, 62), (6, 62), (6, 34), (34, 34)],
        fill=accent,
        outline=DARK,
        width=LINE,
    )


def clean_corridor(d: Draw, accent) -> None:
    # A corridor with a white (clean) route and an arrow.
    box(d, (4, 30, 92, 66), fill=accent)
    d.rectangle((10, 42, 70, 54), fill=WHITE)
    arrow_head(d, (86, 48), "right", 12)


def dirty_corridor(d: Draw, accent) -> None:
    # A corridor with a hatched (dirty) route and an arrow.
    box(d, (4, 30, 92, 66), fill=accent)
    for x in range(12, 66, 14):
        line(d, [(x, 60), (x + 10, 36)], width=5)
    arrow_head(d, (86, 48), "right", 12)


def clinical_support(d: Draw, accent) -> None:
    # Shelves of supplies beside a small medical cross.
    box(d, (6, 10, 58, 90), fill=accent)
    for y in (34, 58):
        line(d, [(6, y), (58, y)], width=6)
    d.rectangle((70, 28, 82, 64), fill=DARK)
    d.rectangle((58, 40, 94, 52), fill=DARK)


def care_bedroom(d: Draw, accent) -> None:
    # A bed in elevation: headboard, mattress, and pillow.
    d.rectangle((6, 24, 16, 86), fill=DARK)
    box(d, (16, 54, 90, 72), fill=accent)
    d.rectangle((20, 42, 42, 54), fill=DARK)
    line(d, [(86, 72), (86, 86)])


def care_communal(d: Draw, accent) -> None:
    # A round table with four people around it.
    d.ellipse((28, 28, 68, 68), fill=accent, outline=DARK, width=LINE)
    for x, y in [(48, 12), (84, 48), (48, 84), (12, 48)]:
        d.ellipse((x - 9, y - 9, x + 9, y + 9), fill=DARK)


def lobby(d: Draw, accent) -> None:
    # A glazed entrance front with an open door and an arrow leading in.
    box(d, (6, 10, 90, 88), fill=accent)
    line(d, [(34, 10), (34, 88)], width=4)
    line(d, [(62, 10), (62, 88)], width=4)
    d.rectangle((38, 44, 58, 88), fill=WHITE, outline=DARK, width=4)
    arrow_head(d, (48, 84), "down", 8)


def activity_hall(d: Draw, accent) -> None:
    # A sports court in plan: the hall, its centre line, and the centre circle.
    box(d, (4, 16, 92, 80), fill=accent)
    line(d, [(48, 16), (48, 80)], width=5)
    d.ellipse((34, 34, 62, 62), outline=DARK, width=5)


def service_rooms(d: Draw, accent) -> None:
    # A row of three lockers with their handles.
    for left in (6, 36, 66):
        box(d, (left, 8, left + 24, 88), fill=accent, width=6)
        d.rectangle((left + 15, 40, left + 19, 56), fill=DARK)


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 point_plate(d: Draw, accent) -> None:
    # A square plate: four dwellings (accent) in a pinwheel around one central core (dark) without façades.
    box(d, (6, 6, 90, 90), fill=accent)
    d.rectangle((36, 36, 60, 60), fill=DARK)
    for a, b in [((6, 36), (60, 36)), ((60, 6), (60, 60)), ((36, 60), (90, 60)), ((36, 36), (36, 90))]:
        line(d, [a, b], width=6)


def winged_band(d: Draw, accent) -> None:
    # A comb in plan: a band (bottom) with three wings projecting north (up), every wing joined to the band along its root; stairs dark.
    d.polygon(
        [(4, 90), (92, 90), (92, 8), (70, 8), (70, 62), (59, 62), (59, 8), (37, 8), (37, 62), (26, 62), (26, 8), (4, 8)],
        fill=accent,
        outline=DARK,
        width=6,
    )
    for left, right in ((4, 26), (37, 59), (70, 92)):
        d.rectangle((left + 3, 30, right - 3, 38), fill=DARK)
    for x in (20, 48, 76):
        d.rectangle((x, 66, x + 6, 87), fill=DARK)


def open_court(d: Draw, accent) -> None:
    # A U in plan: a north wing (top) across the full width and two side wings, around a court open to the south (bottom); stairs dark.
    d.polygon([(4, 92), (28, 92), (28, 34), (68, 34), (68, 92), (92, 92), (92, 6), (4, 6)], fill=accent, outline=DARK, width=6)
    for x in (24, 45, 66):
        d.rectangle((x, 9, x + 6, 31), fill=DARK)
    for left in (4, 68):
        d.rectangle((left + 3, 58, left + 21, 66), fill=DARK)


def enclosed_court(d: Draw, accent) -> None:
    # A closed ring in plan: south and north wings across the full width and west and east wings between them, around a court (the hole,
    # transparent) closed on all four sides; stairs dark.
    box(d, (4, 4, 92, 92), fill=accent, width=6)
    box(d, (30, 30, 66, 66), fill=(0, 0, 0, 0), width=6)
    for y in (7, 69):
        for x in (24, 66):
            d.rectangle((x, y, x + 6, y + 20), fill=DARK)
    for left in (7, 69):
        d.rectangle((left, 45, left + 20, 51), fill=DARK)


def office_plate(d: Draw, accent) -> None:
    # A rectangular plate: one office work area (accent) all around a central service core (dark).
    box(d, (4, 16, 92, 80), fill=accent)
    d.rectangle((32, 40, 64, 56), fill=DARK)


def cafeteria(d: Draw, accent) -> None:
    # A rectangle: the kitchen and servery along the north edge (dark) and the dining field (accent) with tables.
    box(d, (4, 10, 92, 86), fill=accent)
    d.rectangle((8, 14, 88, 34), fill=DARK)
    for y in (48, 68):
        for x in (20, 44, 68):
            d.ellipse((x, y, x + 10, y + 10), 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)


def radial_lobes(d: Draw, accent) -> None:
    # A cross in plan: four lobes (accent) on the sides of a square core (dark), each lobe split along its axis into two dwellings.
    d.polygon(
        [(32, 4), (64, 4), (64, 32), (92, 32), (92, 64), (64, 64), (64, 92), (32, 92), (32, 64), (4, 64), (4, 32), (32, 32)],
        fill=accent,
        outline=DARK,
        width=6,
    )
    d.rectangle((32, 32, 64, 64), fill=DARK)
    for a, b in [((64, 48), (92, 48)), ((48, 4), (48, 32)), ((4, 48), (32, 48)), ((48, 64), (48, 92))]:
        line(d, [a, b], width=4)


def branching_mall(d: Draw, accent) -> None:
    # A T in plan: shop wings (accent) along a mall (dark) that branches from a central court into three branches, anchors at the branch ends.
    d.polygon([(4, 50), (34, 50), (34, 6), (62, 6), (62, 50), (92, 50), (92, 82), (4, 82)], fill=accent, outline=DARK, width=6)
    d.rectangle((18, 62, 78, 70), fill=DARK)
    d.rectangle((44, 20, 52, 70), fill=DARK)
    for a, b in [((18, 50), (18, 82)), ((78, 50), (78, 82)), ((34, 20), (62, 20))]:
        line(d, [a, b], width=6)


def care_hub(d: Draw, accent) -> None:
    # A communal hub (accent, a table in its middle) with three narrower bedroom wings (accent), each with a corridor (dark) along its axis.
    for xy in [(66, 49, 92, 75), (4, 49, 30, 75), (35, 6, 61, 42)]:
        box(d, xy, fill=accent, width=6)
    box(d, (30, 42, 66, 82), fill=accent, width=6)
    for a, b in [((66, 62), (92, 62)), ((4, 62), (30, 62)), ((48, 6), (48, 42))]:
        line(d, [a, b], width=4)
    d.ellipse((40, 54, 56, 70), fill=DARK)


def foyer_halls(d: Draw, accent) -> None:
    # A foyer (white) with two large halls (accent) east and west, the support wing (dark) north, and its entrance from the south.
    box(d, (4, 40, 34, 74), fill=accent, width=6)
    box(d, (62, 40, 92, 74), fill=accent, width=6)
    box(d, (34, 18, 62, 40), fill=DARK, width=6)
    box(d, (34, 40, 62, 74), fill=WHITE, width=6)
    d.polygon([(48, 78), (40, 90), (56, 90)], fill=DARK)


def operating_suite(d: Draw, accent) -> None:
    # Support rooms (accent) across the west end and bands to their east, south to north: a dirty corridor (dark), a theatre bank (accent), a
    # clean corridor (white), a theatre bank, and a dirty corridor.
    box(d, (4, 6, 92, 90), fill=accent, width=6)
    line(d, [(30, 6), (30, 90)], width=6)
    d.rectangle((30, 6, 92, 20), fill=DARK)
    d.rectangle((30, 76, 92, 90), fill=DARK)
    d.rectangle((30, 40, 92, 56), fill=DARK)
    d.rectangle((33, 44, 89, 52), fill=WHITE)


def court_cluster(d: Draw, accent) -> None:
    # Wings around a row of two courts (the holes, transparent): south and north wings across the full width, and a west, middle, and east wing
    # between them; stairs dark.
    box(d, (4, 16, 92, 80), fill=accent, width=6)
    for left in (18, 56):
        box(d, (left, 30, left + 22, 66), fill=(0, 0, 0, 0), width=6)
    for y in (19, 69):
        for x in (24, 46, 68):
            d.rectangle((x, y, x + 5, y + 8), fill=DARK)
    for left in (7, 43, 81):
        d.rectangle((left, 45, left + 8, 51), fill=DARK)


def stepped_band(d: Draw, accent) -> None:
    # A section through the band: three storeys stepping back from the south (left), so each lower roof is a terrace; the rear corridor dark.
    for left, top in ((6, 64), (32, 38), (58, 12)):
        box(d, (left, top, 90, top + 26), fill=accent, width=6)
    d.rectangle((76, 12, 90, 90), fill=DARK)


# --- 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)),
    "OfficePresetComponent": (PROGRAM, component(office)),
    "CorePresetComponent": (PROGRAM, component(core)),
    "RetailPresetComponent": (PROGRAM, component(retail)),
    "MallPresetComponent": (PROGRAM, component(mall)),
    "KitchenPresetComponent": (PROGRAM, component(kitchen)),
    "DiningPresetComponent": (PROGRAM, component(dining)),
    "OperatingTheatrePresetComponent": (PROGRAM, component(operating_theatre)),
    "CleanCorridorPresetComponent": (PROGRAM, component(clean_corridor)),
    "DirtyCorridorPresetComponent": (PROGRAM, component(dirty_corridor)),
    "ClinicalSupportPresetComponent": (PROGRAM, component(clinical_support)),
    "CareBedroomPresetComponent": (PROGRAM, component(care_bedroom)),
    "CareCommunalPresetComponent": (PROGRAM, component(care_communal)),
    "LobbyPresetComponent": (PROGRAM, component(lobby)),
    "ActivityHallPresetComponent": (PROGRAM, component(activity_hall)),
    "ServicePresetComponent": (PROGRAM, component(service_rooms)),
    "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)),
    "PointPlateComponent": (GENERATE, component(point_plate)),
    "WingedBandComponent": (GENERATE, component(winged_band)),
    "OpenCourtComponent": (GENERATE, component(open_court)),
    "EnclosedCourtComponent": (GENERATE, component(enclosed_court)),
    "OfficePlateComponent": (GENERATE, component(office_plate)),
    "CafeteriaComponent": (GENERATE, component(cafeteria)),
    "RadialLobesComponent": (GENERATE, component(radial_lobes)),
    "BranchingMallComponent": (GENERATE, component(branching_mall)),
    "CareHubComponent": (GENERATE, component(care_hub)),
    "FoyerHallsComponent": (GENERATE, component(foyer_halls)),
    "OperatingSuiteComponent": (GENERATE, component(operating_suite)),
    "CourtClusterComponent": (GENERATE, component(court_cluster)),
    "SteppedBandComponent": (GENERATE, component(stepped_band)),
    "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 5: Draw the icons and check the hashes
python scripts/make_icons.py
git status --short
sha256sum src/Lod.Grasshopper/Icons/*.png

Expected: Wrote 60 icons to src\Lod.Grasshopper\Icons; git status --short shows M scripts/make_icons.py, the file of Step 2, and the new PNG (?? src/Lod.Grasshopper/Icons/SteppedBandComponent.png), and no existing PNG modified; the hashes (Pillow 12.3.0):

a4e0709b6d8cd05a79c33af9ee864918850e74c8aef2c3ac617859fc08ac2fdc *src/Lod.Grasshopper/Icons/ActivityHallPresetComponent.png
606a11f2bbd6ee6cb7edede403166ae0f1b39231fcf5882dff360717df990cae *src/Lod.Grasshopper/Icons/BemGenAssemblyInfo.png
e33ae284b7f017ee11851986e79e4b8449e1b5027ffedf41cad68ed2abbfb15d *src/Lod.Grasshopper/Icons/BemGenInfoComponent.png
e399a77a78d4425369acec44f0109c0bf7fee9c261e26cf49227dac0a79e3884 *src/Lod.Grasshopper/Icons/BranchingMallComponent.png
431104585e8745167567797eefef526bbf664ba6777101e3677281cb6976bfde *src/Lod.Grasshopper/Icons/BuildingParameter.png
d0a134b08a035f0ca5608468fd9f12804fd21db1a79a8bd3d19ee1b1ac88bd0a *src/Lod.Grasshopper/Icons/CafeteriaComponent.png
9805fc04d927c05f955ef5dfd0e6913e247bfec90c495a8adf051c36637cf866 *src/Lod.Grasshopper/Icons/CareBedroomPresetComponent.png
88900c3c46841a23766ace6b7a1d22a5a3bbb4e6403c2adc71af1fd3c404ff92 *src/Lod.Grasshopper/Icons/CareCommunalPresetComponent.png
5345437fcdf6cb1d454b912816b3e2836edacfe77ef7be233e1453c52eb7ab9a *src/Lod.Grasshopper/Icons/CareHubComponent.png
516ecd2a3fbb009d1b541e392965c78a85bdec71b5a6a99b71caf71628cf31f3 *src/Lod.Grasshopper/Icons/CleanCorridorPresetComponent.png
69d26c3e73a28fc34768fdb14ada2e202940d28ec0ef159786d9be9e2ff8ebd0 *src/Lod.Grasshopper/Icons/ClinicalSupportPresetComponent.png
edc0ff9b82e6161bf8bdf3b5acebc8f4a43644d42d845827a101593d91d1a110 *src/Lod.Grasshopper/Icons/Convert2BemComponent.png
b35b3a0f58c4893203982ac209cb6004dd8a3d6264ad58dc0875a43aa6c65342 *src/Lod.Grasshopper/Icons/CorePresetComponent.png
c8a815ff220dafff9f84af05411eab8c43191b4b6f4be2f0b4750385e1d95489 *src/Lod.Grasshopper/Icons/CorridorPresetComponent.png
9deda397b6112ed3296ae4581a081f45f40c95a86fe19a04914144f5e7ad32eb *src/Lod.Grasshopper/Icons/CourtClusterComponent.png
5e37af921cd4055aeedb4d409d5e966b6515acb880aa28e9080aabf4c593b201 *src/Lod.Grasshopper/Icons/DiningPresetComponent.png
92c7e19aa3faf6fdbf3a4f8d23f6919d18490f19ccf954f41c78efaee741ded6 *src/Lod.Grasshopper/Icons/DirtyCorridorPresetComponent.png
755de6f9d4122d567040d0df98ec3232c76f4f1c0af9da934276fe33585dc528 *src/Lod.Grasshopper/Icons/DwellingUnitPresetComponent.png
04caf35a33a9b6443b7fa808766237aecb235db70ffe0f869002d9d3287223d8 *src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png
324ffc55b8f3aed81902fdaea4ac6293654a194e9f58aa97c9aa088547e584be *src/Lod.Grasshopper/Icons/FloorParameter.png
b4559cae0f76b1d52ebc691bc0a491cba2e2046897d6a1fc9778cdec6886205a *src/Lod.Grasshopper/Icons/FoyerHallsComponent.png
edc98c6c83c0040d935633c0e8fa014ebeeeeae5ef8f596207fd385ad09a0dbe *src/Lod.Grasshopper/Icons/GalleryBarComponent.png
a7191b8f5e30874eaf7d50f0d4555b3565b77157f83ae584393ab9c3b1c3a792 *src/Lod.Grasshopper/Icons/InspectComponent.png
15d1a772ec51f8411eaa7a61cc847b0344147dbf267badc4b61bdc774359a072 *src/Lod.Grasshopper/Icons/KitchenPresetComponent.png
75b4d98c4ad7cffbec53284781d3ad544ccd8ba762c7fd5c33bfd6d6845e42d4 *src/Lod.Grasshopper/Icons/LinearPlanGeneratorComponent.png
acf5163f017f01a1ebb394861f61750e54f9ae8c730a0a6a614f2120f59feadc *src/Lod.Grasshopper/Icons/LoadComponent.png
5045ceb77bc754553aa717110fab061978ef7b51d0c05e0f7f93788a8158f1d5 *src/Lod.Grasshopper/Icons/LoadParameter.png
197cbe6bd28af5e70cc5987020cf0f91b8bd9641afd81f78596e8be7d820a832 *src/Lod.Grasshopper/Icons/LobbyPresetComponent.png
0f1400bcff9d9791690438d777602fa9e00f8405443d8b5108eefdc1d4c5e667 *src/Lod.Grasshopper/Icons/MallPresetComponent.png
438a45a6b5f3071e4918cb6c0f7e6112e4d0ab9ca0ad1aacdc189b4e5de9ecb8 *src/Lod.Grasshopper/Icons/MapSourceToTargetComponent.png
4a702c6b6ae3c4773678a3594228ce4d1e06c2623bc9eeb5bf1fdaaee8e50c30 *src/Lod.Grasshopper/Icons/NoSimplificationComponent.png
7d8823490704b3321526b2c26d4f434f3df9810bf3d9b00d71cf569fc9aebb58 *src/Lod.Grasshopper/Icons/OfficePlateComponent.png
43e15233e02b20e102659703622e13469c5f024f6c587edc9bf2403667390b8f *src/Lod.Grasshopper/Icons/OfficePresetComponent.png
e9504a1cea0d69fe7da893cd10e220358f439b119c3ea86efe6e4d3a9cc29e3e *src/Lod.Grasshopper/Icons/OpenCourtComponent.png
d98e8eb8f24fe7e55eb230fd67dc7090216221cd427b647cd5b905ab9b880f17 *src/Lod.Grasshopper/Icons/OperatingSuiteComponent.png
c376a8fbfc51c257f0d57f1174fd46c110356261f3bbd8d895df89e76a977b49 *src/Lod.Grasshopper/Icons/OperatingTheatrePresetComponent.png
ac5d421e2cded0be0f7797510b864cdfcdd019dd46caed4147251923602184b8 *src/Lod.Grasshopper/Icons/PerimeterCoreComponent.png
a4bd3109301f4fccec8fa698ceca5dcfbb22e4dc839d850dd5aa4241b5cca95b *src/Lod.Grasshopper/Icons/PlanParameter.png
cb207d9cb3ccc6df62da1fbfc077921526b91ffb8e329ee225c97163e7014c3b *src/Lod.Grasshopper/Icons/PointPlateComponent.png
13741aac4525cf0c0d991bf3b673e55cd22612324fdc3ef3a8aaaaf0e66dec45 *src/Lod.Grasshopper/Icons/ProgramPresetComponent.png
bd0a92bf623dd4b3f6b9c4279e425adc44a7287751d97e8206a26e3c26e7e400 *src/Lod.Grasshopper/Icons/ProgramPresetParameter.png
ea3f7960f14b224edeb7eec0c7c52129bec4308f5853347be5313cc0ac5be130 *src/Lod.Grasshopper/Icons/RadialLobesComponent.png
0c84c2d19c379dfe78aea82f8ed400848665472bcc5b6159b7f1036d25af3f02 *src/Lod.Grasshopper/Icons/RetailPresetComponent.png
90646f46d781eca01dcdf67554987ee04fdd80c450e5f94647858b5f7e7b613d *src/Lod.Grasshopper/Icons/ScheduleComponent.png
693d5513ffe5f813ed3a77dc7b68cfce31e008b407e6410ac34c9ed374c6bfaa *src/Lod.Grasshopper/Icons/ScheduleParameter.png
8217bd3bbe904a6026ef7434606168b5d466341f356280a623f92278908a4068 *src/Lod.Grasshopper/Icons/SemanticMergeComponent.png
b60466bed4dd2bdcb51bc0488af40bdeb5db9f2c24d70f12d32bf1d1b147b436 *src/Lod.Grasshopper/Icons/ServicePresetComponent.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
10de4ea564800e70d50028cb5b64b0353d1df1155c116b30f9b2e71350f92429 *src/Lod.Grasshopper/Icons/SteppedBandComponent.png
ab59722b25ff1a3407cf7344e1c4595142b7a3bed05b7f952d58f92b51ffae34 *src/Lod.Grasshopper/Icons/TerraceRowComponent.png
3ce5e835f0921d1dee8feeaf23a45aecd36c3aedb55be5a8386fdb3e540e5246 *src/Lod.Grasshopper/Icons/TransformPlanComponent.png
20a372402d7ca8ae673db6a0a1299d7a3b6dcae9141d29de2b535cc543d950ce *src/Lod.Grasshopper/Icons/ValidateComponent.png
61af5011fa771797ae48e99ba73cbb7d1cb165a2ec11784187a94dd0fbfbd704 *src/Lod.Grasshopper/Icons/WingedBandComponent.png

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

  • [x] Step 6: 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: 60 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: Stepped Band is the second icon of the tenth row, after Stair Preset (a section of three green storeys stepping back from the left, each lower roof a terrace, with a dark rear corridor on the right), and before Terrace Row. Every icon after it moves one place on, the 59 icons of S8.6 keep their motifs, and the 60 icons fill ten rows of six.

If the hashes of Step 5 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 7: 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().Count"

Expected: Build succeeded., 0 Warning(s), 0 Error(s); counts src/Lod.Grasshopper/BemGenAssemblyInfo.cs:1, src/Lod.Grasshopper/Components/BemGenInfoComponent.cs:1, src/Lod.Grasshopper/Components/Convert2BemComponent.cs:1, src/Lod.Grasshopper/Components/PipelineComponents.cs:4, src/Lod.Grasshopper/Components/PresetComponents.cs:1, src/Lod.Grasshopper/Components/ProgramComponents.cs:3, src/Lod.Grasshopper/Components/SimplifierComponents.cs:3, src/Lod.Grasshopper/Components/TransformPlanComponent.cs:1, src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs:1, src/Lod.Grasshopper/Parameters/BemParameters.cs:1 (the new component inherits the icon of TypologyGeneratorComponent, as the other typology components do); 60 resource names, one for each PNG of Step 5.

  • [x] Step 8: Commit
git add src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): add the stepped band component with one plan per storey"

Expected before git add: the two modified files and the new PNG; nothing under bin/.

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

Expected: VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0. Do not push (D-098).


Slice D — docs/architecture-per-storey-plans-stepped-band

Task 7: Architecture documents, ADR notes, and the S8.7 smoke-test checklist

Files: - Modify: docs/decisions/ADR-006-plan-generation-mechanism.md, docs/architecture/pipeline.md, docs/research/precedents/syn-typologies.md, docs/research/program-presets.md (all last changed in S8.6), docs/decisions/ADR-013-vertical-aggregation-methods.md (last changed in S8.3), docs/architecture/domain-model.md (last changed in S8.4), docs/development/grasshopper-smoke-test.md (a section appended)

Interfaces: - Consumes: Tasks 1–6 (the finding on terraces, ADR-015, the base class, the generator, its rules and defaults, the integration results, the component, its GUID, inputs, list output, and previews). - Produces: ADR-006: its status and Decisions lines name ADR-015, "differences between storeys" now names SYN-TYP-012 as the one family with plans per storey, the 012 section links D-095 and ADR-015, its Implemented (S8.7) note (the default storeys, areas, and windows, and the check Length > StairWidth), and the section Pipeline consequences of S8.7; ADR-013: its status line and the section Storeys that differ (S8.7) with a consequence; pipeline.md after S8.7: the overview and diagram with MultiStoreyPlanGenerator, step 2 with plans per storey and the stepped band, the aggregation step with storeys that differ, "eighteen plan generators", the wiring of a Plan list, the heading ## Grasshopper components through S8.7 (anchor #grasshopper-components-through-s87) with the new row, which the overview of pipeline.md and program-presets.md link to, and the S8.7 row of the open-items table removed; domain-model.md: the base class among the pipeline types; syn-typologies.md: the generator cell of 012 names its component and ADR-015; the smoke-test guide with the S8.7 checklist that Task 8 runs.

  • [x] Step 1: Create the branch
git switch -c docs/architecture-per-storey-plans-stepped-band main
  • [x] Step 2: Edit docs/decisions/ADR-006-plan-generation-mechanism.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  1. Replace
Status: Accepted (by the controller under D-084; provisional pending the owner's reading); extended to the families of the four-source set in S8.4 (by the controller under D-098); hub-and-arms families implemented in S8.5; operating suite and court cluster implemented in S8.6

with

Status: Accepted (by the controller under D-084; provisional pending the owner's reading); extended to the families of the four-source set in S8.4 (by the controller under D-098); hub-and-arms families implemented in S8.5; operating suite and court cluster implemented in S8.6; stepped band implemented in S8.7 with one plan per storey ([ADR-015](ADR-015-plans-per-storey.md))
  1. Replace
Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084, D-085, D-093 to D-098, D-100; applies [ADR-014](ADR-014-program-types-and-department-zoning.md), [ADR-002](ADR-002-geometry.md), [ADR-008](ADR-008-perimeter-core-corners.md), and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)

with

Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084, D-085, D-093 to D-098, D-100; applies [ADR-014](ADR-014-program-types-and-department-zoning.md), [ADR-015](ADR-015-plans-per-storey.md), [ADR-002](ADR-002-geometry.md), [ADR-008](ADR-008-perimeter-core-corners.md), and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)
  1. Replace
- **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).

with

- **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), except in `SYN-TYP-012`, whose generator returns one plan per storey (D-095, [ADR-015](ADR-015-plans-per-storey.md), S8.7).
  1. Replace
- Presets `SteppedBandPresets(DwellingUnit, Corridor, Stair)`. The generator returns one plan per storey (D-095; the interface, how the floor aggregators treat storeys that differ, and the designation of terraces are fixed in the ADR of S8.7). On storey `k` (from 0) the dwellings span `y ∈ [k·StepDepth, BaseDepth]` and `x ∈ [StairWidth, Length]`, divided evenly into `max(1, round((Length − StairWidth) / UnitWidth))` dwellings; the corridor `CO` spans `[0, Length] × [BaseDepth, BaseDepth + CorridorWidth]` on every storey and the stair `ST` `[0, StairWidth] × [k·StepDepth, BaseDepth]`. The part of storey `k − 1`'s roof that storey `k` does not cover, `[0, Length] × [(k − 1)·StepDepth, k·StepDepth]`, is a terrace: an outdoor roof (D-068). Zones per storey `ST`, `CO`, `U1…Un` (west to east).

with

- Presets `SteppedBandPresets(DwellingUnit, Corridor, Stair)`. The generator returns one plan per storey ([D-095](decision-log.md#d-095--a-plan-generator-may-produce-a-different-plan-per-storey); the interface, how the floor aggregators treat storeys that differ, and the designation of terraces are fixed in [ADR-015](ADR-015-plans-per-storey.md)). On storey `k` (from 0) the dwellings span `y ∈ [k·StepDepth, BaseDepth]` and `x ∈ [StairWidth, Length]`, divided evenly into `max(1, round((Length − StairWidth) / UnitWidth))` dwellings; the corridor `CO` spans `[0, Length] × [BaseDepth, BaseDepth + CorridorWidth]` on every storey and the stair `ST` `[0, StairWidth] × [k·StepDepth, BaseDepth]`. The part of storey `k − 1`'s roof that storey `k` does not cover, `[0, Length] × [(k − 1)·StepDepth, k·StepDepth]`, is a terrace: an outdoor roof (D-068). Zones per storey `ST`, `CO`, `U1…Un` (west to east).
  1. Replace
- Not modelled: terrace parapets, planting, and shading; an internal gallery instead of a rear corridor; steps on both sides of the band.

with

- Not modelled: terrace parapets, planting, and shading; an internal gallery instead of a rear corridor; steps on both sides of the band.
- Implemented (S8.7): as specified, as the first `MultiStoreyPlanGenerator` ([ADR-015](ADR-015-plans-per-storey.md)): `Generate` returns the storeys bottom-up, each an ordinary plan whose provenance adds `Storey` (from 0); the dwelling count is `SteppedBandGenerator.DwellingsPerStorey`, the same on every storey, so dwelling `Ui` of storey `k + 1` stands on dwelling `Ui` of storey `k`. Zones per storey `ST` (*Stair*), `CO` (*Corridor*), `U1…U5` (*Unit 1…*; five dwellings of 6.4 m by default). By default the three storeys are 576 m², 468 m², and 360 m² (1404 m²), each lower storey keeping a 36 m × 3 m terrace of 108 m²; every storey has 11 windows (33 in all, 201.6 m² of glazing: 70.8, 67.2, and 63.6 m² from the ground up), and the south walls of the stair and the dwellings of storeys 1 and 2 face the terrace below them and keep their windows. Every dwelling and the stair adjoin the corridor over their full width on every storey (6.4 m and 4 m). Validation settles two further essential relationships: `Length` must exceed `StairWidth` (`Length 4 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002).`), and the top storey must keep its dwellings (`BaseDepth 12 m leaves no dwelling depth on the top storey: its 4 steps of StepDepth 3 m take 12 m; SYN-TYP-012 needs dwellings on every storey, so reduce StoreyCount or StepDepth, or increase BaseDepth.`); both are checked only when every length is positive. `StoreyCount` < 2 is `StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.` *Semantic Merge* gives each storey the stair, the corridor, and one dwelling zone (448, 352, and 256 m²); *Perimeter Core* gives every storey `P-North`, `P-East`, `P-South`, `P-West`, and a `CORE` 26.86 m long, and needs the top storey, the narrowest, to be deeper than twice the perimeter depth: with the default 4.57 m the cores of the three default storeys are 6.86 m, 3.86 m, and 0.86 m deep, and four storeys of the default 14 m leave a 7 m deep top storey that fails with `PlanTooNarrow` (D-087) while the storeys below pass. The family has no upper bound; 2 to 5 storeys are tested in the generator's tests and 2 to 4 through every simplifier and aggregator.

#### Pipeline consequences of S8.7

Every plan simplifier and floor aggregator was run on the stepped band, each storey simplified on its own and the storeys stacked bottom-up with multiplier 1, canonical, at the two rotated placements of the test suite (every storey placed alike), with 2 and 4 storeys (4 with a 17 m base depth), and with 4 at 131.4°, and, while building the reference, in a sweep of 50 rotations from 0° to 353.5° with translations for the default band, 2 storeys, 4 storeys of 17 m, and a band of other dimensions, and, as a regression, the linear plan, the enclosed court, the court cluster, and the care hub (entries 1/3/1): 6400 combinations without a failure. No step of `Lod.Core` changed apart from the new base class and the helper it shares with `PlanGenerator`, and no tolerance or check was relaxed:

- The floor aggregators already take floors that differ (setbacks since S4) and designate floors and ceilings on the assembled stack (D-068): the part of each lower ceiling that the storey above does not cover faces outdoors and counts as roof, the covered part lies between the storeys. In every method the terraces on each lower storey are `Length × StepDepth`, the roofs and terraces together equal the ground storey's footprint, no floor is exposed, and no storey is unsupported ([ADR-015](ADR-015-plans-per-storey.md), [ADR-013](ADR-013-vertical-aggregation-methods.md)).
- `StackedFloorZoneMultiplier` models every storey, since every entry has multiplier 1; `SingleZonePerFloorType` makes one zone per storey with interzone pairs over the covered slabs; `SingleZoneBuilding` makes one zone with one part per storey outline and turns the covered slabs into internal mass. Their rebuilt walls include the south walls that face the terraces, each with one centred window (D-079).
  1. Replace
- The hub-and-arms families share one layout (`HubArmsLayout`, S8.5); arms narrower than the hub leave short hub façades at its corners, which get no window below the ADR-011 limits (the care hub's 0.8 m corners by default).

with

- The hub-and-arms families share one layout (`HubArmsLayout`, S8.5); arms narrower than the hub leave short hub façades at its corners, which get no window below the ADR-011 limits (the care hub's 0.8 m corners by default).
- The stepped band (`SYN-TYP-012`, S8.7) is the only family whose generator returns one plan per storey (`MultiStoreyPlanGenerator`, [ADR-015](ADR-015-plans-per-storey.md)); its terraces are outdoor roofs designated on the assembled stack, and its narrowest storey, the top one, decides whether *Perimeter Core* applies (D-087).
  • [x] Step 3: Edit docs/decisions/ADR-013-vertical-aggregation-methods.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  1. Replace
Status: Accepted (by the controller under D-075; provisional pending the owner's reading); superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls); footprints with holes noted in S8.3

with

Status: Accepted (by the controller under D-075; provisional pending the owner's reading); superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls); footprints with holes noted in S8.3; storeys that differ (plans per storey, [ADR-015](ADR-015-plans-per-storey.md)) noted in S8.7
  1. Replace
The four methods need no rule of their own for a footprint with holes (a court, [ADR-002](ADR-002-geometry.md)). The court lies outside every zone footprint, so the designation on the stack gives it no floor or ceiling piece: the ground and roof areas are the footprint's area without the court, and a storey with the same court as the storey below is fully supported. The storey outlines of the single-zone methods are unions of tiles that keep the hole, one part per outline with an inner ring per court, and their rebuilt walls include the court walls of every storey, which must cover the source court façades (D-041) and get one centred window each with the glazed area they cover (D-079). Every combination of a plan simplifier and a floor aggregator validates for the enclosed court of `HDHM-TYP-005`, canonical and at the two rotated placements of the test suite.

### Heat transfer is a conversion rule (D-069)

with

The four methods need no rule of their own for a footprint with holes (a court, [ADR-002](ADR-002-geometry.md)). The court lies outside every zone footprint, so the designation on the stack gives it no floor or ceiling piece: the ground and roof areas are the footprint's area without the court, and a storey with the same court as the storey below is fully supported. The storey outlines of the single-zone methods are unions of tiles that keep the hole, one part per outline with an inner ring per court, and their rebuilt walls include the court walls of every storey, which must cover the source court façades (D-041) and get one centred window each with the glazed area they cover (D-079). Every combination of a plan simplifier and a floor aggregator validates for the enclosed court of `HDHM-TYP-005`, canonical and at the two rotated placements of the test suite.

### Storeys that differ (S8.7)

A generator may return one plan per storey (D-095, [ADR-015](ADR-015-plans-per-storey.md)); its storeys are floor entries with multiplier 1, bottom to top, and the four methods need no rule of their own. The designation on the stack makes the part of a lower storey's ceiling that the storey above does not cover an outdoor roof piece (a terrace) and the covered part a piece between the storeys: `Interzone` in `Stack`, `Adiabatic` in `StackedFloorZoneMultiplier`, `Interzone` between `E{k}` and `E{k+1}` in `SingleZonePerFloorType`, which makes one zone per storey, and internal mass in `SingleZoneBuilding`. `Building.RoofArea` counts terraces with the top roof, so for storeys that each stand on the storey below it equals the ground area, and no storey is unsupported. A multiplier above 1 repeats its storey as for any floor and is allowed; `StackedFloorZoneMultiplier` then splits every storey with a terrace off as × 1 (D-046). `TerraceTests` (`Lod.Core.Tests`) and the stepped band of `SYN-TYP-012` through every simplifier and aggregator, canonical and at the two rotated placements, confirm this; nothing in this ADR changed for it.

### Heat transfer is a conversion rule (D-069)
  1. Replace
- The thermal mass of partitions is still not represented at Z1–Z3 or in the single-zone methods (D-034); the slabs within a single-zone group are internal mass (D-031).

with

- The thermal mass of partitions is still not represented at Z1–Z3 or in the single-zone methods (D-034); the slabs within a single-zone group are internal mass (D-031).
- Storeys that differ, from a generator that returns one plan per storey, are ordinary floor entries; their terraces are designated by the rule above ([ADR-015](ADR-015-plans-per-storey.md), S8.7).
  • [x] Step 4: Edit docs/architecture/pipeline.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  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; 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; stage S8.2 (D-084) adds the non-rectangular families without holes, the point plate, the winged band, and the open court, and makes the united zones and the edge tests of the surface builder exact on rotated plans; stage S8.3 (D-084) adds the enclosed court, the first footprint with a hole, which every simplifier and aggregator supports; stage S8.4 (D-093 to D-098, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)) adds the non-residential space types with their presets and default preset components, and the office plate and the cafeteria of the four-source precedent set; stage S8.5 (D-098, D-100) adds the four families that branch from a hub on the shared hub-and-arms layout, the radial lobes, the branching mall, the care hub, and the foyer with halls, and makes the perimeter zones of *Perimeter Core* keep the footprint's own vertices; stage S8.6 (D-098) adds the operating suite with separated clean and dirty routes and the court cluster, the first footprint with several holes. 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; stage S8.2 (D-084) adds the non-rectangular families without holes, the point plate, the winged band, and the open court, and makes the united zones and the edge tests of the surface builder exact on rotated plans; stage S8.3 (D-084) adds the enclosed court, the first footprint with a hole, which every simplifier and aggregator supports; stage S8.4 (D-093 to D-098, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)) adds the non-residential space types with their presets and default preset components, and the office plate and the cafeteria of the four-source precedent set; stage S8.5 (D-098, D-100) adds the four families that branch from a hub on the shared hub-and-arms layout, the radial lobes, the branching mall, the care hub, and the foyer with halls, and makes the perimeter zones of *Perimeter Core* keep the footprint's own vertices; stage S8.6 (D-098) adds the operating suite with separated clean and dirty routes and the court cluster, the first footprint with several holes; stage S8.7 (D-095, D-098, [ADR-015](../decisions/ADR-015-plans-per-storey.md)) adds generators that return one plan per storey and the stepped band, whose lower roofs become terraces. 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
                         FoyerHalls | OperatingSuite | CourtCluster

with

                         FoyerHalls | OperatingSuite | CourtCluster
                         MultiStoreyPlanGenerator<P, Presets>:              → IGeneratedPlan per storey, bottom-up (ADR-015)
                         SteppedBand
  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-s86)).

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-s87)).
  1. Replace
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, `RadialLobesGenerator`, `BranchingMallGenerator`, `CareHubGenerator`, `FoyerHallsGenerator`, `OperatingSuiteGenerator`, `CourtClusterGenerator`, 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*, *Point Plate*, *Winged Band*, *Open Court*, *Enclosed Court*, *Office Plate*, *Cafeteria*, *Radial Lobes*, *Branching Mall*, *Care Hub*, *Foyer Halls*, *Operating Suite*, *Court Cluster*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070, D-082, D-083, D-084, D-093, D-094, D-097, D-098, D-100; 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) |

with

| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, `RadialLobesGenerator`, `BranchingMallGenerator`, `CareHubGenerator`, `FoyerHallsGenerator`, `OperatingSuiteGenerator`, `CourtClusterGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `MultiStoreyPlanGenerator<TParameters, TPresets>` → `IReadOnlyList<IGeneratedPlan>`, one plan per storey bottom-up (ADR-015): `SteppedBandGenerator` with `SteppedBandParameters` and `SteppedBandPresets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Point Plate*, *Winged Band*, *Open Court*, *Enclosed Court*, *Office Plate*, *Cafeteria*, *Radial Lobes*, *Branching Mall*, *Care Hub*, *Foyer Halls*, *Operating Suite*, *Court Cluster*, *Stepped Band*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070, D-082, D-083, D-084, D-093, D-094, D-095, D-097, D-098, D-100; 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), [ADR-015](../decisions/ADR-015-plans-per-storey.md) |
  1. Replace
- Non-residential families place one zone per department (D-097, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)); their default dimensions are controller design defaults (D-098). The remaining family of the four-source set, `SYN-TYP-012`, is specified in ADR-006 and built in S8.7.

with

- Plans per storey (S8.7, D-095, [ADR-015](../decisions/ADR-015-plans-per-storey.md)): a `MultiStoreyPlanGenerator` returns one ordinary plan per storey, bottom-up, for a family whose plan differs by storey. It checks the parameters and presets once and builds every storey as `PlanGenerator` builds its plan; each plan lies at elevation 0, has the parameters' floor height and orientation, and records its storey index as the provenance parameter `Storey` (from 0). A diagnostic of one storey keeps its code and subject, and its message starts with `Storey k: `.
- Stepped band (`SYN-TYP-012`, S8.7): `StoreyCount` (≥ 2, default 3) storeys of a `Length` 36 m band; on storey `k` the dwellings `U1…Un` (west to east, `n = max(1, round((Length − StairWidth) / UnitWidth))`, five of 6.4 m by default) span from `k · StepDepth` (3 m) to `BaseDepth` (14 m), beside a west stair `ST` (`StairWidth` 4 m) over the same depth, and the corridor `CO` (`CorridorWidth` 2 m) runs along the rear on every storey, adjoined by every dwelling. Each storey stands on the one below, which keeps a terrace of `Length × StepDepth` (108 m² by default), an outdoor roof once stacked (D-068); the walls facing the terraces are outdoor walls with windows. `Length` must exceed `StairWidth`, and `BaseDepth − (StoreyCount − 1) · StepDepth` must be positive (`InvalidParameter`). 576, 468, and 360 m² by default, 11 windows per storey.
- Non-residential families place one zone per department (D-097, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)); their default dimensions are controller design defaults (D-098). The last family of the four-source set, `SYN-TYP-012`, is built in S8.7 as the first generator of one plan per storey ([ADR-015](../decisions/ADR-015-plans-per-storey.md)); with it every family of D-093 except the set-aside `SYN-TYP-013` (D-096) has a generator, eighteen in all.
  1. Replace
The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines whose vertices are the zones' own vertices (S8.2). An outline keeps the footprint's holes, its rebuilt walls include the court walls of every storey, and the court has no floor or ceiling piece, so it adds neither ground nor roof area (S8.3). Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).

with

The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines whose vertices are the zones' own vertices (S8.2). An outline keeps the footprint's holes, its rebuilt walls include the court walls of every storey, and the court has no floor or ceiling piece, so it adds neither ground nor roof area (S8.3). Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).

Storeys that differ (S8.7, [ADR-015](../decisions/ADR-015-plans-per-storey.md)): the plans of a multi-storey generator, each simplified on its own, are floor entries with multiplier 1, bottom to top, and every aggregator takes them unchanged. The part of a lower storey's ceiling that the storey above does not cover is an outdoor roof piece (a terrace), the covered part lies between the storeys; `StackedFloorZoneMultiplier` then models every storey, `SingleZonePerFloorType` makes one zone per storey, and `SingleZoneBuilding` one zone with one part per storey outline. `Building.RoofArea` counts the terraces with the top roof. A multiplier above 1 repeats a storey as for any floor and is allowed, but gives another building than the generator's.
  1. Replace
## Grasshopper components through S8.6

with

## Grasshopper components through S8.7
  1. Replace
- **Preview Location (D-062).** The seventeen 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.

with

- **Preview Location (D-062).** The eighteen 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
- **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. *Point Plate*, *Winged Band*, and *Open Court* (S8.2) follow the same pattern with the preset inputs *Dwelling Unit* and *Stair*; on *Point Plate* the *Stair* preset is the core's. *Enclosed Court* (S8.3) follows the same pattern; its plan previews the court as a hole, and every zone, floor, and ceiling Brep of a footprint with holes is one face or closed extrusion with an inner loop per hole (`RhinoGeometry`). *Office Plate* and *Cafeteria* (S8.4) follow the same pattern with the preset inputs *Office* and *Core*, and *Kitchen* and *Dining*; their descriptions name the `SYN-TYP-*` family. The office work area is previewed as a ring around the core. *Radial Lobes*, *Branching Mall*, *Care Hub*, and *Foyer Halls* (S8.5) follow the same pattern, their count input (*Lobe Count*, *Branch Count*, *Wing Count*, *Hall Count*) an integer first, with the preset inputs *Dwelling Unit* and *Stair* (the core's); *Mall* and *Retail*; *Care Bedroom*, *Care Communal*, and *Corridor*; and *Lobby*, *Activity Hall*, and *Service*. *Operating Suite* and *Court Cluster* (S8.6) follow the same pattern, their count input (*Bank Count*, *Court Count*) an integer first, with the preset inputs *Operating Theatre*, *Clean Corridor*, *Dirty Corridor*, and *Clinical Support*, and *Dwelling Unit* and *Stair*; the court cluster's plan previews every court as a hole.

with

- **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. *Point Plate*, *Winged Band*, and *Open Court* (S8.2) follow the same pattern with the preset inputs *Dwelling Unit* and *Stair*; on *Point Plate* the *Stair* preset is the core's. *Enclosed Court* (S8.3) follows the same pattern; its plan previews the court as a hole, and every zone, floor, and ceiling Brep of a footprint with holes is one face or closed extrusion with an inner loop per hole (`RhinoGeometry`). *Office Plate* and *Cafeteria* (S8.4) follow the same pattern with the preset inputs *Office* and *Core*, and *Kitchen* and *Dining*; their descriptions name the `SYN-TYP-*` family. The office work area is previewed as a ring around the core. *Radial Lobes*, *Branching Mall*, *Care Hub*, and *Foyer Halls* (S8.5) follow the same pattern, their count input (*Lobe Count*, *Branch Count*, *Wing Count*, *Hall Count*) an integer first, with the preset inputs *Dwelling Unit* and *Stair* (the core's); *Mall* and *Retail*; *Care Bedroom*, *Care Communal*, and *Corridor*; and *Lobby*, *Activity Hall*, and *Service*. *Operating Suite* and *Court Cluster* (S8.6) follow the same pattern, their count input (*Bank Count*, *Court Count*) an integer first, with the preset inputs *Operating Theatre*, *Clean Corridor*, *Dirty Corridor*, and *Clinical Support*, and *Dwelling Unit* and *Stair*; the court cluster's plan previews every court as a hole. *Stepped Band* (S8.7) follows the same pattern, its *Storey Count* an integer first, with the preset inputs *Dwelling Unit*, *Corridor*, and *Stair*; its *Plan* output is a list, one plan per storey bottom-up (ADR-015), and each storey is previewed at its elevation (the floor heights below it) on top of the *Preview Location*, while the plans keep elevation 0.
- **Wiring storeys that differ (S8.7, ADR-015).** Connect the *Stepped Band*'s *Plan* list to a plan simplifier (optionally through *Transform Plan*); Grasshopper runs it once per storey, and its *Floor* output is the list of floors in the same order. Connect that list to a floor aggregator's *Floors* and leave *Multipliers* unconnected: every storey counts once, bottom to top. The simplifiers preview their floors at elevation 0, so the storeys overlap there until they are aggregated. A *Single Zone Building* or *Single Zone per Floor Type* zone whose storey outlines differ is previewed by a boolean union of its storeys, as for setbacks (`RhinoGeometry.MergedZoneBreps`).
  1. Replace
| 2 Generate | *Court Cluster* (CCluster) | Court Count (2), Court Width (24), Court Depth (24), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `CourtClusterComponent` | `01fc2ca9-3ec0-4142-b827-2519a4523d36` | S8.6 |
with
| 2 Generate | *Court Cluster* (CCluster) | Court Count (2), Court Width (24), Court Depth (24), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `CourtClusterComponent` | `01fc2ca9-3ec0-4142-b827-2519a4523d36` | S8.6 |
| 2 Generate | *Stepped Band* (SBand) | Storey Count (3), Length (36), Base Depth (14), Step Depth (3), Corridor Width (2), Unit Width (6), Stair Width (4), Floor Height (3), Orientation (0), Dwelling Unit, Corridor, Stair, Preview Location → Plan (list, one per storey) | `SteppedBandComponent` | `3c685bfb-b45b-45af-98d2-8318a6314922` | S8.7 |
  1. Replace
| --- | --- | --- |
| The plan generator for the four-source family `SYN-TYP-012` with plans per storey, specified in ADR-006 | S8.7 | an ADR on plans per storey (S8.7, D-095) |
with
| --- | --- | --- |
  • [x] Step 5: Edit docs/architecture/domain-model.md

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

  1. Replace
- **Pipeline types**: `IGeneratedPlan`, `IFloor`, `IGeneratedBuilding`, `IPlanSimplifier`, `IFloorAggregator`, the `PlanGenerator` base with `LinearPlanGenerator` (in `Lod.Generators`), `NoSimplification`, `Stack`, and provenance records of each operation, its parameters, and the code version.

with

- **Pipeline types**: `IGeneratedPlan`, `IFloor`, `IGeneratedBuilding`, `IPlanSimplifier`, `IFloorAggregator`, the `PlanGenerator` base with `LinearPlanGenerator` (in `Lod.Generators`), `NoSimplification`, `Stack`, and provenance records of each operation, its parameters, and the code version.
- **Plans per storey (S8.7, [ADR-015](../decisions/ADR-015-plans-per-storey.md))**: `MultiStoreyPlanGenerator<TParameters, TPresets>` (`Lod.Core.Plans`) is the sibling of `PlanGenerator` for a family whose plan differs per storey: `Generate` returns `Result<IReadOnlyList<IGeneratedPlan>>`, one ordinary plan per storey, bottom-up, each with the provenance parameter `Storey` (from 0); the two bases share the preset checks, surfaces, windows, and provenance (`PlanBuilder`, internal). `SteppedBandGenerator` (`SYN-TYP-012`) is the first such generator. The floor aggregators take the storeys as floor entries with multiplier 1; no pipeline type changes.
  • [x] Step 6: Edit docs/research/precedents/syn-typologies.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  1. Replace
| `SYN-TYP-012` | Stepped dwelling bands with exposed terraces | new | FPMH recurrent (2) | recurrent | `SteppedBandGenerator` (specified, plans per storey, D-095) | S8.7 |

with

| `SYN-TYP-012` | Stepped dwelling bands with exposed terraces | new | FPMH recurrent (2) | recurrent | `SteppedBandGenerator` (*Stepped Band*, one plan per storey, D-095, [ADR-015](../../decisions/ADR-015-plans-per-storey.md)) | S8.7 |
  1. Replace
- **Not supported by the source:** setback dimensions, the number of storeys, whether access is by stairs or an internal gallery (both occur), and dimensions. The plan differs by storey, so its generator needs plans per storey (D-095).

with

- **Not supported by the source:** setback dimensions, the number of storeys, whether access is by stairs or an internal gallery (both occur), and dimensions. The plan differs by storey, so its generator returns one plan per storey (D-095, [ADR-015](../../decisions/ADR-015-plans-per-storey.md)); implemented in S8.7 with a rear corridor as the shared circulation edge, a west stair, and steps on the south side only.
  • [x] Step 7: Edit docs/research/program-presets.md

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

  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." S8.4 adds one default preset component per non-residential space type of §2.2, *Office Preset*, *Core Preset*, *Retail Preset*, *Mall Preset*, *Kitchen Preset*, *Dining Preset*, *Operating Theatre Preset*, *Clean Corridor Preset*, *Dirty Corridor Preset*, *Clinical Support Preset*, *Care Bedroom Preset*, *Care Communal Preset*, *Lobby Preset*, *Activity Hall Preset*, and *Service Preset*, with the same inputs; *Gas Equipment* is in W/m², and *Ventilation* and *Infiltration* are in 1/h. Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s86).

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." S8.4 adds one default preset component per non-residential space type of §2.2, *Office Preset*, *Core Preset*, *Retail Preset*, *Mall Preset*, *Kitchen Preset*, *Dining Preset*, *Operating Theatre Preset*, *Clean Corridor Preset*, *Dirty Corridor Preset*, *Clinical Support Preset*, *Care Bedroom Preset*, *Care Communal Preset*, *Lobby Preset*, *Activity Hall Preset*, and *Service Preset*, with the same inputs; *Gas Equipment* is in W/m², and *Ventilation* and *Infiltration* are in 1/h. Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s87).
  • [x] Step 8: Append the S8.7 checklist at the end of docs/development/grasshopper-smoke-test.md

Append the content of this code block (not the fence lines around it) after the last line of the file (after the S8.6 result), separated by one blank line, so that it nests under ## Stage checklists like the S2–S8.6 checklists:

### S8.7 checklist — plans per storey and the stepped band (`v0.8.6`)

S8.7 adds one plan generator component in *2 Generate* ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)), the stepped dwelling band with exposed terraces (`SYN-TYP-012`), the first generator that returns one plan per storey ([ADR-015](../decisions/ADR-015-plans-per-storey.md)). Its *Plan* output is a list; no existing component, input, output, or GUID changes.

#### Toolbar and icons

- [ ] *2 Generate* shows, after *Court Cluster*, *Stepped Band* (a section of three green storeys stepping back from the left, with a dark rear corridor on the right), with its own green icon.
- [ ] *Stepped Band* (SBand) inputs, in order, with their defaults: Storey Count 3, Length 36, Base Depth 14, Step Depth 3, Corridor Width 2, Unit Width 6, Stair Width 4, Floor Height 3, Orientation 0, Dwelling Unit, Corridor, Stair, Preview Location; one output, *Plan*, with list access.
- [ ] Storey Count is an integer. With a preset input unconnected, the generator shows Grasshopper's missing-input warning and outputs nothing (D-065); a *Stair Preset* on the *Corridor* input gives `Error PresetSpaceTypeMismatch [Corridor]: Input Corridor needs a Corridor preset, got 'Example Stair' (Stair).` once, not once per storey.

#### Default plans

1. Place *Stepped Band* with *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* connected; connect *Inspect* to its *Plan*.
   - [ ] No runtime message; *Plan* holds three plans, bottom-up, of 576 m², 468 m², and 360 m²: storey `k` spans x = 0–36 m and y = 3k–16 m, with `ST` at x = 0–4 m, `U1` to `U5` of 6.4 m each, and `CO` at y = 14–16 m over the full length.
   - [ ] The preview shows the three storeys stacked at 0 m, 3 m, and 6 m, each lower storey with a 36 m × 3 m terrace in front of the one above; with *Preview Location* 0, 40, 0 the whole stack moves, and the plans' data stay at elevation 0 (*Inspect*: every zone `z=0`).
   - [ ] 11 windows per storey (33 in all, 201.6 m² of glazing); on storeys 1 and 2 the south walls of `ST` and `U1` to `U5`, which face the terrace below, have windows.
   - [ ] The three *Inspect* reports equal the three sections `storey 0` to `storey 2` of `tests/Lod.Generators.Tests/SteppedBand/Snapshots/stepped-band-canonical.txt`, and each *Provenance* shows `SteppedBandGenerator … Storey=k …`.

#### Combinations

2. Connect the *Plan* list to each of the four plan simplifiers (*Perimeter Core* Depth 4.57) and each simplifier's *Floor* list to the four floor aggregators, with *Multipliers* unconnected.
   - [ ] Every simplifier outputs three floors; *Semantic Merge* gives each storey `ST`, `CO`, and one dwelling zone; *Perimeter Core* gives each storey `P-North`, `P-East`, `P-South`, `P-West`, and `CORE`.
   - [ ] *Stack Floors* (NoSimplification): the building is 9 m high; *Inspect* shows on storeys 0 and 1 ceiling pieces facing `Outdoors` of 108 m² in all (the terrace: 12 m² of `ST` and 19.2 m² of each dwelling) and the rest `Interzone` with the storey above; no `UnsupportedStorey` warning.
   - [ ] *Single Zone per Floor Type*: zones `E0`, `E1`, `E2`; *Single Zone Building*: one zone `BUILDING` previewed as one stepped solid, (0, 0, 0)–(36, 16, 9), with its windows.
   - [ ] For all 16 combinations, *Validate* gives Passed = True (only `ConditionedFloorArea` may read `note`), `Building.RoofArea` reads 576, no aggregator shows a warning, and *Convert2BEM* with Override = False converts without an error; every zone Brep has a positive volume (D-091).

#### Counts and rotated placement

3. Set Storey Count to 2, and to 4 with Base Depth 17, and repeat step 2 for each.
   - [ ] Two and four plans; *Validate* gives Passed = True for every combination.
   - [ ] Storey Count 4 with Base Depth 14 through *Perimeter Core*: the top storey (7 m deep) gives `Error PlanTooNarrow: …` and no floor for it; the other three storeys give floors.
4. Put the *Plan* list through *Transform Plan* with a 30° rotation and a move by 5.3, −2.1, 0 (every storey alike), then through each simplifier and aggregator; repeat with 131.4° and 15.08, −153.9, 0, and with Storey Count 4 and Base Depth 17 at 131.4°.
   - [ ] *Validate* gives Passed = True for all 16 combinations of each placement, with the same total glazing as the unrotated band.

#### Parameters

5. Change inputs one at a time and set each back afterwards.
   - [ ] Storey Count 1: `Error InvalidParameter: StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.`
   - [ ] Storey Count 5 with Base Depth 12: `Error InvalidParameter: BaseDepth 12 m leaves no dwelling depth on the top storey: its 4 steps of StepDepth 3 m take 12 m; SYN-TYP-012 needs dwellings on every storey, so reduce StoreyCount or StepDepth, or increase BaseDepth.`
   - [ ] Length 4: `Error InvalidParameter: Length 4 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002).`; Step Depth 0: `Error InvalidParameter: StepDepth must be positive, got 0.`
   - [ ] Corridor Width 0.8: six `WindowOmitted` remarks, two per storey, each starting `Storey k: Outdoor wall CO/…`.
  • [x] Step 9: Check the code fences of the edited documents

Check the code fences of the documents of Tasks 2 and 7: every opening fence line has a language, every closing one is bare, and no file ends inside a block.

python - <<'EOF'
from pathlib import Path

fence = "`" * 3
names = [
    "docs/architecture/domain-model.md",
    "docs/architecture/pipeline.md",
    "docs/decisions/ADR-006-plan-generation-mechanism.md",
    "docs/decisions/ADR-013-vertical-aggregation-methods.md",
    "docs/decisions/ADR-015-plans-per-storey.md",
    "docs/development/grasshopper-smoke-test.md",
    "docs/research/precedents/syn-typologies.md",
    "docs/research/program-presets.md",
]
for name in names:
    lines = Path(name).read_text(encoding="utf-8").split("\n")
    inside, blocks, problems = False, 0, []
    for number, line in enumerate(lines, 1):
        mark = line.strip()
        if not mark.startswith(fence):
            continue
        if not inside and mark == fence:
            problems.append(f"line {number} opens a block without a language")
        if inside and mark != fence:
            problems.append(f"line {number} opens a block inside a block")
        blocks += inside
        inside = not inside
    if inside:
        problems.append("the file ends inside a block")
    print(f"{name}: {blocks} code blocks, " + ("; ".join(problems) or "fences balanced"))
EOF

Expected:

docs/architecture/domain-model.md: 3 code blocks, fences balanced
docs/architecture/pipeline.md: 1 code blocks, fences balanced
docs/decisions/ADR-006-plan-generation-mechanism.md: 0 code blocks, fences balanced
docs/decisions/ADR-013-vertical-aggregation-methods.md: 0 code blocks, fences balanced
docs/decisions/ADR-015-plans-per-storey.md: 0 code blocks, fences balanced
docs/development/grasshopper-smoke-test.md: 2 code blocks, fences balanced
docs/research/precedents/syn-typologies.md: 0 code blocks, fences balanced
docs/research/program-presets.md: 1 code blocks, fences balanced

If a line reports anything else, stop: find the stray or missing fence line, fix the file, and run the check again.

  • [x] Step 10: Check the links and commit
git grep -n "grasshopper-components-through-s86" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s87" -- docs/architecture/pipeline.md docs/research/program-presets.md
git grep -c -e "Implemented (S8.7)" -e "### Pipeline consequences of S8.7" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git grep -c "ADR-015-plans-per-storey.md" -- docs/architecture docs/decisions docs/development docs/research
git add docs/decisions/ADR-006-plan-generation-mechanism.md docs/decisions/ADR-013-vertical-aggregation-methods.md docs/architecture/pipeline.md docs/architecture/domain-model.md docs/research/precedents/syn-typologies.md docs/research/program-presets.md docs/development/grasshopper-smoke-test.md
git commit -m "docs(architecture): document plans per storey, the stepped band, and its component"

Expected: no output from the first command; docs/architecture/pipeline.md:1, docs/research/program-presets.md:1 from the second; docs/decisions/ADR-006-plan-generation-mechanism.md:2 from the third; and docs/architecture/domain-model.md:1, docs/architecture/pipeline.md:5, docs/decisions/ADR-006-plan-generation-mechanism.md:7, docs/decisions/ADR-013-vertical-aggregation-methods.md:3, docs/development/grasshopper-smoke-test.md:1, docs/research/precedents/syn-typologies.md:2 from the fourth.

Task 8: Smoke test (the controller headless, D-056; viewport items for a person)

Files: none (the result goes into docs/development/grasshopper-smoke-test.md and the body of the close-out commit, Task 9)

  • [x] Step 1: Build the branch and check that it is the tested code
dotnet build BEMGen.sln -c Release
git rev-parse HEAD
git diff --stat a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b HEAD -- src tests scripts

Expected: Build succeeded., 0 Warning(s), 0 Error(s); note the SHA; the last command prints nothing: src/, tests/, and scripts/ are identical to the verified reference a98d4c5 on which the controller ran the headless session (D-056) whose values Step 2 lists (note 9). a98d4c5 is the tip of the local reference branch ref/s8.7 in the controller's repository. If the command prints anything, or the commit is not available, stop: the record of Step 2 does not apply, and the session is run again on this branch.

  • [x] Step 2: The S8.7 checklist and its record

The controller ran 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, the preview offsets and bounding boxes of every PlanGoo, Brep face, solid, and validity counts, Brep.GetVolume, and BrepFace.NormalAt at each face's domain centre written to a file; 18 scenarios) on the verified reference a98d4c5 in Rhino 8.25.25314.11001 (.NET 8.0.31); the reference assembly reported the informational version 0.8.5+a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b (the bump to 0.8.6 comes in Task 9). "The presets" are Dwelling Unit Preset, Corridor Preset, and Stair Preset with nothing connected; the Plan list is wired item-wise through each simplifier into the aggregators' Floors with Multipliers 1 per storey. Observed, and all passed:

  1. Registration. 53 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, primary: Stepped Band SBand 3c685bfb-b45b-45af-98d2-8318a6314922. Every other component and parameter as in S8.6.
  2. Inputs. The inputs, nicknames, and defaults of the table of Task 6, in that order: Storey Count a Param_Integer with persistent value 3, the lengths Param_Number, the presets required ProgramPresetParameter inputs, Preview Location an optional vector {0,0,0}. With the presets unconnected, the warning state with Input parameter DU failed to collect data, Input parameter Co failed to collect data, and Input parameter St failed to collect data, and no output.
  3. Default plans. Stepped Band with the presets → Inspect; → No Simplification → Stack Floors → Convert2BEM and Validate; and → Semantic Merge, Perimeter Core (default depth), and Single Zone per Floor: no runtime message. Plan holds three plans, bottom-up, Plan (7 zones, SteppedBandGenerator) each; Inspect footprint area=576.000000, 468.000000, and 360.000000, each with floorHeight=3.000000 orientation=0.000000; the previews offset by (0, 0, 0), (0, 0, 3), and (0, 0, 6), boxes (0, 0, 0)–(36, 16, 3), (0, 3, 3)–(36, 16, 6), and (0, 6, 6)–(36, 16, 9). Each simplifier returns three floors: No Simplification 7 zones, Semantic Merge 3, Perimeter Core 5, Single Zone per Floor 1 per storey. Stack Floors builds Building (21 zones, Stack), with multipliers 1, 1, 1 and also with its Multipliers input left at its default; Validate Passed = True, Building.RoofArea reference 576, actual 576, and Building.GroundArea reference 576, actual 576; Convert2BEM Boundaries Adiabatic 94, Ground 7, Outdoors 52; the preview shows 33 windows.
  4. Terraces. Single Zone per Floor → Stack Floors: the ground storey's ceiling has a piece between storeys over y = 3–16 m (Adiabatic in Convert2BEM, normal +z) and a terrace piece facing outdoors over y = 0–3 m (+z); the middle storey likewise (y = 6–16 m between storeys, y = 3–6 m terrace); the top ceiling is a roof (+z); the floors above the ground face −z (Adiabatic), the ground floor −z (Ground).
  5. Breps. 42 zone Breps of Convert2BEM inspected (Stack Floors over No Simplification 21, over Perimeter Core 15, and over Single Zone per Floor 3, and Single Zone Building 3, one zone of three parts), all valid closed solids of 6 faces, no negative volume anywhere; Single Zone Building (1, 1, 1) previews one solid (0, 0, 0)–(36, 16, 9) built of three parts, Validate Passed = True, Building.RoofArea 576.
  6. Every combination. The band on its canonical placement, through Transform Plan with a 30° rotation and a move by (5.3, −2.1, 0) and with a 131.4° rotation and a move by (15.08, −153.9, 0), and with 2 storeys and with 4 storeys and Base Depth 17 m at the 131.4° placement, each through the four plan simplifiers and the four floor aggregators: Validate Passed = True for all 80 buildings, no error, Building.RoofArea passed.
  7. Parameters. Storey Count 1: Error InvalidParameter: StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.; Base Depth 6 with three storeys: Error InvalidParameter: BaseDepth 6 m leaves no dwelling depth on the top storey: its 2 steps of StepDepth 3 m take 6 m; SYN-TYP-012 needs dwellings on every storey, so reduce StoreyCount or StepDepth, or increase BaseDepth.; Length 3: Error InvalidParameter: Length 3 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002).; Step Depth 0: Error InvalidParameter: StepDepth must be positive, got 0.; 4 storeys with the default 14 m base depth into Perimeter Core: Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier. (the 7 m top storey).

Items that a headless session cannot observe stay open for a person (D-056, D-058): the toolbar and canvas icons, the viewport previews (the stepped storeys, the terraces, the zone colours), the Inspect reports compared with the canonical snapshot (the generator tests check them), the zone IDs of Semantic Merge and Perimeter Core (the integration tests check them), the glazing values of Validate, the WindowOmitted remarks with a 0.8 m corridor, a wrong preset on the Corridor input, and Convert2BEM with Override = False on each of the combinations. No example definition is part of S8.7; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).

If Step 1 printed a difference, or a person or a repeated session finds a failure, the stage stops: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Task 9 records this result in the guide and in the close-out commit, following the guide's "Recording the result".

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

Expected: VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0. Do not push (D-098).


Stage close-out

Task 9: Version 0.8.6, S8.7 records, tag v0.8.6

Files: - Modify: Directory.Build.props, AGENTS.md, README.md, docs/plans/2026-09-30-implementation-roadmap.md, docs/development/grasshopper-smoke-test.md, docs/decisions/ADR-015-plans-per-storey.md, docs/decisions/decision-log.md, docs/plans/2026-10-02-s8.7-per-storey-plans-stepped-band.md (this plan: checkboxes and status, D-051)

Interfaces: - Consumes: the merged Slices A–D; the smoke-test record of Task 8; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump. - Produces: the tag v0.8.6; the S8.7 records: the project state (S8 complete, eighteen plan generators, S6 next), the status, the roadmap's Progress line and detailed-plans row, the smoke-test result in the guide, ADR-015's Decisions line citing D-106, and the decision-log entries D-106 (plans per storey: the sibling base, no aggregator change, list wiring, multipliers, the stepped band's extra check Length > StairWidth, the four-storey Perimeter Core case, and the thin top-storey core below the size of D-090 that still validates) and D-107 (S8.7 complete, S8 complete, S6 next).

The decision-log entries of Step 9 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 entries are decided by the controller (unsupervised run, D-098) and Provisional. The close-out uses its own branch, as the S1–S8.6 close-outs do. Text that this task appends or inserts is the content of its code blocks only: the fence lines around a block are never copied, and Step 10 checks every file.

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

Expected: VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0.

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

Replace

    <Version>0.8.5</Version>

with

    <Version>0.8.6</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.6</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.6"
  • [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 one blank line after the first and before the second) with the content of this block:

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-stages S8.1 (tag `v0.8.0`), S8.2 (tag `v0.8.1`), S8.3 (tag `v0.8.2`, D-084), S8.4 (tag `v0.8.3`, D-101), S8.5 (tag `v0.8.4`, D-103), S8.6 (tag `v0.8.5`, D-105), and S8.7 (tag `v0.8.6`, D-107): S8 is complete, with a plan generator for each of the 18 families of the four-source set except the set-aside `SYN-TYP-013` (D-096). The pipeline runs end to end for eighteen plan generators: program presets (the residential `ExampleResidentialPresets` and, for the non-residential departments of [ADR-014](docs/decisions/ADR-014-program-types-and-department-zoning.md), `ExampleNonResidentialPresets`, one typed preset and default preset component per space type) → a plan generator (one `PlanGenerator` subclass per typology family with typed parameter and preset records, [ADR-006](docs/decisions/ADR-006-plan-generation-mechanism.md); `SYN-TYP-001` to `009` are the *High-Density Housing* families `HDHM-TYP-001` to `009`, D-099: `LinearPlanGenerator`, the double-sided internal corridor bar `SYN-TYP-009`; 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; the non-rectangular families `PointPlateGenerator` 007, a square plate whose core has no outdoor wall, `WingedBandGenerator` 003, a comb, and `OpenCourtGenerator` 004, a U; `EnclosedCourtGenerator` 005, four wings around a court closed on all four sides, the first footprint with a hole; and the non-residential `OfficePlateGenerator` `SYN-TYP-017`, whose office work area is one ring-shaped zone around a central `Core` zone, `CafeteriaGenerator` `SYN-TYP-016`, a `Kitchen` zone along the whole north edge of a larger `Dining` zone; the hub-and-arms families on the shared `HubArmsLayout`, a square hub with up to four arms (D-100, D-102): `RadialLobesGenerator` 010, `BranchingMallGenerator` 014, `CareHubGenerator` 018, and `FoyerHallsGenerator` 019; the operating suite `OperatingSuiteGenerator` 015, theatre banks between alternating clean and dirty corridors beside the support rooms, and the court cluster `CourtClusterGenerator` 011, stair-bay wings around a row of courts, the first footprint with several holes (D-104); and `SteppedBandGenerator` (`SYN-TYP-012`), the first `MultiStoreyPlanGenerator`, which returns one plan per storey, bottom-up; each storey is simplified on its own and stacked by any floor aggregator with multiplier 1, and the part of a lower roof that the storey above leaves free is a terrace, an outdoor roof designated on the assembled stack (D-068, [ADR-015](docs/decisions/ADR-015-plans-per-storey.md)); the non-residential ones zoned one zone per department, D-097; 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. Merged zones are unions of their sources as tiles that keep the sources' own vertices, so they may have holes and stay exact on rotated plans, and vertices on edges are recognised at their original positions (D-088, [ADR-002](docs/decisions/ADR-002-geometry.md)). Every step supports footprints with holes: a court is outside the building, its edges are outdoor walls facing into it, every footprint edge, court edges included, must be covered by outdoor zone edges (ADR-002), and `PerimeterCore` gives the court façades their own zones `P-Court-<Bin>` around a core that is a ring, several of them in the court cluster, where *Perimeter Core* gives each court its own `P-Court-<Bin>-k` zones ([ADR-008](docs/decisions/ADR-008-perimeter-core-corners.md)); `PerimeterCore` needs every part of a footprint, including the wings between the outer and the court façades, to be wider than twice its depth and otherwise fails with `PlanTooNarrow` (D-087). Its perimeter zones keep the footprint's own vertices, so short façades stay covered on rotated plans (ADR-008, S8.5). 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. In *Convert2BEM* and the previews, zone solids are outward-oriented and every surface and window faces out of its zone (D-091). ADR-006, ADR-011, ADR-012, ADR-013, ADR-014, ADR-015, and the S4.2, S5, S8.1, S8.2, S8.3, S8.4, S8.5, S8.6, and S8.7 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084, D-098). 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).

The precedent set is the four-source family set `SYN-TYP-001` to `SYN-TYP-019` of every program type, housing and non-residential ([docs/research/precedents/syn-typologies.md](docs/research/precedents/syn-typologies.md), D-093, D-094); `SYN-TYP-013` is set aside (D-096), non-residential floors are zoned one zone per department (D-097, [ADR-014](docs/decisions/ADR-014-program-types-and-department-zoning.md)), and BEMGen documents use the `SYN-TYP-*` IDs (D-099). The families `017` and `016` were built in S8.4, `010`, `014`, `018`, `019` in S8.5, the hub-and-arms families with at most four arms because the shared layout is axis-aligned (D-100), `015` and `011` in S8.6, and `012` in S8.7, with one plan per storey (D-095, D-106): all nine new families are built (D-098).

Not implemented yet: `Lod.Export` and Convert2IDF (S6, next), 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). A zone only about 1 m wide on a rotated plan can fail validation after a merging simplifier, a known limit of clipping on the grid (D-090). 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 one blank line after the first and before the second) with the content of this block:

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-stages S8.1 (tag `v0.8.0`), S8.2 (tag `v0.8.1`), S8.3 (tag `v0.8.2`), S8.4 (tag `v0.8.3`), S8.5 (tag `v0.8.4`), S8.6 (tag `v0.8.5`), and S8.7 (tag `v0.8.6`), which completes S8. In Rhino 8 the plugin runs the whole pipeline for eighteen plan generators: program presets with conditioning (a default preset component per space type, housing and non-residential); the linear plan generator and the eight other typologies of the *High-Density Housing* precedent study (`SYN-TYP-001`–`009`, ADR-006), the bars *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row*, the non-rectangular *Point Plate*, *Winged Band*, and *Open Court*, *Enclosed Court*, whose footprint has the court as a hole, the non-residential *Office Plate* (`SYN-TYP-017`, a work-area ring around a central core) and *Cafeteria* (`SYN-TYP-016`, a kitchen and servery edge beside a dining field), and the branching plans on one hub-and-arms layout, *Radial Lobes* (`SYN-TYP-010`), *Branching Mall* (`SYN-TYP-014`), *Care Hub* (`SYN-TYP-018`), *Foyer Halls* (`SYN-TYP-019`), the operating suite *Operating Suite* (`SYN-TYP-015`, theatre banks between separate clean and dirty corridors), *Court Cluster* (`SYN-TYP-011`, wings around a row of courts, one hole per court), and *Stepped Band* (`SYN-TYP-012`, a dwelling band that steps back storey by storey so that each lower roof keeps a terrace, the first generator with one plan per storey, [ADR-015](docs/decisions/ADR-015-plans-per-storey.md)), the non-residential ones zoned one zone per department (D-097, ADR-014), 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); merged zones stay exact on rotated plans (D-088); every simplifier and aggregator supports footprints with one or more holes, with the court façades as outdoor walls; and zone solids, surfaces, and windows are oriented out of their zones (D-091). 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), then Grasshopper UX and release (S9). 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, one per typology family (the linear plan, the S8.1 bars, the S8.2 non-rectangular families, the S8.3 enclosed court, the S8.4 office plate and cafeteria, the S8.5 hub-and-arms families, and the S8.6 operating suite and court cluster)

with

  Lod.Generators/    plan generators, one per typology family (the linear plan, the S8.1 bars, the S8.2 non-rectangular families, the S8.3 enclosed court, the S8.4 office plate and cafeteria, the S8.5 hub-and-arms families, the S8.6 operating suite and court cluster, and the S8.7 stepped band with one plan per storey)
  • [x] Step 6: Update the roadmap

In docs/plans/2026-09-30-implementation-roadmap.md, make these replacements, in order; each "Replace" text occurs exactly once in the file. The first adds the S8.7 Progress line below the S8.6 Progress line (the last > **Progress:** line), with a > separator line between them; the second adds the S8.7 row of the detailed-plans table.

  1. Replace
> **Progress:** 2026-10-02 · S8.6 Operating suite and court clusters complete (tag `v0.8.5`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.7. Two new generators and components: *Operating Suite* (015, theatre banks between alternating clean and dirty corridors beside the support rooms) and *Court Cluster* (011, stair-bay wings around a row of courts, the first footprint with several holes, which every step already handles; D-104); existing snapshots byte-identical, two new; 193 core, 427 generator, and 1385 integration tests pass on `net8.0`.

with

> **Progress:** 2026-10-02 · S8.6 Operating suite and court clusters complete (tag `v0.8.5`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.7. Two new generators and components: *Operating Suite* (015, theatre banks between alternating clean and dirty corridors beside the support rooms) and *Court Cluster* (011, stair-bay wings around a row of courts, the first footprint with several holes, which every step already handles; D-104); existing snapshots byte-identical, two new; 193 core, 427 generator, and 1385 integration tests pass on `net8.0`.
>
> **Progress:** <date> · S8.7 Plans per storey and the stepped band complete (tag `v0.8.6`; `main` and the tag pushed after the fresh-clone gate, D-098); S8 is complete with 18 plan generators; next: S6. ADR-015 decides plans per storey (D-095, D-106): a `MultiStoreyPlanGenerator` returns one plan per storey, bottom-up, sharing every rule of `PlanGenerator`, and every simplifier and floor aggregator takes the storeys unchanged, the terraces designated on the assembled stack (D-068); new generator and component *Stepped Band* (012, a dwelling band stepping back storey by storey, with a *Plan* list); existing snapshots byte-identical, one new; 210 core, 463 generator, and 1485 integration tests pass on `net8.0`.
  1. Replace
| S8.6 | [2026-10-02-s8.6-operating-suite-court-cluster.md](2026-10-02-s8.6-operating-suite-court-cluster.md) | 193 core, 427 generators, 1385 integration |

with

| S8.6 | [2026-10-02-s8.6-operating-suite-court-cluster.md](2026-10-02-s8.6-operating-suite-court-cluster.md) | 193 core, 427 generators, 1385 integration |
| S8.7 | [2026-10-02-s8.7-per-storey-plans-stepped-band.md](2026-10-02-s8.7-per-storey-plans-stepped-band.md) | 210 core, 463 generators, 1485 integration |

The S8.7 row of the sub-stage table (written in S8.4) stays as it is.

  • [x] Step 7: Record the smoke test in docs/development/grasshopper-smoke-test.md

Append the content of this code block (not the fence lines around it) at the end of docs/development/grasshopper-smoke-test.md (after the S8.7 checklist of Task 7), separated by one blank line; it records Task 8 as the guide's "Recording the result" asks, and the commit body of Step 11 repeats it:

#### Result (S8.7)

Run by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02, on the verified reference of this sub-stage (`a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b`, whose `src/`, `tests/`, and `scripts/` are identical to the executed branch): Rhino 8.25.25314.11001, .NET 8.0.31, `BEMGen.gha` built in Release with 0 warnings, *BEMGen Info* version `0.8.5+a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b` (the version becomes 0.8.6 at the close-out); 18 scenarios.

- Passed: *Stepped Band* (SBand) in *2 Generate*, primary, with the GUID of the S8.7 plan and a 24 × 24 icon; the integer Storey Count first, the presets in record order (Dwelling Unit, Corridor, Stair), *Preview Location* last; unconnected presets give Grasshopper's missing-input warnings.
- Passed: the *Plan* output holds three plans by default, bottom-up (`Plan (7 zones, SteppedBandGenerator)` each), footprints 576, 468, and 360 m²; each storey's preview is drawn at its elevation (offsets (0, 0, 0), (0, 0, 3), and (0, 0, 6); boxes (0, 0, 0)–(36, 16, 3), (0, 3, 3)–(36, 16, 6), and (0, 6, 6)–(36, 16, 9)). Wired item-wise, each simplifier returns three floors (*No Simplification* 7 zones, *Semantic Merge* 3, *Perimeter Core* 5, *Single Zone per Floor* 1 per storey); *Stack Floors* builds `Building (21 zones, Stack)` with multipliers 1, 1, 1 and also with its *Multipliers* input left at its default; *Validate* Passed = `True`; `Building.RoofArea` and `Building.GroundArea` reference 576, actual 576.
- Passed: the terraces (*Single Zone per Floor* → *Stack Floors*): the ground storey's ceiling has a piece between storeys over y = 3–16 m (Adiabatic, +z) and an outdoor terrace piece over y = 0–3 m (Outdoors, +z); the middle storey likewise (y = 6–16 m between storeys, y = 3–6 m terrace); the top ceiling is a roof (+z); the floors above the ground face −z (Adiabatic), the ground floor −z (Ground).
- Passed: the 42 zone Breps inspected are valid solids, with no negative volume anywhere; *Single Zone Building* (1, 1, 1) previews one solid (0, 0, 0)–(36, 16, 9) with three parts, *Validate* Passed = `True`, `Building.RoofArea` 576.
- Passed: all 80 combinations (the band on the canonical plan, rotated by 30° and moved by (5.3, −2.1, 0), rotated by 131.4° and moved by (15.08, −153.9, 0), and with 2 storeys and with 4 storeys of Base Depth 17 m at 131.4°, each through the four simplifiers and the four aggregators with multiplier 1 per storey): *Validate* Passed = `True`, no error, `Building.RoofArea` passed.
- Passed: the error cases (`Error InvalidParameter: StoreyCount must be at least 2 (the band steps back from storey to storey, R001), got 1.`; Base Depth 6: `Error InvalidParameter: BaseDepth 6 m leaves no dwelling depth on the top storey: its 2 steps of StepDepth 3 m take 6 m; …`; Length 3: `Error InvalidParameter: Length 3 m must exceed StairWidth 4 m, so that the band has dwellings beside the stair (R002).`; `Error InvalidParameter: StepDepth must be positive, got 0.`); 4 storeys with the default 14 m base depth into *Perimeter Core*: `Error PlanTooNarrow: …` (the 7 m top storey).
- Not performed, open for a person (D-058): the viewport look of the previews, the icons in the toolbar, the *Inspect* reports compared with the snapshot (the generator tests check them), and example definitions.
  • [x] Step 8: Cite D-106 in ADR-015

In docs/decisions/ADR-015-plans-per-storey.md, make this replacement; the "Replace" text occurs exactly once in the file.

  1. Replace
Decisions: D-018, D-068, D-070, D-071, D-072, D-074, D-085, D-095, D-098; applies

with

Decisions: D-018, D-068, D-070, D-071, D-072, D-074, D-085, D-095, D-098, D-106; applies
  • [x] Step 9: Append the decision-log entries (template; fill <date> now)

Append at the end of docs/decisions/decision-log.md, after a blank line, the content of the two code blocks below (not their fence lines), separated by one blank line. The log ends with D-105 when this plan is approved, so the entries are D-106 and D-107; if the log ends with another number, use the next free numbers, change the references to them (D-107 cites D-106; Tasks 4 and 9 of this plan cite D-106 for the check Length > StairWidth; ADR-015's Decisions line of Step 8 and AGENTS.md of Step 5 cite D-106; AGENTS.md cites D-107; the roadmap Progress line of Step 6 cites D-106) to match, and say so in the commit body.

### D-106 — Plans per storey: a sibling generator base, list wiring, and multipliers

- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Why: D-095 left the interface, the treatment of storeys that differ, and the designation of terraces to an ADR; [ADR-015](ADR-015-plans-per-storey.md) records the options and this decision.
- A family whose plan differs per storey is a `MultiStoreyPlanGenerator<TParameters, TPresets>` (`Lod.Core.Plans`) returning one ordinary `IGeneratedPlan` per storey, bottom-up, sharing the preset checks, surfaces, windows, and provenance with `PlanGenerator` through one internal helper (`PlanBuilder`), so the plans of the sixteen single-plan families are unchanged; each plan records `Storey` (from 0), and a storey's diagnostics are prefixed `Storey k: `. Rejected: every generator returning a list, a storey index parameter, and a new pipeline type for a stack of storeys.
- No floor aggregator, validation rule, or pipeline type changes: the storeys are floor entries with multiplier 1, and the designation on the assembled stack (D-068) already makes the uncovered part of a lower ceiling an outdoor roof (a terrace) and the covered part a piece between storeys, with ground and roof areas that validate (`TerraceTests`, which passed on the S8.6 code). Multipliers above 1 are allowed and repeat a storey; the generator's building is the one with every multiplier 1.
- In Grasshopper the generator's *Plan* output is a list, wired item-wise through a simplifier into an aggregator's *Floors* with *Multipliers* unconnected; each storey's preview is raised to its elevation, while the plans stay at elevation 0.
- `SteppedBandGenerator` additionally requires `Length > StairWidth` (a dwelling row of no width otherwise), a check ADR-006 did not list; its *Implemented (S8.7)* note records it.
- With four storeys of the default 14 m base depth the top storey is 7 m deep and fails *Perimeter Core* with `PlanTooNarrow` (D-087); the integration tests therefore run four storeys with a 17 m base depth and test the default-depth case as that failure, and no default changed. The core of the default band's top storey under *Perimeter Core* is 0.86 m deep, below the about 1 m of D-090, yet every combination validates, at both rotated placements and in a sweep of 50 rotations; D-090 stays the known limit.
### D-107 — S8.7 complete; S8 complete

- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Sub-stage S8.7 (D-098) is complete and tagged `v0.8.6`; after its gate and a fresh clone of the tag pass `scripts/verify.ps1`, `main` and `v0.8.6` are pushed (D-098). [ADR-015](ADR-015-plans-per-storey.md), accepted by the controller under D-098 and pending the owner's reading, fixes plans per storey (D-095, D-106).
- New generator and component in *2 Generate*, with icon: *Stepped Band* (`SteppedBandGenerator`, `SYN-TYP-012`, SBand, `3c685bfb-b45b-45af-98d2-8318a6314922`): a band of a west stair, dwellings, and a rear corridor that every dwelling adjoins, stepping back `StepDepth` per storey so that each lower roof keeps a terrace of `Length × StepDepth`; by default 3 storeys of 576, 468, and 360 m², 33 windows. Its *Plan* output is the list of storeys, bottom-up.
- No step of `Lod.Core` changed apart from the new base and its shared helper; no tolerance or check was relaxed. The twenty-four existing snapshots are byte-identical; one canonical snapshot is new. 210 core, 463 generator, and 1485 integration tests pass on `net8.0`, including the band with every simplifier and aggregator on the canonical plans, at the two rotated placements, with 2 and 4 storeys, and with 4 at 131.4°; a sweep of 50 rotations for four bands and, as a regression, the linear plan, the enclosed court, the court cluster, and the care hub found no failure in 6400 combinations.
- Smoke test (D-056): the controller's scripted headless Grasshopper session on the verified reference (`a98d4c5`, whose `src/`, `tests/`, and `scripts/` equal the executed branch) in Rhino 8.25.25314.11001 (.NET 8.0.31) passed every performed item: the component registered with its GUID, the integer Storey Count first, the presets in record order, *Preview Location* last, and a 24 × 24 icon; the *Plan* list of three storeys of 576, 468, and 360 m², each previewed at its elevation; every simplifier returning three floors and *Stack Floors* validating with multipliers 1 and with its *Multipliers* left at the default, roof and ground area 576 m²; the terraces facing outdoors and the covered parts between storeys; all 42 zone Breps inspected valid solids and no negative volume anywhere; all 80 combinations (five placements and counts, four simplifiers, four aggregators) with *Validate* passing; and the error cases. It is recorded in `docs/development/grasshopper-smoke-test.md` and the close-out commit; the viewport look, the icons in the toolbar, and example definitions stay open for a person (D-058).
- With S8.7, S8 is complete: 18 plan generators, one for every family of the four-source set except the set-aside `SYN-TYP-013` (D-096). S6 (Convert2IDF) can start; S9 follows S6.

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 10: Check the code fences of every document of the stage

Check the code fences of every Markdown file this stage touches, this plan included, and the entries and sections appended to the decision log and the smoke-test guide: every opening fence line has a language, every closing one is bare, and no file ends inside a block.

python - <<'EOF'
import re
from pathlib import Path

fence = "`" * 3
names = [
    "AGENTS.md",
    "README.md",
    "docs/architecture/domain-model.md",
    "docs/architecture/pipeline.md",
    "docs/decisions/ADR-006-plan-generation-mechanism.md",
    "docs/decisions/ADR-013-vertical-aggregation-methods.md",
    "docs/decisions/ADR-015-plans-per-storey.md",
    "docs/decisions/decision-log.md",
    "docs/development/grasshopper-smoke-test.md",
    "docs/plans/2026-09-30-implementation-roadmap.md",
    "docs/plans/2026-10-02-s8.7-per-storey-plans-stepped-band.md",
    "docs/research/precedents/syn-typologies.md",
    "docs/research/program-presets.md",
]
for name in names:
    lines = Path(name).read_text(encoding="utf-8").split("\n")
    inside, blocks, problems = False, 0, []
    for number, line in enumerate(lines, 1):
        mark = line.strip()
        if not mark.startswith(fence):
            continue
        if not inside and mark == fence:
            problems.append(f"line {number} opens a block without a language")
        if inside and mark != fence:
            problems.append(f"line {number} opens a block inside a block")
        blocks += inside
        inside = not inside
    if inside:
        problems.append("the file ends inside a block")
    print(f"{name}: {blocks} code blocks, " + ("; ".join(problems) or "fences balanced"))
log = Path("docs/decisions/decision-log.md").read_text(encoding="utf-8")
guide = Path("docs/development/grasshopper-smoke-test.md").read_text(encoding="utf-8")
entries = [part for part in log.split("\n### ")[1:] if part.startswith("D-10")]
sections = [part for part in re.split(r"\n(?=#{3,4} )", guide) if re.match(r"#{3,4} .*S8\.[67]", part)]
for kind, parts in (("decision-log entries D-100 to D-107", entries), ("smoke-test sections of S8.6 and S8.7", sections)):
    ending = [part.split("\n")[0] for part in parts if part.rstrip().split("\n")[-1].strip().startswith(fence)]
    print(f"{len(parts)} {kind}, ending with a fence line: " + (", ".join(ending) or "none"))
EOF

Expected:

AGENTS.md: 3 code blocks, fences balanced
README.md: 3 code blocks, fences balanced
docs/architecture/domain-model.md: 3 code blocks, fences balanced
docs/architecture/pipeline.md: 1 code blocks, fences balanced
docs/decisions/ADR-006-plan-generation-mechanism.md: 0 code blocks, fences balanced
docs/decisions/ADR-013-vertical-aggregation-methods.md: 0 code blocks, fences balanced
docs/decisions/ADR-015-plans-per-storey.md: 0 code blocks, fences balanced
docs/decisions/decision-log.md: 1 code blocks, fences balanced
docs/development/grasshopper-smoke-test.md: 2 code blocks, fences balanced
docs/plans/2026-09-30-implementation-roadmap.md: 4 code blocks, fences balanced
docs/plans/2026-10-02-s8.7-per-storey-plans-stepped-band.md: 129 code blocks, fences balanced
docs/research/precedents/syn-typologies.md: 0 code blocks, fences balanced
docs/research/program-presets.md: 1 code blocks, fences balanced
8 decision-log entries D-100 to D-107, ending with a fence line: none
4 smoke-test sections of S8.6 and S8.7, ending with a fence line: none

If a line reports anything else, stop: find the stray or missing fence line (a fence line copied from this plan with a block's content is the usual cause), fix the file, and run the check again.

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

Tick every completed checkbox of this plan and set its status line to > **Status:** Done (controller, D-098; pending the owner's reading) · … (D-051). Leave unticked the steps that run after this commit (Steps 12 and 13 below) and every step of Task 10, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 7, Step 8 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").

git add AGENTS.md README.md docs/plans/2026-09-30-implementation-roadmap.md docs/development/grasshopper-smoke-test.md docs/decisions/ADR-015-plans-per-storey.md docs/decisions/decision-log.md docs/plans/2026-10-02-s8.7-per-storey-plans-stepped-band.md
git commit -m "docs(repo): record s8.7 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.7 checklist) by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02: Rhino 8.25.25314.11001 (.NET 8.0.31), .NET Core runtime, build a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b (the verified reference; src, tests, and scripts identical to this branch), Version output '0.8.5+a98d4c52d53bfcfde8a5f5686f0ab7e5aea85c1b', all performed items passed. Not performed, pending a person (D-058): the viewport previews, the icons in the toolbar and on the canvas, and the other items named in Task 8 of the S8.7 plan." -m "Ticked per D-051: every completed checkbox of Tasks 1 to 8 and of Task 9 up to this commit (Steps 1 to 11). Left unticked: Task 9 Step 12 (merge) and Step 13 (tag v0.8.6), which run after this commit, and every step of Task 10 (fresh-clone gate and push), which the controller performs after the tag (D-098). The checklist items inside Task 7 are file content of grasshopper-smoke-test.md and stay unticked."
  • [ ] Step 12: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s8.7-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.7-close-out
git branch -d chore/repo-s8.7-close-out

Expected: VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0.

  • [ ] Step 13: Tag
git tag -a v0.8.6 -m "S8.7 plans per storey and the stepped band complete"
git describe --tags

Expected: v0.8.6. Nothing is pushed before Task 10.

Task 10: Fresh-clone gate and push (controller, D-098)

Files: none

D-098 pushes each sub-stage after its gate: the controller performs this task itself (a push is never delegated), and never with force. Its steps stay unticked in the plan, because they run after the close-out commit.

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

Expected: no output from git status --short; the two SHAs are equal (the tag points at the tip of main); fast-forward (local main contains origin/main, which was at a4db33e when this plan was written); the tags v0.8.0 to v0.8.6; ls-remote shows which of them the remote has. If origin/main has commits that local main does not, or the remote already has a v0.8.6 that points at another commit, stop and report: nothing is pushed, and no rebase or force push is attempted. If the remote lacks one of v0.8.0 to v0.8.5, report it to the person; this task pushes only main and v0.8.6.

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

Expected: the clone reports a detached HEAD at v0.8.6; verify builds the clone with 0 Warning(s) and 0 Error(s) and ends with VERIFY PASSED, with 210 core, 463 generators, and 1485 integration tests passed on net8.0; describe prints v0.8.6. Then delete the clone (rm -rf ../BEMGen-v0.8.6-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.6
git ls-remote --tags origin "v0.8.6*"
git status -sb

Expected: each push succeeds without --force (main as a fast-forward, the tag as [new tag]); ls-remote lists v0.8.6 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

  • Every floor aggregator designates the uncovered part of a lower storey's ceiling as an outdoor roof (a terrace) and the covered part as lying between the storeys, with ground and roof areas that validate (TerraceTests, passing on the code of S8.6); no aggregation or validation rule changed.
  • ADR-015 decides plans per storey and cites D-106; ADR-006 and ADR-013 refer to it.
  • MultiStoreyPlanGenerator returns one plan per storey, bottom-up, with the provenance key Storey and storey-prefixed diagnostics; its tests failed to build on the code before it; PlanGenerator shares its rules through PlanBuilder and every existing output is unchanged.
  • SteppedBandGenerator (012) implements ADR-006 with its defaults, zone IDs and order, and validation (at least 2 storeys, no upper bound, the top storey keeps its dwellings, and Length > StairWidth); its tiling, adjacency, stepping, window, invalid-parameter, preset, determinism, provenance, and snapshot tests failed to build on the code before it.
  • The band validates with every plan simplifier and floor aggregator, on its canonical plans, at two rotated and moved placements, with 2 and 4 storeys, and with 4 at 131.4° (100 cases), each lower storey keeping a terrace of Length × StepDepth and the roofs covering the ground storey; a 7 m top storey fails Perimeter Core with PlanTooNarrow (D-087).
  • One generator component in 2 Generate, with the GUID of this plan, its defaults, an integer Storey Count, required preset inputs, Preview Location last, a Plan list previewed per storey at its elevation, and an icon (60 icons, the existing ones byte-identical); no existing component, input, output, or GUID changed.
  • Snapshots: the twenty-four existing snapshots byte-identical; one new canonical snapshot.
  • ADR-006, ADR-013, pipeline.md, domain-model.md, syn-typologies.md, and program-presets.md describe S8.7; the S8.7 smoke-test checklist and its headless result are in the guide.
  • Every Markdown file the stage touches has balanced code fences.
  • scripts/verify.ps1 prints VERIFY PASSED; tests: 210 core, 463 generators, 1485 integration on net8.0; 0 warnings.
  • Version 0.8.6, tag v0.8.6; AGENTS.md, README, the roadmap Progress line and detailed-plans row, and the decision-log entries D-106 and D-107 are written; this plan is ticked and marked Done.
  • After a fresh clone of v0.8.6 passes scripts/verify.ps1, main and v0.8.6 are pushed without force (D-098). S8 is complete; S6 follows.

Notes for the reviewer

  1. Source. Every code block, snapshot, and documentation replacement of Tasks 1 to 7 was extracted programmatically from a verified reference implementation built on main at a4db33e, 7 commits from 1d286da to a98d4c5, each passing scripts/verify.ps1 with 0 warnings; the tasks keep the reference's commit order, one task per commit (no reordering was needed: a relative-link check of every reference commit found no link to a file that a later commit creates; ADR-015 is created in Task 2, and the first links to it come in Task 7). The only content not taken from the reference is this plan's own commit, the fence checks (Task 7, Step 9; Task 9, Step 10), and the close-out of Task 9 (adapted from the reference's proposed texts, with ADR-015's citation of D-106 added there because the reference could not cite an entry that did not exist yet). Every task was then replayed on a worktree of main at a4db33e (its branch standing in for main; nothing fetched, pulled, tagged, or pushed), taking each file content, each replacement, and each script from this plan's text: the passing characterisation tests of Task 1, the red build errors of Tasks 3 and 4 (with line and column numbers and in the compiler's order), the single failing snapshot test of Task 4 and the received snapshot (byte-identical to the expected content), the unchanged snapshots after Task 3, the integration matrix of Task 5, the failure of make_icons.py before Task 6 and the 60 hashes after it (every PNG byte-identical to the reference's), 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), the fence checks, the link checks, the close-out edits, and VERIFY PASSED on every merge are observed results. The replay's end state equals the reference: git diff --stat ref/s8.7 replay/s8.7 limited to the 20 paths the reference changes prints nothing for the state before the close-out, and after it lists only docs/decisions/ADR-015-plans-per-storey.md (the citation of D-106, Task 9, Step 8) and docs/development/grasshopper-smoke-test.md (the result section of Task 9, Step 7); Directory.Build.props (0.8.6), AGENTS.md, README.md, the decision log, the roadmap, and this plan are files the reference does not change. Every observation of a first replay was observed again, unchanged, in a second replay of the final plan from a fresh worktree.
  2. Slices and branches. The roadmap names no slices for S8.7; this plan uses four slices and a close-out, named after D-004: feature/per-storey-plans (Tasks 1–3), feature/stepped-band (Tasks 4–5), feature/stepped-band-component (Task 6), docs/architecture-per-storey-plans-stepped-band (Tasks 7–8), and chore/repo-s8.7-close-out. ADR-015 is committed in the first slice, before the base class it decides. Merges rebase onto local main. As in S8.4 to S8.6, the stage is pushed at its end (D-098): Task 10 is the controller's, after the tag and a fresh clone of it pass scripts/verify.ps1. origin/main was at a4db33e when this plan was written, so the push is a fast-forward.
  3. Links. A relative-link check (file targets and heading anchors, GitHub's slug rule) over every Markdown file of the replay found: no broken link at the plan commit or after any commit of the replay (checked after Tasks 3, 5, 7, 8, and 9). Task 7 renames the heading ## Grasshopper components through S8.6 of pipeline.md to … through S8.7 and updates its two links (in pipeline.md and program-presets.md) in the same commit.
  4. The baseline is 193 core, 427 generators, and 1385 integration tests (D-105).
  5. Tests that were not test-first, and why. The characterisation tests of Task 1 (11) passed at once in the reference and in the replay: they confirm that the designation on the assembled stack (D-068, since S4.2) already makes the terraces of storeys that step back outdoor roofs and their covered parts pieces between storeys, with every aggregator, and that ground and roof areas validate; this is the finding of ADR-015 that no aggregation or validation rule needs a change, so there was nothing to fix test-first in Lod.Core's aggregation. The integration matrix of Task 5 (100 cases) passed at once for the same reason. Each of those tasks says to stop if a case fails. Task 3's move of PlanGenerator's body into PlanBuilder is a refactoring, checked by every existing generator test and the twenty-four snapshots. Task 6 is Grasshopper code, checked by the build, the embedded icons, and Task 8. The build-error red phases list only declaration-level errors: the compiler stops before binding method bodies.
  6. Choices of the reference (D-106). A sibling base class rather than a list from every generator, a storey index parameter, or a new pipeline type (ADR-015, options 1 to 4); shared rules through one internal helper, so a test proves that storey 0 equals the plan of a PlanGenerator of the same layout, provenance aside from Storey=0; parameter and preset errors once, before any layout; storey diagnostics prefixed Storey {k}: with their subject kept as the ID within the storey's plan, not L{k}/…; multipliers above 1 allowed. The stepped band follows ADR-006 and adds one validation ADR-006 does not list: Length > StairWidth, without which the dwelling row would have no width (the linear plan has the same check); both it and the top-storey check run only when every length is positive, so each invalid input gives exactly one error.
  7. Four storeys and Perimeter Core. Four storeys of the default 14 m base depth leave a 7 m deep top storey, which Perimeter Core (4.57 m) rejects with PlanTooNarrow (D-087); the integration tests therefore run four storeys with BaseDepth 17 m through every combination and test the default-depth case as that expected failure (storeys 0 to 2 pass, storey 3 fails). No default changed. The core of the default band's top storey under Perimeter Core is 26.86 m × 0.86 m, below the "about 1 m" of D-090, yet every combination validates, at both rotated placements and in the sweep of note 8 (D-106).
  8. Sweeps. An uncommitted sweep in the reference (50 rotations from 0° to 353.5° with translations, every storey placed alike, each through the 4 simplifiers × 4 aggregators, 800 runs per configuration) found no failure for the stepped band with 3 storeys, 2 storeys, 4 storeys of BaseDepth 17 m, and 4 storeys with other dimensions, and, as a regression sweep, the linear plan, the enclosed court, the court cluster, and the care hub (1, 3, 1): 8 configurations, 6400 combinations. The sweep was not replayed.
  9. Smoke-test values. The values of Task 8 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit a98d4c5, which reported the informational version 0.8.5+a98d4c5…; they were not re-observed on the replay. Task 8, Step 1 checks that src/, tests/, and scripts/ of the branch equal the reference's, so the record applies; the replay found no difference. The viewport, the toolbar icons, the Inspect texts beyond the footprint line, the zone IDs, the Validate glazing values, a wrong preset on one input, the WindowOmitted remarks with a narrow corridor, and Convert2BEM with Override = False were not observed.
  10. Icons. The 59 existing PNGs are byte-identical after Task 6 (Pillow 12.3.0); the new one equals the reference's bytes. The contact-sheet description is from the replay's sheet.
  11. Code fences. The S8.6 close-out on main (a4db33e) left no stray fence line: an independent check of every Markdown file it touched finds none, so this plan repairs nothing. It still says, wherever text is appended, that a block's content is appended without its fence lines, and checks every Markdown file of the stage, this plan included, after the documentation commit and before the close-out commit (Task 7, Step 9; Task 9, Step 10): every opening fence line has a language, every closing one is bare, no file ends inside a block, and no decision-log entry and no appended smoke-test section ends with a fence line. In the replay, the checks of Task 7, Step 9 and Task 9, Step 10 printed the expected output; an independent check written apart from the plan's script found no problem in the thirteen files after the close-out merge, nor on main at a4db33e in the six Markdown files the S8.6 close-out touched.
  12. Clean working tree. The git status expectations assume a working tree with no untracked files outside .gitignore before Task 1.
  13. D-numbers. The reference's report proposed D-106 for the choices of notes 6 and 7, which Tasks 4 and 9 cite, and D-107 for the completion, which cites D-106.
  14. Roadmap order. The roadmap lists its Progress lines oldest first, so Task 9 adds the S8.7 line below the S8.6 one.
  15. 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 to S8.6.