Skip to content

S8.8 — Storey previews, the any-program preset, and the preset panel Implementation Plan

Status: Done (controller, D-108; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.8 (revision stage after S8.7, before S6) · Decisions: D-017, D-024, D-026, D-056, D-058, D-061, D-062, D-063, D-064, D-065, D-068, D-091, D-095, D-098, D-106, D-107, D-108; ADR-014 (the any-program preset, Task 8), ADR-015 (the storey preview height, Task 8), ADR-006

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 the three requests of D-108 as the revision stage S8.8. (1) An any-program preset: any preset, built-in or general, can be cast to a preset without a space type, which every preset input of every plan generator accepts; the zones keep the space type of the generator's layout and take the preset's program, a preset that keeps its space type must still match its input (PresetSpaceTypeMismatch), and the plan's provenance records every input given an any-program preset, so no program assumption changes silently (GLOBAL.md scientific rule 3). In Grasshopper the cast is the floating parameter Any Program Preset (AnyP) in panel 1 Program. (2) The eighteen built-in preset components move to a panel of their own, 1 Program Presets. (3) The floors simplified from a storey plan are previewed at the storey's height, as the plan is: storey plans record their base elevation, from which the plan, Transform Plan, and simplifier previews draw a storey; plans and floors that are not storeys stay at 0, and buildings are unchanged. Data, Breps, Convert2BEM outputs, and every existing snapshot and icon stay unchanged; no component or GUID changes, and one parameter is added. S8.8 ends with version 0.8.7 and the tag v0.8.7. After the stage gate the controller verifies a fresh clone of the tag and then asks the owner before pushing main and v0.8.7 (D-108); S6 (Convert2IDF) follows.

Architecture: Lod.Core.Programs.ProgramPreset.SpaceType becomes SpaceType?, with IsAnyProgram and AsAnyProgram(), which returns the same name, the same ZoneProgram object, and the same WWR without a space type; presets are still created typed. Lod.Core.Plans.PlanBuilder (the shared helper of PlanGenerator and MultiStoreyPlanGenerator, S8.7) accepts an any-program preset on every slot, records Presets from the slots' space types (unchanged for typed presets), and adds AnyProgramPreset.{Input} = the preset's name for each slot given one (PlanPresets.AnyProgramPresetKeyPrefix). MultiStoreyPlanGenerator records StoreyElevation, the sum of the floor heights below a storey, in each storey's provenance, and the new static class StoreyProvenance reads it, following operations of exactly one input (Transform Plan, every simplifier). In Lod.Grasshopper, PreviewGoo gains a virtual StoreyElevation (0) that PlanGoo and FloorGoo take from StoreyProvenance, and the multi-storey Output of TypologyGeneratorComponent no longer raises the plans itself; AnyProgramPresetGoo and AnyProgramPresetParameter carry the cast, the generators' preset inputs (ProgramPresetParameter) take it through PreferredCast, and every preset input has one shared description (ComponentSupport.PresetInputDescription); ComponentCategories.ProgramPresets is the panel of DefaultPresetComponent. 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.7 is complete on main (tag v0.8.6, D-107), and D-108 is recorded (3d9ec0b); this plan is committed to main before Task 1 ("docs(plans): add the s8.8 storey previews, any-program preset, and preset panel plan"); it is the only file of that commit. ADR-014 and ADR-015 are accepted. 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 keep their order. The provenance parameters of a plan stay ordered by key, so AnyProgramPreset.{Input} entries come first and StoreyElevation follows StoreyCount.
  • Explicit semantics (AGENTS.md, D-061). The absence of a space type is explicit data (SpaceType is null), never inferred from a name; a zone's space type always comes from the generator's layout (PlannedZone.SpaceType), its program from its slot's preset.
  • No silent program change (GLOBAL.md scientific rule 3). An any-program preset is only ever the user's explicit cast, and every slot given one is recorded in the plan's provenance as AnyProgramPreset.{Input}, which every floor and building made from the plan carries in its provenance inputs. A typed preset on another type's input is still the error PresetSpaceTypeMismatch, with its message unchanged.
  • Numerical tolerances come only from ToleranceSettings (ADR-004); S8.8 adds none and changes none, and ToleranceSettings.cs is not touched.
  • Existing outputs do not change. The twenty-five existing snapshots stay byte-identical: plans of typed presets record no new key, and the stepped-band snapshot has no provenance. Every existing test keeps passing; no test is removed. Three existing tests that list a stepped band's or a multi-storey plan's exact provenance gain StoreyElevation (Task 4): an added expectation, no check weakened. No existing icon PNG changes.
  • Previews only (D-062, D-108). The storey elevation moves only the previews of plans and floors: the data stay at elevation 0, and the Breps of Convert2BEM, the building previews, and every data output are unchanged.
  • Never weaken a check (AGENTS.md rules 11, 12). Tasks 1, 2, and 4 start from tests that fail the way each task says. The integration tests of Task 3 pass at once, because Task 2 already accepts the presets; it says to stop if a case fails. No tolerance is widened, no validation check is relaxed, and no test is weakened or removed.
  • No backward compatibility (D-064). ProgramPreset.SpaceType becomes nullable (a public API change; every repository use compiles unchanged). One parameter is added with a new GUID: Any Program Preset 24bc6a12-ad06-4c25-bc9d-4e8318b8744c. No component is added or removed, and no existing component, parameter, input, output, or GUID changes; the eighteen default preset components only change their panel.
  • Grasshopper components and parameters are thin adaptors (AGENTS.md): the cast, the slot check, the provenance, and the storey elevation live in Lod.Core; Grasshopper reads them.
  • Research equivalence rules are not changed (GLOBAL.md scientific rule 7): S8.8 changes no simplifier, aggregator, validation rule, or equivalence rule, so the research documentation needs no edit; ADR-014 and ADR-015 record the choices.
  • Links. No commit contains a relative link to a file that a later commit creates.
  • 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 9 and 11 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-108). Slices are rebased onto local main and nothing is pushed while the stage runs. After the close-out (Task 11) and its tag, the controller verifies a fresh clone of v0.8.7 and then asks the owner; main and v0.8.7 are pushed only after the owner says so (Task 12), never with force.
  • .gh example definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.8 adds none.
  • S8.8 changes these earlier files: ProgramPreset.cs (S1, last changed in S4.1), PlanPresets.cs (S4.1), PlanGeneratorTests.cs (S4.1, last changed in S8.1), PlanBuilder.cs, PlanGenerator.cs (S2), MultiStoreyPlanGenerator.cs, MultiStoreyPlanGeneratorTests.cs, SteppedBandGeneratorTests.cs, TypologyGeneratorComponents.cs (S8.1), and scripts/make_icons.py (S4.1) (all last changed in S8.7), BemGoo.cs (S2, last changed in S8.4), ComponentSupport.cs and BemParameters.cs (S2, last changed in S4.1), PipelineComponents.cs (S2, last changed in S8.1), PresetComponents.cs (S4.1, last changed in S8.4), ComponentCategories.cs (S0), and Directory.Build.props (S0, last changed in S8.7); and the documents ADR-014 (S8.4), ADR-015, pipeline.md, domain-model.md, program-presets.md, docs/development/grasshopper-smoke-test.md, AGENTS.md, README.md, and the roadmap (all last changed in S8.7), and the decision log (last changed by D-108).

Files

Path Change Responsibility
src/Lod.Core/Programs/ProgramPreset.cs Modify (S1 file, last changed in S4.1) SpaceType?, IsAnyProgram, AsAnyProgram()
tests/Lod.Core.Tests/Programs/AnyProgramPresetTests.cs Create The cast keeps every other value, for the 18 example presets and a custom one
src/Lod.Core/Plans/PlanPresets.cs Modify (S4.1 file) AnyProgramPresetKeyPrefix; the slot documentation
src/Lod.Core/Plans/PlanBuilder.cs, src/Lod.Core/Plans/PlanGenerator.cs, src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs Modify (S8.7 files; PlanGenerator.cs S2) Slot check, Presets, AnyProgramPreset.{Input}; documentation; StoreyElevation (Task 4)
tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs, tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs Modify Any-program presets on the slots; the storey elevation (Task 4)
tests/Lod.Generators.Tests/AnyProgramPresetTests.cs Create All 18 generators with any-program presets on every input
tests/Lod.Integration.Tests/AnyProgramPresetTests.cs Create The linear plan and the stepped band with any-program presets through every simplifier and aggregator
src/Lod.Core/Plans/StoreyProvenance.cs Create ElevationKey, ElevationOf
tests/Lod.Core.Tests/Plans/StoreyProvenanceTests.cs Create The elevation through one-input operations, 0 otherwise
tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs Modify (S8.7 file) The stepped band's provenance with StoreyElevation
src/Lod.Grasshopper/Goo/BemGoo.cs Modify (S2 file, last changed in S8.4) StoreyElevation of the previews; AnyProgramPresetGoo
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs Modify (S8.1 file, last changed in S8.7) The multi-storey Output without its own raise; the shared preset description
src/Lod.Grasshopper/Parameters/BemParameters.cs Modify (S2 file, last changed in S4.1) AnyProgramPresetParameter; ProgramPresetParameter.PreferredCast
src/Lod.Grasshopper/Components/ComponentSupport.cs, src/Lod.Grasshopper/Components/PipelineComponents.cs Modify PresetInputDescription; the linear plan's preset inputs
scripts/make_icons.py Modify (S4.1 file, last changed in S8.7) One new motif
src/Lod.Grasshopper/Icons/AnyProgramPresetParameter.png Create (generated) 61 icons
src/Lod.Grasshopper/ComponentCategories.cs, src/Lod.Grasshopper/Components/PresetComponents.cs Modify The panel 1 Program Presets
docs/decisions/ADR-014-program-types-and-department-zoning.md, docs/decisions/ADR-015-plans-per-storey.md Modify The options and decisions of D-108; cite D-109 at the close-out
docs/architecture/pipeline.md, docs/architecture/domain-model.md, docs/research/program-presets.md Modify The any-program preset, the parameter, the panels, the storey previews, the renamed components heading
docs/development/grasshopper-smoke-test.md Modify Append the S8.8 checklist (Task 9) and its result (Task 11)
Directory.Build.props Modify Version 0.8.7
AGENTS.md, README.md Modify "Project state" and "Status" after S8.8
docs/plans/2026-09-30-implementation-roadmap.md Modify Progress line, the S8.8 stage row and order, and the detailed-plans row
docs/decisions/decision-log.md Modify D-109 (the realisation of D-108), D-110 (S8.8 complete)

Test counts on net8.0 at the start of S8.8: 210 core, 463 generators, 1485 integration (D-107). After Task 1: 231 / 463 / 1485. After Task 2: 236 / 518 / 1485. After Task 3: 236 / 518 / 1518. After Task 4 and at the end: 243 core, 518 generators, 1518 integration (core: +21 AnyProgramPresetTests, +4 PlanGeneratorTests, +2 MultiStoreyPlanGeneratorTests, +6 StoreyProvenanceTests; generators: +55 AnyProgramPresetTests; integration: +33 AnyProgramPresetTests). No test is removed. Grasshopper code has no unit tests; Tasks 5 to 7 are checked by a 0-warning build, the embedded icons, and the smoke test of Task 10.

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

Task Agent Why
1 The any-program preset bemgen-complex Core pipeline type; test-first
2 Any-program presets on every preset input bemgen-complex The slot check and provenance shared by every generator
3 Any-program presets through every simplifier and aggregator bemgen-complex Combination matrix
4 The base elevation of every storey plan bemgen-complex Provenance of the multi-storey base; test-first
5 Storey previews bemgen-complex Preview offsets of every plan and floor
6 Any Program Preset parameter and its icon bemgen-complex Grasshopper casting; generated icon and a visual check
7 The preset panel bemgen-bounded One constant and one base-class argument, given in full
8 ADR-014 and ADR-015 bemgen-bounded Documents, exact replacements
9 Architecture documents and smoke-test checklist bemgen-bounded Documents, exact replacements
10 Smoke test The controller headless (D-056); person for the viewport items Needs Rhino 8
11 Close-out bemgen-bounded Bookkeeping
12 Fresh-clone gate and push Controller; the push waits for the owner (D-108) 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/any-program-preset

The any-program preset in Lod.Core, test-first: the cast, then the slot check and provenance of every generator, then the integration tests, which pass at once on the code of Task 2; if a case fails, stop and report.

Task 1: The any-program preset without a space type

Files: - Modify: src/Lod.Core/Programs/ProgramPreset.cs (S1 file, last changed in S4.1) - Test: create tests/Lod.Core.Tests/Programs/AnyProgramPresetTests.cs

Interfaces: - Consumes (S1–S8.4): ProgramPreset (Name, SpaceType, Program, WindowToWallRatio, Create), ZoneProgram, PresetValues, ExampleResidentialPresets (3) and ExampleNonResidentialPresets (15). - Produces: ProgramPreset.SpaceType is SpaceType? (null for an any-program preset); public bool IsAnyProgram => SpaceType is null; public ProgramPreset AsAnyProgram(), which returns a preset with the same Name, the same ZoneProgram instance, the same WindowToWallRatio, and no space type, or this preset when it already is one. Create and every other way to make a preset still give a typed preset. 21 tests: the cast of each of the 18 example presets has no space type and keeps every other value (name, program, loads with their schedules, conditioning, setpoints, WWR); a custom preset likewise; casting twice gives the same instance; a typed preset is not any-program.

  • [x] Step 1: Create the branch
git switch -c feature/any-program-preset main
  • [x] Step 2: Write the failing tests — create tests/Lod.Core.Tests/Programs/AnyProgramPresetTests.cs (full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Programs;
using Xunit;

namespace Lod.Core.Tests.Programs;

public sealed class AnyProgramPresetTests
{
    public static IEnumerable<object[]> ExamplePresets() =>
        new[] { ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair }
            .Concat(ExampleNonResidentialPresets.SpaceTypes.Select(ExampleNonResidentialPresets.PresetOf))
            .Select(p => new object[] { p });

    [Theory]
    [MemberData(nameof(ExamplePresets))]
    public void TheCastHasNoSpaceTypeAndKeepsEveryOtherValue(ProgramPreset typed)
    {
        SpaceType? spaceType = typed.SpaceType;

        ProgramPreset any = typed.AsAnyProgram();

        Assert.Null(any.SpaceType);
        Assert.True(any.IsAnyProgram);
        Assert.Equal(typed.Name, any.Name);
        Assert.Equal(typed.WindowToWallRatio, any.WindowToWallRatio);
        Assert.Same(typed.Program, any.Program);
        Assert.Equal(typed.Program.IsConditioned, any.Program.IsConditioned);
        Assert.Same(typed.Program.Thermostat, any.Program.Thermostat);
        Assert.Equal(typed.Program.Loads, any.Program.Loads);
        Assert.Equal(typed.Program.Loads.Select(l => l.Schedule), any.Program.Loads.Select(l => l.Schedule));
        Assert.False(typed.IsAnyProgram);
        Assert.Equal(spaceType, typed.SpaceType);
    }

    [Fact]
    public void ACustomPresetKeepsItsValuesToo()
    {
        PresetValues values = ExampleResidentialPresets.CorridorValues with { Name = "Custom", WindowToWallRatio = 0.45, Conditioned = false };
        ProgramPreset typed = values.ToPreset().Value;

        ProgramPreset any = typed.AsAnyProgram();

        Assert.Equal("Custom", any.Name);
        Assert.Equal(0.45, any.WindowToWallRatio);
        Assert.False(any.Program.IsConditioned);
        Assert.Same(typed.Program, any.Program);
    }

    [Fact]
    public void CastingTwiceGivesTheSamePreset()
    {
        ProgramPreset any = ExampleResidentialPresets.DwellingUnit.AsAnyProgram();

        Assert.Same(any, any.AsAnyProgram());
    }

    [Fact]
    public void ATypedPresetIsNotAnyProgram()
    {
        Assert.False(ExampleResidentialPresets.DwellingUnit.IsAnyProgram);
        Assert.Equal(SpaceType.DwellingUnit, ExampleResidentialPresets.DwellingUnit.SpaceType);
    }
}
  • [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.AnyProgramPresetTests"

Expected: the build fails because the cast does not exist yet and SpaceType is not nullable, with these errors for the net8.0 target (in this order; xUnit2002 is the xUnit analyzer refusing Assert.Null on the non-nullable SpaceType):

tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(21,35): error CS1061: 'ProgramPreset' does not contain a definition for 'AsAnyProgram' and no accessible extension method 'AsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(24,25): error CS1061: 'ProgramPreset' does not contain a definition for 'IsAnyProgram' and no accessible extension method 'IsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(32,28): error CS1061: 'ProgramPreset' does not contain a definition for 'IsAnyProgram' and no accessible extension method 'IsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(42,35): error CS1061: 'ProgramPreset' does not contain a definition for 'AsAnyProgram' and no accessible extension method 'AsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(53,68): error CS1061: 'ProgramPreset' does not contain a definition for 'AsAnyProgram' and no accessible extension method 'AsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(55,30): error CS1061: 'ProgramPreset' does not contain a definition for 'AsAnyProgram' and no accessible extension method 'AsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(61,61): error CS1061: 'ProgramPreset' does not contain a definition for 'IsAnyProgram' and no accessible extension method 'IsAnyProgram' accepting a first argument of type 'ProgramPreset' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Programs\AnyProgramPresetTests.cs(23,9): error xUnit2002: Do not use Assert.Null() on value type 'SpaceType'. Remove this assert. (https://xunit.net/xunit.analyzers/rules/xUnit2002)
  • [x] Step 4: Implement — replace src/Lod.Core/Programs/ProgramPreset.cs (full file)
using System.Collections.Generic;
using Lod.Core.Common;

namespace Lod.Core.Programs;

/// <summary>
/// A program preset for one space type (D-024): its zone program and window-to-wall ratio (D-026). An any-program preset
/// (<see cref="AsAnyProgram"/>) has no space type and fits every preset input of a plan generator (D-108, ADR-014).
/// </summary>
public sealed class ProgramPreset
{
    private ProgramPreset(string name, SpaceType? spaceType, ZoneProgram program, double windowToWallRatio)
    {
        Name = name;
        SpaceType = spaceType;
        Program = program;
        WindowToWallRatio = windowToWallRatio;
    }

    /// <summary>Preset name; descriptive only (reports, provenance, messages), never used for matching (D-061).</summary>
    public string Name { get; }

    /// <summary>The space type this preset applies to; <c>null</c> for an any-program preset, which applies to the space type of any input it is given.</summary>
    public SpaceType? SpaceType { get; }

    /// <summary>Whether the preset has no space type (<see cref="AsAnyProgram"/>).</summary>
    public bool IsAnyProgram => SpaceType is null;

    /// <summary>Loads and setpoints.</summary>
    public ZoneProgram Program { get; }

    /// <summary>Window-to-wall ratio in [0, 1) used by the plan generator to size the window on every exterior wall of the space (D-026, D-039).</summary>
    public double WindowToWallRatio { get; }

    /// <summary>Creates a preset.</summary>
    /// <param name="name">Non-empty name.</param>
    /// <param name="spaceType">Space type.</param>
    /// <param name="program">Loads and setpoints.</param>
    /// <param name="windowToWallRatio">WWR in [0, 1).</param>
    /// <returns>The preset or validation errors.</returns>
    public static Result<ProgramPreset> Create(string name, SpaceType spaceType, ZoneProgram program, double windowToWallRatio)
    {
        var diagnostics = new List<Diagnostic>();
        if (string.IsNullOrWhiteSpace(name))
        {
            diagnostics.Add(Diagnostic.Error(DiagnosticCodes.EmptyName, "A program preset needs a name."));
        }

        if (double.IsNaN(windowToWallRatio) || windowToWallRatio < 0 || windowToWallRatio >= 1)
        {
            diagnostics.Add(Diagnostic.Error(DiagnosticCodes.WindowToWallRatio, $"WWR must be in [0, 1), got {windowToWallRatio}.", name));
        }

        return Result.FromDiagnostics(() => new ProgramPreset(name, spaceType, program, windowToWallRatio), diagnostics);
    }

    /// <summary>
    /// The same preset without a space type (D-108, ADR-014): the name, the program (loads, schedules, conditioning, setpoints), and the WWR are kept
    /// unchanged. A plan generator accepts it on every preset input, and its zones keep the space type of the generator's layout.
    /// </summary>
    /// <returns>The any-program preset; this preset when it already is one.</returns>
    public ProgramPreset AsAnyProgram() => IsAnyProgram ? this : new ProgramPreset(Name, null, Program, WindowToWallRatio);
}
  • [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.AnyProgramPresetTests"

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

  • [x] Step 6: 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) (every use of ProgramPreset.SpaceType in the repository compiles unchanged on the nullable type); 231 core, 463 generators, and 1485 integration tests passed on net8.0; git status lists M src/Lod.Core/Programs/ProgramPreset.cs and ?? tests/Lod.Core.Tests/Programs/AnyProgramPresetTests.cs only.

  • [x] Step 7: Commit
git add src/Lod.Core/Programs/ProgramPreset.cs tests/Lod.Core.Tests/Programs/AnyProgramPresetTests.cs
git commit -m "feature(programs): add the any-program preset without a space type"

Task 2: Any-program presets on every preset input of a plan generator

Files: - Modify: src/Lod.Core/Plans/PlanPresets.cs (S4.1 file), src/Lod.Core/Plans/PlanBuilder.cs, src/Lod.Core/Plans/PlanGenerator.cs, src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs (all last changed in S8.7) - Test: modify tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs (S4.1 file, last changed in S8.1) and tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs (S8.7 file); create tests/Lod.Generators.Tests/AnyProgramPresetTests.cs

Interfaces: - Consumes: ProgramPreset.IsAnyProgram and AsAnyProgram() (Task 1); PlanBuilder.CheckInputs, PlanBuilder.Build (S8.7), PresetSlot(Input, SpaceType, Preset), PlanPresets.Slots, DiagnosticCodes.PresetSpaceTypeMismatch, Provenance.Of; the 18 generators of Lod.Generators with their parameter and preset records, their Default parameters, and the example presets; test support TextReport.Describe and the Examples of each generator's test class (S8.1–S8.7). - Produces: public const string AnyProgramPresetKeyPrefix = "AnyProgramPreset." on PlanPresets. In PlanBuilder the slot check is !slot.Preset.IsAnyProgram && slot.Preset.SpaceType != slot.SpaceType (a typed mismatch keeps its message, for example Input Corridor needs a Corridor preset, got 'Example Office' (Office).); Presets lists {slot.SpaceType}:{preset name}, which equals the preset's own space type for a typed preset; and for each slot given an any-program preset the plan records AnyProgramPreset.{Input} = the preset's name. The zones keep the layout's space type and take the slot's program and WWR. Plans of typed presets are unchanged. Tests: core +4 (PlanGeneratorTests: an any-program preset on every slot, the provenance key, none for typed presets, a typed mismatch beside an any-program preset still rejected) and +1 (MultiStoreyPlanGeneratorTests: every storey records the key); generators +55 (AnyProgramPresetTests: every generator is covered; for each of the 18 generators, casting every example preset changes only the provenance, the report being identical and only the AnyProgramPreset.* keys added; the kitchen program on every input keeps the layout's zone IDs and space types; a typed Mechanical preset is still rejected on every input).

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

Four tests are added after EveryPresetOfTheWrongSpaceTypeIsAnError; nothing else changes.

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

namespace Lod.Core.Tests.Plans;

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

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

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

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

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

    [Fact]
    public void AnAnyProgramPresetFitsEverySlotAndItsZonesKeepTheLayoutSpaceType()
    {
        ProgramPreset unit = ExampleResidentialPresets.Stair.AsAnyProgram();
        ProgramPreset hall = ExampleResidentialPresets.DwellingUnit.AsAnyProgram();

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

        Assert.True(result.IsSuccess);
        Zone u1 = Assert.Single(result.Value.Zones, z => z.Id.Value == "U1");
        Zone c1 = Assert.Single(result.Value.Zones, z => z.Id.Value == "C1");
        Assert.Equal(SpaceType.DwellingUnit, u1.SpaceType);
        Assert.Equal(SpaceType.Corridor, c1.SpaceType);
        Assert.Same(ExampleResidentialPresets.Stair.Program, u1.Program);
        Assert.Same(ExampleResidentialPresets.DwellingUnit.Program, c1.Program);
        WallSurface[] outdoor = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
        Assert.All(outdoor.Where(w => w.Zone.Value == "U1"), w => Assert.Equal(0.1 * w.Area, Assert.Single(w.Windows).Area, 9));
        Assert.All(outdoor.Where(w => w.Zone.Value == "C1"), w => Assert.Equal(0.3 * w.Area, Assert.Single(w.Windows).Area, 9));
    }

    [Fact]
    public void ProvenanceRecordsEverySlotThatReceivedAnAnyProgramPreset()
    {
        IGeneratedPlan plan = _generator.Generate(new TwoZoneParameters(3.0), Presets(hall: ExampleResidentialPresets.Stair.AsAnyProgram())).Value;

        Assert.Equal(
            new[] { "AnyProgramPreset.Hall=Example Stair", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Stair" },
            plan.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

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

        Assert.Equal(
            new[] { "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor" },
            plan.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

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

        Assert.False(result.IsSuccess);
        Diagnostic mismatch = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.PresetSpaceTypeMismatch, mismatch.Code);
        Assert.Equal("Unit", mismatch.Subject);
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        public override string Name => nameof(TwoZoneGenerator);

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

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

        protected override IEnumerable<KeyValuePair<string, string>> Describe(TwoZoneParameters parameters) => Enumerable.Empty<KeyValuePair<string, string>>();
    }
}
  • [x] Step 2: Write the failing core tests — replace tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs (full file)

One test is added after ProvenanceRecordsTheGeneratorAndTheStoreyIndex; nothing else changes.

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 EveryStoreyRecordsTheSlotsThatReceivedAnAnyProgramPreset()
    {
        var presets = new BandPresets(ExampleResidentialPresets.Corridor.AsAnyProgram(), ExampleResidentialPresets.Stair.AsAnyProgram());

        IReadOnlyList<IGeneratedPlan> plans = _generator.Generate(new BandParameters(2), presets).Value;

        Assert.All(plans, p => Assert.Equal(new[] { SpaceType.DwellingUnit, SpaceType.Corridor }, p.Zones.Select(z => z.SpaceType)));
        Assert.All(plans, p => Assert.Same(ExampleResidentialPresets.Corridor.Program, p.Zones[0].Program));
        Assert.All(
            plans,
            p => Assert.Equal(
                new[] { "AnyProgramPreset.Hall=Example Stair", "AnyProgramPreset.Unit=Example Corridor" },
                p.Provenance.Parameters.Where(q => q.Key.StartsWith("AnyProgramPreset.")).Select(q => $"{q.Key}={q.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 3: Write the failing generator tests — create tests/Lod.Generators.Tests/AnyProgramPresetTests.cs (full file)
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.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.Linear;
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.Tests.BranchingMall;
using Lod.Generators.Tests.Cafeteria;
using Lod.Generators.Tests.CareHub;
using Lod.Generators.Tests.CourtCluster;
using Lod.Generators.Tests.EnclosedCourt;
using Lod.Generators.Tests.FoyerHalls;
using Lod.Generators.Tests.GalleryBar;
using Lod.Generators.Tests.Linear;
using Lod.Generators.Tests.OfficePlate;
using Lod.Generators.Tests.OpenCourt;
using Lod.Generators.Tests.OperatingSuite;
using Lod.Generators.Tests.PointPlate;
using Lod.Generators.Tests.RadialLobes;
using Lod.Generators.Tests.StairBayBar;
using Lod.Generators.Tests.StairPair;
using Lod.Generators.Tests.SteppedBand;
using Lod.Generators.Tests.TerraceRow;
using Lod.Generators.Tests.WingedBand;
using Lod.Generators.WingedBand;
using Xunit;

namespace Lod.Generators.Tests;

/// <summary>Any-program presets (D-108) on every preset input of every plan generator, with its example presets and default parameters.</summary>
public sealed class AnyProgramPresetTests
{
    /// <summary>Generates a family's default plans (one, or one per storey) with each example preset replaced by the mapped preset.</summary>
    public delegate Result<IReadOnlyList<IGeneratedPlan>> Run(Func<ProgramPreset, ProgramPreset> map);

    private static readonly Dictionary<string, Run> Families = new()
    {
        ["Linear"] = m => One(new LinearPlanGenerator(ToleranceSettings.Default).Generate(
            LinearPlanGeneratorTests.Canonical,
            new LinearPlanPresets(m(LinearPlanGeneratorTests.Examples.DwellingUnit), m(LinearPlanGeneratorTests.Examples.Corridor), m(LinearPlanGeneratorTests.Examples.Stair)))),
        ["StairBayBar"] = m => One(new StairBayBarGenerator(ToleranceSettings.Default).Generate(
            StairBayBarParameters.Default,
            new StairBayBarPresets(m(StairBayBarGeneratorTests.Examples.DwellingUnit), m(StairBayBarGeneratorTests.Examples.Stair)))),
        ["StairPair"] = m => One(new StairPairGenerator(ToleranceSettings.Default).Generate(
            StairPairParameters.Default,
            new StairPairPresets(m(StairPairGeneratorTests.Examples.DwellingUnit), m(StairPairGeneratorTests.Examples.Stair)))),
        ["GalleryBar"] = m => One(new GalleryBarGenerator(ToleranceSettings.Default).Generate(
            GalleryBarParameters.Default,
            new GalleryBarPresets(m(GalleryBarGeneratorTests.Examples.DwellingUnit), m(GalleryBarGeneratorTests.Examples.Stair)))),
        ["TerraceRow"] = m => One(new TerraceRowGenerator(ToleranceSettings.Default).Generate(
            TerraceRowParameters.Default,
            new TerraceRowPresets(m(TerraceRowGeneratorTests.Examples.DwellingUnit)))),
        ["PointPlate"] = m => One(new PointPlateGenerator(ToleranceSettings.Default).Generate(
            PointPlateParameters.Default,
            new PointPlatePresets(m(PointPlateGeneratorTests.Examples.DwellingUnit), m(PointPlateGeneratorTests.Examples.Stair)))),
        ["WingedBand"] = m => One(new WingedBandGenerator(ToleranceSettings.Default).Generate(
            WingedBandParameters.Default,
            new WingedBandPresets(m(WingedBandGeneratorTests.Examples.DwellingUnit), m(WingedBandGeneratorTests.Examples.Stair)))),
        ["OpenCourt"] = m => One(new OpenCourtGenerator(ToleranceSettings.Default).Generate(
            OpenCourtParameters.Default,
            new OpenCourtPresets(m(OpenCourtGeneratorTests.Examples.DwellingUnit), m(OpenCourtGeneratorTests.Examples.Stair)))),
        ["EnclosedCourt"] = m => One(new EnclosedCourtGenerator(ToleranceSettings.Default).Generate(
            EnclosedCourtParameters.Default,
            new EnclosedCourtPresets(m(EnclosedCourtGeneratorTests.Examples.DwellingUnit), m(EnclosedCourtGeneratorTests.Examples.Stair)))),
        ["OfficePlate"] = m => One(new OfficePlateGenerator(ToleranceSettings.Default).Generate(
            OfficePlateParameters.Default,
            new OfficePlatePresets(m(OfficePlateGeneratorTests.Examples.Office), m(OfficePlateGeneratorTests.Examples.Core)))),
        ["Cafeteria"] = m => One(new CafeteriaGenerator(ToleranceSettings.Default).Generate(
            CafeteriaParameters.Default,
            new CafeteriaPresets(m(CafeteriaGeneratorTests.Examples.Kitchen), m(CafeteriaGeneratorTests.Examples.Dining)))),
        ["RadialLobes"] = m => One(new RadialLobesGenerator(ToleranceSettings.Default).Generate(
            RadialLobesParameters.Default,
            new RadialLobesPresets(m(RadialLobesGeneratorTests.Examples.DwellingUnit), m(RadialLobesGeneratorTests.Examples.Stair)))),
        ["BranchingMall"] = m => One(new BranchingMallGenerator(ToleranceSettings.Default).Generate(
            BranchingMallParameters.Default,
            new BranchingMallPresets(m(BranchingMallGeneratorTests.Examples.Mall), m(BranchingMallGeneratorTests.Examples.Retail)))),
        ["CareHub"] = m => One(new CareHubGenerator(ToleranceSettings.Default).Generate(
            CareHubParameters.Default,
            new CareHubPresets(m(CareHubGeneratorTests.Examples.CareBedroom), m(CareHubGeneratorTests.Examples.CareCommunal), m(CareHubGeneratorTests.Examples.Corridor)))),
        ["FoyerHalls"] = m => One(new FoyerHallsGenerator(ToleranceSettings.Default).Generate(
            FoyerHallsParameters.Default,
            new FoyerHallsPresets(m(FoyerHallsGeneratorTests.Examples.Lobby), m(FoyerHallsGeneratorTests.Examples.ActivityHall), m(FoyerHallsGeneratorTests.Examples.Service)))),
        ["OperatingSuite"] = m => One(new OperatingSuiteGenerator(ToleranceSettings.Default).Generate(
            OperatingSuiteParameters.Default,
            new OperatingSuitePresets(
                m(OperatingSuiteGeneratorTests.Examples.OperatingTheatre),
                m(OperatingSuiteGeneratorTests.Examples.CleanCorridor),
                m(OperatingSuiteGeneratorTests.Examples.DirtyCorridor),
                m(OperatingSuiteGeneratorTests.Examples.ClinicalSupport)))),
        ["CourtCluster"] = m => One(new CourtClusterGenerator(ToleranceSettings.Default).Generate(
            CourtClusterParameters.Default,
            new CourtClusterPresets(m(CourtClusterGeneratorTests.Examples.DwellingUnit), m(CourtClusterGeneratorTests.Examples.Stair)))),
        ["SteppedBand"] = m => new SteppedBandGenerator(ToleranceSettings.Default).Generate(
            SteppedBandParameters.Default,
            new SteppedBandPresets(m(SteppedBandGeneratorTests.Examples.DwellingUnit), m(SteppedBandGeneratorTests.Examples.Corridor), m(SteppedBandGeneratorTests.Examples.Stair))),
    };

    private static readonly Dictionary<string, PlanPresets> ExampleRecords = new()
    {
        ["Linear"] = LinearPlanGeneratorTests.Examples,
        ["StairBayBar"] = StairBayBarGeneratorTests.Examples,
        ["StairPair"] = StairPairGeneratorTests.Examples,
        ["GalleryBar"] = GalleryBarGeneratorTests.Examples,
        ["TerraceRow"] = TerraceRowGeneratorTests.Examples,
        ["PointPlate"] = PointPlateGeneratorTests.Examples,
        ["WingedBand"] = WingedBandGeneratorTests.Examples,
        ["OpenCourt"] = OpenCourtGeneratorTests.Examples,
        ["EnclosedCourt"] = EnclosedCourtGeneratorTests.Examples,
        ["OfficePlate"] = OfficePlateGeneratorTests.Examples,
        ["Cafeteria"] = CafeteriaGeneratorTests.Examples,
        ["RadialLobes"] = RadialLobesGeneratorTests.Examples,
        ["BranchingMall"] = BranchingMallGeneratorTests.Examples,
        ["CareHub"] = CareHubGeneratorTests.Examples,
        ["FoyerHalls"] = FoyerHallsGeneratorTests.Examples,
        ["OperatingSuite"] = OperatingSuiteGeneratorTests.Examples,
        ["CourtCluster"] = CourtClusterGeneratorTests.Examples,
        ["SteppedBand"] = SteppedBandGeneratorTests.Examples,
    };

    public static IEnumerable<object[]> FamilyNames() => Families.Keys.Select(k => new object[] { k });

    [Fact]
    public void EveryPlanGeneratorIsCovered()
    {
        Assert.Equal(18, Families.Count);
        Assert.Equal(Families.Keys, ExampleRecords.Keys);
    }

    [Theory]
    [MemberData(nameof(FamilyNames))]
    public void CastingEveryExamplePresetChangesOnlyTheProvenance(string family)
    {
        IReadOnlyList<IGeneratedPlan> typed = Families[family](p => p).Value;

        IReadOnlyList<IGeneratedPlan> cast = Families[family](p => p.AsAnyProgram()).Value;

        Assert.Equal(typed.Select(TextReport.Describe), cast.Select(TextReport.Describe));
        IEnumerable<string> recorded = ExampleRecords[family].Slots.Select(s => $"{PlanPresets.AnyProgramPresetKeyPrefix}{s.Input}={s.Preset.Name}");
        for (int k = 0; k < typed.Count; k++)
        {
            Assert.Equal(
                typed[k].Provenance.Parameters.Select(p => $"{p.Key}={p.Value}").Concat(recorded).OrderBy(p => p, StringComparer.Ordinal),
                cast[k].Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
            Assert.DoesNotContain(typed[k].Provenance.Parameters, p => p.Key.StartsWith(PlanPresets.AnyProgramPresetKeyPrefix, StringComparison.Ordinal));
        }
    }

    [Theory]
    [MemberData(nameof(FamilyNames))]
    public void AnotherProgramOnEveryInputKeepsTheLayoutsSpaceTypes(string family)
    {
        ProgramPreset kitchen = ExampleNonResidentialPresets.Kitchen.AsAnyProgram();
        IReadOnlyList<IGeneratedPlan> typed = Families[family](p => p).Value;

        Result<IReadOnlyList<IGeneratedPlan>> result = Families[family](_ => kitchen);

        Assert.True(result.IsSuccess, string.Join("\n", result.Diagnostics));
        Assert.Equal(typed.Count, result.Value.Count);
        for (int k = 0; k < typed.Count; k++)
        {
            Assert.Equal(typed[k].Zones.Select(z => (z.Id, z.SpaceType)), result.Value[k].Zones.Select(z => (z.Id, z.SpaceType)));
            Assert.All(result.Value[k].Zones, z => Assert.Same(kitchen.Program, z.Program));
            Assert.All(
                ExampleRecords[family].Slots,
                s => Assert.Contains(result.Value[k].Provenance.Parameters, p => p.Key == PlanPresets.AnyProgramPresetKeyPrefix + s.Input && p.Value == "Example Kitchen"));
        }
    }

    [Theory]
    [MemberData(nameof(FamilyNames))]
    public void ATypedPresetOfAnotherSpaceTypeIsStillRejectedOnEveryInput(string family)
    {
        ProgramPreset mechanical = (ExampleResidentialPresets.StairValues with { Name = "Plant", SpaceType = SpaceType.Mechanical }).ToPreset().Value;

        Result<IReadOnlyList<IGeneratedPlan>> result = Families[family](_ => mechanical);

        Assert.False(result.IsSuccess);
        Assert.Equal(ExampleRecords[family].Slots.Select(s => s.Input), result.Diagnostics.Select(d => d.Subject));
        Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.PresetSpaceTypeMismatch, d.Code));
    }

    private static Result<IReadOnlyList<IGeneratedPlan>> One(Result<IGeneratedPlan> result) => result.IsSuccess
        ? Result.Success<IReadOnlyList<IGeneratedPlan>>(new[] { result.Value }, result.Diagnostics)
        : Result.Failure<IReadOnlyList<IGeneratedPlan>>(result.Diagnostics);
}
  • [x] Step 4: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.PlanGeneratorTests|FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.AnyProgramPresetTests"

Expected from the first command, on net8.0 (the core tests build, since Task 1 gives the cast, but the slot check still rejects every any-program preset): the summary

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

with these failing tests and messages:

Failed Lod.Core.Tests.Plans.PlanGeneratorTests.ProvenanceRecordsEverySlotThatReceivedAnAnyProgramPreset
  System.InvalidOperationException : Result has no value: Error PresetSpaceTypeMismatch [Hall]: Input Hall needs a Corridor preset, got 'Example Stair' ().
Failed Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests.EveryStoreyRecordsTheSlotsThatReceivedAnAnyProgramPreset
  System.InvalidOperationException : Result has no value: Error PresetSpaceTypeMismatch [Unit]: Input Unit needs a DwellingUnit preset, got 'Example Corridor' ().; Error PresetSpaceTypeMismatch [Hall]: Input Hall needs a Corridor preset, got 'Example Stair' ().
Failed Lod.Core.Tests.Plans.PlanGeneratorTests.AnAnyProgramPresetFitsEverySlotAndItsZonesKeepTheLayoutSpaceType
  Assert.True() Failure
  Expected: True
  Actual:   False
Failed Lod.Core.Tests.Plans.PlanGeneratorTests.ATypedPresetBesideAnAnyProgramPresetMustStillMatchItsSlot
  Assert.Single() Failure: The collection contained 2 items
  Collection: [Error PresetSpaceTypeMismatch [Unit]: Input Unit needs a DwellingUnit preset, got 'Example Stair' (Stair)., Error PresetSpaceTypeMismatch [Hall]: Input Hall needs a Corridor preset, got 'Example Stair' ().]

Expected from the second command: the build fails because the provenance key prefix does not exist yet, with these errors for the net8.0 target (in this order):

tests\Lod.Generators.Tests\AnyProgramPresetTests.cs(156,96): error CS0117: 'PlanPresets' does not contain a definition for 'AnyProgramPresetKeyPrefix'
tests\Lod.Generators.Tests\AnyProgramPresetTests.cs(162,101): error CS0117: 'PlanPresets' does not contain a definition for 'AnyProgramPresetKeyPrefix'
tests\Lod.Generators.Tests\AnyProgramPresetTests.cs(183,103): error CS0117: 'PlanPresets' does not contain a definition for 'AnyProgramPresetKeyPrefix'
  • [x] Step 5: Implement — replace src/Lod.Core/Plans/PlanPresets.cs (full file)
using System.Collections.Generic;
using Lod.Core.Programs;

namespace Lod.Core.Plans;

/// <summary>One preset input of a plan generator (D-061): the space type it feeds and the preset given for it.</summary>
/// <param name="Input">Name of the input, used in messages.</param>
/// <param name="SpaceType">The space type this input feeds; the preset must have it, unless it is an any-program preset (D-108).</param>
/// <param name="Preset">The preset given for the input.</param>
public sealed record PresetSlot(string Input, SpaceType SpaceType, ProgramPreset Preset);

/// <summary>
/// The program presets of a plan generator, one per space type it places (D-061). Each typology defines a record with one property per space type
/// and lists them as <see cref="Slots"/>.
/// </summary>
public abstract record PlanPresets
{
    /// <summary>
    /// Prefix of the provenance parameter <c>AnyProgramPreset.{Input}</c>, whose value is the preset's name, that a plan records for each slot given an
    /// any-program preset (<see cref="ProgramPreset.AsAnyProgram"/>, D-108).
    /// </summary>
    public const string AnyProgramPresetKeyPrefix = "AnyProgramPreset.";

    /// <summary>The preset inputs in a fixed order; the order is kept in provenance.</summary>
    public abstract IReadOnlyList<PresetSlot> Slots { get; }
}
  • [x] Step 6: Implement — replace src/Lod.Core/Plans/PlanBuilder.cs (full file)

The slot check accepts an any-program preset, Presets uses the slot's space type, and DescribeAnyProgramPresets adds the keys; nothing else changes.

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.SpaceType}:{s.Preset.Name}"))),
            })
            .Concat(DescribeAnyProgramPresets(presets))
            .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)),
        };

    /// <summary>
    /// One entry <c>AnyProgramPreset.{Input}</c> = the preset's name for each slot given an any-program preset, so that a program applied to another
    /// space type than its own is never silent (GLOBAL.md scientific rule 3); none when every preset is typed, which keeps those plans unchanged.
    /// </summary>
    private static IEnumerable<KeyValuePair<string, string>> DescribeAnyProgramPresets(PlanPresets presets) => presets.Slots
        .Where(s => s.Preset.IsAnyProgram)
        .Select(s => new KeyValuePair<string, string>(PlanPresets.AnyProgramPresetKeyPrefix + s.Input, s.Preset.Name));

    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.IsAnyProgram && 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 7: Implement — replace src/Lod.Core/Plans/PlanGenerator.cs and src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs (full files)

Only the documentation of the presets parameter of Generate changes in each.

src/Lod.Core/Plans/PlanGenerator.cs:

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 or be an any-program preset (D-108).</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);
}

src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs:

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 or be an any-program preset (D-108).</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 8: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.PlanGeneratorTests|FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.AnyProgramPresetTests"

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

  • [x] Step 9: 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.6 -- "tests/*/Snapshots/*"
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 236 core, 518 generators, and 1485 integration tests passed on net8.0; git diff prints nothing (the twenty-five snapshots are byte-identical); git status lists the four M files of Steps 5 to 7, the two M test files of Steps 1 and 2, and ?? tests/Lod.Generators.Tests/AnyProgramPresetTests.cs, and no *.received.txt.

  • [x] Step 10: Commit
git add src/Lod.Core/Plans/PlanPresets.cs src/Lod.Core/Plans/PlanBuilder.cs src/Lod.Core/Plans/PlanGenerator.cs src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs tests/Lod.Generators.Tests/AnyProgramPresetTests.cs
git commit -m "feature(core): accept any-program presets on every preset input of a plan generator"

Task 3: Any-program presets through every simplifier and aggregator

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

Interfaces: - Consumes: AsAnyProgram() (Task 1), the slot check and provenance key (Task 2); LinearPlanGenerator, SteppedBandGenerator and their records; Pipeline.Simplifier, Pipeline.Aggregator, FloorValidator, BuildingValidator, FloorEntry, ValidationReport.Describe, Provenance.Describe. - Produces: 33 tests. The linear plan with the office program on its corridor and the kitchen program on its stair keeps the zones' space types and takes the presets' programs (1); it validates with every simplifier and aggregator (16); the stepped band with the care-bedroom program on its dwellings and the service program on its stair validates with every simplifier and aggregator, every storey through the simplifier and the storeys stacked with multiplier 1 (16); in each case every floor and the building validate, the aggregation gives no warning, and the building's provenance names every AnyProgramPreset.* key. No production code changes.

  • [x] Step 1: Write the integration tests — create tests/Lod.Integration.Tests/AnyProgramPresetTests.cs (full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Validation;
using Lod.Generators.Linear;
using Lod.Generators.SteppedBand;
using Xunit;

namespace Lod.Integration.Tests;

/// <summary>
/// Plans built with any-program presets (D-108) through every simplifier and aggregator: the zones keep the layout's space types, their programs
/// come from the presets, every floor and building validates, and the use of each any-program preset stays in the provenance of the building.
/// </summary>
public sealed class AnyProgramPresetTests
{
    private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
    private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };

    /// <summary>The linear plan with the example dwelling preset, the office program on the corridor, and the kitchen program on the stair.</summary>
    private static readonly LinearPlanPresets LinearPresets = new(
        ExampleResidentialPresets.DwellingUnit,
        ExampleNonResidentialPresets.Office.AsAnyProgram(),
        ExampleNonResidentialPresets.Kitchen.AsAnyProgram());

    /// <summary>The stepped band with the care bedroom program on the dwellings, the example corridor, and the service program on the stair.</summary>
    private static readonly SteppedBandPresets BandPresets = new(
        ExampleNonResidentialPresets.CareBedroom.AsAnyProgram(),
        ExampleResidentialPresets.Corridor,
        ExampleNonResidentialPresets.Service.AsAnyProgram());

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

    [Fact]
    public void TheZonesOfTheLinearPlanKeepTheirSpaceTypesAndTakeThePresetsPrograms()
    {
        IGeneratedPlan plan = new LinearPlanGenerator(Pipeline.Tolerances).Generate(Pipeline.Canonical, LinearPresets).Value;
        IGeneratedPlan typed = Pipeline.Plan();

        Assert.Equal(typed.Zones.Select(z => (z.Id, z.SpaceType)), plan.Zones.Select(z => (z.Id, z.SpaceType)));
        Assert.Same(ExampleNonResidentialPresets.Office.Program, plan.Zones.Single(z => z.SpaceType == SpaceType.Corridor).Program);
        Assert.Same(ExampleNonResidentialPresets.Kitchen.Program, plan.Zones.Single(z => z.SpaceType == SpaceType.Stair).Program);
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Same(ExampleResidentialPresets.DwellingUnit.Program, z.Program));
    }

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationOfTheLinearPlanValidates(string simplifier, string aggregator)
    {
        IGeneratedPlan plan = new LinearPlanGenerator(Pipeline.Tolerances).Generate(Pipeline.Canonical, LinearPresets).Value;

        AssertValidates(new[] { plan }, simplifier, aggregator, new[] { 3 }, "AnyProgramPreset.Corridor=Example Office", "AnyProgramPreset.Stair=Example Kitchen");
    }

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EveryCombinationOfTheSteppedBandValidates(string simplifier, string aggregator)
    {
        IReadOnlyList<IGeneratedPlan> plans = new SteppedBandGenerator(Pipeline.Tolerances).Generate(SteppedBandParameters.Default, BandPresets).Value;

        AssertValidates(plans, simplifier, aggregator, plans.Select(_ => 1).ToArray(), "AnyProgramPreset.DwellingUnit=Example Care Bedroom", "AnyProgramPreset.Stair=Example Service");
    }

    private static void AssertValidates(IReadOnlyList<IGeneratedPlan> plans, string simplifier, string aggregator, int[] multipliers, params string[] recorded)
    {
        var entries = new List<FloorEntry>();
        for (int k = 0; k < plans.Count; k++)
        {
            Result<IFloor> floor = Pipeline.Simplifier(simplifier).Simplify(plans[k]);
            Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
            ValidationReport floorReport = new FloorValidator(Pipeline.Tolerances).Validate(floor.Value);
            Assert.True(floorReport.Passed, floorReport.Describe());
            entries.Add(new FloorEntry(floor.Value, multipliers[k]));
        }

        Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(entries);
        Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
        Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
        Assert.True(report.Passed, report.Describe());
        string provenance = building.Value.Provenance.Describe();
        Assert.All(recorded, r => Assert.Contains(r, provenance));
    }
}
  • [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AnyProgramPresetTests"

Expected: Passed! - Failed: 0, Passed: 33, Skipped: 0, Total: 33, Duration: … - Lod.Integration.Tests.dll (net8.0). The tests pass at once: zones carry their space type and program, never their preset, so no simplifier, aggregator, or validation rule reads a preset's space type (the only readers are the slot check and Presets text of Task 2 and the Grasshopper preset text of Task 6). If a case fails, stop and report: a pipeline change needs its own test-first task.

  • [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); 236 core, 518 generators, and 1518 integration tests passed on net8.0; git status lists only ?? tests/Lod.Integration.Tests/AnyProgramPresetTests.cs.

  • [x] Step 4: Commit
git add tests/Lod.Integration.Tests/AnyProgramPresetTests.cs
git commit -m "test(integration): run any-program presets through every simplifier and aggregator"
  • [x] Step 5: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/any-program-preset
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/any-program-preset
git branch -d feature/any-program-preset

Expected: VERIFY PASSED, with 236 core, 518 generators, and 1518 integration tests passed on net8.0. Do not push (D-108).


Slice B — feature/storey-previews

The storey's base elevation recorded in Lod.Core, test-first, then the previews of plans and floors that read it.

Task 4: The base elevation of every storey plan

Files: - Create: src/Lod.Core/Plans/StoreyProvenance.cs - Modify: src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs (S8.7 file, last changed in Task 2) - Test: create tests/Lod.Core.Tests/Plans/StoreyProvenanceTests.cs; modify tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs (Task 2) and tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs (S8.7 file)

Interfaces: - Consumes: MultiStoreyPlanGenerator<TParameters, TPresets> (S8.7: StoreyKey, Generate, the storeys bottom-up), PlanParameters.FloorHeight, Provenance (Of, Format, Operation, Parameters, Inputs), PlanTransform.Apply and PlanTransform.Operation, the four simplifiers, Stack, FloorEntry. - Produces: public static class StoreyProvenance (Lod.Core.Plans) with public const string ElevationKey = "StoreyElevation" and public static double ElevationOf(Provenance): the value of StoreyElevation in the provenance or, while an operation has exactly one input, in its input's (invariant culture); 0 when none is recorded, and for an operation of several inputs such as an aggregated building. MultiStoreyPlanGenerator.Generate records on storey k the provenance parameter StoreyElevation = the sum of the floor heights below it (Provenance.Format: 0, 3, 6 for the default stepped band); the plans stay at elevation 0. Tests: core +1 (MultiStoreyPlanGeneratorTests: four storeys of 3.1 m at 0, 3.1, 6.2, 9.3, every zone at elevation 0) and +6 (StoreyProvenanceTests: a storey plan, a plan that is not one, Transform Plan and a simplifier after it, an operation of several inputs, every simplifier on a moved storey, a stacked building); the exact provenance lists of MultiStoreyPlanGeneratorTests.ProvenanceRecordsTheGeneratorAndTheStoreyIndex, MultiStoreyPlanGeneratorTests.AStoreyIsBuiltExactlyAsAPlanGeneratorBuildsItsPlan, and SteppedBandGeneratorTests.ProvenanceRecordsParametersPresetsAndTheStorey gain StoreyElevation.

  • [x] Step 1: Create the branch
git switch -c feature/storey-previews main
  • [x] Step 2: Write the failing tests — create tests/Lod.Core.Tests/Plans/StoreyProvenanceTests.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 Xunit;

namespace Lod.Core.Tests.Plans;

/// <summary>The storey elevation that the previews read from a plan or floor of a multi-storey generator (D-108, ADR-015).</summary>
public sealed class StoreyProvenanceTests
{
    private readonly TwoStoreyBand _generator = new();

    [Fact]
    public void AStoreyPlanGivesItsRecordedElevation()
    {
        Assert.Equal(6.2, StoreyProvenance.ElevationOf(Storey(6.2)));
    }

    [Fact]
    public void APlanThatIsNotAStoreyIsAtZero()
    {
        Assert.Equal(0.0, StoreyProvenance.ElevationOf(Provenance.Of("LinearPlanGenerator", Enumerable.Empty<KeyValuePair<string, string>>())));
    }

    [Fact]
    public void TheElevationIsFollowedThroughOperationsOfOneInput()
    {
        Provenance moved = Provenance.Of(PlanTransform.Operation, Enumerable.Empty<KeyValuePair<string, string>>(), Storey(6.0));
        Provenance simplified = Provenance.Of("SemanticMerge", Enumerable.Empty<KeyValuePair<string, string>>(), moved);

        Assert.Equal(6.0, StoreyProvenance.ElevationOf(moved));
        Assert.Equal(6.0, StoreyProvenance.ElevationOf(simplified));
    }

    [Fact]
    public void AnOperationOfSeveralInputsIsAtZero()
    {
        Provenance building = Provenance.Of("Stack", Enumerable.Empty<KeyValuePair<string, string>>(), Storey(0.0), Storey(3.0));

        Assert.Equal(0.0, StoreyProvenance.ElevationOf(building));
    }

    [Fact]
    public void EverySimplifierKeepsTheElevationOfItsStoreyOnAMovedPlan()
    {
        IGeneratedPlan storey = _generator.Generate(new BandParameters(), Presets).Value[1];
        IPlanSimplifier[] simplifiers =
        {
            new NoSimplification(),
            new SemanticMerge(ToleranceSettings.Default),
            new PerimeterCore(ToleranceSettings.Default, 1.0),
            new SingleZonePerFloor(ToleranceSettings.Default),
        };

        IGeneratedPlan moved = PlanTransform.Apply(storey, 30.0, 5.0, -2.0).Value;

        Assert.Equal(3.0, StoreyProvenance.ElevationOf(storey.Provenance));
        Assert.Equal(3.0, StoreyProvenance.ElevationOf(moved.Provenance));
        Assert.All(simplifiers, s => Assert.Equal(3.0, StoreyProvenance.ElevationOf(s.Simplify(moved).Value.Provenance)));
    }

    [Fact]
    public void ABuildingOfStoreysIsAtZero()
    {
        FloorEntry[] entries = _generator.Generate(new BandParameters(), Presets).Value
            .Select(p => new FloorEntry(new NoSimplification().Simplify(p).Value, 1))
            .ToArray();

        IGeneratedBuilding building = new Stack(ToleranceSettings.Default).Aggregate(entries).Value;

        Assert.Equal(0.0, StoreyProvenance.ElevationOf(building.Provenance));
    }

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

    private static Provenance Storey(double elevation) => Provenance.Of(
        "Band",
        new[]
        {
            new KeyValuePair<string, string>("Storey", "2"),
            new KeyValuePair<string, string>(StoreyProvenance.ElevationKey, Provenance.Format(elevation)),
        });

    private sealed record BandParameters() : PlanParameters(3.0, 0.0);

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

    /// <summary>Two equal storeys of an 8 m unit <c>U1</c> west of a 4 m hall <c>C1</c>, 8 m deep.</summary>
    private sealed class TwoStoreyBand : MultiStoreyPlanGenerator<BandParameters, BandPresets>
    {
        public TwoStoreyBand()
            : base(ToleranceSettings.Default)
        {
        }

        public override string Name => nameof(TwoStoreyBand);

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

        protected override IReadOnlyList<PlanLayout> Layout(BandParameters parameters) => Enumerable.Range(0, 2)
            .Select(_ => new PlanLayout(
                Polygon2.Rectangle(0.0, 0.0, 12.0, 8.0),
                new[]
                {
                    new PlannedZone(new ZoneId("U1"), "Unit", SpaceType.DwellingUnit, Polygon2.Rectangle(0.0, 0.0, 8.0, 8.0)),
                    new PlannedZone(new ZoneId("C1"), "Hall", SpaceType.Corridor, Polygon2.Rectangle(8.0, 0.0, 12.0, 8.0)),
                }))
            .ToArray();

        protected override IEnumerable<KeyValuePair<string, string>> Describe(BandParameters parameters) => Enumerable.Empty<KeyValuePair<string, string>>();
    }
}
  • [x] Step 3: Write the failing tests — replace tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs (full file)

ProvenanceRecordsTheGeneratorAndTheStoreyIndex expects StoreyElevation=0 on storey 0 and 3 on storey 1, EveryStoreyRecordsItsElevationTheSumOfTheFloorHeightsBelowIt is added after it, and AStoreyIsBuiltExactlyAsAPlanGeneratorBuildsItsPlan expects StoreyElevation=0 beside Storey=0; nothing else changes.

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",
                "StoreyElevation=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);
        Assert.Equal("3", plans[1].Provenance.Parameters.Single(p => p.Key == "StoreyElevation").Value);
    }

    [Fact]
    public void EveryStoreyRecordsItsElevationTheSumOfTheFloorHeightsBelowIt()
    {
        IReadOnlyList<IGeneratedPlan> plans = _generator.Generate(new BandParameters(4, FloorHeight: 3.1), Presets()).Value;

        Assert.Equal(new[] { 0.0, 3.1, 6.2, 9.3 }, plans.Select(p => StoreyProvenance.ElevationOf(p.Provenance)));
        Assert.Equal(new[] { "0", "3.1", "6.2", "9.3" }, plans.Select(p => p.Provenance.Parameters.Single(q => q.Key == StoreyProvenance.ElevationKey).Value));
        Assert.All(plans, p => Assert.All(p.Zones, z => Assert.Equal(0.0, z.Parts[0].Elevation)));
    }

    [Fact]
    public void EveryStoreyRecordsTheSlotsThatReceivedAnAnyProgramPreset()
    {
        var presets = new BandPresets(ExampleResidentialPresets.Corridor.AsAnyProgram(), ExampleResidentialPresets.Stair.AsAnyProgram());

        IReadOnlyList<IGeneratedPlan> plans = _generator.Generate(new BandParameters(2), presets).Value;

        Assert.All(plans, p => Assert.Equal(new[] { SpaceType.DwellingUnit, SpaceType.Corridor }, p.Zones.Select(z => z.SpaceType)));
        Assert.All(plans, p => Assert.Same(ExampleResidentialPresets.Corridor.Program, p.Zones[0].Program));
        Assert.All(
            plans,
            p => Assert.Equal(
                new[] { "AnyProgramPreset.Hall=Example Stair", "AnyProgramPreset.Unit=Example Corridor" },
                p.Provenance.Parameters.Where(q => q.Key.StartsWith("AnyProgramPreset.")).Select(q => $"{q.Key}={q.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").Append("StoreyElevation=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 4: Write the failing test — replace tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs (full file)

ProvenanceRecordsParametersPresetsAndTheStorey expects StoreyElevation=0 in the provenance of storey 0 and the elevations 0, 3, and 6; nothing else changes.

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", "StoreyElevation=0", "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));
        Assert.Equal(new[] { "0", "3", "6" }, plans.Select(p => p.Provenance.Parameters.Single(q => q.Key == "StoreyElevation").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 5: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.StoreyProvenanceTests|FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.SteppedBand.SteppedBandGeneratorTests"

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

tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(22,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(28,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(37,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(38,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(46,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(63,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(64,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(65,56): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(77,27): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\StoreyProvenanceTests.cs(87,46): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\MultiStoreyPlanGeneratorTests.cs(53,70): error CS0103: The name 'StoreyProvenance' does not exist in the current context
tests\Lod.Core.Tests\Plans\MultiStoreyPlanGeneratorTests.cs(54,121): error CS0103: The name 'StoreyProvenance' does not exist in the current context

Expected from the second command, on net8.0: the summary

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.ProvenanceRecordsParametersPresetsAndTheStorey
  Assert.Equal() Failure: Collections differ
                                                                                                                     ↓ (pos 10)
  Expected: string[]                                                  [···, "StepDepth=3", "Storey=0", "StoreyCount=3", "StoreyElevation=0", "UnitWidth=6"]
  Actual:   SelectIListIterator<KeyValuePair<string, string>, string> [···, "StairWidth=4", "StepDepth=3", "Storey=0", "StoreyCount=3", "UnitWidth=6"]
                                                                                                                                     ↑ (pos 10)
  • [x] Step 6: Implement — create src/Lod.Core/Plans/StoreyProvenance.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Model;

namespace Lod.Core.Plans;

/// <summary>
/// The base elevation of a storey of a <see cref="MultiStoreyPlanGenerator{TParameters, TPresets}"/> (ADR-015, D-108): the sum of the floor heights
/// below it, recorded in the storey plan's provenance as <see cref="ElevationKey"/>. The plan itself stays at elevation 0; only the Grasshopper
/// previews of the plan and of what is made from it alone use the elevation, so a storey is drawn where the floor aggregators place it.
/// </summary>
public static class StoreyProvenance
{
    /// <summary>Provenance key of a storey's base elevation, m, as invariant-culture text.</summary>
    public const string ElevationKey = "StoreyElevation";

    /// <summary>
    /// The storey elevation an object descends from: the value of <see cref="ElevationKey"/> in its provenance or, while an operation has exactly one
    /// input (<see cref="PlanTransform"/>, a plan simplifier), in its input's. An object of several inputs (an aggregated building) or one that does not
    /// descend from a storey plan is at 0.
    /// </summary>
    /// <param name="provenance">The provenance of a plan, floor, or building.</param>
    /// <returns>The elevation, m; 0 when none is recorded.</returns>
    public static double ElevationOf(Provenance provenance)
    {
        Provenance? current = provenance;
        while (current is not null)
        {
            KeyValuePair<string, string> recorded = current.Parameters.FirstOrDefault(p => p.Key == ElevationKey);
            if (recorded.Key is not null)
            {
                return double.Parse(recorded.Value, NumberStyles.Float, CultureInfo.InvariantCulture);
            }

            current = current.Inputs.Count == 1 ? current.Inputs[0] : null;
        }

        return 0.0;
    }
}
  • [x] Step 7: Implement — replace src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs (full file)

Generate appends StoreyElevation after Storey and adds the floor height after each storey; the class summary names the key; nothing else changes.

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, and its base elevation, the sum of the floor heights below it, as <see cref="StoreyProvenance.ElevationKey"/>, which only previews read
/// (D-108); every plan lies at elevation 0. 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 or be an any-program preset (D-108).</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);
        double elevation = 0.0;
        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)))
                .Append(new KeyValuePair<string, string>(StoreyProvenance.ElevationKey, Provenance.Format(elevation)));
            elevation += parameters.FloorHeight;
            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 8: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Plans.StoreyProvenanceTests|FullyQualifiedName~Lod.Core.Tests.Plans.MultiStoreyPlanGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.SteppedBand.SteppedBandGeneratorTests"

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

  • [x] Step 9: 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.6 -- "tests/*/Snapshots/*"
git status --short --untracked-files=all

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 243 core, 518 generators, and 1518 integration tests passed on net8.0; git diff prints nothing (the stepped-band snapshot has no provenance and is byte-identical, as are the other twenty-four); git status lists M src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs, the two M test files of Steps 3 and 4, ?? src/Lod.Core/Plans/StoreyProvenance.cs, and ?? tests/Lod.Core.Tests/Plans/StoreyProvenanceTests.cs, and no *.received.txt.

  • [x] Step 10: Commit
git add src/Lod.Core/Plans/StoreyProvenance.cs src/Lod.Core/Plans/MultiStoreyPlanGenerator.cs tests/Lod.Core.Tests/Plans/StoreyProvenanceTests.cs tests/Lod.Core.Tests/Plans/MultiStoreyPlanGeneratorTests.cs tests/Lod.Generators.Tests/SteppedBand/SteppedBandGeneratorTests.cs
git commit -m "feature(core): record the base elevation of every storey plan"

Task 5: Every storey plan and its floor previewed at the storey's elevation

Files: - Modify: src/Lod.Grasshopper/Goo/BemGoo.cs (S2 file, last changed in S8.4), src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file, last changed in S8.7)

Interfaces: - Consumes: StoreyProvenance.ElevationOf (Task 4); PreviewGoo<T> (S2–S8.4: PreviewOffset, the Brep preview built once and moved), PlanGoo, FloorGoo, RhinoGeometry.UnitScale; the multi-storey Output of TypologyGeneratorComponent (S8.7), ComponentSupport.Unwrap, ComponentSupport.PreviewLocation. - Produces: PreviewGoo<T> gains protected virtual double StoreyElevation => 0.0, and its preview is moved by PreviewOffset + (0, 0, StoreyElevation × RhinoGeometry.UnitScale); PlanGoo and FloorGoo override it with StoreyProvenance.ElevationOf(Value.Provenance); BuildingGoo keeps 0. The multi-storey Output sets each storey's PlanGoo with the Preview Location only, so the elevation is not added twice and the generator's previews are where S8.7 drew them. Transform Plan and the four simplifier components need no change: their goo takes the height from the data, plus their own Preview Location. PreviewOffset still means the Preview Location only. Data, Breps, and Convert2BEM outputs are unchanged.

  • [x] Step 1: Replace src/Lod.Grasshopper/Goo/BemGoo.cs (full file)
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Schedules;
using Lod.Grasshopper.Convert;
using Rhino.Display;
using Rhino.Geometry;
using Surface = Lod.Core.Model.Surface;

namespace Lod.Grasshopper.Goo;

/// <summary>Grasshopper wrapper of an immutable pipeline object. Values are never modified, so duplicates share the value.</summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class BemGoo<T> : GH_Goo<T>
    where T : class
{
    protected BemGoo(T value)
        : base(value)
    {
    }

    public override bool IsValid => Value is not null;

    public override string TypeDescription => $"BEMGen {TypeName}";
}

public sealed class ScheduleGoo : BemGoo<Schedule>
{
    public ScheduleGoo(Schedule value)
        : base(value)
    {
    }

    public override string TypeName => "Schedule";

    public override IGH_Goo Duplicate() => new ScheduleGoo(Value);

    public override string ToString() => $"Schedule {Value.Name} ({Value.Kind})";
}

public sealed class LoadGoo : BemGoo<LoadDefinition>
{
    public LoadGoo(LoadDefinition value)
        : base(value)
    {
    }

    public override string TypeName => "Load";

    public override IGH_Goo Duplicate() => new LoadGoo(Value);

    public override string ToString() => $"Load {Value.Type} {Value.Value} {Value.Basis}";
}

public sealed class ProgramPresetGoo : BemGoo<ProgramPreset>
{
    public ProgramPresetGoo(ProgramPreset value)
        : base(value)
    {
    }

    public override string TypeName => "Program Preset";

    public override IGH_Goo Duplicate() => new ProgramPresetGoo(Value);

    public override string ToString() => $"Preset {Value.Name} ({Value.SpaceType}, WWR {Value.WindowToWallRatio})";
}

/// <summary>
/// Wrapper with viewport preview: zones coloured by space type (a zone with several parts as one merged volume, D-071), storeys that are represented
/// but not modelled in transparent grey (D-071, D-075), windows in blue. The preview is raised to <see cref="StoreyElevation"/> and moved by
/// <see cref="PreviewOffset"/> (D-062, D-108); the wrapped value keeps its true coordinates.
/// </summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class PreviewGoo<T> : BemGoo<T>, IGH_PreviewData
    where T : class
{
    private static readonly Color WindowColour = Color.FromArgb(70, 140, 220);
    private static readonly Color UnmodelledColour = Color.FromArgb(150, 150, 150);
    private List<(Brep Brep, DisplayMaterial Material)>? _preview;

    protected PreviewGoo(T value, Vector3d previewOffset)
        : base(value)
    {
        PreviewOffset = previewOffset;
    }

    /// <summary>Translation of the viewport preview only, in model units (D-062).</summary>
    public Vector3d PreviewOffset { get; }

    public BoundingBox ClippingBox
    {
        get
        {
            BoundingBox box = BoundingBox.Empty;
            foreach ((Brep brep, DisplayMaterial _) in Preview)
            {
                box.Union(brep.GetBoundingBox(false));
            }

            return box;
        }
    }

    private List<(Brep Brep, DisplayMaterial Material)> Preview => _preview ??= BuildPreview();

    public void DrawViewportMeshes(GH_PreviewMeshArgs args)
    {
        foreach ((Brep brep, DisplayMaterial material) in Preview)
        {
            args.Pipeline.DrawBrepShaded(brep, material);
        }
    }

    public void DrawViewportWires(GH_PreviewWireArgs args)
    {
        foreach ((Brep brep, DisplayMaterial _) in Preview)
        {
            args.Pipeline.DrawBrepWires(brep, args.Color);
        }
    }

    protected abstract IEnumerable<Zone> Zones { get; }

    /// <summary>
    /// The base elevation, m, of the storey the value is or comes from (<see cref="StoreyProvenance"/>, ADR-015), at which its preview is drawn below
    /// the Preview Location; 0 unless a plan or floor says so.
    /// </summary>
    protected virtual double StoreyElevation => 0.0;

    protected abstract IEnumerable<Surface> Surfaces { get; }

    /// <summary>Parts of storeys that are represented but not modelled, drawn transparent grey; none unless a building says so.</summary>
    protected virtual IEnumerable<ZonePart> UnmodelledParts => Enumerable.Empty<ZonePart>();

    private List<(Brep, DisplayMaterial)> BuildPreview()
    {
        var items = new List<(Brep, DisplayMaterial)>();
        foreach (Zone zone in Zones)
        {
            var material = new DisplayMaterial(SpaceTypeColour(zone.SpaceType), 0.6);
            IEnumerable<Brep> volumes = zone.Parts.Count > 1 ? RhinoGeometry.MergedZoneBreps(zone) : RhinoGeometry.ZoneBreps(zone);
            items.AddRange(volumes.Select(b => (b, material)));
        }

        var unmodelled = new DisplayMaterial(UnmodelledColour, 0.85);
        items.AddRange(RhinoGeometry.PartBreps(UnmodelledParts).Select(b => (b, unmodelled)));

        var glass = new DisplayMaterial(WindowColour, 0.2);
        foreach (WallSurface wall in Surfaces.OfType<WallSurface>())
        {
            items.AddRange(RhinoGeometry.WindowBreps(wall).Select(b => (b, glass)));
        }

        Vector3d offset = PreviewOffset + new Vector3d(0.0, 0.0, StoreyElevation * RhinoGeometry.UnitScale);
        if (!offset.IsZero)
        {
            Transform move = Transform.Translation(offset);
            foreach ((Brep brep, DisplayMaterial _) in items)
            {
                brep.Transform(move);
            }
        }

        return items;
    }

    private static Color SpaceTypeColour(SpaceType type) => type switch
    {
        SpaceType.DwellingUnit => Color.FromArgb(240, 200, 120),
        SpaceType.Corridor => Color.FromArgb(180, 180, 180),
        SpaceType.Stair => Color.FromArgb(200, 120, 120),
        SpaceType.Core => Color.FromArgb(160, 120, 200),
        SpaceType.Mixed => Color.FromArgb(120, 190, 160),
        SpaceType.Office => Color.FromArgb(130, 170, 220),
        SpaceType.Retail => Color.FromArgb(230, 150, 170),
        SpaceType.Mall => Color.FromArgb(210, 210, 160),
        SpaceType.Kitchen => Color.FromArgb(220, 130, 90),
        SpaceType.Dining => Color.FromArgb(240, 220, 150),
        SpaceType.OperatingTheatre => Color.FromArgb(110, 190, 200),
        SpaceType.CleanCorridor => Color.FromArgb(200, 230, 230),
        SpaceType.DirtyCorridor => Color.FromArgb(160, 140, 120),
        SpaceType.ClinicalSupport => Color.FromArgb(150, 200, 180),
        SpaceType.CareBedroom => Color.FromArgb(230, 180, 200),
        SpaceType.CareCommunal => Color.FromArgb(200, 160, 220),
        SpaceType.ActivityHall => Color.FromArgb(170, 210, 120),
        _ => Color.FromArgb(150, 170, 200),
    };
}

public sealed class PlanGoo : PreviewGoo<IGeneratedPlan>
{
    public PlanGoo(IGeneratedPlan value, Vector3d previewOffset = default)
        : base(value, previewOffset)
    {
    }

    public override string TypeName => "Plan";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    /// <summary>The elevation of the storey plan it is or comes from (D-108); 0 for any other plan or floor.</summary>
    protected override double StoreyElevation => StoreyProvenance.ElevationOf(Value.Provenance);

    public override IGH_Goo Duplicate() => new PlanGoo(Value, PreviewOffset);

    public override string ToString() => $"Plan ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}

public sealed class FloorGoo : PreviewGoo<IFloor>
{
    public FloorGoo(IFloor value, Vector3d previewOffset = default)
        : base(value, previewOffset)
    {
    }

    public override string TypeName => "Floor";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    /// <summary>The elevation of the storey plan it is or comes from (D-108); 0 for any other plan or floor.</summary>
    protected override double StoreyElevation => StoreyProvenance.ElevationOf(Value.Provenance);

    public override IGH_Goo Duplicate() => new FloorGoo(Value, PreviewOffset);

    public override string ToString() => $"Floor ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}

public sealed class BuildingGoo : PreviewGoo<IGeneratedBuilding>
{
    private readonly IReadOnlyList<ZonePart> _unmodelled;

    /// <param name="value">The building.</param>
    /// <param name="previewOffset">Preview Location (D-062).</param>
    /// <param name="unmodelled">Parts of the storeys the building represents but does not model, from the aggregator (preview only).</param>
    public BuildingGoo(IGeneratedBuilding value, Vector3d previewOffset = default, IReadOnlyList<ZonePart>? unmodelled = null)
        : base(value, previewOffset)
    {
        _unmodelled = unmodelled ?? new ZonePart[0];
    }

    public override string TypeName => "Building";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    protected override IEnumerable<ZonePart> UnmodelledParts => _unmodelled;

    public override IGH_Goo Duplicate() => new BuildingGoo(Value, PreviewOffset, _unmodelled);

    public override string ToString() => $"Building ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
  • [x] Step 2: Replace src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (full file)

The multi-storey Output passes the Preview Location only; using System.Linq; is added and using Lod.Grasshopper.Convert; removed; nothing else changes.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
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.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, with this component's preview location; each storey's preview is
    /// drawn at the elevation recorded in its plan (<see cref="Lod.Core.Plans.StoreyProvenance"/>), while the plans keep elevation 0 (ADR-015, D-108).
    /// </summary>
    protected void Output(IGH_DataAccess DA, Result<IReadOnlyList<IGeneratedPlan>> result)
    {
        IReadOnlyList<IGeneratedPlan>? plans = ComponentSupport.Unwrap(this, result);
        if (plans is not null)
        {
            Vector3d location = ComponentSupport.PreviewLocation(this, DA);
            DA.SetDataList(0, plans.Select(p => new PlanGoo(p, location)));
        }
    }
}

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 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); 243 core, 518 generators, and 1518 integration tests passed on net8.0; git status lists the two M files of this task only. The previews are checked by the smoke test of Task 10.

  • [x] Step 4: Commit
git add src/Lod.Grasshopper/Goo/BemGoo.cs src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs
git commit -m "feature(grasshopper): preview every storey plan and its floor at the storey's elevation"
  • [x] Step 5: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/storey-previews
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/storey-previews
git branch -d feature/storey-previews
git diff --name-status v0.8.6 main -- "tests/*/Snapshots/*"

Expected: VERIFY PASSED, with 243 core, 518 generators, and 1518 integration tests passed on net8.0; the last command prints nothing: the twenty-five existing snapshots are byte-identical. Do not push (D-108).


Slice C — feature/any-program-preset-parameter

Task 6: Any Program Preset parameter, accepted by every preset input, and its icon

Files: - Modify: src/Lod.Grasshopper/Parameters/BemParameters.cs (S2 file, last changed in S4.1), src/Lod.Grasshopper/Goo/BemGoo.cs (Task 5), src/Lod.Grasshopper/Components/ComponentSupport.cs (S2 file, last changed in S4.1), src/Lod.Grasshopper/Components/PipelineComponents.cs (S2 file, last changed in S8.1), src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (Task 5), scripts/make_icons.py (S4.1 file, last changed in S8.7) - Create (generated): src/Lod.Grasshopper/Icons/AnyProgramPresetParameter.png

Interfaces: - Consumes: ProgramPreset.AsAnyProgram() and SpaceType? (Task 1); BemGoo<T>, ProgramPresetGoo, BemParameter<TGoo> (S2–S4.1, a GH_Param<TGoo> in panel 0 Info, hidden), ProgramPresetParameter (GUID 56578999-825c-4926-afdd-ce897b53221a, the type of every generator's preset input), GH_Param<T>.PreferredCast, GH_ObjectWrapper, ComponentCategories.Program; TypologyGeneratorComponent.AddPreset (S8.1) and the three preset inputs of LinearPlanGeneratorComponent (S4.1); IconLibrary.For. - Produces: AnyProgramPresetGoo : BemGoo<ProgramPreset>, whose constructor always stores value.AsAnyProgram(), TypeName "Any Program Preset", ToString Preset {Name} (any program, no space type, WWR {ratio}) (NoSpaceType), and CastTo to ProgramPresetGoo and ProgramPreset; ProgramPresetGoo.ToString prints the same text for a preset without a space type. AnyProgramPresetParameter : BemParameter<AnyProgramPresetGoo>: Any Program Preset, nickname AnyP, GUID 24bc6a12-ad06-4c25-bc9d-4e8318b8744c, panel 1 Program, primary, item access, a floating parameter without persistent data; its PreferredCast turns a ProgramPresetGoo with a value, a plain ProgramPreset, or a GH_ObjectWrapper around one (a script output) into an AnyProgramPresetGoo, and leaves anything else to Grasshopper's own conversion, which fails for it. ProgramPresetParameter.PreferredCast turns an AnyProgramPresetGoo into a ProgramPresetGoo of its any-program value, so every preset input of every generator takes it; its description names any-program presets. BemParameter gains a constructor with a panel argument; the six existing parameters stay in 0 Info. ComponentSupport.PresetInputDescription(role, spaceType) = Program preset of {role}; space type {spaceType}. plus the one shared sentence An Any Program Preset (D-108) is accepted too: its zones keep this space type and take the preset's program, and the plan's provenance records it., used by AddPreset (17 generators) and the three preset inputs of the Linear Plan Generator. The motif small_any_preset (the small preset tag with a wildcard asterisk where its hole was, in the Program accent, inside the parameter hexagon), mapped after ProgramPresetParameter: 61 icons. No component, input, output, or GUID changes.

The parameter's PreferredCast is taken from the final state of the verified reference (d9c3059, which added the GH_ObjectWrapper case to the parameter of 9b97dfa); this task squashes the two reference commits into one, and the tree after it equals the reference's after d9c3059 for every file of this task (note 6).

  • [x] Step 1: Create the branch
git switch -c feature/any-program-preset-parameter main
  • [x] Step 2: Replace src/Lod.Grasshopper/Parameters/BemParameters.cs (full file)
using System;
using System.Drawing;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Programs;
using Lod.Grasshopper.Goo;
using Rhino.Geometry;

namespace Lod.Grasshopper.Parameters;

/// <summary>A parameter carrying BEMGen objects; previews them when they support it. Hidden from the toolbar.</summary>
/// <typeparam name="TGoo">Goo type.</typeparam>
public abstract class BemParameter<TGoo> : GH_Param<TGoo>, IGH_PreviewObject
    where TGoo : class, IGH_Goo
{
    protected BemParameter(string name, string nickname, string description)
        : this(name, nickname, description, ComponentCategories.Info)
    {
    }

    protected BemParameter(string name, string nickname, string description, string subCategory)
        : base(name, nickname, description, ComponentCategories.Category, subCategory, GH_ParamAccess.item)
    {
    }

    public override GH_Exposure Exposure => GH_Exposure.hidden;

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

    public bool Hidden { get; set; }

    public bool IsPreviewCapable => true;

    public BoundingBox ClippingBox => Preview_ComputeClippingBox();

    public void DrawViewportMeshes(IGH_PreviewArgs args) => Preview_DrawMeshes(args);

    public void DrawViewportWires(IGH_PreviewArgs args) => Preview_DrawWires(args);
}

public sealed class ScheduleParameter : BemParameter<ScheduleGoo>
{
    public ScheduleParameter()
        : base("Schedule", "Sch", "An 8760-hour schedule.")
    {
    }

    public override Guid ComponentGuid => new("a4ae84ac-36d6-41b8-9b3d-c77cca52a2df");
}

public sealed class LoadParameter : BemParameter<LoadGoo>
{
    public LoadParameter()
        : base("Load", "L", "A load definition.")
    {
    }

    public override Guid ComponentGuid => new("9e9fbc12-9b81-42b7-8c5d-a4f8397a4487");
}

public sealed class ProgramPresetParameter : BemParameter<ProgramPresetGoo>
{
    public ProgramPresetParameter()
        : base("Program Preset", "P", "A program preset for one space type, or an any-program preset without one (D-108).")
    {
    }

    public override Guid ComponentGuid => new("56578999-825c-4926-afdd-ce897b53221a");

    /// <summary>Takes an any-program preset (D-108) as the preset it wraps, which has no space type.</summary>
    protected override ProgramPresetGoo PreferredCast(object data) => data is AnyProgramPresetGoo any ? new ProgramPresetGoo(any.Value) : null!;
}

/// <summary>
/// The data node of D-108, a floating parameter like Grasshopper's <i>Geometry</i>, visible in panel 1 Program: every preset wired into it, built-in
/// or general, comes out as an any-program preset (<see cref="ProgramPreset.AsAnyProgram"/>), which every preset input of every plan generator
/// accepts. It holds no data of its own: an unconnected one is empty.
/// </summary>
public sealed class AnyProgramPresetParameter : BemParameter<AnyProgramPresetGoo>
{
    public AnyProgramPresetParameter()
        : base(
            "Any Program Preset",
            "AnyP",
            "Casts a program preset of any space type to an any-program preset: the same name, loads, schedules, conditioning, setpoints, and WWR, without a space type. Wire it into any preset input of any plan generator; the zones keep that input's space type, and the plan records the use in its provenance (AnyProgramPreset.<Input>, D-108).",
            ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("24bc6a12-ad06-4c25-bc9d-4e8318b8744c");

    public override GH_Exposure Exposure => GH_Exposure.primary;

    /// <summary>
    /// Casts a preset to an any-program preset: one in a <see cref="ProgramPresetGoo"/> (any preset component or parameter), or a plain preset, also
    /// wrapped by Grasshopper (a script output). Anything else is left to Grasshopper's own conversion, which fails for it.
    /// </summary>
    protected override AnyProgramPresetGoo PreferredCast(object data) => data switch
    {
        ProgramPresetGoo { Value: { } preset } => new AnyProgramPresetGoo(preset),
        ProgramPreset preset => new AnyProgramPresetGoo(preset),
        GH_ObjectWrapper { Value: ProgramPreset preset } => new AnyProgramPresetGoo(preset),
        _ => null!,
    };
}

public sealed class PlanParameter : BemParameter<PlanGoo>
{
    public PlanParameter()
        : base("Plan", "Plan", "A detailed generated plan (IGeneratedPlan).")
    {
    }

    public override Guid ComponentGuid => new("fca85387-7aaf-41fe-9654-382b2980f18f");
}

public sealed class FloorParameter : BemParameter<FloorGoo>
{
    public FloorParameter()
        : base("Floor", "Floor", "A simplified floor (IFloor).")
    {
    }

    public override Guid ComponentGuid => new("15a085fa-f308-46b7-93e6-88a7a85e6a89");
}

public sealed class BuildingParameter : BemParameter<BuildingGoo>
{
    public BuildingParameter()
        : base("Building", "Bldg", "An aggregated building (IGeneratedBuilding).")
    {
    }

    public override Guid ComponentGuid => new("d6726442-189d-4c44-b043-b56417a3826a");
}
  • [x] Step 3: Replace src/Lod.Grasshopper/Goo/BemGoo.cs (full file)

AnyProgramPresetGoo is added after ProgramPresetGoo, whose ToString names a preset without a space type; nothing else changes.

using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Schedules;
using Lod.Grasshopper.Convert;
using Rhino.Display;
using Rhino.Geometry;
using Surface = Lod.Core.Model.Surface;

namespace Lod.Grasshopper.Goo;

/// <summary>Grasshopper wrapper of an immutable pipeline object. Values are never modified, so duplicates share the value.</summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class BemGoo<T> : GH_Goo<T>
    where T : class
{
    protected BemGoo(T value)
        : base(value)
    {
    }

    public override bool IsValid => Value is not null;

    public override string TypeDescription => $"BEMGen {TypeName}";
}

public sealed class ScheduleGoo : BemGoo<Schedule>
{
    public ScheduleGoo(Schedule value)
        : base(value)
    {
    }

    public override string TypeName => "Schedule";

    public override IGH_Goo Duplicate() => new ScheduleGoo(Value);

    public override string ToString() => $"Schedule {Value.Name} ({Value.Kind})";
}

public sealed class LoadGoo : BemGoo<LoadDefinition>
{
    public LoadGoo(LoadDefinition value)
        : base(value)
    {
    }

    public override string TypeName => "Load";

    public override IGH_Goo Duplicate() => new LoadGoo(Value);

    public override string ToString() => $"Load {Value.Type} {Value.Value} {Value.Basis}";
}

public sealed class ProgramPresetGoo : BemGoo<ProgramPreset>
{
    public ProgramPresetGoo(ProgramPreset value)
        : base(value)
    {
    }

    public override string TypeName => "Program Preset";

    public override IGH_Goo Duplicate() => new ProgramPresetGoo(Value);

    public override string ToString() => $"Preset {Value.Name} ({(Value.SpaceType is { } spaceType ? spaceType.ToString() : AnyProgramPresetGoo.NoSpaceType)}, WWR {Value.WindowToWallRatio})";
}

/// <summary>
/// A preset cast to an any-program preset (D-108): <see cref="ProgramPreset.AsAnyProgram"/> of the wrapped preset, so it has no space type and fits
/// every preset input of every plan generator. Carried by <see cref="Parameters.AnyProgramPresetParameter"/>.
/// </summary>
public sealed class AnyProgramPresetGoo : BemGoo<ProgramPreset>
{
    /// <summary>How a preset without a space type is described.</summary>
    public const string NoSpaceType = "any program, no space type";

    /// <param name="value">A preset of any space type, or an any-program preset; it is cast to an any-program preset.</param>
    public AnyProgramPresetGoo(ProgramPreset value)
        : base(value.AsAnyProgram())
    {
    }

    public override string TypeName => "Any Program Preset";

    public override IGH_Goo Duplicate() => new AnyProgramPresetGoo(Value);

    public override string ToString() => $"Preset {Value.Name} ({NoSpaceType}, WWR {Value.WindowToWallRatio})";

    /// <summary>Gives the any-program preset as a <see cref="ProgramPresetGoo"/> (a plan generator's preset input) or as the plain preset.</summary>
    public override bool CastTo<TQ>(ref TQ target)
    {
        if (typeof(TQ).IsAssignableFrom(typeof(ProgramPresetGoo)))
        {
            target = (TQ)(object)new ProgramPresetGoo(Value);
            return true;
        }

        if (typeof(TQ).IsAssignableFrom(typeof(ProgramPreset)))
        {
            target = (TQ)(object)Value;
            return true;
        }

        return false;
    }
}

/// <summary>
/// Wrapper with viewport preview: zones coloured by space type (a zone with several parts as one merged volume, D-071), storeys that are represented
/// but not modelled in transparent grey (D-071, D-075), windows in blue. The preview is raised to <see cref="StoreyElevation"/> and moved by
/// <see cref="PreviewOffset"/> (D-062, D-108); the wrapped value keeps its true coordinates.
/// </summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class PreviewGoo<T> : BemGoo<T>, IGH_PreviewData
    where T : class
{
    private static readonly Color WindowColour = Color.FromArgb(70, 140, 220);
    private static readonly Color UnmodelledColour = Color.FromArgb(150, 150, 150);
    private List<(Brep Brep, DisplayMaterial Material)>? _preview;

    protected PreviewGoo(T value, Vector3d previewOffset)
        : base(value)
    {
        PreviewOffset = previewOffset;
    }

    /// <summary>Translation of the viewport preview only, in model units (D-062).</summary>
    public Vector3d PreviewOffset { get; }

    public BoundingBox ClippingBox
    {
        get
        {
            BoundingBox box = BoundingBox.Empty;
            foreach ((Brep brep, DisplayMaterial _) in Preview)
            {
                box.Union(brep.GetBoundingBox(false));
            }

            return box;
        }
    }

    private List<(Brep Brep, DisplayMaterial Material)> Preview => _preview ??= BuildPreview();

    public void DrawViewportMeshes(GH_PreviewMeshArgs args)
    {
        foreach ((Brep brep, DisplayMaterial material) in Preview)
        {
            args.Pipeline.DrawBrepShaded(brep, material);
        }
    }

    public void DrawViewportWires(GH_PreviewWireArgs args)
    {
        foreach ((Brep brep, DisplayMaterial _) in Preview)
        {
            args.Pipeline.DrawBrepWires(brep, args.Color);
        }
    }

    protected abstract IEnumerable<Zone> Zones { get; }

    /// <summary>
    /// The base elevation, m, of the storey the value is or comes from (<see cref="StoreyProvenance"/>, ADR-015), at which its preview is drawn below
    /// the Preview Location; 0 unless a plan or floor says so.
    /// </summary>
    protected virtual double StoreyElevation => 0.0;

    protected abstract IEnumerable<Surface> Surfaces { get; }

    /// <summary>Parts of storeys that are represented but not modelled, drawn transparent grey; none unless a building says so.</summary>
    protected virtual IEnumerable<ZonePart> UnmodelledParts => Enumerable.Empty<ZonePart>();

    private List<(Brep, DisplayMaterial)> BuildPreview()
    {
        var items = new List<(Brep, DisplayMaterial)>();
        foreach (Zone zone in Zones)
        {
            var material = new DisplayMaterial(SpaceTypeColour(zone.SpaceType), 0.6);
            IEnumerable<Brep> volumes = zone.Parts.Count > 1 ? RhinoGeometry.MergedZoneBreps(zone) : RhinoGeometry.ZoneBreps(zone);
            items.AddRange(volumes.Select(b => (b, material)));
        }

        var unmodelled = new DisplayMaterial(UnmodelledColour, 0.85);
        items.AddRange(RhinoGeometry.PartBreps(UnmodelledParts).Select(b => (b, unmodelled)));

        var glass = new DisplayMaterial(WindowColour, 0.2);
        foreach (WallSurface wall in Surfaces.OfType<WallSurface>())
        {
            items.AddRange(RhinoGeometry.WindowBreps(wall).Select(b => (b, glass)));
        }

        Vector3d offset = PreviewOffset + new Vector3d(0.0, 0.0, StoreyElevation * RhinoGeometry.UnitScale);
        if (!offset.IsZero)
        {
            Transform move = Transform.Translation(offset);
            foreach ((Brep brep, DisplayMaterial _) in items)
            {
                brep.Transform(move);
            }
        }

        return items;
    }

    private static Color SpaceTypeColour(SpaceType type) => type switch
    {
        SpaceType.DwellingUnit => Color.FromArgb(240, 200, 120),
        SpaceType.Corridor => Color.FromArgb(180, 180, 180),
        SpaceType.Stair => Color.FromArgb(200, 120, 120),
        SpaceType.Core => Color.FromArgb(160, 120, 200),
        SpaceType.Mixed => Color.FromArgb(120, 190, 160),
        SpaceType.Office => Color.FromArgb(130, 170, 220),
        SpaceType.Retail => Color.FromArgb(230, 150, 170),
        SpaceType.Mall => Color.FromArgb(210, 210, 160),
        SpaceType.Kitchen => Color.FromArgb(220, 130, 90),
        SpaceType.Dining => Color.FromArgb(240, 220, 150),
        SpaceType.OperatingTheatre => Color.FromArgb(110, 190, 200),
        SpaceType.CleanCorridor => Color.FromArgb(200, 230, 230),
        SpaceType.DirtyCorridor => Color.FromArgb(160, 140, 120),
        SpaceType.ClinicalSupport => Color.FromArgb(150, 200, 180),
        SpaceType.CareBedroom => Color.FromArgb(230, 180, 200),
        SpaceType.CareCommunal => Color.FromArgb(200, 160, 220),
        SpaceType.ActivityHall => Color.FromArgb(170, 210, 120),
        _ => Color.FromArgb(150, 170, 200),
    };
}

public sealed class PlanGoo : PreviewGoo<IGeneratedPlan>
{
    public PlanGoo(IGeneratedPlan value, Vector3d previewOffset = default)
        : base(value, previewOffset)
    {
    }

    public override string TypeName => "Plan";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    /// <summary>The elevation of the storey plan it is or comes from (D-108); 0 for any other plan or floor.</summary>
    protected override double StoreyElevation => StoreyProvenance.ElevationOf(Value.Provenance);

    public override IGH_Goo Duplicate() => new PlanGoo(Value, PreviewOffset);

    public override string ToString() => $"Plan ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}

public sealed class FloorGoo : PreviewGoo<IFloor>
{
    public FloorGoo(IFloor value, Vector3d previewOffset = default)
        : base(value, previewOffset)
    {
    }

    public override string TypeName => "Floor";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    /// <summary>The elevation of the storey plan it is or comes from (D-108); 0 for any other plan or floor.</summary>
    protected override double StoreyElevation => StoreyProvenance.ElevationOf(Value.Provenance);

    public override IGH_Goo Duplicate() => new FloorGoo(Value, PreviewOffset);

    public override string ToString() => $"Floor ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}

public sealed class BuildingGoo : PreviewGoo<IGeneratedBuilding>
{
    private readonly IReadOnlyList<ZonePart> _unmodelled;

    /// <param name="value">The building.</param>
    /// <param name="previewOffset">Preview Location (D-062).</param>
    /// <param name="unmodelled">Parts of the storeys the building represents but does not model, from the aggregator (preview only).</param>
    public BuildingGoo(IGeneratedBuilding value, Vector3d previewOffset = default, IReadOnlyList<ZonePart>? unmodelled = null)
        : base(value, previewOffset)
    {
        _unmodelled = unmodelled ?? new ZonePart[0];
    }

    public override string TypeName => "Building";

    protected override IEnumerable<Zone> Zones => Value.Zones;

    protected override IEnumerable<Surface> Surfaces => Value.Surfaces;

    protected override IEnumerable<ZonePart> UnmodelledParts => _unmodelled;

    public override IGH_Goo Duplicate() => new BuildingGoo(Value, PreviewOffset, _unmodelled);

    public override string ToString() => $"Building ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
  • [x] Step 4: Replace src/Lod.Grasshopper/Components/ComponentSupport.cs (full file)
using System;
using System.Collections.Generic;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Parameters;
using Grasshopper.Kernel.Types;
using Lod.Core.Common;
using Lod.Core.Programs;
using Rhino.Geometry;

namespace Lod.Grasshopper.Components;

/// <summary>Adapters between core diagnostics and Grasshopper runtime messages, text-to-enum input parsing, and the shared Preview Location input.</summary>
internal static class ComponentSupport
{
    private const string PreviewLocationName = "Preview Location";
    private const string PreviewLocationNickname = "PL";
    private const string PreviewLocationDescription =
        "Moves this component's viewport preview by this vector, in model units; the data and all outputs keep their true coordinates (D-062).";

    private const string AnyProgramPresetNote =
        " An Any Program Preset (D-108) is accepted too: its zones keep this space type and take the preset's program, and the plan's provenance records it.";

    /// <summary>Adds each diagnostic as a runtime message: errors as errors, warnings as warnings, info as remarks.</summary>
    public static void Report(GH_Component component, IEnumerable<Diagnostic> diagnostics)
    {
        foreach (Diagnostic diagnostic in diagnostics)
        {
            GH_RuntimeMessageLevel level = diagnostic.Severity switch
            {
                DiagnosticSeverity.Error => GH_RuntimeMessageLevel.Error,
                DiagnosticSeverity.Warning => GH_RuntimeMessageLevel.Warning,
                _ => GH_RuntimeMessageLevel.Remark,
            };
            component.AddRuntimeMessage(level, diagnostic.ToString());
        }
    }

    /// <summary>Reports the diagnostics and returns the value, or <c>null</c> on failure.</summary>
    public static T? Unwrap<T>(GH_Component component, Result<T> result)
        where T : class
    {
        Report(component, result.Diagnostics);
        return result.IsSuccess ? result.Value : null;
    }

    /// <summary>Parses an enum name case-insensitively; on failure adds an error listing the valid names.</summary>
    public static bool TryParse<TEnum>(GH_Component component, string text, out TEnum value)
        where TEnum : struct
    {
        if (Enum.TryParse(text?.Trim(), ignoreCase: true, out value) && Enum.IsDefined(typeof(TEnum), value))
        {
            return true;
        }

        component.AddRuntimeMessage(
            GH_RuntimeMessageLevel.Error,
            $"'{text}' is not a valid {typeof(TEnum).Name}. Use one of: {string.Join(", ", Enum.GetNames(typeof(TEnum)))}.");
        return false;
    }

    /// <summary>The valid names of an enum, for input descriptions.</summary>
    public static string Names<TEnum>() => string.Join(", ", Enum.GetNames(typeof(TEnum)).Select(n => n));

    /// <summary>
    /// The description of a plan generator's preset input (D-061), the same for every generator: its role and space type, and that an any-program
    /// preset is accepted too (D-108).
    /// </summary>
    public static string PresetInputDescription(string role, SpaceType spaceType) => $"Program preset of {role}; space type {spaceType}." + AnyProgramPresetNote;

    /// <summary>
    /// The optional Preview Location input (D-062), default zero. Register it last, <c>pManager.AddParameter(ComponentSupport.PreviewLocationParameter())</c>,
    /// so <see cref="PreviewLocation"/> finds it as the component's last input.
    /// </summary>
    public static IGH_Param PreviewLocationParameter()
    {
        var parameter = new Param_Vector
        {
            Name = PreviewLocationName,
            NickName = PreviewLocationNickname,
            Description = PreviewLocationDescription,
            Access = GH_ParamAccess.item,
            Optional = true,
        };
        parameter.SetPersistentData(new GH_Vector(Vector3d.Zero));
        return parameter;
    }

    /// <summary>Reads the Preview Location input, the component's last input; zero when it holds no vector.</summary>
    public static Vector3d PreviewLocation(GH_Component component, IGH_DataAccess DA)
    {
        Vector3d offset = Vector3d.Zero;
        return DA.GetData(component.Params.Input.Count - 1, ref offset) ? offset : Vector3d.Zero;
    }
}
  • [x] Step 5: Replace src/Lod.Grasshopper/Components/PipelineComponents.cs and src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (full files)

The three preset inputs of the Linear Plan Generator and AddPreset use ComponentSupport.PresetInputDescription; nothing else changes.

src/Lod.Grasshopper/Components/PipelineComponents.cs:

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

namespace Lod.Grasshopper.Components;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    protected abstract string MultiplierDescription { get; }

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

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

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

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

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

    protected abstract IFloorAggregator CreateAggregator();

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

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

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

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

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

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

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

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

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

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

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

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

        DA.SetData(0, described.Value.Report);
        DA.SetData(1, described.Value.Provenance.Describe());
    }
}

src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
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.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; it also takes an any-program preset (D-108).</summary>
    protected static void AddPreset(GH_InputParamManager pManager, string name, string nickname, SpaceType spaceType, string role) =>
        pManager.AddParameter(new ProgramPresetParameter(), name, nickname, ComponentSupport.PresetInputDescription(role, 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, with this component's preview location; each storey's preview is
    /// drawn at the elevation recorded in its plan (<see cref="Lod.Core.Plans.StoreyProvenance"/>), while the plans keep elevation 0 (ADR-015, D-108).
    /// </summary>
    protected void Output(IGH_DataAccess DA, Result<IReadOnlyList<IGeneratedPlan>> result)
    {
        IReadOnlyList<IGeneratedPlan>? plans = ComponentSupport.Unwrap(this, result);
        if (plans is not null)
        {
            Vector3d location = ComponentSupport.PreviewLocation(this, DA);
            DA.SetDataList(0, plans.Select(p => new PlanGoo(p, location)));
        }
    }
}

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 6: 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: ['AnyProgramPresetParameter']; icons without a class: [], and writes nothing (git status --short lists only the five M files of Steps 2 to 5). With another Pillow version the script still runs, but the PNG bytes may differ from Step 8's hashes; then Step 9 decides.

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

The motif is added after small_preset, and its entry after ProgramPresetParameter; 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_any_preset(d: Draw, accent) -> None:
    # The small preset tag with a wildcard asterisk where its hole was: a preset of any program, without a space type (D-108).
    d.polygon([(20, 36), (32, 24), (76, 24), (76, 72), (32, 72), (20, 60)], fill=accent, outline=DARK, width=4)
    for (x0, y0), (x1, y1) in (((52, 34), (52, 62)), ((40, 41), (64, 55)), ((40, 55), (64, 41))):
        line(d, [(x0, y0), (x1, y1)], width=6)


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)),
    "AnyProgramPresetParameter": (PROGRAM, parameter(small_any_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 8: Draw the icons and check the hashes
python scripts/make_icons.py
git status --short
sha256sum src/Lod.Grasshopper/Icons/*.png

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

a4e0709b6d8cd05a79c33af9ee864918850e74c8aef2c3ac617859fc08ac2fdc *src/Lod.Grasshopper/Icons/ActivityHallPresetComponent.png
ebbc112a30816229ed1c6019d724e18b065b76bee2ffe0e388996d6b32ec348e *src/Lod.Grasshopper/Icons/AnyProgramPresetParameter.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 9: 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: 61 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: Any Program Preset is the second icon of the first row, after Activity Hall Preset (a white parameter hexagon holding an orange preset tag with a dark asterisk where the hole of the Program Preset parameter's tag is), and before BEMGen Assembly Info. Every icon after it moves one place on, the 60 icons of S8.7 keep their motifs (the Program Preset parameter, a hexagon with a tag and its hole, is the last icon of the seventh row), and the 61 icons fill ten rows of six and one icon of an eleventh row.

If the hashes of Step 8 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 10: Build and check the descriptions and the embedded icons
dotnet build BEMGen.sln -c Release
git grep -c "PresetInputDescription" -- 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/Components/ComponentSupport.cs:1, src/Lod.Grasshopper/Components/PipelineComponents.cs:3, src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs:1 (the definition and the four callers: the linear plan's three inputs and AddPreset, which the seventeen typology components call); 61 resource names, one for each PNG of Step 8.

  • [x] Step 11: Test and commit
dotnet test BEMGen.sln -c Release
git add src/Lod.Grasshopper/Parameters/BemParameters.cs src/Lod.Grasshopper/Goo/BemGoo.cs src/Lod.Grasshopper/Components/ComponentSupport.cs src/Lod.Grasshopper/Components/PipelineComponents.cs src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): add the any program preset parameter accepted by every preset input"

Expected: 243 core, 518 generators, and 1518 integration tests passed on net8.0; before git add, the six modified files and the new PNG, and nothing under bin/.

Task 7: The built-in preset components in their own panel

Files: - Modify: src/Lod.Grasshopper/ComponentCategories.cs (S0 file), src/Lod.Grasshopper/Components/PresetComponents.cs (S4.1 file, last changed in S8.4)

Interfaces: - Consumes: ComponentCategories (S0: Category, Info, Program, Generate, …), DefaultPresetComponent (S4.1), the base of the eighteen default preset components (three of S4.1, fifteen of S8.4). - Produces: public const string ProgramPresets = "1 Program Presets", after Program; DefaultPresetComponent registers its components in it, so the eighteen default preset components move to the panel 1 Program Presets with their GUIDs, icons (the Program accent), and inputs unchanged. Schedule, Load, Program Preset, and Any Program Preset stay in 1 Program.

  • [x] Step 1: Replace src/Lod.Grasshopper/ComponentCategories.cs (full file)
namespace Lod.Grasshopper;

/// <summary>Toolbar tab and panels, ordered along the pipeline.</summary>
internal static class ComponentCategories
{
    public const string Category = "BEMGen";

    public const string Info = "0 Info";

    public const string Program = "1 Program";

    /// <summary>The built-in default preset components, one per space type (D-061, ADR-014, D-108).</summary>
    public const string ProgramPresets = "1 Program Presets";

    public const string Generate = "2 Generate";

    public const string Simplify = "3 Simplify";

    public const string Aggregate = "4 Aggregate";

    public const string Convert = "5 Convert";

    public const string Inspect = "6 Inspect";
}
  • [x] Step 2: Replace src/Lod.Grasshopper/Components/PresetComponents.cs (full file)

Only the summary and the panel of DefaultPresetComponent's constructor change.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Lod.Core.Loads;
using Lod.Core.Programs;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

/// <summary>
/// Shared inputs and solving of the default preset components (D-061), in their own panel 1 Program Presets (D-108): one component per space type
/// whose inputs all default to its <see cref="PresetValues"/>, so it works with nothing connected. Inputs: Name, WWR, Conditioned, Heating, Cooling,
/// then a value and an optional schedule per load of the defaults, in their order.
/// </summary>
public abstract class DefaultPresetComponent : GH_Component
{
    private const string IllustrativeRemark = "Illustrative values; not sourced from DOE prototypes or standards.";
    private const int FirstLoadInput = 5;

    protected DefaultPresetComponent(string name, string nickname, string description)
        : base(name, nickname, description, ComponentCategories.Category, ComponentCategories.ProgramPresets)
    {
    }

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

    /// <summary>The default values; read while inputs are registered, so they must not depend on instance state.</summary>
    protected abstract PresetValues Defaults { get; }

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        PresetValues defaults = Defaults;
        pManager.AddTextParameter("Name", "N", "Preset name; descriptive only (reports, provenance, messages), never used for matching.", GH_ParamAccess.item, defaults.Name);
        pManager.AddNumberParameter("WWR", "WWR", "Window-to-wall ratio in [0, 1) used by the plan generator for every exterior wall.", GH_ParamAccess.item, defaults.WindowToWallRatio);
        pManager.AddBooleanParameter("Conditioned", "Cond", "Whether the space is conditioned (D-038); the setpoints are ignored when it is not.", GH_ParamAccess.item, defaults.Conditioned);
        pManager.AddNumberParameter("Heating", "H", "Constant heating setpoint, °C.", GH_ParamAccess.item, defaults.HeatingSetpoint);
        pManager.AddNumberParameter("Cooling", "C", "Constant cooling setpoint, °C.", GH_ParamAccess.item, defaults.CoolingSetpoint);
        foreach (PresetLoadValue load in defaults.Loads)
        {
            (string label, string nickname) = LoadLabel(defaults.Loads, load);
            pManager.AddNumberParameter(label, nickname, $"{label} design value, {Unit(load.Type, load.Basis)}.", GH_ParamAccess.item, load.Value);
            int schedule = pManager.AddParameter(
                new ScheduleParameter(),
                $"{label} Schedule",
                nickname + "S",
                $"Optional fraction schedule of {label}; replaces the built-in schedule '{load.Schedule.Name}' when connected.",
                GH_ParamAccess.item);
            pManager[schedule].Optional = true;
        }
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new ProgramPresetParameter(), "Preset", "P", "The program preset.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        AddRuntimeMessage(GH_RuntimeMessageLevel.Remark, IllustrativeRemark);
        PresetValues defaults = Defaults;
        string name = string.Empty;
        double wwr = 0.0;
        bool conditioned = false;
        double heating = 0.0;
        double cooling = 0.0;
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref wwr) || !DA.GetData(2, ref conditioned) || !DA.GetData(3, ref heating) || !DA.GetData(4, ref cooling))
        {
            return;
        }

        var loads = new PresetLoadValue[defaults.Loads.Count];
        for (int k = 0; k < loads.Length; k++)
        {
            double value = 0.0;
            ScheduleGoo? schedule = null;
            if (!DA.GetData(FirstLoadInput + (2 * k), ref value))
            {
                return;
            }

            DA.GetData(FirstLoadInput + (2 * k) + 1, ref schedule);
            loads[k] = defaults.Loads[k] with { Value = value, Schedule = schedule?.Value ?? defaults.Loads[k].Schedule };
        }

        PresetValues values = defaults with
        {
            Name = name,
            WindowToWallRatio = wwr,
            Conditioned = conditioned,
            HeatingSetpoint = heating,
            CoolingSetpoint = cooling,
            Loads = loads,
        };
        ProgramPreset? preset = ComponentSupport.Unwrap(this, values.ToPreset());
        if (preset is not null)
        {
            DA.SetData(0, new ProgramPresetGoo(preset));
        }
    }

    /// <summary>Input name and nickname of a load; the basis is added when the defaults hold the load type in more than one basis (D-047).</summary>
    private static (string Label, string Nickname) LoadLabel(IReadOnlyList<PresetLoadValue> loads, PresetLoadValue load)
    {
        (string label, string nickname) = load.Type switch
        {
            LoadType.Occupancy => ("Occupancy", "Occ"),
            LoadType.Lighting => ("Lighting", "Lt"),
            LoadType.ElectricEquipment => ("Equipment", "Eq"),
            LoadType.GasEquipment => ("Gas Equipment", "Gas"),
            LoadType.DomesticHotWater => ("Hot Water", "DHW"),
            LoadType.Ventilation => ("Ventilation", "Vent"),
            _ => ("Infiltration", "Inf"),
        };
        return loads.Count(l => l.Type == load.Type) > 1
            ? ($"{label} {load.Basis}", nickname + string.Concat(load.Basis.ToString().Where(char.IsUpper)))
            : (label, nickname);
    }

    /// <summary>Unit of a load value: the magnitude unit of its type (people, W, m³/h) per its basis (ADR-007).</summary>
    private static string Unit(LoadType type, LoadBasis basis)
    {
        string magnitude = type switch
        {
            LoadType.Occupancy => "people",
            LoadType.Lighting or LoadType.ElectricEquipment or LoadType.GasEquipment => "W",
            _ => "m³/h",
        };
        return basis switch
        {
            LoadBasis.PerFloorArea => $"{magnitude}/m²",
            LoadBasis.PerPerson => $"{magnitude}/person",
            LoadBasis.PerExteriorWallArea => $"{magnitude}/m² of exterior wall",
            LoadBasis.AirChangesPerHour => "1/h (air changes per hour)",
            _ => magnitude,
        };
    }
}

public sealed class DwellingUnitPresetComponent : DefaultPresetComponent
{
    public DwellingUnitPresetComponent()
        : base("Dwelling Unit Preset", "Unit", "Program preset of a dwelling unit (one zone per unit). Every input defaults to the illustrative example values (D-061).")
    {
    }

    public override Guid ComponentGuid => new("b491330c-bc92-42e7-b494-6946e66b599a");

    protected override PresetValues Defaults => ExampleResidentialPresets.DwellingUnitValues;
}

public sealed class CorridorPresetComponent : DefaultPresetComponent
{
    public CorridorPresetComponent()
        : base("Corridor Preset", "Corr", "Program preset of a corridor. Every input defaults to the illustrative example values (D-061).")
    {
    }

    public override Guid ComponentGuid => new("5947d3ea-25fe-4dc3-b388-384d8326826e");

    protected override PresetValues Defaults => ExampleResidentialPresets.CorridorValues;
}

public sealed class StairPresetComponent : DefaultPresetComponent
{
    public StairPresetComponent()
        : base("Stair Preset", "Stair", "Program preset of a stair. Every input defaults to the illustrative example values (D-061).")
    {
    }

    public override Guid ComponentGuid => new("717ccc25-1097-4b66-addc-d2fcdd4e385a");

    protected override PresetValues Defaults => ExampleResidentialPresets.StairValues;
}

public sealed class OfficePresetComponent : DefaultPresetComponent
{
    public OfficePresetComponent()
        : base("Office Preset", "Office", "Program preset of an office work area (one zone per work-area department, D-097); space type Office. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("4160f791-07dc-4529-9986-1c9ba7d21a98");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.OfficeValues;
}

public sealed class CorePresetComponent : DefaultPresetComponent
{
    public CorePresetComponent()
        : base("Core Preset", "Core", "Program preset of an office service core: lifts, stairs, toilets, risers; space type Core. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("ca44b061-1ced-4ae3-afed-d806e6db2b7c");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.CoreValues;
}

public sealed class RetailPresetComponent : DefaultPresetComponent
{
    public RetailPresetComponent()
        : base("Retail Preset", "Retail", "Program preset of the shops of one mall wing or an anchor store; space type Retail. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("b760860d-05c9-4cbe-a22f-ddd2e38e4d64");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.RetailValues;
}

public sealed class MallPresetComponent : DefaultPresetComponent
{
    public MallPresetComponent()
        : base("Mall Preset", "Mall", "Program preset of the public mall route; space type Mall. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("dbd514d5-7553-4000-b10c-bca748e3b301");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.MallValues;
}

public sealed class KitchenPresetComponent : DefaultPresetComponent
{
    public KitchenPresetComponent()
        : base("Kitchen Preset", "Kitch", "Program preset of a kitchen with its servery, one food-service department; space type Kitchen. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("658e05dc-c935-4518-bc99-512546153e1e");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.KitchenValues;
}

public sealed class DiningPresetComponent : DefaultPresetComponent
{
    public DiningPresetComponent()
        : base("Dining Preset", "Dine", "Program preset of a dining field; space type Dining. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("c8b05fa2-330a-4e8a-acaa-e57ca57b4fd9");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.DiningValues;
}

public sealed class OperatingTheatrePresetComponent : DefaultPresetComponent
{
    public OperatingTheatrePresetComponent()
        : base("Operating Theatre Preset", "OT", "Program preset of a bank of operating theatres; space type OperatingTheatre. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("eb55b6d5-08f1-4a5e-9598-13951c34c2a4");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.OperatingTheatreValues;
}

public sealed class CleanCorridorPresetComponent : DefaultPresetComponent
{
    public CleanCorridorPresetComponent()
        : base("Clean Corridor Preset", "Clean", "Program preset of the clean route of an operating suite; space type CleanCorridor. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("c199a986-28b8-41c7-b260-5f4581b7362b");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.CleanCorridorValues;
}

public sealed class DirtyCorridorPresetComponent : DefaultPresetComponent
{
    public DirtyCorridorPresetComponent()
        : base("Dirty Corridor Preset", "Dirty", "Program preset of the dirty (disposal) route of an operating suite; space type DirtyCorridor. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("0e61c86e-5981-4a11-b922-52650757e4a9");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.DirtyCorridorValues;
}

public sealed class ClinicalSupportPresetComponent : DefaultPresetComponent
{
    public ClinicalSupportPresetComponent()
        : base("Clinical Support Preset", "ClinS", "Program preset of the support rooms of an operating suite; space type ClinicalSupport. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("274e0ac9-de95-4bd4-8028-25d74bfd1e4b");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.ClinicalSupportValues;
}

public sealed class CareBedroomPresetComponent : DefaultPresetComponent
{
    public CareBedroomPresetComponent()
        : base("Care Bedroom Preset", "CBed", "Program preset of a group of care bedrooms; space type CareBedroom. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("8447ea38-faec-4630-96dd-92569cd7d0d8");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.CareBedroomValues;
}

public sealed class CareCommunalPresetComponent : DefaultPresetComponent
{
    public CareCommunalPresetComponent()
        : base("Care Communal Preset", "CComm", "Program preset of the communal and service hub of a care home; space type CareCommunal. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("a3000f8c-633f-4a54-9fb8-1a4109e337bb");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.CareCommunalValues;
}

public sealed class LobbyPresetComponent : DefaultPresetComponent
{
    public LobbyPresetComponent()
        : base("Lobby Preset", "Lobby", "Program preset of a public foyer or entrance lobby; space type Lobby. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("395aebb3-3e49-4814-93ab-388da9de2360");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.LobbyValues;
}

public sealed class ActivityHallPresetComponent : DefaultPresetComponent
{
    public ActivityHallPresetComponent()
        : base("Activity Hall Preset", "Hall", "Program preset of an activity hall for sport or community use; space type ActivityHall. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("6bf00cd3-70b9-4337-8b80-70fc00d79666");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.ActivityHallValues;
}

public sealed class ServicePresetComponent : DefaultPresetComponent
{
    public ServicePresetComponent()
        : base("Service Preset", "Serv", "Program preset of service or support rooms: changing rooms, stores, toilets; space type Service. Every input defaults to the illustrative example values (ADR-014, D-061).")
    {
    }

    public override Guid ComponentGuid => new("60251c8e-ad4b-4ef5-8f68-64f4c6bf6f8b");

    protected override PresetValues Defaults => ExampleNonResidentialPresets.ServiceValues;
}
  • [x] Step 3: Build, test, and check the panels
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git grep -c ": DefaultPresetComponent" -- src/Lod.Grasshopper
git grep -c -w "ComponentCategories.Program" -- src/Lod.Grasshopper

Expected: Build succeeded., 0 Warning(s), 0 Error(s); 243 core, 518 generators, and 1518 integration tests passed on net8.0; src/Lod.Grasshopper/Components/PresetComponents.cs:18 from the first git grep (the eighteen default preset components) and src/Lod.Grasshopper/Components/ProgramComponents.cs:3, src/Lod.Grasshopper/Parameters/BemParameters.cs:1 from the second (Schedule, Load, and Program Preset in ProgramComponents.cs and Any Program Preset in BemParameters.cs stay in 1 Program).

  • [x] Step 4: Commit
git add src/Lod.Grasshopper/ComponentCategories.cs src/Lod.Grasshopper/Components/PresetComponents.cs
git commit -m "feature(grasshopper): move the built-in preset components to their own panel"
  • [x] Step 5: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/any-program-preset-parameter
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/any-program-preset-parameter
git branch -d feature/any-program-preset-parameter

Expected: VERIFY PASSED, with 243 core, 518 generators, and 1518 integration tests passed on net8.0. Do not push (D-108).


Slice D — docs/architecture-previews-any-preset-panel

Task 8: ADR-014 and ADR-015, the any-program preset and the storey preview height

Files: - Modify: docs/decisions/ADR-014-program-types-and-department-zoning.md (S8.4), docs/decisions/ADR-015-plans-per-storey.md (S8.7)

Interfaces: - Consumes: D-108 and Tasks 1 to 7 (the representation, the slot check, the provenance, the parameter and its casting, the panel, the recorded elevation, the previews). - Produces: ADR-014 cites D-108, its options section gains A preset without a space type (S8.8, D-108) (a SpaceType value Any, a flag beside a kept space type, no space type (chosen), matching by name or a compatibility list), the note on the default preset components names their new panel, the decision gains Any-program presets (S8.8, D-108) (model, plan generators, provenance, downstream, Grasshopper), and a consequence. ADR-015 cites D-108, its options section gains Where the preview finds a storey's height (S8.8, D-108) (raise the floors in the simplifier components, a field on IGeneratedPlan and IFloor, the storey index times the floor height, the base elevation in provenance (chosen)), and its Provenance, Preview, and Consequences bullets name StoreyElevation and StoreyProvenance.ElevationOf. Neither file has a code block. The close-out (Task 11) adds D-109 to both Decisions lines.

  • [x] Step 1: Create the branch
git switch -c docs/architecture-previews-any-preset-panel main
  • [x] Step 2: Edit docs/decisions/ADR-014-program-types-and-department-zoning.md

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

  1. Replace
Decisions: D-009, D-023, D-024, D-026, D-038, D-047, D-061, D-065, D-085, D-093, D-094, D-096, D-097, D-098; applies [ADR-005](ADR-005-conditioning-and-setpoints.md), [ADR-006](ADR-006-plan-generation-mechanism.md), and [ADR-007](ADR-007-load-basis-aggregation.md)

with

Decisions: D-009, D-023, D-024, D-026, D-038, D-047, D-061, D-065, D-085, D-093, D-094, D-096, D-097, D-098, D-108; applies [ADR-005](ADR-005-conditioning-and-setpoints.md), [ADR-006](ADR-006-plan-generation-mechanism.md), and [ADR-007](ADR-007-load-basis-aggregation.md)
  1. Replace
Rooms or tenancies as zones would need a room layout the precedents do not give and would break the parity with housing, where a dwelling is never subdivided (D-009). One zone per department is the most detailed level; the simplifiers and aggregators coarsen it as for housing.

with

Rooms or tenancies as zones would need a room layout the precedents do not give and would break the parity with housing, where a dwelling is never subdivided (D-009). One zone per department is the most detailed level; the simplifiers and aggregators coarsen it as for housing.

### A preset without a space type (S8.8, D-108)

D-108 asks for a preset that every program input of every plan generator accepts, made from any preset, built-in or general, while a preset that keeps its space type must still match its input.

1. **A `SpaceType` value `Any`.** One enum value, but `SpaceType` is the semantic category of a zone, and no zone would ever have this one; every switch over space types (preview colours, *Semantic Merge* grouping, *Convert2BEM*) would have to handle a value that never occurs on a zone, and `Other` and `Mixed` already mean something else.
2. **A flag beside a kept space type.** The preset would keep, say, `Kitchen` and say that it fits every input: two sources of truth, and a preset that reads `Kitchen` while it programs a dwelling.
3. **No space type (chosen).** `ProgramPreset.SpaceType` becomes `SpaceType?`, `null` meaning that the preset has no space type; `AsAnyProgram()` returns the preset without it. The absence is explicit data, nothing is inferred from a name (D-061), and the slot check needs one condition more.
4. **Matching by name or by a list of compatible types.** Names are descriptive only (D-061), and a compatibility table would be a research rule that no source supports.
  1. Replace
**Default preset components (D-061, D-065).** One per type in panel *1 Program*, each visible on the toolbar and built on `DefaultPresetComponent`, with these values as input defaults and the remark that they are illustrative: *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*. The existing label and unit mapping of `DefaultPresetComponent` already covers every load above (*Gas Equipment* in W/m², *Ventilation* and *Infiltration* in 1/h); no component is special-cased.

with

**Default preset components (D-061, D-065).** One per type in panel *1 Program* (since S8.8 in panel *1 Program Presets*, with *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, D-108), each visible on the toolbar and built on `DefaultPresetComponent`, with these values as input defaults and the remark that they are illustrative: *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*. The existing label and unit mapping of `DefaultPresetComponent` already covers every load above (*Gas Equipment* in W/m², *Ventilation* and *Infiltration* in 1/h); no component is special-cased.
  1. Replace
`SYN-TYP-013` has no generator (D-096). The `001`–`009` families are unchanged (ADR-006).

## Consequences

with

`SYN-TYP-013` has no generator (D-096). The `001`–`009` families are unchanged (ADR-006).

### Any-program presets (S8.8, D-108)

- **Model.** `ProgramPreset.SpaceType` is `SpaceType?`; `IsAnyProgram` is `true` when it is `null`. A preset is always created with a space type (`ProgramPreset.Create`, `PresetValues.ToPreset`, every default preset component, *Program Preset*); `AsAnyProgram()` returns a copy without it that keeps everything else: the name, the same `ZoneProgram` (loads, their schedules, conditioning, setpoints), and the WWR. Casting an any-program preset again returns it unchanged.
- **Plan generators.** The slot check of every generator, single-plan and multi-storey (`PlanBuilder`), accepts an any-program preset on any slot; a preset with a space type must still have its slot's (`PresetSpaceTypeMismatch`, unchanged). A zone keeps the space type the generator's layout gives it (`PlannedZone.SpaceType`) and takes its program and the WWR of its windows from its slot's preset, as for a typed preset.
- **Provenance (GLOBAL.md scientific rule 3).** The plan's `Presets` parameter lists `{slot space type}:{preset name}` per slot, which for a typed preset is its own space type, as before. For each slot that received an any-program preset the plan also records `AnyProgramPreset.{Input}` = the preset's name (`PlanPresets.AnyProgramPresetKeyPrefix`), for example `AnyProgramPreset.Corridor=Example Office`; every floor and building made from the plan carries it in its provenance inputs. A plan of typed presets records no such key, so its output is unchanged.
- **Downstream.** Zones carry their space type and program, never their preset, so simplifiers, aggregators, validation, the mapping, *Inspect*, and *Convert2BEM* are unchanged: a dwelling zone with an office program is a `DwellingUnit` zone with that program, it is coloured and grouped by *Semantic Merge* as a dwelling, and *Convert2BEM* reports its space type `DwellingUnit`. Integration tests run a linear plan and a stepped band with any-program presets through every simplifier and aggregator, and every result validates.
- **Grasshopper.** The parameter *Any Program Preset* (AnyP, `AnyProgramPresetParameter`, `24bc6a12-ad06-4c25-bc9d-4e8318b8744c`) in panel *1 Program* is a floating data node like Grasshopper's *Geometry* parameter: wire any preset into it (a default preset component, *Program Preset*, or a *Program Preset* parameter), and it outputs the any-program cast (`AnyProgramPresetGoo`, shown as `Preset <name> (any program, no space type, WWR <ratio>)`). It holds no data of its own. Every preset input of every generator, a *Program Preset* parameter, takes it; the input's description says so.

## Consequences
  1. Replace
- Changing a value changes the snapshots of the families that use it and is recorded in this ADR and in [program-presets.md](../research/program-presets.md).

with

- Changing a value changes the snapshots of the families that use it and is recorded in this ADR and in [program-presets.md](../research/program-presets.md).
- An any-program preset lets a zone's program differ from its space type (D-108). This is always the user's explicit choice and always in the plan's provenance; a typed preset still cannot be given to another type's input.
  • [x] Step 3: Edit docs/decisions/ADR-015-plans-per-storey.md

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

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

with

Decisions: D-018, D-068, D-070, D-071, D-072, D-074, D-085, D-095, D-098, D-106, D-108; applies [ADR-006](ADR-006-plan-generation-mechanism.md), [ADR-013](ADR-013-vertical-aggregation-methods.md), and [ADR-002](ADR-002-geometry.md)
  1. Replace
- **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

with

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

### Where the preview finds a storey's height (S8.8, D-108)

Through S8.7 the generator component raised each storey's preview by the floor heights below it, but a simplifier's floor of that storey is previewed at height 0, so the plans and the floors of one band were drawn at different heights.

- **Raise the floors in the simplifier components.** A simplifier component does not know which storey a plan is; the information has to travel with the data.
- **A dedicated field on `IGeneratedPlan` and `IFloor`.** Explicit, but a pipeline type change for a value that only the previews use: `GeneratedPlan`, `PlanTransform`, and every simplifier would carry it, and the data must stay at elevation 0.
- **The storey index times the floor height.** `Storey` is already recorded, and every storey has the parameters' floor height, but it would tie the previews to equal floor heights.
- **The base elevation in provenance (chosen).** The storey's plan records its base elevation, which provenance already carries through *Transform Plan* and every simplifier as their one input.

## Decision
  1. Replace
- **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.

with

- **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), `Storey`, the storey index from 0 at the bottom, and `StoreyElevation` (S8.8, D-108), the storey's base elevation in metres, the sum of the floor heights below it (`StoreyProvenance.ElevationKey`).
  1. Replace
- **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.

with

- **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. Since S8.8 (D-108) the height comes from the data: `StoreyProvenance.ElevationOf` reads `StoreyElevation` from an object's provenance, following an operation of exactly one input (*Transform Plan*, a plan simplifier) to its input, and is 0 for anything else, a building included. The plan and floor previews (`PlanGoo`, `FloorGoo`) are raised by it, so the generator, *Transform Plan*, and every simplifier draw a storey at the same height, each plus its own *Preview Location*. Plans and floors that are not storeys stay at 0, and buildings, which stack their storeys in their data, are unchanged. Data, Breps, and *Convert2BEM* outputs are unchanged.
  1. Replace
- 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.

with

- Changing the interface, the provenance keys `Storey` and `StoreyElevation`, the diagnostic prefix, or the multiplier rule needs a decision-log entry and an update of this ADR.
  • [x] Step 4: Commit
git add docs/decisions/ADR-014-program-types-and-department-zoning.md docs/decisions/ADR-015-plans-per-storey.md
git commit -m "docs(decisions): record the any-program preset and the storey preview height"

Task 9: Architecture documents and the S8.8 smoke-test checklist

Files: - Modify: docs/architecture/pipeline.md, docs/architecture/domain-model.md, docs/research/program-presets.md (all last changed in S8.7), docs/development/grasshopper-smoke-test.md (a section appended)

Interfaces: - Consumes: Tasks 1 to 8. - Produces: pipeline.md after S8.8: the overview names S8.8; the any-program preset, the parameter, and the storey previews in its program and generation sections and the validation table; the heading ## Grasshopper components through S8.8 (anchor #grasshopper-components-through-s88), which the overview of pipeline.md and program-presets.md link to; the Preview Location, default preset, and storey wiring bullets; new bullets Any Program Preset (how to wire it) and Panels; the eighteen preset rows in panel 1 Program Presets; the parameter table with AnyP. domain-model.md: SpaceType? in the overview diagram, the ProgramPreset paragraph, StoreyElevation and StoreyProvenance with the plans per storey. program-presets.md: status S8.8 (v0.8.7), the any-program preset among the inputs, and the S8.8 paragraph of In Grasshopper. The smoke-test guide with the S8.8 checklist that Task 10 runs.

  • [x] Step 1: 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; 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).

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; the revision stage S8.8 (D-108) previews a storey's floors at the storey's height, as its plan, adds the *Any Program Preset* parameter, which casts any preset to one without a space type that every preset input takes, and moves the built-in preset components to a panel of their own. 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
- 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)).

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-s88)).
  1. Replace
| Components | *Schedule*, *Load*, *Program Preset* (input *Conditioned*), *Dwelling Unit Preset*, *Corridor Preset*, *Stair Preset*, and the fifteen non-residential default preset components (*Office Preset* … *Service Preset*, S8.4) |
| Decisions | D-024, D-026, D-038, D-047, D-061, D-094, D-097; ADR-003, ADR-005, ADR-007, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md) |

with

| Components | Panel *1 Program*: *Schedule*, *Load*, *Program Preset* (input *Conditioned*), and the parameter *Any Program Preset* (S8.8); panel *1 Program Presets* (S8.8): *Dwelling Unit Preset*, *Corridor Preset*, *Stair Preset*, and the fifteen non-residential default preset components (*Office Preset* … *Service Preset*, S8.4) |
| Decisions | D-024, D-026, D-038, D-047, D-061, D-094, D-097, D-108; ADR-003, ADR-005, ADR-007, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md) |
  1. Replace
- A program preset holds the zone program of one space type and the window-to-wall ratio in [0, 1) that the plan generator uses (D-024, D-026). Its name is descriptive only: it appears in reports, provenance, and messages and is never used for matching (D-061).

with

- A program preset holds the zone program of one space type and the window-to-wall ratio in [0, 1) that the plan generator uses (D-024, D-026). Its name is descriptive only: it appears in reports, provenance, and messages and is never used for matching (D-061).
- An any-program preset (S8.8, D-108, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)) has no space type: `ProgramPreset.SpaceType` is `null` and `IsAnyProgram` is `true`. Every preset is created with a space type; `AsAnyProgram()` returns it without one and keeps its name, program (loads, schedules, conditioning, setpoints), and WWR.
  1. Replace
- Presets (D-061): a generator takes a typed preset record with one preset per space type it places, a `PlanPresets` whose `Slots` list its inputs as `PresetSlot(Input, SpaceType, Preset)`. For the linear block this is `LinearPlanPresets(DwellingUnit, Corridor, Stair)`. Before the layout is built every slot is checked: a preset of another space type is the error `PresetSpaceTypeMismatch` (one per wrong slot, naming the input, both space types, and the preset), a missing preset the error `MissingPreset`. Each zone gets the preset of the slot of its space type; provenance records `Presets=DwellingUnit:<name>;Corridor:<name>;Stair:<name>` in slot order.

with

- Presets (D-061): a generator takes a typed preset record with one preset per space type it places, a `PlanPresets` whose `Slots` list its inputs as `PresetSlot(Input, SpaceType, Preset)`. For the linear block this is `LinearPlanPresets(DwellingUnit, Corridor, Stair)`. Before the layout is built every slot is checked: a preset of another space type is the error `PresetSpaceTypeMismatch` (one per wrong slot, naming the input, both space types, and the preset), a missing preset the error `MissingPreset`. Each zone gets the preset of the slot of its space type; provenance records `Presets=DwellingUnit:<name>;Corridor:<name>;Stair:<name>` in slot order, with each slot's space type. An any-program preset (S8.8, D-108) is accepted on every slot: the zones keep the space type of the layout and take the preset's program and WWR, and the plan records `AnyProgramPreset.<Input>=<name>` for each such slot (for example `AnyProgramPreset.Corridor=Example Office`), so the change of program is never silent (GLOBAL.md scientific rule 3). A plan of typed presets records no such parameter and is unchanged.
  1. Replace
- 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: `.

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) and its base elevation, the sum of the floor heights below it, as `StoreyElevation` (m, S8.8, D-108), which only the previews read (`StoreyProvenance`). A diagnostic of one storey keeps its code and subject, and its message starts with `Storey k: `.
  1. Replace
| Plan generator | Parameters (`InvalidParameter`, including the family minimum counts of ADR-006); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`, also for a boundary edge, holes included, that zones do not cover outdoors); polygons (`DegeneratePolygon`). A wall or window below the ADR-011 minimum gets no window (`WindowOmitted`) | Error; no plan (`WindowOmitted` is a remark; the plan is produced) |

with

| Plan generator | Parameters (`InvalidParameter`, including the family minimum counts of ADR-006); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061) or an any-program preset (D-108); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`, also for a boundary edge, holes included, that zones do not cover outdoors); polygons (`DegeneratePolygon`). A wall or window below the ADR-011 minimum gets no window (`WindowOmitted`) | Error; no plan (`WindowOmitted` is a remark; the plan is produced) |
  1. Replace
## Grasshopper components through S8.7

with

## Grasshopper components through S8.8
  1. Replace
- **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.
- **Default preset components (D-061).** *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* share their inputs: Name (default the example name), WWR, Conditioned, Heating (°C), Cooling (°C), then for each load of the space type's example preset, in its order, a value input (its unit in the description: people/m², W/m², or 1/h) and an optional schedule input that replaces the built-in schedule when connected. Every input except the schedules has a default; the output is one *Preset*.

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. A plan or floor that is a storey of a multi-storey generator, or is made from one alone through *Transform Plan* or a simplifier, is previewed at the storey's elevation (`StoreyElevation`, S8.8, D-108) plus the *Preview Location*; every other plan and floor, and every building, is previewed at its own coordinates plus the *Preview Location*.
- **Default preset components (D-061).** *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, in panel *1 Program Presets* since S8.8 (D-108) like every built-in preset component, share their inputs: Name (default the example name), WWR, Conditioned, Heating (°C), Cooling (°C), then for each load of the space type's example preset, in its order, a value input (its unit in the description: people/m², W/m², or 1/h) and an optional schedule input that replaces the built-in schedule when connected. Every input except the schedules has a default; the output is one *Preset*.
  1. Replace
- **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`).
- **Default preset components of the non-residential types (S8.4, ADR-014).** *Office Preset* to *Service Preset* share the inputs of the residential ones (above), one value and one optional schedule input per load of their example preset; *Gas Equipment* is in W/m², *Ventilation* and *Infiltration* in 1/h. Zones of the new types have their own preview colours.
with
- **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. *Transform Plan* and every simplifier preview each storey's plan or floor at the storey's elevation, as the generator does (S8.8, D-108; through S8.7 they previewed it at 0), each plus its own *Preview Location*; the data stay at elevation 0, and the aggregator places the storeys. 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`).
- **Default preset components of the non-residential types (S8.4, ADR-014).** *Office Preset* to *Service Preset* share the inputs of the residential ones (above), one value and one optional schedule input per load of their example preset; *Gas Equipment* is in W/m², *Ventilation* and *Infiltration* in 1/h. Zones of the new types have their own preview colours.
- **Any Program Preset (S8.8, D-108).** A floating parameter in panel *1 Program*, like Grasshopper's *Geometry* parameter: place it on the canvas, wire a preset into its left grip (any default preset component, *Program Preset*, or another preset parameter), and wire its right grip into any preset input of any plan generator. It outputs each preset as an any-program preset (`AnyProgramPresetGoo`, shown as `Preset <name> (any program, no space type, WWR <ratio>)`), with the same name, program, and WWR; it holds no data of its own, so an unconnected one is empty. The generator's preset inputs take it (`ProgramPresetParameter` converts it), and their descriptions say so in one shared text. The zones keep the input's space type (preview colour, *Semantic Merge* groups, *Convert2BEM* *Space Types*) and take the preset's program; *Inspect*'s provenance shows `AnyProgramPreset.<Input>`. A preset wired directly, without the parameter, must still have the input's space type.
- **Panels (S8.8, D-108).** *1 Program* holds *Schedule*, *Load*, *Program Preset*, and *Any Program Preset*; the eighteen built-in preset components are in *1 Program Presets*, next to it. Their icons keep the *Program* accent. No GUID changed.
  1. Replace
| 1 Program | *Dwelling Unit Preset* (Unit) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `DwellingUnitPresetComponent` | `b491330c-bc92-42e7-b494-6946e66b599a` | S4.1 |
| 1 Program | *Corridor Preset* (Corr) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `CorridorPresetComponent` | `5947d3ea-25fe-4dc3-b388-384d8326826e` | S4.1 |
| 1 Program | *Stair Preset* (Stair) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `StairPresetComponent` | `717ccc25-1097-4b66-addc-d2fcdd4e385a` | S4.1 |
| 1 Program | *Office Preset* (Office) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `OfficePresetComponent` | `4160f791-07dc-4529-9986-1c9ba7d21a98` | S8.4 |
| 1 Program | *Core Preset* (Core) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CorePresetComponent` | `ca44b061-1ced-4ae3-afed-d806e6db2b7c` | S8.4 |
| 1 Program | *Retail Preset* (Retail) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `RetailPresetComponent` | `b760860d-05c9-4cbe-a22f-ddd2e38e4d64` | S8.4 |
| 1 Program | *Mall Preset* (Mall) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `MallPresetComponent` | `dbd514d5-7553-4000-b10c-bca748e3b301` | S8.4 |
| 1 Program | *Kitchen Preset* (Kitch) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Gas Equipment, Gas Equipment Schedule, Ventilation, Ventilation Schedule, Infiltration, Infiltration Schedule → Preset | `KitchenPresetComponent` | `658e05dc-c935-4518-bc99-512546153e1e` | S8.4 |
| 1 Program | *Dining Preset* (Dine) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `DiningPresetComponent` | `c8b05fa2-330a-4e8a-acaa-e57ca57b4fd9` | S8.4 |
| 1 Program | *Operating Theatre Preset* (OT) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Ventilation, Ventilation Schedule, Infiltration, Infiltration Schedule → Preset | `OperatingTheatrePresetComponent` | `eb55b6d5-08f1-4a5e-9598-13951c34c2a4` | S8.4 |
| 1 Program | *Clean Corridor Preset* (Clean) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `CleanCorridorPresetComponent` | `c199a986-28b8-41c7-b260-5f4581b7362b` | S8.4 |
| 1 Program | *Dirty Corridor Preset* (Dirty) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `DirtyCorridorPresetComponent` | `0e61c86e-5981-4a11-b922-52650757e4a9` | S8.4 |
| 1 Program | *Clinical Support Preset* (ClinS) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `ClinicalSupportPresetComponent` | `274e0ac9-de95-4bd4-8028-25d74bfd1e4b` | S8.4 |
| 1 Program | *Care Bedroom Preset* (CBed) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CareBedroomPresetComponent` | `8447ea38-faec-4630-96dd-92569cd7d0d8` | S8.4 |
| 1 Program | *Care Communal Preset* (CComm) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CareCommunalPresetComponent` | `a3000f8c-633f-4a54-9fb8-1a4109e337bb` | S8.4 |
| 1 Program | *Lobby Preset* (Lobby) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `LobbyPresetComponent` | `395aebb3-3e49-4814-93ab-388da9de2360` | S8.4 |
| 1 Program | *Activity Hall Preset* (Hall) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `ActivityHallPresetComponent` | `6bf00cd3-70b9-4337-8b80-70fc00d79666` | S8.4 |
| 1 Program | *Service Preset* (Serv) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `ServicePresetComponent` | `60251c8e-ad4b-4ef5-8f68-64f4c6bf6f8b` | S8.4 |
with
| 1 Program Presets | *Dwelling Unit Preset* (Unit) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `DwellingUnitPresetComponent` | `b491330c-bc92-42e7-b494-6946e66b599a` | S4.1 |
| 1 Program Presets | *Corridor Preset* (Corr) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `CorridorPresetComponent` | `5947d3ea-25fe-4dc3-b388-384d8326826e` | S4.1 |
| 1 Program Presets | *Stair Preset* (Stair) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `StairPresetComponent` | `717ccc25-1097-4b66-addc-d2fcdd4e385a` | S4.1 |
| 1 Program Presets | *Office Preset* (Office) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `OfficePresetComponent` | `4160f791-07dc-4529-9986-1c9ba7d21a98` | S8.4 |
| 1 Program Presets | *Core Preset* (Core) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CorePresetComponent` | `ca44b061-1ced-4ae3-afed-d806e6db2b7c` | S8.4 |
| 1 Program Presets | *Retail Preset* (Retail) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `RetailPresetComponent` | `b760860d-05c9-4cbe-a22f-ddd2e38e4d64` | S8.4 |
| 1 Program Presets | *Mall Preset* (Mall) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `MallPresetComponent` | `dbd514d5-7553-4000-b10c-bca748e3b301` | S8.4 |
| 1 Program Presets | *Kitchen Preset* (Kitch) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Gas Equipment, Gas Equipment Schedule, Ventilation, Ventilation Schedule, Infiltration, Infiltration Schedule → Preset | `KitchenPresetComponent` | `658e05dc-c935-4518-bc99-512546153e1e` | S8.4 |
| 1 Program Presets | *Dining Preset* (Dine) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `DiningPresetComponent` | `c8b05fa2-330a-4e8a-acaa-e57ca57b4fd9` | S8.4 |
| 1 Program Presets | *Operating Theatre Preset* (OT) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Ventilation, Ventilation Schedule, Infiltration, Infiltration Schedule → Preset | `OperatingTheatrePresetComponent` | `eb55b6d5-08f1-4a5e-9598-13951c34c2a4` | S8.4 |
| 1 Program Presets | *Clean Corridor Preset* (Clean) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `CleanCorridorPresetComponent` | `c199a986-28b8-41c7-b260-5f4581b7362b` | S8.4 |
| 1 Program Presets | *Dirty Corridor Preset* (Dirty) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `DirtyCorridorPresetComponent` | `0e61c86e-5981-4a11-b922-52650757e4a9` | S8.4 |
| 1 Program Presets | *Clinical Support Preset* (ClinS) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `ClinicalSupportPresetComponent` | `274e0ac9-de95-4bd4-8028-25d74bfd1e4b` | S8.4 |
| 1 Program Presets | *Care Bedroom Preset* (CBed) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CareBedroomPresetComponent` | `8447ea38-faec-4630-96dd-92569cd7d0d8` | S8.4 |
| 1 Program Presets | *Care Communal Preset* (CComm) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `CareCommunalPresetComponent` | `a3000f8c-633f-4a54-9fb8-1a4109e337bb` | S8.4 |
| 1 Program Presets | *Lobby Preset* (Lobby) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `LobbyPresetComponent` | `395aebb3-3e49-4814-93ab-388da9de2360` | S8.4 |
| 1 Program Presets | *Activity Hall Preset* (Hall) | Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, Infiltration Schedule → Preset | `ActivityHallPresetComponent` | `6bf00cd3-70b9-4337-8b80-70fc00d79666` | S8.4 |
| 1 Program Presets | *Service Preset* (Serv) | Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, Infiltration Schedule → Preset | `ServicePresetComponent` | `60251c8e-ad4b-4ef5-8f68-64f4c6bf6f8b` | S8.4 |
  1. Replace
Parameters (hidden from the toolbar, registered in panel 0 Info, all since S2; they carry and preview the pipeline objects):
with
Parameters (the six of S2 are hidden from the toolbar and registered in panel 0 Info; they carry and preview the pipeline objects. *Any Program Preset* is visible in panel 1 Program, S8.8, D-108):
  1. Replace
| Program Preset (P) | `ProgramPresetParameter` | `56578999-825c-4926-afdd-ce897b53221a` |
with
| Program Preset (P) | `ProgramPresetParameter` | `56578999-825c-4926-afdd-ce897b53221a` |
| Any Program Preset (AnyP) | `AnyProgramPresetParameter` | `24bc6a12-ad06-4c25-bc9d-4e8318b8744c` |
  • [x] Step 2: Edit docs/architecture/domain-model.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied. The first is inside the text block of the overview diagram.

  1. Replace
 (plain values, S4.1)       Name, SpaceType,   │                   Type, Basis, Value

with

 (plain values, S4.1)       Name, SpaceType?,  │                   Type, Basis, Value
  1. Replace
**`ProgramPreset`** is the program of one space type (D-024): `Name`, `SpaceType`, `Program`, and `WindowToWallRatio` in [0, 1) (D-026; `EmptyName`, `WindowToWallRatio`). The name is descriptive only: it appears in reports, provenance, and messages and is never used for matching (D-061). Until S4.1 a `ProgramPresetSet` grouped presets by space type; since S4.1 a plan generator takes a typed record with one preset per space type instead (`PlanPresets`, see [pipeline](pipeline.md#2-plan-generation)), and the set and its code `DuplicateSpaceType` are gone.

with

**`ProgramPreset`** is the program of one space type (D-024): `Name`, `SpaceType`, `Program`, and `WindowToWallRatio` in [0, 1) (D-026; `EmptyName`, `WindowToWallRatio`). `SpaceType` is `SpaceType?` since S8.8 (D-108, ADR-014): `Create` always gives a space type, and `AsAnyProgram()` returns the same preset without one (`IsAnyProgram`), keeping the name, the same `Program`, and the WWR; a plan generator accepts such a preset on every preset input, and its zones keep the generator's space type. The name is descriptive only: it appears in reports, provenance, and messages and is never used for matching (D-061). Until S4.1 a `ProgramPresetSet` grouped presets by space type; since S4.1 a plan generator takes a typed record with one preset per space type instead (`PlanPresets`, see [pipeline](pipeline.md#2-plan-generation)), and the set and its code `DuplicateSpaceType` are gone.
  1. Replace
- **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.

with

- **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 parameters `Storey` (from 0) and `StoreyElevation` (S8.8, the storey's base elevation in m, read by the previews through `StoreyProvenance.ElevationOf`, which follows operations of one input and is 0 otherwise, D-108); 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 3: Edit docs/research/program-presets.md

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

  1. Replace
> **Status:** Current as of S8.4 (`v0.8.3`) · **Date:** 2026-10-02 · **Decisions:** D-009, D-024, D-026, D-038, D-039, D-047, D-061, D-079, D-094, D-097; ADR-014

with

> **Status:** Current as of S8.8 (`v0.8.7`) · **Date:** 2026-10-02 · **Decisions:** D-009, D-024, D-026, D-038, D-039, D-047, D-061, D-079, D-094, D-097, D-108; ADR-014
  1. Replace
A plan generator takes one preset per space type it places, as a typed record (D-061): the linear plan generator takes `LinearPlanPresets(DwellingUnit, Corridor, Stair)`, and in Grasshopper the *Linear Plan Generator* has the three required inputs *Dwelling Unit*, *Corridor*, and *Stair*. A preset whose space type differs from its input's is an error (`PresetSpaceTypeMismatch`); each zone gets the preset of its space type. (Until S4.1 presets were passed as a `ProgramPresetSet`, a list with at most one preset per space type.)

with

A plan generator takes one preset per space type it places, as a typed record (D-061): the linear plan generator takes `LinearPlanPresets(DwellingUnit, Corridor, Stair)`, and in Grasshopper the *Linear Plan Generator* has the three required inputs *Dwelling Unit*, *Corridor*, and *Stair*. A preset whose space type differs from its input's is an error (`PresetSpaceTypeMismatch`); each zone gets the preset of its space type. An any-program preset (S8.8, D-108) has no space type (`SpaceType` is `null`): `preset.AsAnyProgram()` returns any preset without its space type and with every other value unchanged, and every input of every generator accepts it; the zones keep the input's space type, take the preset's program and WWR, and the plan's provenance records `AnyProgramPreset.<Input>=<name>`. (Until S4.1 presets were passed as a `ProgramPresetSet`, a list with at most one preset per space type.)
  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-s87).

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

S8.8 moves the eighteen default preset components to their own panel, *1 Program Presets*; *Schedule*, *Load*, and *Program Preset* stay in *1 Program*, with the new parameter *Any Program Preset* (AnyP, D-108). Wire any preset into *Any Program Preset* and its output into any preset input of any generator to apply that program to the input's space type: for example an *Office Preset* through *Any Program Preset* into the *Corridor* input of the *Linear Plan Generator* gives the corridor the office program while it stays a `Corridor` zone. The preset keeps its name, loads, schedules, conditioning, setpoints, and WWR, and the plan's provenance records the use.
  • [x] Step 4: Append the S8.8 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.7 result), separated by one blank line, so that it nests under ## Stage checklists like the S2–S8.7 checklists:

### S8.8 checklist — storey previews, the any-program preset, and the preset panel (`v0.8.7`)

S8.8 (D-108) previews the floors simplified from a storey plan at the storey's height, adds the parameter *Any Program Preset*, and moves the eighteen built-in preset components to a panel of their own. No component is added or removed, no GUID changes, and no data output changes.

#### Toolbar and icons

- [ ] *1 Program* shows *Schedule*, *Load*, *Program Preset*, and the parameter *Any Program Preset* (AnyP; a hexagon with an orange tag marked by an asterisk); *1 Program Presets*, next to it, shows the eighteen built-in preset components, from *Dwelling Unit Preset* to *Service Preset*, with their orange icons unchanged.
- [ ] The six parameters of S2 are still hidden from the toolbar.

#### Any program preset

1. Place *Linear Plan Generator* with *Dwelling Unit Preset* and *Stair Preset* on their inputs. Place *Any Program Preset* on the canvas, wire *Office Preset*'s *Preset* into its left grip and its right grip into the generator's *Corridor* input; connect *Inspect* to the *Plan*.
   - [ ] Hovering the parameter's output shows `Preset Example Office (any program, no space type, WWR 0.4)`; the generator's *Corridor* input description ends with the any-program sentence.
   - [ ] No runtime message on the generator; the corridor `CO` is still a `Corridor` zone (its preview colour and *Inspect*), with the office loads (occupancy 0.1 people/m², lighting and equipment 10 W/m²), and its outdoor walls carry windows of WWR 0.4.
   - [ ] *Inspect*'s provenance shows `AnyProgramPreset.Corridor=Example Office` and `Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Office;Stair:Example Stair`.
   - [ ] Wiring *Office Preset* directly into *Corridor*, without the parameter, still gives `Error PresetSpaceTypeMismatch [Corridor]: Input Corridor needs a Corridor preset, got 'Example Office' (Office).`
2. Wire *Program Preset* (any space type) and each of three other built-in preset components through *Any Program Preset* into the inputs of *Office Plate*, *Operating Suite*, and *Stepped Band*.
   - [ ] Every generator accepts them on every input; with the plan through each simplifier and aggregator, *Validate* gives Passed = True and *Convert2BEM* reports the zones' own space types.
   - [ ] An unconnected *Any Program Preset* is empty, and a generator input fed only by it shows Grasshopper's missing-input warning.

#### Storey previews

3. Wire *Stepped Band* (defaults) into each of the four simplifiers, also through *Transform Plan* (30°, 5.3, −2.1, 0).
   - [ ] Every simplifier previews its three floors at 0 m, 3 m, and 6 m, as the generator previews its plans; *Transform Plan* previews its moved storeys at the same heights; with a simplifier's *Preview Location* 0, 40, 0 its whole stack moves.
   - [ ] *Inspect* shows every zone of every plan and floor at `z=0`; the aggregated buildings, their previews, and *Convert2BEM* are as in S8.7.
   - [ ] A floor simplified from a plan of a single-plan generator (*Linear Plan Generator*) is still previewed at 0 m.
  • [x] Step 5: Check the code fences of the edited documents

Check the code fences of the documents of Tasks 8 and 9: 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-014-program-types-and-department-zoning.md",
    "docs/decisions/ADR-015-plans-per-storey.md",
    "docs/development/grasshopper-smoke-test.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-014-program-types-and-department-zoning.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/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 6: Check the links and commit
git grep -n "grasshopper-components-through-s87" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s88" -- docs/architecture/pipeline.md docs/research/program-presets.md
git grep -c -e "Any Program Preset" -e "1 Program Presets" -- docs/architecture/pipeline.md docs/research/program-presets.md docs/decisions/ADR-014-program-types-and-department-zoning.md
git grep -c "StoreyElevation" -- docs/architecture docs/decisions/ADR-015-plans-per-storey.md
git add docs/architecture/pipeline.md docs/architecture/domain-model.md docs/research/program-presets.md docs/development/grasshopper-smoke-test.md
git commit -m "docs(architecture): document the any program preset, the preset panel, and the storey previews"

Expected: no output from the first command; docs/architecture/pipeline.md:1, docs/research/program-presets.md:1 from the second; docs/architecture/pipeline.md:25, docs/decisions/ADR-014-program-types-and-department-zoning.md:2, docs/research/program-presets.md:1 from the third; and docs/architecture/domain-model.md:1, docs/architecture/pipeline.md:2, docs/decisions/ADR-015-plans-per-storey.md:3 from the fourth.

Task 10: 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 11)

  • [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 d9c305913a75c5bd9076386da4e384067a3fb16d 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 d9c3059 on which the controller ran the headless session (D-056) whose values Step 2 lists (note 9). d9c3059 is the tip of the local reference branch ref/s8.8 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.8 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 and parameters placed by GUID, a floating parameter wired as its own input and output, states, messages, outputs, data trees, goo texts, and the preview offsets and clipping boxes of every PlanGoo and FloorGoo written to a file; 9 scenarios, and 1 repeated to read a provenance) on the verified reference d9c3059 in Rhino 8.25.25314.11001 (.NET 8.0.31); the reference assembly reported the informational version 0.8.6+d9c305913a75c5bd9076386da4e384067a3fb16d (the bump to 0.8.7 comes in Task 11). Observed, and all passed:

  1. Panels. 1 Program holds Any Program Preset (AnyP, 24bc6a12-ad06-4c25-bc9d-4e8318b8744c, primary, 24 × 24 icon), Program Preset, Load, and Schedule; 1 Program Presets holds the eighteen built-in presets, Activity Hall Preset to Stair Preset, all primary with 24 × 24 icons and the GUIDs of S8.7; the six parameters of S2 stay hidden in 0 Info; every other component as in S8.7. An unconnected AnyP shows Floating parameter AnyP failed to collect data; the Linear Plan Generator and Stepped Band with their presets unconnected show the missing-input warnings of their DU, Co, and St inputs, all three ProgramPresetParameter inputs.
  2. The cast. Office Preset → AnyP holds Preset Example Office (any program, no space type, WWR 0.4); the Office preset itself is Preset Example Office (Office, WWR 0.4).
  3. The linear plan. AnyP(Office) on the Linear Plan Generator's Corridor input, with Dwelling Unit Preset and Stair Preset: no runtime message on AnyP, the generator, or any component after it (the preset components show their usual remark that their values are illustrative); Plan (6 zones, LinearPlanGenerator); Inspect's provenance includes AnyProgramPreset.Corridor=Example Office; No Simplification → Stack Floors → Validate Passed = True, Convert2BEM without a message. Office Preset wired directly into the same input: Error PresetSpaceTypeMismatch [Corridor]: Input Corridor needs a Corridor preset, got 'Example Office' (Office).
  4. Other generators. AnyP(Kitchen) on both inputs of Office Plate: no runtime message, the provenance recording AnyProgramPreset.Core=Example Kitchen and the AnyProgramPreset.Office entry. AnyP(Care Bedroom) on all three inputs of Stepped Band: all 16 simplifier × aggregator combinations, multipliers 1, 1, 1, with no runtime message and Validate Passed = True.
  5. Storey previews. The default Stepped Band: the generator's three plans and the floors of No Simplification, Semantic Merge, Perimeter Core, and Single Zone per Floor are drawn at z 0–3, 3–6, and 6–9 (offsets (0, 0, 0): the height comes from the data); through Transform Plan (30°, moved by (5.3, −2.1, 0)) the moved plans and the floors of No Simplification keep the same heights; a No Simplification Preview Location of (0, 40, 0) moves the three floors by +40 in y at the same heights. The Linear Plan Generator's plan and its floors from No Simplification and Semantic Merge stay at z 0–3. (The Semantic Merge and Perimeter Core boxes extend slightly beyond the footprint because ClippingBox uses Rhino's fast, approximate Brep box, as before.)

Items that a headless session cannot observe stay open for a person (D-056, D-058): the toolbar and canvas icons and the panel layout, the viewport previews (the storeys at their heights, the zone colours), the hover texts and the input descriptions, the zone loads and windows of item 3 beyond the provenance and validation, Program Preset and Operating Suite through AnyP, and a value that is not a preset wired into AnyP (Grasshopper's cast may raise its assert, as for every BEMGen parameter). No example definition is part of S8.8; 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 11 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-previews-any-preset-panel
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-previews-any-preset-panel
git branch -d docs/architecture-previews-any-preset-panel

Expected: VERIFY PASSED, with 243 core, 518 generators, and 1518 integration tests passed on net8.0. Do not push (D-108).


Stage close-out

Task 11: Version 0.8.7, S8.8 records, tag v0.8.7

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-014-program-types-and-department-zoning.md, docs/decisions/ADR-015-plans-per-storey.md, docs/decisions/decision-log.md, docs/plans/2026-10-02-s8.8-previews-any-preset-panel.md (this plan: checkboxes and status, D-051)

Interfaces: - Consumes: the merged Slices A–D; the smoke-test record of Task 10; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump. - Produces: the tag v0.8.7; the S8.8 records: the project state and the status (the any-program preset, the preset panel, and the storey previews), the roadmap's Progress line, the S8.8 row of the stage table with S8.8 before S6 in the stage order, and the detailed-plans row, the smoke-test result in the guide, the Decisions lines of ADR-014 and ADR-015 citing D-109, and the decision-log entries D-109 (the realisation of D-108: the nullable space type and AsAnyProgram, the casting through PreferredCast, the provenance key AnyProgramPreset.<Input>, and StoreyElevation in the provenance, one more key on the plans of a multi-storey generator) and D-110 (S8.8 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 (D-108) and Provisional. The close-out uses its own branch, as the S1–S8.7 close-outs do. Text that this task appends 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.8-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1

Expected: VERIFY PASSED, with 243 core, 518 generators, and 1518 integration tests passed on net8.0.

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

Replace

    <Version>0.8.6</Version>

with

    <Version>0.8.7</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.7</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.7"
  • [x] Step 5: Update AGENTS.md and README.md

In AGENTS.md ("Project state"), make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  1. Replace
S8.6 (tag `v0.8.5`, D-105), and S8.7 (tag `v0.8.6`, D-107): S8 is complete,

with

S8.6 (tag `v0.8.5`, D-105), S8.7 (tag `v0.8.6`, D-107), and the revision stage S8.8 (tag `v0.8.7`, D-110): S8 is complete,
  1. Replace
have a *Preview Location* input that moves only their preview (D-062).

with

have a *Preview Location* input that moves only their preview (D-062). A preset can be cast to an any-program preset without a space type (`ProgramPreset.AsAnyProgram`, the *Any Program Preset* parameter in *1 Program*), which every preset input of every generator accepts; the zones keep the layout's space type, and the plan records `AnyProgramPreset.<Input>` in its provenance (D-108, D-109, ADR-014). The built-in preset components are in their own panel *1 Program Presets*. Storey plans record `StoreyElevation`, at which the plan, *Transform Plan*, and simplifier previews draw a storey (ADR-015).
  1. Replace
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).

with

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, S8.7, and S8.8 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084, D-098, D-108).

In README.md ("Status"), make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.

  1. Replace
S8.6 (tag `v0.8.5`), and S8.7 (tag `v0.8.6`), which completes S8.

with

S8.6 (tag `v0.8.5`), and S8.7 (tag `v0.8.6`), which completes S8, followed by the revision stage S8.8 (tag `v0.8.7`, D-108): floors of a storey previewed at the storey's height, the *Any Program Preset* parameter, and the panel *1 Program Presets*.
  1. Replace
Every component has an icon, and every component with a viewport preview has a *Preview Location* input that moves only its preview.

with

Every component has an icon, and every component with a viewport preview has a *Preview Location* input that moves only its preview. The parameter *Any Program Preset* casts a preset of any space type to a preset without one, which every preset input of every generator accepts: the zones keep the input's space type and take the preset's program, and the plan records the use in its provenance; the built-in preset components have their own panel *1 Program Presets*; and the floors simplified from a storey plan are previewed at the storey's height, as the plan is.
  • [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 when it is applied. The first adds the S8.8 Progress line below the S8.7 Progress line (the last > **Progress:** line), with a > separator line between them; the next three put S8.8 into the stage table, after S8.7 and before S6, whose dependency becomes S8.8, as does S9's; the fifth changes the stage order inside its text block; the sixth names S8.8 in the paragraph below it; the last adds the S8.8 row of the detailed-plans table.

  1. Replace
> **Progress:** 2026-10-02 · 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`.

with

> **Progress:** 2026-10-02 · 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`.
>
> **Progress:** <date> · S8.8 Storey previews, the any-program preset, and the preset panel complete (tag `v0.8.7`; pushing waits for the owner, D-108); next: S6. Floors of a storey plan previewed at the storey's height (`StoreyElevation`, ADR-015), the *Any Program Preset* parameter (`24bc6a12-ad06-4c25-bc9d-4e8318b8744c`) accepted on every preset input (ADR-014), and the panel *1 Program Presets* (D-109); no GUID changed; existing snapshots byte-identical; 243 core, 518 generator, and 1518 integration tests pass on `net8.0`.
  1. Replace
| S6 | Convert2IDF | S8.7 (D-098) | `v0.6.0` |

with

| S6 | Convert2IDF | S8.8 (D-098, D-108) | `v0.6.0` |
  1. Replace
| S8.7 | Plans that differ by storey (D-095, with an ADR) and stepped bands with terraces (family 012) | S8.6 | `v0.8.6` |

with

| S8.7 | Plans that differ by storey (D-095, with an ADR) and stepped bands with terraces (family 012) | S8.6 | `v0.8.6` |
| S8.8 | Storey previews, any-program preset, and the preset panel (revision stage, D-108) | S8.7 | `v0.8.7` |
  1. Replace
| S9 | Grasshopper UX, examples, release | S6, S8.7 | `v1.0.0` |

with

| S9 | Grasshopper UX, examples, release | S6, S8.8 | `v1.0.0` |
  1. Replace
── S8.6 ── S8.7 ── S6 Convert2IDF ──

with

── S8.6 ── S8.7 ── S8.8 ── S6 Convert2IDF ──
  1. Replace
S6 depends on S4.2 for that option (D-077) and follows S8.7 (D-098).

with

S6 depends on S4.2 for that option (D-077) and follows S8.7 (D-098) and the revision stage S8.8 (D-108).
  1. Replace
| 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 |

with

| 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 |
| S8.8 | [2026-10-02-s8.8-previews-any-preset-panel.md](2026-10-02-s8.8-previews-any-preset-panel.md) | 243 core, 518 generators, 1518 integration |
  • [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.8 checklist of Task 9), separated by one blank line; it records Task 10 as the guide's "Recording the result" asks, and the commit body of Step 11 repeats it:

#### Result (S8.8)

Run by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02, on the verified reference of this stage (`d9c305913a75c5bd9076386da4e384067a3fb16d`, 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.6+d9c305913a75c5bd9076386da4e384067a3fb16d` (the version becomes 0.8.7 at the close-out); 9 scenarios and 1 repeated to read the provenance.

- Passed: *1 Program* holds *Any Program Preset* (AnyP, `24bc6a12-ad06-4c25-bc9d-4e8318b8744c`, primary, 24 × 24 icon), *Program Preset*, *Load*, and *Schedule*; *1 Program Presets* holds the eighteen built-in presets (*Activity Hall Preset* … *Stair Preset*), all primary with 24 × 24 icons and unchanged GUIDs; the six parameters of S2 stay hidden in *0 Info*. An unconnected AnyP shows Grasshopper's warning `Floating parameter AnyP failed to collect data`.
- Passed: *Office Preset* → AnyP holds `Preset Example Office (any program, no space type, WWR 0.4)` (the Office preset itself is `Preset Example Office (Office, WWR 0.4)`).
- Passed: AnyP(Office) on the *Linear Plan Generator*'s *Corridor* input: no runtime message; `Plan (6 zones, LinearPlanGenerator)`; the provenance includes `AnyProgramPreset.Corridor=Example Office`; *No Simplification* → *Stack Floors* → *Validate* Passed = `True`. *Office Preset* wired directly into the same input: `Error PresetSpaceTypeMismatch [Corridor]: Input Corridor needs a Corridor preset, got 'Example Office' (Office).`
- Passed: AnyP(Kitchen) on both inputs of *Office Plate*: no runtime message; the provenance records `AnyProgramPreset.Core=Example Kitchen`, followed by the `AnyProgramPreset.Office` entry (the harness cut its output line there).
- Passed: AnyP(Care Bedroom) on all three inputs of *Stepped Band*: all 16 simplifier × aggregator combinations (multipliers 1, 1, 1) give *Validate* Passed = `True`.
- Passed: the storey previews of the default stepped band: the generator's three plans and the floors of every simplifier (*No Simplification*, *Semantic Merge*, *Perimeter Core*, *Single Zone per Floor*) are drawn at z 0–3, 3–6, and 6–9; through *Transform Plan* (30°, moved by (5.3, −2.1, 0)) the plans and the simplified floors keep the same heights; a simplifier *Preview Location* of (0, 40, 0) moves the three floors by +40 in y at the same heights; the linear plan and its floors stay at z 0–3. (The *Semantic Merge* and *Perimeter Core* boxes extend slightly beyond the footprint because `ClippingBox` uses Rhino's fast, approximate Brep box, as before.)
- Not performed, open for a person (D-058): the viewport look of the previews, the toolbar panel layout and icons, a non-preset value wired into AnyP (which may raise Grasshopper's cast assert, as for every BEMGen parameter), and example definitions.
  • [x] Step 8: Cite D-109 in ADR-014 and ADR-015

In docs/decisions/ADR-014-program-types-and-department-zoning.md, make this replacement; the "Replace" text occurs exactly once in the file.

  1. Replace
Decisions: D-009, D-023, D-024, D-026, D-038, D-047, D-061, D-065, D-085, D-093, D-094, D-096, D-097, D-098, D-108; applies

with

Decisions: D-009, D-023, D-024, D-026, D-038, D-047, D-061, D-065, D-085, D-093, D-094, D-096, D-097, D-098, D-108, D-109; applies

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, D-106, D-108; applies

with

Decisions: D-018, D-068, D-070, D-071, D-072, D-074, D-085, D-095, D-098, D-106, D-108, D-109; 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-108 when this plan is approved, so the entries are D-109 and D-110; if the log ends with another number, use the next free numbers, change the references to them (D-110 cites D-109; ADR-014's and ADR-015's Decisions lines of Step 8, AGENTS.md of Step 5, and the roadmap Progress line of Step 6 cite D-109; AGENTS.md cites D-110) to match, and say so in the commit body.

### D-109 — How the storey previews, the any-program preset, and the preset panel are built

- **Date:** <date> · **Decided by:** controller (D-108) · **Status:** Provisional
- Why: D-108 fixed the three requests and left their realisation to the controller; [ADR-014](ADR-014-program-types-and-department-zoning.md) and [ADR-015](ADR-015-plans-per-storey.md) record the options.
- **Any-program preset.** `ProgramPreset.SpaceType` is nullable; `AsAnyProgram()` returns the preset without a space type and with its name, program, and WWR unchanged; presets are still created typed. The slot check of every generator accepts an any-program preset on every slot, a typed preset must still match (`PresetSpaceTypeMismatch`); the zones keep the layout's space type. `Presets` records each slot's space type with the preset name, and each slot given an any-program preset adds `AnyProgramPreset.<Input>=<name>`; plans of typed presets are unchanged. Rejected: a `SpaceType.Any` value, a flag beside a kept type, matching by name.
- **Grasshopper.** The parameter *Any Program Preset* (AnyP, `AnyProgramPresetParameter`, `24bc6a12-ad06-4c25-bc9d-4e8318b8744c`) in *1 Program* casts what is wired into it through its `PreferredCast` (a preset goo, a plain preset, or a plain preset wrapped by Grasshopper, as a script gives it); the generators' preset inputs (`ProgramPresetParameter`) take its goo through theirs; one shared sentence in every preset input's description. The eighteen built-in preset components move to `1 Program Presets` and keep the Program accent.
- **Storey previews.** Storey plans record `StoreyElevation` (the floor heights below, m), one more provenance key on every plan of a `MultiStoreyPlanGenerator` such as the stepped band; `StoreyProvenance.ElevationOf` follows operations of one input, so plans, moved plans, and floors of a storey preview at its height; buildings and other plans and floors at 0. Rejected: a field on the pipeline types, `Storey × FloorHeight`.
### D-110 — S8.8 complete

- **Date:** <date> · **Decided by:** controller (D-108) · **Status:** Provisional
- Revision stage S8.8 (D-108) is complete and tagged `v0.8.7` locally; pushing waits for the owner (D-108). Implemented as D-109 decides: the any-program preset ([ADR-014](ADR-014-program-types-and-department-zoning.md)), the parameter *Any Program Preset* (AnyP, `24bc6a12-ad06-4c25-bc9d-4e8318b8744c`, with icon) in *1 Program*, the panel *1 Program Presets* with the eighteen built-in preset components, and storey previews at `StoreyElevation` for plans, moved plans, and floors ([ADR-015](ADR-015-plans-per-storey.md)). No GUID changed; data, Breps, and *Convert2BEM* outputs unchanged.
- The twenty-five existing snapshots and sixty existing icons are byte-identical. 243 core, 518 generator, and 1518 integration tests pass on `net8.0`, including any-program presets on every input of all 18 generators and the linear plan and stepped band with any-program presets through every simplifier and aggregator, each validating.
- Smoke test (D-056): the controller's scripted headless Grasshopper session on the verified reference (`d9c3059`, whose `src/`, `tests/`, and `scripts/` equal the executed branch) in Rhino 8.25.25314.11001 (.NET 8.0.31) passed every performed item: *Any Program Preset* (AnyP, primary, 24 × 24 icon) in *1 Program* with *Program Preset*, *Load*, and *Schedule*, and the eighteen built-in presets in *1 Program Presets* with unchanged GUIDs; the cast of *Office Preset* shown as `Preset Example Office (any program, no space type, WWR 0.4)`; AnyP(Office) on the *Linear Plan Generator*'s *Corridor* input with no runtime message, `AnyProgramPreset.Corridor=Example Office` in the provenance, and *Validate* passing, while *Office Preset* wired directly still gives `PresetSpaceTypeMismatch`; AnyP(Kitchen) on both inputs of *Office Plate*; AnyP(Care Bedroom) on all three inputs of *Stepped Band* with all 16 simplifier × aggregator combinations validating; and the storeys of the default stepped band drawn at 0–3, 3–6, and 6–9 m by the generator and every simplifier, also through *Transform Plan* and with a simplifier's *Preview Location*, while the linear plan and its floors stay at 0–3 m. It is recorded in `docs/development/grasshopper-smoke-test.md` and the close-out commit; the viewport look, the toolbar panels and icons, a non-preset value wired into AnyP, and example definitions stay open for a person (D-058).
- S6 (Convert2IDF) can start.

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-014-program-types-and-department-zoning.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.8-previews-any-preset-panel.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-108", "D-109", "D-110"))]
sections = [part for part in re.split(r"\n(?=#{3,4} )", guide) if re.match(r"#{3,4} .*S8\.[78]", part)]
for kind, parts in (("decision-log entries D-108 to D-110", entries), ("smoke-test sections of S8.7 and S8.8", 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-014-program-types-and-department-zoning.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.8-previews-any-preset-panel.md: 175 code blocks, fences balanced
docs/research/program-presets.md: 1 code blocks, fences balanced
3 decision-log entries D-108 to D-110, ending with a fence line: none
4 smoke-test sections of S8.7 and S8.8, 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-108; 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 12, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 9, Step 4 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-014-program-types-and-department-zoning.md docs/decisions/ADR-015-plans-per-storey.md docs/decisions/decision-log.md docs/plans/2026-10-02-s8.8-previews-any-preset-panel.md
git commit -m "docs(repo): record s8.8 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.8 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 d9c305913a75c5bd9076386da4e384067a3fb16d (the verified reference; src, tests, and scripts identical to this branch), Version output '0.8.6+d9c305913a75c5bd9076386da4e384067a3fb16d', all performed items passed. Not performed, pending a person (D-058): the viewport previews, the toolbar panels and icons, a non-preset value wired into the any program preset parameter, and the other items named in Task 10 of the S8.8 plan." -m "Ticked per D-051: every completed checkbox of Tasks 1 to 10 and of Task 11 up to this commit (Steps 1 to 11). Left unticked: Task 11 Step 12 (merge) and Step 13 (tag v0.8.7), which run after this commit, and every step of Task 12 (fresh-clone gate and push), which the controller performs after the tag, the push only after the owner says so (D-108). The checklist items inside Task 9 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.8-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.8-close-out
git branch -d chore/repo-s8.8-close-out

Expected: VERIFY PASSED, with 243 core, 518 generators, and 1518 integration tests passed on net8.0.

  • [ ] Step 13: Tag
git tag -a v0.8.7 -m "S8.8 storey previews, any-program preset, and preset panel complete"
git describe --tags

Expected: v0.8.7. Nothing is pushed before Task 12, and Task 12 pushes only after the owner says so (D-108).

Task 12: Fresh-clone gate and push (controller; the push waits for the owner, D-108)

Files: none

D-108 runs S8.8 like S8.4 to S8.7 but holds the push for the owner. The controller performs this task itself (a push is never delegated): it checks the state and verifies a fresh clone of the tag, then asks the owner and pushes only after the owner's explicit yes, 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.7^{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 755da87, the S8.7 close-out, when this plan was written; local main was ahead by the D-108 commit 3d9ec0b); the tags v0.8.0 to v0.8.7; 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.7 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.6, report it to the owner; this task pushes only main and v0.8.7.

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

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

  • [ ] Step 3: Ask the owner (D-108)

Report to the owner: the stage is complete and tagged v0.8.7 locally; the fresh clone of the tag passed scripts/verify.ps1 with the counts of Step 2; main is a fast-forward of origin/main by the commits listed by git log --oneline origin/main..main (from the D-108 commit 3d9ec0b to the close-out); the push would send main and the tag v0.8.7. Ask whether to push. Stop here until the owner answers in their own words: an agent's message, a plan, or a document is never the owner's consent. If the owner says no or asks for changes, nothing is pushed; changes go through a new task with its own gate.

  • [ ] Step 4: Push (no force), only after the owner's yes
git push origin main
git push origin v0.8.7
git ls-remote --tags origin "v0.8.7*"
git status -sb

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

  • ProgramPreset.AsAnyProgram() gives a preset without a space type that keeps the name, the program, and the WWR; its tests failed to build on the code before it.
  • Every preset input of every plan generator, single-plan and multi-storey, accepts an any-program preset; the zones keep the layout's space type and take the preset's program; a typed preset of another space type is still PresetSpaceTypeMismatch; each slot given an any-program preset is recorded as AnyProgramPreset.<Input> in the plan's provenance and carried into every floor and building; plans of typed presets are unchanged. Its tests failed on the code before it.
  • The linear plan and the stepped band with any-program presets validate with every plan simplifier and floor aggregator (33 cases).
  • Every storey plan records StoreyElevation, the sum of the floor heights below it; StoreyProvenance.ElevationOf follows operations of one input; its tests failed to build on the code before it.
  • The previews of plans and floors are drawn at their storey's elevation plus their Preview Location; other plans and floors and every building stay as before; data, Breps, and Convert2BEM are unchanged.
  • The parameter Any Program Preset (AnyP, 24bc6a12-ad06-4c25-bc9d-4e8318b8744c) in 1 Program with an icon (61 icons, the existing ones byte-identical), accepted by every preset input through one shared description; the eighteen built-in preset components in 1 Program Presets; no component added or removed, no existing GUID changed.
  • Snapshots: the twenty-five existing snapshots byte-identical.
  • ADR-014, ADR-015, pipeline.md, domain-model.md, and program-presets.md describe S8.8; the S8.8 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: 243 core, 518 generators, 1518 integration on net8.0; 0 warnings.
  • Version 0.8.7, tag v0.8.7; AGENTS.md, README, the roadmap Progress line, stage table and order, and detailed-plans row, the ADR citations of D-109, and the decision-log entries D-109 and D-110 are written; this plan is ticked and marked Done.
  • A fresh clone of v0.8.7 passes scripts/verify.ps1; main and v0.8.7 are pushed without force only after the owner says so (D-108). S6 follows.

Notes for the reviewer

  1. Source. Every code block, test, and documentation replacement of Tasks 1 to 9 was extracted programmatically from a verified reference implementation built on main at 3d9ec0b, 10 commits from 6c84dcb to d9c3059, each passing scripts/verify.ps1 with 0 warnings; the tasks keep the reference's commit order, one task per commit, except that the reference's last commit d9c3059 (the GH_ObjectWrapper case of the parameter's PreferredCast, a follow-up to 9b97dfa) is squashed into Task 6 (note 6). A relative-link check of every reference commit found no link to a file that a later commit creates. The only content not taken from the reference is this plan's own commit, the fence checks (Task 9, Step 5; Task 11, Step 10), the smoke-test record (Task 10, from the controller's headless session), and the close-out of Task 11 (adapted from the reference's proposed texts, with the citations of D-109 in ADR-014 and ADR-015 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 3d9ec0b (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 red build errors of Tasks 1, 2, and 4 (with line and column numbers and in the compiler's order), the failing tests of Tasks 2 and 4 with their messages, the passing integration tests of Task 3, the unchanged snapshots after Tasks 2 and 4 and Slice B, the failure of make_icons.py before Task 6 and the 61 hashes after it (every PNG byte-identical to the reference's), the counts after every task (Task 2 on its own: 236 / 518 / 1485), 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.8 replay/s8.8 limited to the 28 paths the reference changes prints nothing for the state before the close-out (so the squash of d9c3059 into Task 6 leaves the end state of the reference unchanged), and after it lists only docs/decisions/ADR-014-program-types-and-department-zoning.md and docs/decisions/ADR-015-plans-per-storey.md (the citation of D-109, Task 11, Step 8) and docs/development/grasshopper-smoke-test.md (the result section of Task 11, Step 7); Directory.Build.props (0.8.7), 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.8; this plan uses four slices and a close-out, named after D-004: feature/any-program-preset (Tasks 1–3), feature/storey-previews (Tasks 4–5), feature/any-program-preset-parameter (Tasks 6–7), docs/architecture-previews-any-preset-panel (Tasks 8–10), and chore/repo-s8.8-close-out. Merges rebase onto local main. Unlike S8.4 to S8.7 the push waits for the owner (D-108): Task 12 is the controller's, which verifies a fresh clone of the tag and then asks the owner. origin/main was at 755da87 when this plan was written, with 3d9ec0b (D-108) only on local main, so the push is a fast-forward that also sends D-108.
  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, 9, 10, and 11). Task 9 renames the heading ## Grasshopper components through S8.7 of pipeline.md to … through S8.8 and updates its two links (in pipeline.md and program-presets.md) in the same commit.
  4. The baseline is 210 core, 463 generators, and 1485 integration tests (D-107).
  5. Tests that were not test-first, and why. The integration tests of Task 3 (33) pass at once on the code of Task 2: zones carry their space type and program, never their preset, so no simplifier, aggregator, validation rule, mapping, report, or Convert2BEM reads a preset's space type; the reference found its only readers (the slot check and Presets text of PlanBuilder, and ProgramPresetGoo.ToString) by a search of src. Against the code before Task 2 they fail with PresetSpaceTypeMismatch (the reference ran them with that change set aside). Tasks 5 to 7 are Grasshopper code, checked by the build, the embedded icons, and Task 10; the casting order of Grasshopper (PreferredCast before Grasshopper's own conversion, which for a BEMGen goo without a parameterless constructor fails) was established by the reference from the IL of Grasshopper.dll and confirmed by the headless session. The build-error red phases list only the errors the compiler reports for the net8.0 target in the order it reports them.
  6. The squash of d9c3059. The reference added the parameter in 9b97dfa and, after the documentation commits, extended its PreferredCast by the case GH_ObjectWrapper { Value: ProgramPreset } (a plain preset from a script) in d9c3059, which touches only BemParameters.cs. Task 6 writes that file at its d9c3059 state, so the tree after Task 6 equals the reference after 9b97dfa except for that file, which equals d9c3059; no later commit of the reference touches it, so every later tree state equals the reference's, and the end state of src/, tests/, and scripts/ equals d9c3059, the commit the headless session ran on (Task 10, Step 1). The documents of Tasks 8 and 9, written before d9c3059, describe the cast without the wrapper case; D-109 names it.
  7. Choices of the reference (D-109). A nullable space type rather than a SpaceType.Any value (no zone would carry it, and every switch over space types would need it), a flag beside a kept type (two sources of truth), or matching by name (D-061); Presets built from the slot's space type, equal to the preset's for typed presets; one provenance key per slot given an any-program preset, none otherwise, so typed plans are unchanged; the storey elevation in provenance rather than a field on the pipeline types (preview-only data) or Storey × FloorHeight (equal floor heights assumed), read through operations of one input, so Transform Plan and the simplifiers need no change; the casting in PreferredCast of both parameters; the default preset components in their own panel with the Program accent, so their icons stay byte-identical.
  8. Provenance of multi-storey plans. StoreyElevation is one more provenance key on every plan of a MultiStoreyPlanGenerator (the stepped band): the only change visible with typed presets. Three tests that list such a provenance exactly gain the key (Task 4); no snapshot holds provenance, so the twenty-five snapshots are byte-identical.
  9. Smoke-test values. The values of Task 10 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit d9c3059, which reported the informational version 0.8.6+d9c3059…; they were not re-observed on the replay. Task 10, 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 panels and icons, the hover texts, the zone loads and windows, Program Preset and Operating Suite through AnyP, and a non-preset value wired into AnyP were not observed. The provenance line of Office Plate was cut by the harness after its second key's prefix, so only AnyProgramPreset.Core=Example Kitchen was read in full; the generator tests check every key.
  10. Icons. The 60 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.7 close-out on main 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 9, Step 5; Task 11, 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 9, Step 5 and Task 11, Step 10 printed the expected output; an independent check written apart from the plan's script found no problem in the eleven files after the close-out merge, nor on main at 3d9ec0b in the seven Markdown files the S8.7 close-out and D-108 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-109 for the realisation of D-108, which Task 11 cites in ADR-014, ADR-015, AGENTS.md, and the roadmap, and D-110 for the completion, which cites D-108 and D-109.
  14. Roadmap order. The roadmap lists its Progress lines oldest first, so Task 11 adds the S8.8 line below the S8.7 one; the stage table lists stages by number, so the S8.8 row follows S8.7, and S6's and S9's dependencies name S8.8.
  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.7.