Skip to content

S4.1 — Grasshopper inputs, presets, and icons Implementation Plan

Status: Done · Date: 2026-10-01 · Roadmap: S4.1 (revision stage, D-064) · Decisions: D-061, D-062, D-063, D-064, D-065, D-066

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 Checkpoint 1 review decisions before S5 (D-064): plan generators take one explicit preset per space type, with default preset components per space type that replace Example Residential Presets (D-061); every component with a viewport preview gets a Preview Location input (D-062); every component and parameter gets a bitmap icon drawn by a script kept in the repository (D-063). Plans, floors, and buildings stay exactly as they are.

Architecture: Lod.Core gains PresetValues (the plain values a preset is built from, with ToPreset()) and the generator base PlanGenerator<TParameters, TPresets>, whose preset record (PlanPresets) lists one PresetSlot per space type; every slot is checked before the layout is built (PresetSpaceTypeMismatch, MissingPreset). Lod.Generators adds LinearPlanPresets(DwellingUnit, Corridor, Stair). ProgramPresetSet and DuplicateSpaceType are deleted. In Lod.Grasshopper three thin components (Dwelling Unit Preset, Corridor Preset, Stair Preset) share one base class driven by the ExampleResidentialPresets values; the Linear Plan Generator gets three preset inputs; PreviewGoo carries a preview-only offset read from a shared Preview Location input; scripts/make_icons.py draws 26 PNG icons that are embedded in the plugin and loaded by IconLibrary.

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

Global Constraints

  • Gate: D-061 to D-064 are accepted (commit 8c6feaa), and this plan is committed to main together with the roadmap's S4.1 stage row before Task 1 starts. S5 starts only after S4.1 is tagged (D-064, roadmap: S5 depends on S4.1).
  • Rhino 8 (8.19 or later) only (D-003); stages exchange Grasshopper objects, not JSON (D-017).
  • Lod.Core and Lod.Generators have no RhinoCommon or Grasshopper reference; their tests run with plain dotnet test.
  • Nullable reference types on; warnings are errors; .editorconfig rules are enforced in the build; every public member of Lod.Core and Lod.Generators has XML docs (CS1591 is an error).
  • Generation is deterministic; outputs have a stable order. The icon script is deterministic for a given Pillow version.
  • Numerical tolerances come only from ToleranceSettings (ADR-004); S4.1 adds none.
  • No change to plans, floors, or buildings. Zones, surfaces, windows, loads, schedules of loads, conditioning, validation results, and provenance (apart from the order of the Presets entry, which already was dwelling unit, corridor, stair) are unchanged. All seven snapshots (linear-plan-canonical, stack-two-storeys, simplifier-SemanticMerge, simplifier-PerimeterCore, simplifier-SingleZonePerFloor, aggregator-FloorAreaMultiplier, aggregator-SingleZoneMerged) stay byte-identical. The only data change is the name of the setpoint schedules of presets built from PresetValues ("<preset name> Heating Setpoint" instead of "Example Heating Setpoint"), which no snapshot, check, or output shows.
  • No backward compatibility (D-064). Components, inputs, outputs, and core types may be deleted or changed without migration; saved definitions may break. Component GUIDs never change once merged, and a deleted component's GUID is never reused: Example Residential Presets (0239f4c9-0f19-4cf1-9d90-08db878eac8a) is retired.
  • Grasshopper components are thin adaptors (AGENTS.md): presets are built by PresetValues.ToPreset(), slots are checked by PlanGenerator; the components only read inputs, call core code, and report diagnostics.
  • The PNG icons are generated by scripts/make_icons.py and never edited by hand. A component or parameter class without an icon makes the script fail; a missing embedded icon gives Grasshopper's default icon, never an exception.
  • 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.
  • No push without the person's approval (execution handoff §6). The merge steps below end on local main; git push origin main and the tag push run only after the person approves them.
  • .gh example definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S4.1 adds none.
  • S4.1 changes these earlier code files: DiagnosticCodes.cs (S1–S4: DuplicateSpaceType deleted, PresetSpaceTypeMismatch appended, MissingPreset summary widened), ProgramPreset.cs (S1: ProgramPresetSet deleted), ExampleResidentialPresets.cs (S1), PlanGenerator.cs (S2), LinearPlanGenerator.cs (S2), ProgramTests.cs (S1), LinearPlanGeneratorTests.cs (S2), Pipeline.cs (S2), and every file in src/Lod.Grasshopper that defines a component, a parameter, the Goo, or the assembly info, plus Lod.Grasshopper.csproj.

Files

Path Change Responsibility
src/Lod.Core/Programs/PresetValues.cs Create PresetValues and PresetLoadValue: plain preset values; ToPreset() builds loads, a constant-setpoint thermostat when conditioned, the program, and the preset, reporting every error at once
tests/Lod.Core.Tests/Programs/PresetValuesTests.cs Create ToPreset behaviour, unconditioned values, overrides, aggregated errors, program rules
src/Lod.Core/Programs/ExampleResidentialPresets.cs Modify (S1 file) Example presets exposed as PresetValues (DwellingUnitValues, CorridorValues, StairValues) and as the presets built from them; All deleted
tests/Lod.Core.Tests/Programs/ExampleResidentialPresetsTests.cs Create The illustrative values and the presets built from them
src/Lod.Core/Plans/PlanPresets.cs Create PresetSlot and the abstract PlanPresets record (D-061)
src/Lod.Core/Plans/PlanGenerator.cs Modify (S2 file) PlanGenerator<TParameters, TPresets>: slot check before the layout, preset lookup through the slots
src/Lod.Core/Common/DiagnosticCodes.cs Modify (S1 file, last changed in S4) Delete DuplicateSpaceType; widen MissingPreset; append PresetSpaceTypeMismatch
src/Lod.Core/Programs/ProgramPreset.cs Modify (S1 file) Delete ProgramPresetSet; Name documented as descriptive only
src/Lod.Generators/Linear/LinearPlanGenerator.cs Modify (S2 file) LinearPlanPresets; LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>
tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs Create Slot assignment, mismatch per slot, null preset, uncovered layout space type, error order
tests/Lod.Core.Tests/Programs/ProgramTests.cs Modify (S1 file) Delete the two ProgramPresetSet tests
tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs Modify (S2 file) Presets passed as LinearPlanPresets; slot order; swapped presets; null stair
tests/Lod.Integration.Tests/Pipeline.cs Modify (S2 file) Pipeline.Presets
src/Lod.Grasshopper/Components/PresetComponents.cs Create DefaultPresetComponent and Dwelling Unit Preset, Corridor Preset, Stair Preset
src/Lod.Grasshopper/Components/ProgramComponents.cs Modify (S2 file) Example Residential Presets deleted; Program Preset Name description; icons
src/Lod.Grasshopper/Components/PipelineComponents.cs Modify (S2 file) Generator preset inputs; Preview Location on the generator, No Simplification, and the aggregator base; icons
src/Lod.Grasshopper/Components/SimplifierComponents.cs Modify (S3 file) Preview Location on the simplifier base; icons
src/Lod.Grasshopper/Components/ComponentSupport.cs Modify (S2 file) PreviewLocationParameter() and PreviewLocation(component, DA)
src/Lod.Grasshopper/Goo/BemGoo.cs Modify (S2 file) PreviewGoo.PreviewOffset, kept by Duplicate, applied to the preview only
scripts/make_icons.py Create Draws the 26 icons; fails when a class has no icon or an icon no class
src/Lod.Grasshopper/Icons/*.png Create (generated, 26 files) One 24 × 24 icon per component, parameter, and the assembly info
src/Lod.Grasshopper/IconLibrary.cs Create Loads an embedded icon by class name; null when missing
src/Lod.Grasshopper/Lod.Grasshopper.csproj Modify (S0 file, last changed in S2) Embed Icons\*.png
src/Lod.Grasshopper/BemGenAssemblyInfo.cs, Components/BemGenInfoComponent.cs, Components/Convert2BemComponent.cs, Parameters/BemParameters.cs Modify Icon => IconLibrary.For(this)
docs/architecture/pipeline.md Modify Presets, preset slots, preview location, icons, component table through S4.1
docs/architecture/domain-model.md Modify PresetValues; ProgramPresetSet and DuplicateSpaceType gone
docs/research/program-presets.md Modify Preset values, typed generator presets, default preset components, setpoint schedule names
docs/development/grasshopper-smoke-test.md Modify Append the S4.1 checklist
Directory.Build.props Modify Version 0.4.1
AGENTS.md Modify "Project state" after S4.1; make_icons.py in "Commands"
README.md Modify "Status" after S4.1
docs/plans/2026-09-30-implementation-roadmap.md Modify Progress line; PresetValues in the repository layout (the S4.1 stage row is committed with this plan)
docs/decisions/decision-log.md Modify S4.1 completion entry (number, date, and name at execution)

Test counts on net8.0 at the start of S4.1: 104 core, 17 generators, 63 integration. After Task 1: 109 / 17 / 63. After Task 2: 117 / 17 / 63. After Task 3 and at the end: 120 core, 19 generators, 63 integration (core: +5 PresetValuesTests, +8 ExampleResidentialPresetsTests, +5 PlanGeneratorTests, −2 ProgramTests; generators: +2). Grasshopper code has no unit tests; Tasks 4–7 are checked by a 0-warning build and the smoke test of Task 11.

Roadmap edits

The roadmap edits that describe S4.1 as a planned stage are committed together with this plan, before Task 1: the revision note v5 in the header, the S4.1 row of the stage table (depends on S4, tag v0.4.1) with S5 now depending on S4.1, the S4.1 branch in the stage diagram, the sentence on S4.1 below the diagram, and the S4.1 row of the detailed stage plans table. The close-out (Task 12) adds the Progress line and replaces ProgramPresetSet by PresetValues in the repository layout; the stage table has no status column, so the S4.1 row is not changed again.

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

Task Agent Why
1 PresetValues bemgen-bounded A model type fully given by the plan
2 Example presets from preset values bemgen-bounded Data moved into a new type; values unchanged
3 One typed preset per space type bemgen-complex Changes the generator base used by every plan, deletes a public type and a diagnostic code, and touches three projects at once; snapshots must stay byte-identical
4 Default preset components bemgen-bounded Thin Grasshopper components
5 Delete Example Residential Presets bemgen-bounded Deletion fully given by the plan
6 Preview Location bemgen-complex Preview geometry, model units, input indices in shared base classes
7 Icons bemgen-complex Toolchain (Python, Pillow), generated binaries, embedded resources, a visual check
8 Architecture documents bemgen-bounded Documents
9 Program presets document bemgen-bounded Documents
10 S4.1 smoke-test checklist bemgen-bounded Documents
11 Smoke test in Rhino 8 Manual (person), or the controller headless (D-056) Needs Rhino 8
12 Close-out bemgen-bounded Bookkeeping

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/programs-preset-values

Task 1: PresetValues

Files: - Create: src/Lod.Core/Programs/PresetValues.cs - Test: tests/Lod.Core.Tests/Programs/PresetValuesTests.cs

Interfaces: - Consumes (S1): LoadDefinition.Create(LoadType type, LoadBasis basis, double value, Schedule schedule, AggregationRecord? aggregation = null) : Result<LoadDefinition>; Schedule.Constant(string name, ScheduleKind kind, double value) : Result<Schedule>; Thermostat.Create(Schedule heatingSetpoint, Schedule coolingSetpoint) : Result<Thermostat>; ZoneProgram.Create(IEnumerable<LoadDefinition> loads, Thermostat? thermostat) : Result<ZoneProgram>; ProgramPreset.Create(string name, SpaceType spaceType, ZoneProgram program, double windowToWallRatio) : Result<ProgramPreset>; Result.Failure<T>(IEnumerable<Diagnostic>), Result.FromDiagnostics<T>(Func<T>, IReadOnlyCollection<Diagnostic>); diagnostic codes EmptyName, LoadValue, ScheduleValue, WindowToWallRatio, DuplicateLoadType. Test support (S1): TestPrograms.Fraction(double value, string name = "Fraction"). - Produces: - public sealed record PresetLoadValue(LoadType Type, LoadBasis Basis, double Value, Schedule Schedule). - public sealed record PresetValues(string Name, SpaceType SpaceType, double WindowToWallRatio, bool Conditioned, double HeatingSetpoint, double CoolingSetpoint, IReadOnlyList<PresetLoadValue> Loads) with public Result<ProgramPreset> ToPreset(). A conditioned preset gets constant temperature schedules "{Name} Heating Setpoint" and "{Name} Cooling Setpoint"; an unconditioned one ignores the setpoints. Every invalid value is reported: the loads, the setpoints, the program rules, and the name and WWR (checked with an empty program when the program is invalid).

  • [x] Step 1: Create the branch
git switch -c feature/programs-preset-values main
  • [x] Step 2: Write the failing tests — create tests/Lod.Core.Tests/Programs/PresetValuesTests.cs

The test preset is an office of space type Other with lighting listed before occupancy, so ToPreset must hand the loads to ZoneProgram.Create, which orders them by type. Setpoints of NaN are ignored when the values are unconditioned, and reported (ScheduleValue) when they are conditioned.

using System;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Loads;
using Lod.Core.Programs;
using Lod.Core.Schedules;
using Lod.Core.Tests.TestSupport;
using Xunit;

namespace Lod.Core.Tests.Programs;

public sealed class PresetValuesTests
{
    private static readonly PresetValues Office = new(
        "Office",
        SpaceType.Other,
        0.4,
        Conditioned: true,
        HeatingSetpoint: 20.0,
        CoolingSetpoint: 26.0,
        new[]
        {
            new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 8.0, TestPrograms.Fraction(0.5, "Office Lighting")),
            new PresetLoadValue(LoadType.Occupancy, LoadBasis.PerFloorArea, 0.1, TestPrograms.Fraction(1.0, "Office Occupancy")),
        });

    [Fact]
    public void ToPresetBuildsLoadsThermostatAndWwr()
    {
        ProgramPreset preset = Office.ToPreset().Value;

        Assert.Equal("Office", preset.Name);
        Assert.Equal(SpaceType.Other, preset.SpaceType);
        Assert.Equal(0.4, preset.WindowToWallRatio);
        Assert.Equal(
            new[] { (LoadType.Occupancy, LoadBasis.PerFloorArea, 0.1, "Office Occupancy"), (LoadType.Lighting, LoadBasis.PerFloorArea, 8.0, "Office Lighting") },
            preset.Program.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule.Name)));
        Thermostat thermostat = preset.Program.Thermostat!;
        Assert.Equal("Office Heating Setpoint", thermostat.HeatingSetpoint.Name);
        Assert.Equal("Office Cooling Setpoint", thermostat.CoolingSetpoint.Name);
        Assert.Equal(ScheduleKind.Temperature, thermostat.HeatingSetpoint.Kind);
        Assert.All(thermostat.HeatingSetpoint.Values, v => Assert.Equal(20.0, v));
        Assert.All(thermostat.CoolingSetpoint.Values, v => Assert.Equal(26.0, v));
    }

    [Fact]
    public void UnconditionedValuesHaveNoThermostatAndIgnoreSetpoints()
    {
        ProgramPreset preset = (Office with { Conditioned = false, HeatingSetpoint = double.NaN, CoolingSetpoint = double.NaN }).ToPreset().Value;

        Assert.False(preset.Program.IsConditioned);
        Assert.Null(preset.Program.Thermostat);
    }

    [Fact]
    public void OverridingOneValueChangesOnlyThatValue()
    {
        ProgramPreset original = Office.ToPreset().Value;
        ProgramPreset changed = (Office with { WindowToWallRatio = 0.25 }).ToPreset().Value;

        Assert.Equal(0.25, changed.WindowToWallRatio);
        Assert.Equal(original.Name, changed.Name);
        Assert.Equal(original.SpaceType, changed.SpaceType);
        Assert.Equal(
            original.Program.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule)),
            changed.Program.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule)));
        Assert.Equal(original.Program.Thermostat!.HeatingSetpoint.Values, changed.Program.Thermostat!.HeatingSetpoint.Values);
        Assert.Equal(original.Program.Thermostat.CoolingSetpoint.Values, changed.Program.Thermostat.CoolingSetpoint.Values);
    }

    [Fact]
    public void InvalidValuesAreReportedTogether()
    {
        PresetValues invalid = Office with
        {
            Name = " ",
            WindowToWallRatio = 1.0,
            HeatingSetpoint = double.NaN,
            Loads = new[] { new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, -1.0, TestPrograms.Fraction(1.0)) },
        };

        Result<ProgramPreset> result = invalid.ToPreset();

        Assert.False(result.IsSuccess);
        Assert.Equal(
            new[] { DiagnosticCodes.EmptyName, DiagnosticCodes.LoadValue, DiagnosticCodes.ScheduleValue, DiagnosticCodes.WindowToWallRatio },
            result.Diagnostics.Select(d => d.Code).OrderBy(c => c, StringComparer.Ordinal));
    }

    [Fact]
    public void ProgramRulesStillApply()
    {
        PresetValues duplicate = Office with
        {
            Loads = Office.Loads.Concat(new[] { new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 2.0, TestPrograms.Fraction(1.0)) }).ToArray(),
        };

        Assert.Contains(duplicate.ToPreset().Diagnostics, d => d.Code == DiagnosticCodes.DuplicateLoadType);
    }
}
  • [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.PresetValuesTests"

Expected: the build fails because PresetValues does not exist yet, reported once, for the net8.0 target:

tests\Lod.Core.Tests\Programs\PresetValuesTests.cs(14,29): error CS0246: The type or namespace name 'PresetValues' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 4: Implement — create src/Lod.Core/Programs/PresetValues.cs (full file)
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Loads;
using Lod.Core.Schedules;

namespace Lod.Core.Programs;

/// <summary>One load of a <see cref="PresetValues"/>: its type, basis, design value, and fraction schedule.</summary>
/// <param name="Type">Load type.</param>
/// <param name="Basis">Basis the value is expressed per.</param>
/// <param name="Value">Design value in the basis' unit.</param>
/// <param name="Schedule">A fraction schedule multiplying the design magnitude.</param>
public sealed record PresetLoadValue(LoadType Type, LoadBasis Basis, double Value, Schedule Schedule);

/// <summary>
/// The plain values a program preset is built from (D-061): name, space type, window-to-wall ratio, conditioning, constant setpoints, and loads.
/// Change a value with a <c>with</c> expression, then call <see cref="ToPreset"/>.
/// </summary>
/// <param name="Name">Preset name; descriptive only, never used for matching.</param>
/// <param name="SpaceType">The space type the preset applies to.</param>
/// <param name="WindowToWallRatio">WWR in [0, 1) (D-026).</param>
/// <param name="Conditioned">Whether the space is conditioned (D-038); the setpoints are ignored when it is not.</param>
/// <param name="HeatingSetpoint">Constant heating setpoint, °C.</param>
/// <param name="CoolingSetpoint">Constant cooling setpoint, °C.</param>
/// <param name="Loads">Loads; at most one per type and basis (D-047).</param>
public sealed record PresetValues(
    string Name,
    SpaceType SpaceType,
    double WindowToWallRatio,
    bool Conditioned,
    double HeatingSetpoint,
    double CoolingSetpoint,
    IReadOnlyList<PresetLoadValue> Loads)
{
    /// <summary>
    /// Builds the preset. A conditioned preset gets a thermostat of constant temperature schedules named <c>"{Name} Heating Setpoint"</c> and
    /// <c>"{Name} Cooling Setpoint"</c>. Every invalid value is reported, not only the first.
    /// </summary>
    /// <returns>The preset or validation errors.</returns>
    public Result<ProgramPreset> ToPreset()
    {
        var diagnostics = new List<Diagnostic>();
        Result<LoadDefinition>[] loads = Loads.Select(l => LoadDefinition.Create(l.Type, l.Basis, l.Value, l.Schedule)).ToArray();
        diagnostics.AddRange(loads.SelectMany(l => l.Diagnostics));

        Result<Thermostat>? thermostat = Conditioned ? CreateThermostat() : null;
        diagnostics.AddRange(thermostat?.Diagnostics ?? Array.Empty<Diagnostic>());

        bool ready = loads.All(l => l.IsSuccess) && (thermostat?.IsSuccess ?? true);
        Result<ZoneProgram>? program = ready ? ZoneProgram.Create(loads.Select(l => l.Value), thermostat?.Value) : null;
        diagnostics.AddRange(program?.Diagnostics ?? Array.Empty<Diagnostic>());

        // The name and WWR are checked even when the program is invalid, so every error is reported at once.
        ZoneProgram checkedProgram = program is { IsSuccess: true } ? program.Value : ZoneProgram.Create(Array.Empty<LoadDefinition>(), null).Value;
        Result<ProgramPreset> preset = ProgramPreset.Create(Name, SpaceType, checkedProgram, WindowToWallRatio);
        diagnostics.AddRange(preset.Diagnostics);
        return Result.FromDiagnostics(() => preset.Value, diagnostics);
    }

    private Result<Thermostat> CreateThermostat()
    {
        Result<Schedule> heating = Schedule.Constant($"{Name} Heating Setpoint", ScheduleKind.Temperature, HeatingSetpoint);
        Result<Schedule> cooling = Schedule.Constant($"{Name} Cooling Setpoint", ScheduleKind.Temperature, CoolingSetpoint);
        return heating.IsSuccess && cooling.IsSuccess
            ? Thermostat.Create(heating.Value, cooling.Value)
            : Result.Failure<Thermostat>(heating.Diagnostics.Concat(cooling.Diagnostics));
    }
}
  • [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.PresetValuesTests"

Expected:

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

Then the whole core project:

dotnet test tests/Lod.Core.Tests -c Release

Expected: Passed! - Failed: 0, Passed: 109, Skipped: 0, Total: 109 for net8.0 (104 + 5).

  • [x] Step 6: Commit
git add src/Lod.Core/Programs/PresetValues.cs tests/Lod.Core.Tests/Programs/PresetValuesTests.cs
git commit -m "feature(programs): add preset values that build a program preset"

Task 2: Example presets built from preset values

Files: - Modify: src/Lod.Core/Programs/ExampleResidentialPresets.cs (S1 file) - Test: tests/Lod.Core.Tests/Programs/ExampleResidentialPresetsTests.cs

Interfaces: - Consumes: PresetValues, PresetLoadValue (Task 1); Schedule.FromDailyProfiles and Schedule.Constant (S1). - Produces: ExampleResidentialPresets.DwellingUnitValues, CorridorValues, StairValues (PresetValues) with today's illustrative values (names, space types, WWR 0.3 / 0.2 / 0.1, dwelling unit and corridor conditioned, stair unconditioned, setpoints 21 °C and 24 °C, the same loads and load schedules in the same order); DwellingUnit, Corridor, Stair (ProgramPreset) built from them. All stays until Task 3 deletes it with ProgramPresetSet. The setpoint schedules are now named after the preset ("Example Dwelling Unit Heating Setpoint", ...); no snapshot shows them.

  • [x] Step 1: Write the failing tests — create tests/Lod.Core.Tests/Programs/ExampleResidentialPresetsTests.cs

The annual sums of the load schedules (6391.4, 2627.8, 4380, 8760 full-load hours) identify the S1 schedules without listing 8760 values: the tests prove that the values moved into PresetValues unchanged.

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Loads;
using Lod.Core.Programs;
using Xunit;

namespace Lod.Core.Tests.Programs;

public sealed class ExampleResidentialPresetsTests
{
    public static IEnumerable<object[]> Examples() => new[]
    {
        new object[] { "Example Dwelling Unit", SpaceType.DwellingUnit, 0.3, true },
        new object[] { "Example Corridor", SpaceType.Corridor, 0.2, true },
        new object[] { "Example Stair", SpaceType.Stair, 0.1, false },
    };

    [Theory]
    [MemberData(nameof(Examples))]
    public void ValuesHoldTheIllustrativeDefaults(string name, SpaceType spaceType, double wwr, bool conditioned)
    {
        PresetValues values = ValuesOf(spaceType);

        Assert.Equal(name, values.Name);
        Assert.Equal(spaceType, values.SpaceType);
        Assert.Equal(wwr, values.WindowToWallRatio);
        Assert.Equal(conditioned, values.Conditioned);
        Assert.Equal(21.0, values.HeatingSetpoint);
        Assert.Equal(24.0, values.CoolingSetpoint);
    }

    [Fact]
    public void DwellingUnitLoads()
    {
        Assert.Equal(
            new[]
            {
                (LoadType.Occupancy, LoadBasis.PerFloorArea, 0.03, "Example Dwelling Occupancy", 6391.4),
                (LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, "Example Dwelling Lighting", 2627.8),
                (LoadType.ElectricEquipment, LoadBasis.PerFloorArea, 5.0, "Example Dwelling Equipment", 4380.0),
                (LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, "Example Always On", 8760.0),
            },
            Describe(ExampleResidentialPresets.DwellingUnitValues));
    }

    [Fact]
    public void CorridorAndStairLoads()
    {
        Assert.Equal(
            new[]
            {
                (LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, "Example Always On", 8760.0),
                (LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, "Example Always On", 8760.0),
            },
            Describe(ExampleResidentialPresets.CorridorValues));
        Assert.Equal(
            new[]
            {
                (LoadType.Lighting, LoadBasis.PerFloorArea, 3.0, "Example Always On", 8760.0),
                (LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, "Example Always On", 8760.0),
            },
            Describe(ExampleResidentialPresets.StairValues));
    }

    [Theory]
    [MemberData(nameof(Examples))]
    public void PresetsAreBuiltFromTheirValues(string name, SpaceType spaceType, double wwr, bool conditioned)
    {
        ProgramPreset preset = PresetOf(spaceType);
        PresetValues values = ValuesOf(spaceType);

        Assert.Equal(name, preset.Name);
        Assert.Equal(spaceType, preset.SpaceType);
        Assert.Equal(wwr, preset.WindowToWallRatio);
        Assert.Equal(conditioned, preset.Program.IsConditioned);
        Assert.Equal(
            values.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule)),
            preset.Program.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule)));
        if (conditioned)
        {
            Assert.Equal($"{name} Heating Setpoint", preset.Program.Thermostat!.HeatingSetpoint.Name);
            Assert.Equal($"{name} Cooling Setpoint", preset.Program.Thermostat.CoolingSetpoint.Name);
            Assert.All(preset.Program.Thermostat.HeatingSetpoint.Values, v => Assert.Equal(21.0, v));
            Assert.All(preset.Program.Thermostat.CoolingSetpoint.Values, v => Assert.Equal(24.0, v));
        }
    }

    private static PresetValues ValuesOf(SpaceType spaceType) => spaceType switch
    {
        SpaceType.DwellingUnit => ExampleResidentialPresets.DwellingUnitValues,
        SpaceType.Corridor => ExampleResidentialPresets.CorridorValues,
        _ => ExampleResidentialPresets.StairValues,
    };

    private static ProgramPreset PresetOf(SpaceType spaceType) => spaceType switch
    {
        SpaceType.DwellingUnit => ExampleResidentialPresets.DwellingUnit,
        SpaceType.Corridor => ExampleResidentialPresets.Corridor,
        _ => ExampleResidentialPresets.Stair,
    };

    private static IEnumerable<(LoadType Type, LoadBasis Basis, double Value, string Schedule, double AnnualFraction)> Describe(PresetValues values) =>
        values.Loads.Select(l => (l.Type, l.Basis, l.Value, l.Schedule.Name, Math.Round(l.Schedule.Values.Sum(), 6)));
}
  • [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.ExampleResidentialPresetsTests"

Expected: the build fails, each error reported once, for the net8.0 target:

tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(44,48): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'DwellingUnitValues'
tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(56,48): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'CorridorValues'
tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(63,48): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'StairValues'
tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(91,61): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'DwellingUnitValues'
tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(92,57): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'CorridorValues'
tests\Lod.Core.Tests\Programs\ExampleResidentialPresetsTests.cs(93,40): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'StairValues'
  • [x] Step 3: Implement — replace src/Lod.Core/Programs/ExampleResidentialPresets.cs (full file)

The private Build and Load helpers are replaced by Values, which creates PresetValues with the shared setpoints 21 °C and 24 °C; the presets are Values.ToPreset().Value. The profiles, constants, names, and load values are unchanged; Constant now always makes a fraction schedule, because the setpoint schedules come from PresetValues.

using System;
using Lod.Core.Loads;
using Lod.Core.Schedules;

namespace Lod.Core.Programs;

/// <summary>
/// Illustrative residential presets for development and tests. The values, and which spaces are conditioned, are round-number choices for
/// testing; they are NOT taken from the DOE prototypes or any standard. Sourced presets are a separate research input
/// (see docs/research/program-presets.md).
/// </summary>
public static class ExampleResidentialPresets
{
    private const DayOfWeek FirstDayOfYear = DayOfWeek.Monday;
    private const double HeatingSetpoint = 21.0;
    private const double CoolingSetpoint = 24.0;

    private static readonly double[] DwellingOccupancyWeekday =
    {
        1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.5, 0.3, 0.2, 0.2, 0.2,
        0.3, 0.2, 0.2, 0.3, 0.5, 0.7, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0,
    };

    private static readonly double[] DwellingOccupancyWeekend =
    {
        1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.7, 0.6, 0.6,
        0.6, 0.6, 0.6, 0.7, 0.8, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0,
    };

    private static readonly double[] DwellingLightingWeekday =
    {
        0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.4, 0.4, 0.2, 0.1, 0.1, 0.1,
        0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 0.8, 0.9, 0.9, 0.7, 0.4, 0.2,
    };

    private static readonly double[] DwellingLightingWeekend =
    {
        0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.4, 0.3, 0.2, 0.2,
        0.2, 0.2, 0.2, 0.2, 0.3, 0.5, 0.8, 0.9, 0.9, 0.7, 0.4, 0.2,
    };

    /// <summary>Values of the dwelling unit preset: conditioned; occupancy, lighting, equipment, infiltration; WWR 0.3.</summary>
    public static PresetValues DwellingUnitValues { get; } = Values(
        "Example Dwelling Unit",
        SpaceType.DwellingUnit,
        0.3,
        conditioned: true,
        new PresetLoadValue(LoadType.Occupancy, LoadBasis.PerFloorArea, 0.03, Profile("Example Dwelling Occupancy", DwellingOccupancyWeekday, DwellingOccupancyWeekend)),
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, Profile("Example Dwelling Lighting", DwellingLightingWeekday, DwellingLightingWeekend)),
        new PresetLoadValue(LoadType.ElectricEquipment, LoadBasis.PerFloorArea, 5.0, Constant("Example Dwelling Equipment", 0.5)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Values of the corridor preset: conditioned; lighting always on, infiltration; WWR 0.2.</summary>
    public static PresetValues CorridorValues { get; } = Values(
        "Example Corridor",
        SpaceType.Corridor,
        0.2,
        conditioned: true,
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, Constant("Example Always On", 1.0)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Values of the stair preset: unconditioned; lighting always on, infiltration; WWR 0.1.</summary>
    public static PresetValues StairValues { get; } = Values(
        "Example Stair",
        SpaceType.Stair,
        0.1,
        conditioned: false,
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 3.0, Constant("Example Always On", 1.0)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Dwelling unit preset, built from <see cref="DwellingUnitValues"/>.</summary>
    public static ProgramPreset DwellingUnit { get; } = DwellingUnitValues.ToPreset().Value;

    /// <summary>Corridor preset, built from <see cref="CorridorValues"/>.</summary>
    public static ProgramPreset Corridor { get; } = CorridorValues.ToPreset().Value;

    /// <summary>Stair preset, built from <see cref="StairValues"/>.</summary>
    public static ProgramPreset Stair { get; } = StairValues.ToPreset().Value;

    /// <summary>All example presets as a set.</summary>
    public static ProgramPresetSet All { get; } = ProgramPresetSet.Create(new[] { DwellingUnit, Corridor, Stair }).Value;

    private static PresetValues Values(string name, SpaceType spaceType, double windowToWallRatio, bool conditioned, params PresetLoadValue[] loads) =>
        new(name, spaceType, windowToWallRatio, conditioned, HeatingSetpoint, CoolingSetpoint, loads);

    private static Schedule Constant(string name, double value) =>
        Schedule.Constant(name, ScheduleKind.Fraction, value).Value;

    private static Schedule Profile(string name, double[] weekday, double[] weekend) =>
        Schedule.FromDailyProfiles(name, ScheduleKind.Fraction, weekday, weekend, FirstDayOfYear).Value;
}
  • [x] Step 4: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Programs.ExampleResidentialPresetsTests"

Expected (2 facts and 2 × 3 theory cases):

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

Then the whole solution, which shows that the presets built from values give the same plans, floors, and buildings:

dotnet test BEMGen.sln -c Release

Expected, on net8.0:

Passed!  - Failed:     0, Passed:   117, Skipped:     0, Total:   117, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed!  - Failed:     0, Passed:    17, Skipped:     0, Total:    17, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed!  - Failed:     0, Passed:    63, Skipped:     0, Total:    63, Duration: … - Lod.Integration.Tests.dll (net8.0)

(The order of the lines may differ.) git status --short lists no *.received.txt: all seven snapshots are unchanged.

  • [x] Step 5: Commit
git add src/Lod.Core/Programs/ExampleResidentialPresets.cs tests/Lod.Core.Tests/Programs/ExampleResidentialPresetsTests.cs
git commit -m "feature(programs): build the example presets from preset values"
  • [x] Step 6: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/programs-preset-values
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/programs-preset-values
git branch -d feature/programs-preset-values

Expected: verify ends with VERIFY PASSED; its test lines show 117 core, 17 generators, and 63 integration tests passed on net8.0. git pull --ff-only fails only if origin/main has commits that local main does not; then stop and ask the person. Do not push: git push origin main runs only after the person approves it.


Slice B — feature/generators-typed-presets

Task 3: One typed preset per space type

Files: - Create: src/Lod.Core/Plans/PlanPresets.cs - Modify: src/Lod.Core/Plans/PlanGenerator.cs (S2 file), src/Lod.Core/Common/DiagnosticCodes.cs (S1 file, last changed in S4), src/Lod.Core/Programs/ProgramPreset.cs (S1 file), src/Lod.Core/Programs/ExampleResidentialPresets.cs (Task 2), src/Lod.Generators/Linear/LinearPlanGenerator.cs (S2 file) - Modify: src/Lod.Grasshopper/Components/PipelineComponents.cs (S2 file), src/Lod.Grasshopper/Components/ProgramComponents.cs (S2 file), so that the solution still builds - Test: create tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs; modify tests/Lod.Core.Tests/Programs/ProgramTests.cs (S1 file), tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs (S2 file), tests/Lod.Integration.Tests/Pipeline.cs (S2 file)

Interfaces: - Consumes (S2): PlanGenerator<TParameters> with Generate(TParameters parameters, ProgramPresetSet presets) : Result<IGeneratedPlan>, the protected members Name, Validate(TParameters), Layout(TParameters) : PlanLayout, Describe(TParameters); PlanLayout(Polygon2 Footprint, IReadOnlyList<PlannedZone> Zones); PlannedZone(ZoneId Id, string Name, SpaceType SpaceType, Polygon2 Footprint); PlanParameters(double FloorHeight, double OrientationDegrees); Polygon2.Rectangle(double minX, double minY, double maxX, double maxY); ExampleResidentialPresets.DwellingUnit, Corridor, Stair (Task 2). Grasshopper (S2): ProgramPresetGoo.Value, ComponentSupport.Unwrap<T>(GH_Component, Result<T>) : T?. - Produces: - public sealed record PresetSlot(string Input, SpaceType SpaceType, ProgramPreset Preset) and public abstract record PlanPresets { public abstract IReadOnlyList<PresetSlot> Slots { get; } } in Lod.Core.Plans. - public abstract class PlanGenerator<TParameters, TPresets> where TParameters : PlanParameters where TPresets : PlanPresets with public Result<IGeneratedPlan> Generate(TParameters parameters, TPresets presets). Before the layout, every slot is checked: a null preset is MissingPreset ("Input {Input} needs a {SpaceType} preset, got none.", subject the input), a preset of another space type is PresetSpaceTypeMismatch ("Input {Input} needs a {SpaceType} preset, got '{Name}' ({SpaceType}).", subject the input), one error per wrong slot, reported together with the parameter errors. A zone gets the preset of the first slot of its space type; a layout space type without a slot stays MissingPreset with the zone ID as subject. Provenance Presets lists SpaceType:Name in slot order. - DiagnosticCodes.PresetSpaceTypeMismatch; DiagnosticCodes.DuplicateSpaceType deleted. - ProgramPresetSet and ExampleResidentialPresets.All deleted. - public sealed record LinearPlanPresets(ProgramPreset DwellingUnit, ProgramPreset Corridor, ProgramPreset Stair) : PlanPresets with slots DwellingUnit, Corridor, Stair in that order; LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>. - Test support: LinearPlanGeneratorTests.Examples and Pipeline.Presets (the example presets as LinearPlanPresets). - Linear Plan Generator (GUID unchanged): the list input Presets is replaced by three required item inputs Dwelling Unit (DU), Corridor (Co), Stair (St) at indices 7, 8, and 9. Example Residential Presets outputs the three presets without All until Task 5 deletes it.

  • [x] Step 1: Create the branch
git switch -c feature/generators-typed-presets main
  • [x] Step 2: Write the failing core tests

Create tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs. It uses a private generator that places one dwelling unit U1 and one corridor C1 and takes a preset record with the slots Unit and Hall, so the base class is tested without Lod.Generators. UnitOnlyPresets lists only the Unit slot, which leaves the corridor zone without a preset.

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

namespace Lod.Core.Tests.Plans;

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

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

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

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

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

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

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

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

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

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

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

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

    private sealed record TwoZoneParameters(double FloorHeight)
        : 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 4 m × 8 m plan: one dwelling unit <c>U1</c> west of one corridor <c>C1</c>.</summary>
    private sealed class TwoZoneGenerator : PlanGenerator<TwoZoneParameters, TwoZonePresets>
    {
        public TwoZoneGenerator()
            : base(ToleranceSettings.Default)
        {
        }

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

        protected override IEnumerable<KeyValuePair<string, string>> Describe(TwoZoneParameters parameters) => Enumerable.Empty<KeyValuePair<string, string>>();
    }
}

Replace tests/Lod.Core.Tests/Programs/ProgramTests.cs with the following full file. The two tests at its end, PresetSetRejectsDuplicateSpaceTypes and ExamplePresetsCoverTheLinearPlanSpaceTypes, are deleted: the set they test is deleted in this task, and Task 2's ExampleResidentialPresetsTests and this task's slot tests cover what they checked. Nothing else changes.

using System.Linq;
using Lod.Core.Common;
using Lod.Core.Loads;
using Lod.Core.Programs;
using Lod.Core.Tests.TestSupport;
using Xunit;

namespace Lod.Core.Tests.Programs;

public sealed class ProgramTests
{
    [Fact]
    public void LoadRejectsNegativeValues()
    {
        Assert.Contains(
            LoadDefinition.Create(LoadType.Lighting, LoadBasis.PerFloorArea, -1.0, TestPrograms.Fraction(1.0)).Diagnostics,
            d => d.Code == DiagnosticCodes.LoadValue);
    }

    [Fact]
    public void OccupancyCannotBePerPerson()
    {
        Assert.Contains(
            LoadDefinition.Create(LoadType.Occupancy, LoadBasis.PerPerson, 1.0, TestPrograms.Fraction(1.0)).Diagnostics,
            d => d.Code == DiagnosticCodes.LoadBasisNotAllowed);
    }

    [Fact]
    public void LoadNeedsAFractionSchedule()
    {
        Assert.Contains(
            LoadDefinition.Create(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, TestPrograms.Temperature(21.0)).Diagnostics,
            d => d.Code == DiagnosticCodes.ScheduleKindMismatch);
    }

    [Fact]
    public void ProgramOrdersLoadsByType()
    {
        ZoneProgram program = TestPrograms.Program(
            loads: new[]
            {
                TestPrograms.Load(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3),
                TestPrograms.Load(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0),
            });

        Assert.Equal(LoadType.Lighting, program.Loads[0].Type);
        Assert.Equal(LoadType.Infiltration, program.Loads[1].Type);
        Assert.Null(program.Find(LoadType.Occupancy, LoadBasis.PerFloorArea));
    }

    [Fact]
    public void ProgramRejectsTheSameLoadTypeAndBasisTwice()
    {
        Result<ZoneProgram> result = ZoneProgram.Create(
            new[]
            {
                TestPrograms.Load(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0),
                TestPrograms.Load(LoadType.Lighting, LoadBasis.PerFloorArea, 2.0),
            },
            null);

        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.DuplicateLoadType);
    }

    [Fact]
    public void ProgramAcceptsOneComponentPerBasis()
    {
        ZoneProgram program = ZoneProgram.Create(
            new[]
            {
                TestPrograms.Load(LoadType.Ventilation, LoadBasis.PerFloorArea, 1.0),
                TestPrograms.Load(LoadType.Occupancy, LoadBasis.PerFloorArea, 0.05),
                TestPrograms.Load(LoadType.Ventilation, LoadBasis.PerPerson, 10.0),
            },
            null).Value;

        Assert.Equal(
            new[] { (LoadType.Occupancy, LoadBasis.PerFloorArea), (LoadType.Ventilation, LoadBasis.PerFloorArea), (LoadType.Ventilation, LoadBasis.PerPerson) },
            program.Loads.Select(l => (l.Type, l.Basis)));
        Assert.Equal(10.0, program.Find(LoadType.Ventilation, LoadBasis.PerPerson)!.Value);
    }

    [Fact]
    public void PerPersonLoadsNeedOccupancy()
    {
        Result<ZoneProgram> result = ZoneProgram.Create(
            new[] { TestPrograms.Load(LoadType.Ventilation, LoadBasis.PerPerson, 30.0) },
            null);

        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.PerPersonWithoutOccupancy);
    }

    [Fact]
    public void SetpointsNeedTemperatureSchedules()
    {
        Result<Thermostat> result = Thermostat.Create(TestPrograms.Fraction(1.0), TestPrograms.Temperature(24.0));

        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.ScheduleKindMismatch);
    }

    [Fact]
    public void ProgramWithoutThermostatIsUnconditioned()
    {
        ZoneProgram program = TestPrograms.Unconditioned(TestPrograms.Load(LoadType.Lighting, LoadBasis.PerFloorArea, 3.0));

        Assert.False(program.IsConditioned);
        Assert.Null(program.Thermostat);
        Assert.True(TestPrograms.Program().IsConditioned);
    }

    [Theory]
    [InlineData(-0.1)]
    [InlineData(1.0)]
    public void PresetRejectsWwrOutsideRange(double wwr)
    {
        Result<ProgramPreset> result = ProgramPreset.Create("Unit", SpaceType.DwellingUnit, TestPrograms.Program(), wwr);

        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.WindowToWallRatio);
    }
}
  • [x] Step 3: Run the core tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release

Expected: the build fails, each error reported once, for the net8.0 target:

tests\Lod.Core.Tests\Plans\PlanGeneratorTests.cs(73,77): error CS0246: The type or namespace name 'PlanPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Plans\PlanGeneratorTests.cs(75,39): error CS0246: The type or namespace name 'PresetSlot' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Plans\PlanGeneratorTests.cs(84,39): error CS0246: The type or namespace name 'PresetSlot' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Plans\PlanGeneratorTests.cs(88,45): error CS0305: Using the generic type 'PlanGenerator<TParameters>' requires 1 type arguments
  • [x] Step 4: Write the failing generator and integration tests

Replace tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs with the following full file. Changes: the example presets are the static Examples (LinearPlanPresets) and every call passes them; the window test reads the WWR from the slot of the zone's space type; two new facts check the slot order and that swapped dwelling-unit and corridor presets give one PresetSpaceTypeMismatch per wrong slot; MissingPresetIsAnError now passes a null stair and expects MissingPreset with the subject Stair (the input), not ST (the zone).

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

namespace Lod.Generators.Tests.Linear;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    private IGeneratedPlan Generate(LinearPlanParameters parameters) => _generator.Generate(parameters, Examples).Value;
}

Replace tests/Lod.Integration.Tests/Pipeline.cs with the following full file (new field Presets; Plan passes it):

using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Generators.Linear;

namespace Lod.Integration.Tests;

/// <summary>Shared pipeline steps for integration tests.</summary>
internal static class Pipeline
{
    /// <summary>24 m × 18 m, 4 m stair, two 10 m units per row, 3 m storeys.</summary>
    public static readonly LinearPlanParameters Canonical = new(24.0, 8.0, 2.0, 10.0, 4.0, 3.0, 0.0);

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

    public static ToleranceSettings Tolerances => ToleranceSettings.Default;

    public static IGeneratedPlan Plan(LinearPlanParameters? parameters = null) =>
        new LinearPlanGenerator(Tolerances).Generate(parameters ?? Canonical, Presets).Value;

    public static IFloor Floor(IPlanSimplifier simplifier, LinearPlanParameters? parameters = null) =>
        simplifier.Simplify(Plan(parameters)).Value;

    public static IFloor DetailedFloor(LinearPlanParameters? parameters = null) => Floor(new NoSimplification(), parameters);
}
  • [x] Step 5: Run the generator and integration tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release
dotnet test tests/Lod.Integration.Tests -c Release

Expected: both builds fail, each once for the net8.0 target:

tests\Lod.Generators.Tests\Linear\LinearPlanGeneratorTests.cs(25,28): error CS0246: The type or namespace name 'LinearPlanPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\Pipeline.cs(16,28): error CS0246: The type or namespace name 'LinearPlanPresets' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 6: Implement the preset slots — create 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.</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>The preset inputs in a fixed order; the order is kept in provenance.</summary>
    public abstract IReadOnlyList<PresetSlot> Slots { get; }
}
  • [x] Step 7: Generalise the generator base — replace src/Lod.Core/Plans/PlanGenerator.cs (full file)

Changes to the S2 file: the second type parameter TPresets : PlanPresets; Generate takes TPresets; CheckSlots adds its errors after the parameter errors, so all input errors are reported together before any layout is built; the private Find replaces ProgramPresetSet.Find and returns the preset of the first slot with the zone's space type; the Presets provenance entry lists the slots in their order. The layout, surfaces, windows, and the remaining provenance are unchanged.

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

namespace Lod.Core.Plans;

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

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

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

/// <summary>
/// Base class of plan generators (spec §5). Subclasses only validate their parameters and lay out zones; this class assigns program presets,
/// builds surfaces, places centered windows from each preset's WWR, 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.</summary>
    /// <param name="tolerances">Tolerances.</param>
    protected PlanGenerator(ToleranceSettings tolerances)
    {
        Tolerances = tolerances;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// <summary>Whether a value is finite and positive.</summary>
    /// <param name="value">The value.</param>
    /// <returns><c>true</c> when positive and finite.</returns>
    protected static bool IsPositive(double value) => value > 0 && !double.IsInfinity(value);
}
  • [x] Step 8: Update the diagnostic codes — replace src/Lod.Core/Common/DiagnosticCodes.cs (full file)

Three changes: delete DuplicateSpaceType (its only producer, ProgramPresetSet.Create, is deleted in Step 9; no code reuses the name, D-064); widen the summary of MissingPreset, which now also covers an empty slot; append PresetSpaceTypeMismatch after FloorTypeSplit.

namespace Lod.Core.Common;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// <summary>No single target wall contains a source window (D-039).</summary>
    public const string WindowNotHosted = "WindowNotHosted";

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

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

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

    /// <summary>A preset given to a generator's preset input has a different space type than the input feeds (D-061).</summary>
    public const string PresetSpaceTypeMismatch = "PresetSpaceTypeMismatch";
}
  • [x] Step 9: Delete ProgramPresetSet — replace src/Lod.Core/Programs/ProgramPreset.cs (full file)

The class ProgramPresetSet and the usings only it needed (System, System.Linq) are deleted; the summary of Name says that the name is descriptive only (D-061). ProgramPreset itself is unchanged.

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).
/// </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.</summary>
    public SpaceType SpaceType { get; }

    /// <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);
    }
}
  • [x] Step 10: Delete All — replace src/Lod.Core/Programs/ExampleResidentialPresets.cs (full file)

The property All and the using Lod.Core.Common; it needed are deleted; nothing else changes.

using System;
using Lod.Core.Loads;
using Lod.Core.Schedules;

namespace Lod.Core.Programs;

/// <summary>
/// Illustrative residential presets for development and tests. The values, and which spaces are conditioned, are round-number choices for
/// testing; they are NOT taken from the DOE prototypes or any standard. Sourced presets are a separate research input
/// (see docs/research/program-presets.md).
/// </summary>
public static class ExampleResidentialPresets
{
    private const DayOfWeek FirstDayOfYear = DayOfWeek.Monday;
    private const double HeatingSetpoint = 21.0;
    private const double CoolingSetpoint = 24.0;

    private static readonly double[] DwellingOccupancyWeekday =
    {
        1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.5, 0.3, 0.2, 0.2, 0.2,
        0.3, 0.2, 0.2, 0.3, 0.5, 0.7, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0,
    };

    private static readonly double[] DwellingOccupancyWeekend =
    {
        1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.7, 0.6, 0.6,
        0.6, 0.6, 0.6, 0.7, 0.8, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0,
    };

    private static readonly double[] DwellingLightingWeekday =
    {
        0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.4, 0.4, 0.2, 0.1, 0.1, 0.1,
        0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 0.8, 0.9, 0.9, 0.7, 0.4, 0.2,
    };

    private static readonly double[] DwellingLightingWeekend =
    {
        0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.4, 0.3, 0.2, 0.2,
        0.2, 0.2, 0.2, 0.2, 0.3, 0.5, 0.8, 0.9, 0.9, 0.7, 0.4, 0.2,
    };

    /// <summary>Values of the dwelling unit preset: conditioned; occupancy, lighting, equipment, infiltration; WWR 0.3.</summary>
    public static PresetValues DwellingUnitValues { get; } = Values(
        "Example Dwelling Unit",
        SpaceType.DwellingUnit,
        0.3,
        conditioned: true,
        new PresetLoadValue(LoadType.Occupancy, LoadBasis.PerFloorArea, 0.03, Profile("Example Dwelling Occupancy", DwellingOccupancyWeekday, DwellingOccupancyWeekend)),
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, Profile("Example Dwelling Lighting", DwellingLightingWeekday, DwellingLightingWeekend)),
        new PresetLoadValue(LoadType.ElectricEquipment, LoadBasis.PerFloorArea, 5.0, Constant("Example Dwelling Equipment", 0.5)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Values of the corridor preset: conditioned; lighting always on, infiltration; WWR 0.2.</summary>
    public static PresetValues CorridorValues { get; } = Values(
        "Example Corridor",
        SpaceType.Corridor,
        0.2,
        conditioned: true,
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 5.0, Constant("Example Always On", 1.0)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Values of the stair preset: unconditioned; lighting always on, infiltration; WWR 0.1.</summary>
    public static PresetValues StairValues { get; } = Values(
        "Example Stair",
        SpaceType.Stair,
        0.1,
        conditioned: false,
        new PresetLoadValue(LoadType.Lighting, LoadBasis.PerFloorArea, 3.0, Constant("Example Always On", 1.0)),
        new PresetLoadValue(LoadType.Infiltration, LoadBasis.AirChangesPerHour, 0.3, Constant("Example Always On", 1.0)));

    /// <summary>Dwelling unit preset, built from <see cref="DwellingUnitValues"/>.</summary>
    public static ProgramPreset DwellingUnit { get; } = DwellingUnitValues.ToPreset().Value;

    /// <summary>Corridor preset, built from <see cref="CorridorValues"/>.</summary>
    public static ProgramPreset Corridor { get; } = CorridorValues.ToPreset().Value;

    /// <summary>Stair preset, built from <see cref="StairValues"/>.</summary>
    public static ProgramPreset Stair { get; } = StairValues.ToPreset().Value;

    private static PresetValues Values(string name, SpaceType spaceType, double windowToWallRatio, bool conditioned, params PresetLoadValue[] loads) =>
        new(name, spaceType, windowToWallRatio, conditioned, HeatingSetpoint, CoolingSetpoint, loads);

    private static Schedule Constant(string name, double value) =>
        Schedule.Constant(name, ScheduleKind.Fraction, value).Value;

    private static Schedule Profile(string name, double[] weekday, double[] weekend) =>
        Schedule.FromDailyProfiles(name, ScheduleKind.Fraction, weekday, weekend, FirstDayOfYear).Value;
}
  • [x] Step 11: Build the core library and run its tests
dotnet build src/Lod.Core -c Release
dotnet test tests/Lod.Core.Tests -c Release

Expected: Build succeeded. with 0 Warning(s) and 0 Error(s); Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120 for net8.0 (117 + 5 − 2). The generators library does not build yet:

dotnet build src/Lod.Generators -c Release

Expected, once:

src\Lod.Generators\Linear\LinearPlanGenerator.cs(37,43): error CS0305: Using the generic type 'PlanGenerator<TParameters, TPresets>' requires 2 type arguments
  • [x] Step 12: Add LinearPlanPresets — replace src/Lod.Generators/Linear/LinearPlanGenerator.cs (full file)

LinearPlanPresets lists its slots with the input names DwellingUnit, Corridor, and Stair (the property names), which appear in messages and as diagnostic subjects. The generator only changes its base class.

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

namespace Lod.Generators.Linear;

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

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

/// <summary>
/// The simplest linear block (S2): a rectangle with a full-depth stair at the west end and a double-loaded corridor running east to the
/// façade, with one row of equal dwelling units on each side. One zone per dwelling unit (D-009). Refined in S8 (typologies) from the precedent study (S7).
/// </summary>
/// <remarks>
/// Zone IDs: <c>ST</c> stair, <c>CO</c> corridor, <c>US1..USn</c> south units west to east, <c>UN1..UNn</c> north units west to east.
/// </remarks>
public sealed class LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public LinearPlanGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

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

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

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

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

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

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

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

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(LinearPlanParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.Length), Provenance.Format(p.Length)),
        new KeyValuePair<string, string>(nameof(p.UnitDepth), Provenance.Format(p.UnitDepth)),
        new KeyValuePair<string, string>(nameof(p.CorridorWidth), Provenance.Format(p.CorridorWidth)),
        new KeyValuePair<string, string>(nameof(p.TargetUnitWidth), Provenance.Format(p.TargetUnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairLength), Provenance.Format(p.StairLength)),
    };
}
  • [x] Step 13: Run the generator and integration tests
dotnet test tests/Lod.Generators.Tests -c Release
dotnet test tests/Lod.Integration.Tests -c Release

Expected: Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19 (17 + 2) and Passed! - Failed: 0, Passed: 63, Skipped: 0, Total: 63 for net8.0. The snapshot tests pass, so the plan, every floor, and every building are unchanged.

  • [x] Step 14: Adapt the Grasshopper components

The plugin does not build yet:

dotnet build BEMGen.sln -c Release

Expected, each once:

src\Lod.Grasshopper\Components\ProgramComponents.cs(217,53): error CS0117: 'ExampleResidentialPresets' does not contain a definition for 'All'
src\Lod.Grasshopper\Components\PipelineComponents.cs(68,9): error CS0246: The type or namespace name 'ProgramPresetSet' could not be found (are you missing a using directive or an assembly reference?)
src\Lod.Grasshopper\Components\PipelineComponents.cs(68,63): error CS0103: The name 'ProgramPresetSet' does not exist in the current context
src\Lod.Grasshopper\Components\PipelineComponents.cs(75,134): error CS1503: Argument 2: cannot convert from 'ProgramPresetSet?' to 'Lod.Generators.Linear.LinearPlanPresets'

Replace src/Lod.Grasshopper/Components/PipelineComponents.cs with the following full file. In Linear Plan Generator the list input Presets becomes the three required item inputs Dwelling Unit (DU), Corridor (Co), and Stair (St) at indices 7–9 (D-061); SolveInstance returns without output when one of them has no data, which Grasshopper reports as its warning that the input failed to collect data, and otherwise builds LinearPlanPresets and reports the generator's diagnostics, including PresetSpaceTypeMismatch. The GUID and the other inputs are unchanged.

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: 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 => null;

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

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

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 => null;

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

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

/// <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 => null;

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

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

    protected abstract IFloorAggregator CreateAggregator();
}

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 => null;

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

Replace src/Lod.Grasshopper/Components/ProgramComponents.cs with the following full file. Only Example Residential Presets changes: it lists the three presets instead of reading All. Task 5 deletes it.

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.Core.Schedules;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

public sealed class ScheduleComponent : GH_Component
{
    public ScheduleComponent()
        : base("Schedule", "Sch", "An 8760-hour schedule from a 24-hour weekday and weekend profile (holidays not modelled).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("1be96980-e9eb-4636-9ce6-4e5b6a301d1f");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Schedule name.", GH_ParamAccess.item);
        pManager.AddTextParameter("Kind", "K", $"Value meaning: {ComponentSupport.Names<ScheduleKind>()}.", GH_ParamAccess.item, nameof(ScheduleKind.Fraction));
        pManager.AddNumberParameter("Weekday", "Wd", "24 hourly values for Monday to Friday.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Weekend", "We", "24 hourly values for Saturday and Sunday.", GH_ParamAccess.list);
        pManager.AddTextParameter("First Day", "D", "Weekday of 1 January.", GH_ParamAccess.item, nameof(DayOfWeek.Monday));
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "The schedule.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string name = string.Empty;
        string kindText = string.Empty;
        string dayText = string.Empty;
        var weekday = new List<double>();
        var weekend = new List<double>();
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref kindText) || !DA.GetDataList(2, weekday) || !DA.GetDataList(3, weekend) || !DA.GetData(4, ref dayText))
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, kindText, out ScheduleKind kind) || !ComponentSupport.TryParse(this, dayText, out DayOfWeek firstDay))
        {
            return;
        }

        Schedule? schedule = ComponentSupport.Unwrap(this, Schedule.FromDailyProfiles(name, kind, weekday, weekend, firstDay));
        if (schedule is not null)
        {
            DA.SetData(0, new ScheduleGoo(schedule));
        }
    }
}

public sealed class LoadComponent : GH_Component
{
    public LoadComponent()
        : base("Load", "Load", "A load: design value in its basis and a fraction schedule. Magnitudes are people, W, or m³/h.", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("c07304be-fe9c-4810-ba1d-1cac1710c386");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Type", "T", $"Load type: {ComponentSupport.Names<LoadType>()}.", GH_ParamAccess.item);
        pManager.AddTextParameter("Basis", "B", $"Basis: {ComponentSupport.Names<LoadBasis>()}.", GH_ParamAccess.item);
        pManager.AddNumberParameter("Value", "V", "Design value in the basis' unit.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "A fraction schedule.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new LoadParameter(), "Load", "L", "The load.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string typeText = string.Empty;
        string basisText = string.Empty;
        double value = 0.0;
        ScheduleGoo? schedule = null;
        if (!DA.GetData(0, ref typeText) || !DA.GetData(1, ref basisText) || !DA.GetData(2, ref value) || !DA.GetData(3, ref schedule) || schedule is null)
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, typeText, out LoadType type) || !ComponentSupport.TryParse(this, basisText, out LoadBasis basis))
        {
            return;
        }

        LoadDefinition? load = ComponentSupport.Unwrap(this, LoadDefinition.Create(type, basis, value, schedule.Value));
        if (load is not null)
        {
            DA.SetData(0, new LoadGoo(load));
        }
    }
}

public sealed class ProgramPresetComponent : GH_Component
{
    public ProgramPresetComponent()
        : base("Program Preset", "Preset", "Loads, conditioning and setpoints, and window-to-wall ratio for one space type (D-024, D-038).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("56ca1eba-abed-4dbb-a9a7-09d8b5806231");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Preset name.", GH_ParamAccess.item);
        pManager.AddTextParameter("Space Type", "ST", $"Space type: {ComponentSupport.Names<SpaceType>()}.", GH_ParamAccess.item);
        pManager.AddParameter(new LoadParameter(), "Loads", "L", "Loads; at most one per type and basis (D-047).", GH_ParamAccess.list);
        pManager.AddBooleanParameter("Conditioned", "Cond", "Whether the space is conditioned (D-038). Conditioned spaces need both setpoints.", GH_ParamAccess.item, true);
        pManager.AddParameter(new ScheduleParameter(), "Heating", "H", "Heating setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Cooling", "C", "Cooling setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddNumberParameter("WWR", "WWR", "Window-to-wall ratio in [0, 1) used by the plan generator for every exterior wall.", GH_ParamAccess.item);
        pManager[2].Optional = true;
        pManager[4].Optional = true;
        pManager[5].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)
    {
        string name = string.Empty;
        string typeText = string.Empty;
        var loads = new List<LoadGoo>();
        bool conditioned = true;
        ScheduleGoo? heating = null;
        ScheduleGoo? cooling = null;
        double wwr = 0.0;
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref typeText) || !DA.GetData(3, ref conditioned) || !DA.GetData(6, ref wwr))
        {
            return;
        }

        DA.GetDataList(2, loads);
        DA.GetData(4, ref heating);
        DA.GetData(5, ref cooling);
        if (!ComponentSupport.TryParse(this, typeText, out SpaceType spaceType))
        {
            return;
        }

        Thermostat? thermostat = null;
        if (conditioned)
        {
            if (heating is null || cooling is null)
            {
                AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "A conditioned space needs both Heating and Cooling setpoints.");
                return;
            }

            thermostat = ComponentSupport.Unwrap(this, Thermostat.Create(heating.Value, cooling.Value));
            if (thermostat is null)
            {
                return;
            }
        }

        ZoneProgram? program = ComponentSupport.Unwrap(this, ZoneProgram.Create(loads.Where(l => l is not null).Select(l => l.Value), thermostat));
        ProgramPreset? preset = program is null ? null : ComponentSupport.Unwrap(this, ProgramPreset.Create(name, spaceType, program, wwr));
        if (preset is not null)
        {
            DA.SetData(0, new ProgramPresetGoo(preset));
        }
    }
}

public sealed class ExampleResidentialPresetsComponent : GH_Component
{
    public ExampleResidentialPresetsComponent()
        : base(
            "Example Residential Presets",
            "ExPresets",
            "Illustrative presets for dwelling unit, corridor, and stair. Round numbers for testing, NOT sourced from DOE prototypes or standards.",
            ComponentCategories.Category,
            ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("0239f4c9-0f19-4cf1-9d90-08db878eac8a");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new ProgramPresetParameter(), "Presets", "P", "Example presets.", GH_ParamAccess.list);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        AddRuntimeMessage(GH_RuntimeMessageLevel.Remark, "Illustrative values only; not sourced from DOE prototypes.");
        DA.SetDataList(0, new[] { ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair }.Select(p => new ProgramPresetGoo(p)));
    }
}
  • [x] Step 15: Build and test the solution
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release

Expected: Build succeeded., 0 Warning(s), 0 Error(s), the plugin written to src\Lod.Grasshopper\bin\Release\net7.0\BEMGen.gha, and on net8.0:

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

Check that no snapshot changed and nothing refers to the deleted types:

git status --short
git grep -n -E "ProgramPresetSet|DuplicateSpaceType|ExampleResidentialPresets\.All" -- src tests

Expected: git status --short lists exactly the twelve files of this task (no *.received.txt, no snapshot); git grep prints nothing.

  • [x] Step 16: Commit
git add src/Lod.Core/Plans/PlanPresets.cs src/Lod.Core/Plans/PlanGenerator.cs src/Lod.Core/Common/DiagnosticCodes.cs src/Lod.Core/Programs/ProgramPreset.cs src/Lod.Core/Programs/ExampleResidentialPresets.cs src/Lod.Generators/Linear/LinearPlanGenerator.cs src/Lod.Grasshopper/Components/PipelineComponents.cs src/Lod.Grasshopper/Components/ProgramComponents.cs tests/Lod.Core.Tests/Plans/PlanGeneratorTests.cs tests/Lod.Core.Tests/Programs/ProgramTests.cs tests/Lod.Generators.Tests/Linear/LinearPlanGeneratorTests.cs tests/Lod.Integration.Tests/Pipeline.cs
git commit -m "feature(generators): give plan generators one typed preset per space type"
  • [x] Step 17: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/generators-typed-presets
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/generators-typed-presets
git branch -d feature/generators-typed-presets

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0. Do not push without the person's approval.


Slice C — feature/grasshopper-preset-components

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

Task 4: Dwelling Unit Preset, Corridor Preset, and Stair Preset

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

Interfaces: - Consumes: PresetValues, PresetLoadValue, PresetValues.ToPreset() (Task 1); ExampleResidentialPresets.DwellingUnitValues, CorridorValues, StairValues (Task 2); ComponentCategories.Category, ComponentCategories.Program (S0, S2); ComponentSupport.Unwrap<T> (S2); ProgramPresetParameter, ScheduleParameter, ProgramPresetGoo(ProgramPreset), ScheduleGoo.Value (S2). - Produces: public abstract class DefaultPresetComponent : GH_Component with protected abstract PresetValues Defaults { get; }, and the three components below, all in panel 1 Program with primary exposure (the GH_Component default).

Component Nickname Class GUID Defaults
Dwelling Unit Preset Unit DwellingUnitPresetComponent b491330c-bc92-42e7-b494-6946e66b599a ExampleResidentialPresets.DwellingUnitValues
Corridor Preset Corr CorridorPresetComponent 5947d3ea-25fe-4dc3-b388-384d8326826e ExampleResidentialPresets.CorridorValues
Stair Preset Stair StairPresetComponent 717ccc25-1097-4b66-addc-d2fcdd4e385a ExampleResidentialPresets.StairValues

Inputs, in order: Name (N), WWR (WWR), Conditioned (Cond), Heating (H, °C), Cooling (C, °C), then for each load of the defaults, in their order, a value input and an optional schedule input: Occupancy (Occ) and Occupancy Schedule (OccS), Lighting (Lt, LtS), Equipment (Eq, EqS), Infiltration (Inf, InfS), and for load types the examples do not use Gas Equipment (Gas), Hot Water (DHW), Ventilation (Vent). The basis is appended to a load's name only when the defaults hold its type in two bases (D-047). Every input except the schedules has the default value as persistent data; a connected schedule replaces the built-in one. The unit of each value is in its description: people/m², W/m², or 1/h (air changes per hour). Output: Preset (P). Every solution adds the remark "Illustrative values; not sourced from DOE prototypes or standards." Invalid values are reported by ToPreset as error messages, and then there is no output.

  • [x] Step 1: Create the branch
git switch -c feature/grasshopper-preset-components main
  • [x] Step 2: Create src/Lod.Grasshopper/Components/PresetComponents.cs (full file)

Defaults is read while the inputs are registered, which happens in the base constructor, so the three subclasses return static values and hold no state.

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): 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.Program)
    {
    }

    protected override Bitmap? Icon => null;

    /// <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;
}
  • [x] Step 3: Build
dotnet build BEMGen.sln -c Release

Expected: Build succeeded., 0 Warning(s), 0 Error(s), and the plugin written for net7.0:

Lod.Grasshopper -> …\src\Lod.Grasshopper\bin\Release\net7.0\BEMGen.gha
  • [x] Step 4: Commit
git add src/Lod.Grasshopper/Components/PresetComponents.cs
git commit -m "feature(grasshopper): add dwelling unit, corridor, and stair preset components"

Task 5: Delete Example Residential Presets

Files: - Modify: src/Lod.Grasshopper/Components/ProgramComponents.cs (Task 3)

Interfaces: - Consumes: Task 4 (the default preset components replace it). - Produces: ExampleResidentialPresetsComponent and its GUID 0239f4c9-0f19-4cf1-9d90-08db878eac8a are gone; the GUID is retired and never reused (D-064). The Name input of Program Preset gets the description "Preset name; descriptive only (reports, provenance, messages), never used for matching." (D-061); its behaviour is unchanged.

  • [x] Step 1: Replace src/Lod.Grasshopper/Components/ProgramComponents.cs (full file)
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.Core.Schedules;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

public sealed class ScheduleComponent : GH_Component
{
    public ScheduleComponent()
        : base("Schedule", "Sch", "An 8760-hour schedule from a 24-hour weekday and weekend profile (holidays not modelled).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("1be96980-e9eb-4636-9ce6-4e5b6a301d1f");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Schedule name.", GH_ParamAccess.item);
        pManager.AddTextParameter("Kind", "K", $"Value meaning: {ComponentSupport.Names<ScheduleKind>()}.", GH_ParamAccess.item, nameof(ScheduleKind.Fraction));
        pManager.AddNumberParameter("Weekday", "Wd", "24 hourly values for Monday to Friday.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Weekend", "We", "24 hourly values for Saturday and Sunday.", GH_ParamAccess.list);
        pManager.AddTextParameter("First Day", "D", "Weekday of 1 January.", GH_ParamAccess.item, nameof(DayOfWeek.Monday));
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "The schedule.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string name = string.Empty;
        string kindText = string.Empty;
        string dayText = string.Empty;
        var weekday = new List<double>();
        var weekend = new List<double>();
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref kindText) || !DA.GetDataList(2, weekday) || !DA.GetDataList(3, weekend) || !DA.GetData(4, ref dayText))
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, kindText, out ScheduleKind kind) || !ComponentSupport.TryParse(this, dayText, out DayOfWeek firstDay))
        {
            return;
        }

        Schedule? schedule = ComponentSupport.Unwrap(this, Schedule.FromDailyProfiles(name, kind, weekday, weekend, firstDay));
        if (schedule is not null)
        {
            DA.SetData(0, new ScheduleGoo(schedule));
        }
    }
}

public sealed class LoadComponent : GH_Component
{
    public LoadComponent()
        : base("Load", "Load", "A load: design value in its basis and a fraction schedule. Magnitudes are people, W, or m³/h.", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("c07304be-fe9c-4810-ba1d-1cac1710c386");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Type", "T", $"Load type: {ComponentSupport.Names<LoadType>()}.", GH_ParamAccess.item);
        pManager.AddTextParameter("Basis", "B", $"Basis: {ComponentSupport.Names<LoadBasis>()}.", GH_ParamAccess.item);
        pManager.AddNumberParameter("Value", "V", "Design value in the basis' unit.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "A fraction schedule.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new LoadParameter(), "Load", "L", "The load.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string typeText = string.Empty;
        string basisText = string.Empty;
        double value = 0.0;
        ScheduleGoo? schedule = null;
        if (!DA.GetData(0, ref typeText) || !DA.GetData(1, ref basisText) || !DA.GetData(2, ref value) || !DA.GetData(3, ref schedule) || schedule is null)
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, typeText, out LoadType type) || !ComponentSupport.TryParse(this, basisText, out LoadBasis basis))
        {
            return;
        }

        LoadDefinition? load = ComponentSupport.Unwrap(this, LoadDefinition.Create(type, basis, value, schedule.Value));
        if (load is not null)
        {
            DA.SetData(0, new LoadGoo(load));
        }
    }
}

public sealed class ProgramPresetComponent : GH_Component
{
    public ProgramPresetComponent()
        : base("Program Preset", "Preset", "Loads, conditioning and setpoints, and window-to-wall ratio for one space type (D-024, D-038).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("56ca1eba-abed-4dbb-a9a7-09d8b5806231");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Preset name; descriptive only (reports, provenance, messages), never used for matching.", GH_ParamAccess.item);
        pManager.AddTextParameter("Space Type", "ST", $"Space type: {ComponentSupport.Names<SpaceType>()}.", GH_ParamAccess.item);
        pManager.AddParameter(new LoadParameter(), "Loads", "L", "Loads; at most one per type and basis (D-047).", GH_ParamAccess.list);
        pManager.AddBooleanParameter("Conditioned", "Cond", "Whether the space is conditioned (D-038). Conditioned spaces need both setpoints.", GH_ParamAccess.item, true);
        pManager.AddParameter(new ScheduleParameter(), "Heating", "H", "Heating setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Cooling", "C", "Cooling setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddNumberParameter("WWR", "WWR", "Window-to-wall ratio in [0, 1) used by the plan generator for every exterior wall.", GH_ParamAccess.item);
        pManager[2].Optional = true;
        pManager[4].Optional = true;
        pManager[5].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)
    {
        string name = string.Empty;
        string typeText = string.Empty;
        var loads = new List<LoadGoo>();
        bool conditioned = true;
        ScheduleGoo? heating = null;
        ScheduleGoo? cooling = null;
        double wwr = 0.0;
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref typeText) || !DA.GetData(3, ref conditioned) || !DA.GetData(6, ref wwr))
        {
            return;
        }

        DA.GetDataList(2, loads);
        DA.GetData(4, ref heating);
        DA.GetData(5, ref cooling);
        if (!ComponentSupport.TryParse(this, typeText, out SpaceType spaceType))
        {
            return;
        }

        Thermostat? thermostat = null;
        if (conditioned)
        {
            if (heating is null || cooling is null)
            {
                AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "A conditioned space needs both Heating and Cooling setpoints.");
                return;
            }

            thermostat = ComponentSupport.Unwrap(this, Thermostat.Create(heating.Value, cooling.Value));
            if (thermostat is null)
            {
                return;
            }
        }

        ZoneProgram? program = ComponentSupport.Unwrap(this, ZoneProgram.Create(loads.Where(l => l is not null).Select(l => l.Value), thermostat));
        ProgramPreset? preset = program is null ? null : ComponentSupport.Unwrap(this, ProgramPreset.Create(name, spaceType, program, wwr));
        if (preset is not null)
        {
            DA.SetData(0, new ProgramPresetGoo(preset));
        }
    }
}
  • [x] Step 2: Build and check that the GUID is gone
dotnet build BEMGen.sln -c Release
git grep -n -e "0239f4c9" -e "ExampleResidentialPresetsComponent" -- src tests

Expected: Build succeeded., 0 Warning(s), 0 Error(s); git grep prints nothing.

  • [x] Step 3: Commit
git add src/Lod.Grasshopper/Components/ProgramComponents.cs
git commit -m "refactor(grasshopper): remove the example residential presets component"
  • [x] Step 4: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-preset-components
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-preset-components
git branch -d feature/grasshopper-preset-components

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0. Do not push without the person's approval.


Slice D — feature/grasshopper-preview-location

Task 6: Preview Location on every previewing component

Files: - Modify: src/Lod.Grasshopper/Components/ComponentSupport.cs (S2 file), src/Lod.Grasshopper/Goo/BemGoo.cs (S2 file), src/Lod.Grasshopper/Components/PipelineComponents.cs (Task 3), src/Lod.Grasshopper/Components/SimplifierComponents.cs (S3 file)

Interfaces: - Consumes (S2, S3): PreviewGoo<T> and its subclasses PlanGoo, FloorGoo, BuildingGoo; RhinoGeometry.ZoneBreps(Zone) and RhinoGeometry.WindowBreps(WallSurface), which return Breps already scaled to model units; the base classes PlanSimplifierComponent (with protected virtual void RegisterOptions(GH_InputParamManager), used by Perimeter Core for Depth) and FloorAggregatorComponent; RhinoCommon Vector3d, Transform.Translation, Grasshopper Param_Vector, GH_Vector. - Produces: - ComponentSupport.PreviewLocationParameter() : IGH_Param: an optional item Param_Vector named Preview Location (PL), persistent data 0,0,0, description "Moves this component's viewport preview by this vector, in model units; the data and all outputs keep their true coordinates (D-062)." Components add it with pManager.AddParameter(ComponentSupport.PreviewLocationParameter()), because GH_InputParamManager is a protected nested type of GH_Component and cannot be named in a helper class. - ComponentSupport.PreviewLocation(GH_Component component, IGH_DataAccess DA) : Vector3d: the vector of the component's last input, zero when it holds none. - PreviewGoo<T>.PreviewOffset (Vector3d), set by the constructors PlanGoo(IGeneratedPlan value, Vector3d previewOffset = default), FloorGoo(...), and BuildingGoo(...), and kept by Duplicate. The preview Breps, and so ClippingBox, are translated by it after they are built in model units. The wrapped value, Inspect, Validate, and Convert2BEM never see it, and a downstream component wraps its own output with its own input (D-062). - Preview Location is the last input of Linear Plan Generator (index 10), No Simplification (1), Semantic Merge (1), Perimeter Core (2, after Depth), Single Zone per Floor (1), Stack Floors, Floor Area Multiplier, and Single Zone Merged (2). Convert2BEM has none (D-062).

  • [x] Step 1: Create the branch
git switch -c feature/grasshopper-preview-location main
  • [x] Step 2: Add the shared input — 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 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).";

    /// <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 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 3: Carry the offset in the Goo — replace src/Lod.Grasshopper/Goo/BemGoo.cs (full file)

BuildPreview translates every preview Brep by PreviewOffset when it is not zero; ClippingBox is the union of the translated Breps. The constructors take the offset with a default of zero, so every existing call keeps working.

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.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, windows in blue. The preview is moved by <see cref="PreviewOffset"/> (D-062); 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 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; }

    protected abstract IEnumerable<Surface> Surfaces { get; }

    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);
            items.AddRange(RhinoGeometry.ZoneBreps(zone).Select(b => (b, material)));
        }

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

        if (!PreviewOffset.IsZero)
        {
            Transform move = Transform.Translation(PreviewOffset);
            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),
        _ => 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;

    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;

    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>
{
    public BuildingGoo(IGeneratedBuilding value, Vector3d previewOffset = default)
        : base(value, previewOffset)
    {
    }

    public override string TypeName => "Building";

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

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

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

    public override string ToString() => $"Building ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
  • [x] Step 4: Add the input to the generator, No Simplification, and the aggregators — replace src/Lod.Grasshopper/Components/PipelineComponents.cs (full file)

Each of the three registers the input last and wraps its output with ComponentSupport.PreviewLocation(this, DA); FloorAggregatorComponent does so for all three aggregators.

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: 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 => null;

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

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

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

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

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

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

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

    protected override Bitmap? Icon => null;

    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 => null;

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

    protected abstract IFloorAggregator CreateAggregator();
}

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 => null;

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

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

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

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

        DA.SetData(0, described.Value.Report);
        DA.SetData(1, described.Value.Provenance.Describe());
    }
}
  • [x] Step 5: Add the input to the simplifiers — replace src/Lod.Grasshopper/Components/SimplifierComponents.cs (full file)

PlanSimplifierComponent.RegisterInputParams adds the input after RegisterOptions, so Perimeter Core keeps Depth at index 1 and gets Preview Location at index 2.

using System;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

/// <summary>Shared inputs and solving of the plan simplifier components.</summary>
public abstract class PlanSimplifierComponent : GH_Component
{
    protected PlanSimplifierComponent(string name, string nickname, string description)
        : base(name, nickname, description, ComponentCategories.Category, ComponentCategories.Simplify)
    {
    }

    protected override Bitmap? Icon => null;

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

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

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

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

    /// <summary>Registers inputs after Plan; Preview Location is added after them.</summary>
    protected virtual void RegisterOptions(GH_InputParamManager pManager)
    {
    }

    protected abstract IPlanSimplifier? CreateSimplifier(IGH_DataAccess DA);
}

public sealed class SemanticMergeComponent : PlanSimplifierComponent
{
    public SemanticMergeComponent()
        : base("Semantic Merge", "Z1", "Merges connected zones of the same space type (Z1).")
    {
    }

    public override Guid ComponentGuid => new("7d36f1d3-2708-4b69-b1a7-cf294c186e9a");

    protected override IPlanSimplifier CreateSimplifier(IGH_DataAccess DA) => new SemanticMerge(ToleranceSettings.Default);
}

public sealed class PerimeterCoreComponent : PlanSimplifierComponent
{
    public PerimeterCoreComponent()
        : base("Perimeter Core", "Z2", "Perimeter zones per orientation plus a core (Z2); corners split on the bisectors (ADR-008).")
    {
    }

    public override Guid ComponentGuid => new("6a86b976-c838-427f-93a7-344dcc2c18ce");

    protected override void RegisterOptions(GH_InputParamManager pManager)
    {
        pManager.AddNumberParameter("Depth", "D", "Perimeter zone depth, m.", GH_ParamAccess.item, 4.57);
    }

    protected override IPlanSimplifier? CreateSimplifier(IGH_DataAccess DA)
    {
        double depth = 0.0;
        return DA.GetData(1, ref depth) ? new PerimeterCore(ToleranceSettings.Default, depth) : null;
    }
}

public sealed class SingleZonePerFloorComponent : PlanSimplifierComponent
{
    public SingleZonePerFloorComponent()
        : base("Single Zone per Floor", "Z3", "The whole floor as one zone (Z3).")
    {
    }

    public override Guid ComponentGuid => new("4ff83222-5b7d-4d1b-8f26-fd580893a5db");

    protected override IPlanSimplifier CreateSimplifier(IGH_DataAccess DA) => new SingleZonePerFloor(ToleranceSettings.Default);
}

public sealed class MapSourceToTargetComponent : GH_Component
{
    public MapSourceToTargetComponent()
        : base("Map Source to Target", "Map", "The source-to-target overlap rows of a floor (spec §11).", ComponentCategories.Category, ComponentCategories.Inspect)
    {
    }

    public override Guid ComponentGuid => new("eee37e01-28a2-4b5a-9c7d-7566cb9d93d4");

    protected override Bitmap? Icon => null;

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddParameter(new FloorParameter(), "Floor", "Floor", "A simplified floor.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddTextParameter("Source", "S", "Source zone of each row.", GH_ParamAccess.list);
        pManager.AddTextParameter("Target", "T", "Target zone of each row.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Overlap", "A", "Overlap area, m².", GH_ParamAccess.list);
        pManager.AddNumberParameter("Source Fraction", "SF", "Overlap / source area.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Target Fraction", "TF", "Overlap / target area.", GH_ParamAccess.list);
    }

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

        ZoneMapping[] rows = floor.Value.Mapping.ToArray();
        DA.SetDataList(0, rows.Select(r => r.Source.Value));
        DA.SetDataList(1, rows.Select(r => r.Target.Value));
        DA.SetDataList(2, rows.Select(r => r.OverlapArea));
        DA.SetDataList(3, rows.Select(r => r.SourceFraction));
        DA.SetDataList(4, rows.Select(r => r.TargetFraction));
    }
}

public sealed class ValidateComponent : GH_Component
{
    public ValidateComponent()
        : base("Validate", "Validate", "Checks a floor or building against its prescribed conservation invariants.", ComponentCategories.Category, ComponentCategories.Inspect)
    {
    }

    public override Guid ComponentGuid => new("03558c0b-83e8-417d-b122-96ebde64c147");

    protected override Bitmap? Icon => null;

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

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddBooleanParameter("Passed", "P", "Whether every enforced check passed.", GH_ParamAccess.item);
        pManager.AddTextParameter("Report", "R", "One line per check.", GH_ParamAccess.item);
    }

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

        ValidationReport? report = goo switch
        {
            FloorGoo floor => new FloorValidator(ToleranceSettings.Default).Validate(floor.Value),
            BuildingGoo building => new BuildingValidator(ToleranceSettings.Default).Validate(building.Value),
            _ => null,
        };
        if (report is null)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Expected a floor or building, got {goo.TypeName}.");
            return;
        }

        if (!report.Passed)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Validation failed; see Report.");
        }

        DA.SetData(0, report.Passed);
        DA.SetData(1, report.Describe());
    }
}
  • [x] Step 6: Build and check the registrations
dotnet build BEMGen.sln -c Release
git grep -n "AddParameter(ComponentSupport.PreviewLocationParameter())" -- src/Lod.Grasshopper

Expected: Build succeeded., 0 Warning(s), 0 Error(s); five lines: one in ComponentSupport.cs (the XML doc comment of PreviewLocationParameter, which names the call), three in PipelineComponents.cs (generator, No Simplification, FloorAggregatorComponent), and one in SimplifierComponents.cs (PlanSimplifierComponent).

  • [x] Step 7: Commit
git add src/Lod.Grasshopper/Components/ComponentSupport.cs src/Lod.Grasshopper/Goo/BemGoo.cs src/Lod.Grasshopper/Components/PipelineComponents.cs src/Lod.Grasshopper/Components/SimplifierComponents.cs
git commit -m "feature(grasshopper): add a preview location input to every previewing component"
  • [x] Step 8: Merge Slice D (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-preview-location
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-preview-location
git branch -d feature/grasshopper-preview-location

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0. Do not push without the person's approval.


Slice E — feature/grasshopper-icons

Task 7: An icon for every component and parameter

Files: - Create: scripts/make_icons.py, src/Lod.Grasshopper/Icons/*.png (26 files, generated), src/Lod.Grasshopper/IconLibrary.cs - Modify: src/Lod.Grasshopper/Lod.Grasshopper.csproj, src/Lod.Grasshopper/BemGenAssemblyInfo.cs, src/Lod.Grasshopper/Components/BemGenInfoComponent.cs, src/Lod.Grasshopper/Components/Convert2BemComponent.cs, src/Lod.Grasshopper/Components/PipelineComponents.cs (Task 6), src/Lod.Grasshopper/Components/PresetComponents.cs (Task 4), src/Lod.Grasshopper/Components/ProgramComponents.cs (Task 5), src/Lod.Grasshopper/Components/SimplifierComponents.cs (Task 6), src/Lod.Grasshopper/Parameters/BemParameters.cs

Interfaces: - Consumes: every concrete public sealed class … : … in src/Lod.Grasshopper whose name ends in Component or Parameter, and BemGenAssemblyInfo (19 components, 6 parameters, 1 assembly info after Task 5); the panels in ComponentCategories. - Produces: - scripts/make_icons.py: draws each icon at 96 × 96 on a transparent background, downsamples it to 24 × 24 (LANCZOS), and writes src/Lod.Grasshopper/Icons/<ClassName>.png. A dark glyph plus the accent colour of the toolbar panel (0 Info, 1 Program, 2 Generate, 3 Simplify, 4 Aggregate, 5 Convert, 6 Inspect); a parameter is a small version of its object's motif in a hexagon, in the colour of the panel that makes the object. Shapes only, no fonts. It exits with code 1 and writes nothing when a class has no icon, an icon has no class, or Icons/ holds a PNG of no class. - IconLibrary.For(object owner) : Bitmap? (internal): the embedded resource Lod.Grasshopper.Icons.<class name>.png, cached per class name; null when there is none. - Every Icon property returns IconLibrary.For(this): the assembly info, each component class or base class (DefaultPresetComponent, PlanSimplifierComponent, FloorAggregatorComponent serve their subclasses, since GetType() is the subclass), and BemParameter<TGoo> for the six parameters.

  • [x] Step 1: Create the branch and check Python
git switch -c feature/grasshopper-icons main
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"

Expected: True 12.3.0. With another Pillow version the script still runs, but the PNG bytes may differ from Step 4's hashes; then Step 5 decides (see there). Without Pillow, install it in the user's Python (python -m pip install --user Pillow==12.3.0) only after the person approves the download.

  • [x] Step 2: Create scripts/make_icons.py (full file)
"""Draws the BEMGen Grasshopper icons (D-063).

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

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

from __future__ import annotations

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

from PIL import Image, ImageDraw

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

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

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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


def 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_merged(d: Draw, accent) -> None:
    box(d, (24, 4, 71, 91), fill=accent)


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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

    return paint


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

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

    return paint


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


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


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


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

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

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


if __name__ == "__main__":
    sys.exit(main())
  • [x] Step 3: Draw the icons
python scripts/make_icons.py

Expected: exit code 0 and the single line

Wrote 26 icons to src\Lod.Grasshopper\Icons
  • [x] Step 4: Check the icon files
ls src/Lod.Grasshopper/Icons/*.png | wc -l
sha256sum src/Lod.Grasshopper/Icons/*.png

Expected: 26, and with Pillow 12.3.0 exactly these hashes:

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

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

  • [x] Step 5: 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))
sheet.save("src/Lod.Grasshopper/bin/icon-sheet.png")
print(len(icons), "icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png")
EOF

Expected: 26 icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png. Open the image. In alphabetical order, six per row: the assembly info (stepped block, grey), BEMGen Info ("i" in a grey disc), Building parameter (hexagon, three purple storeys), Convert2BEM (arrow into a red box), Corridor Preset (orange band with an arrow), Dwelling Unit Preset (orange house); Floor Area Multiplier (purple slab, "×", three bars), Floor parameter (hexagon, blue slab), Inspect (teal magnifier), Linear Plan Generator (plan with a dark stair, a green corridor, and unit divisions), Load (orange lightning bolt), Load parameter (hexagon, bolt); Map Source to Target (teal grid and box with arrows), No Simplification (blue 3 × 3 cells), Perimeter Core (ring split on the diagonals around a blue core), Plan parameter (hexagon, small plan), Program Preset (orange tag), Program Preset parameter (hexagon, tag); Schedule (orange bars on axes), Schedule parameter (hexagon, bars), Semantic Merge (two blue rows and a corridor), Single Zone Merged (one tall purple block), Single Zone per Floor (one blue block), Stack Floors (three purple slabs); Stair Preset (orange steps), Validate (white check mark in a teal disc). Each glyph is readable at 24 × 24 and the icons of one panel share one accent colour.

If the hashes of Step 4 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 6: Embed the icons — replace src/Lod.Grasshopper/Lod.Grasshopper.csproj (full file)
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <AssemblyName>BEMGen</AssemblyName>
    <RootNamespace>Lod.Grasshopper</RootNamespace>
    <TargetExt>.gha</TargetExt>
    <EnableDynamicLoading>true</EnableDynamicLoading>
    <!-- Rhino 8 loads net7.0 plugins on its .NET 8 runtime; net7.0 is end-of-life for the SDK but required by RhinoCommon 8. -->
    <CheckEolTargetFramework>false</CheckEolTargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="RhinoCommon" ExcludeAssets="runtime" />
    <PackageReference Include="Grasshopper" ExcludeAssets="runtime" />
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Include="Icons\*.png" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Lod.Core\Lod.Core.csproj" />
    <ProjectReference Include="..\Lod.Generators\Lod.Generators.csproj" />
  </ItemGroup>

</Project>
  • [x] Step 7: Load them — create src/Lod.Grasshopper/IconLibrary.cs (full file)
using System.Collections.Concurrent;
using System.Drawing;
using System.IO;

namespace Lod.Grasshopper;

/// <summary>
/// The embedded 24 × 24 icons (D-063), drawn by <c>scripts/make_icons.py</c> into <c>Icons/&lt;class name&gt;.png</c>.
/// </summary>
/// <remarks>
/// A missing icon returns <c>null</c>, which Grasshopper shows as its default icon: an exception in an <c>Icon</c> getter would surface while Grasshopper
/// loads the toolbar, far from its cause. Completeness is checked where it can fail fast instead: <c>make_icons.py</c> stops when a component or
/// parameter class has no icon.
/// </remarks>
internal static class IconLibrary
{
    private static readonly ConcurrentDictionary<string, Bitmap?> Cache = new();

    /// <summary>The icon of a component, parameter, or the assembly info, looked up by its class name.</summary>
    public static Bitmap? For(object owner) => Cache.GetOrAdd(owner.GetType().Name, Load);

    private static Bitmap? Load(string name)
    {
        using Stream? stream = typeof(IconLibrary).Assembly.GetManifestResourceStream($"{typeof(IconLibrary).Namespace}.Icons.{name}.png");
        if (stream is null)
        {
            return null;
        }

        using var image = Image.FromStream(stream);
        return new Bitmap(image);
    }
}
  • [x] Step 8: Return the icons

In each file below, every Icon => null; becomes Icon => IconLibrary.For(this); (the parameters' base class gains the property and the System.Drawing using). Fifteen lines in eight files; the full files follow.

src/Lod.Grasshopper/BemGenAssemblyInfo.cs:

using System;
using System.Drawing;
using Grasshopper.Kernel;
using Lod.Core.Common;

namespace Lod.Grasshopper;

public sealed class BemGenAssemblyInfo : GH_AssemblyInfo
{
    public override string Name => "BEMGen";

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

    public override string Description => "Parametric residential plans and LoD simplification for building energy models.";

    public override Guid Id => new("6f0f5d0e-2a8b-4c55-9a51-0a7b3d2c9e41");

    public override string AuthorName => "Environmental Systems Lab, Cornell University";

    public override string AuthorContact => "https://github.com/EnvironmentalSystemsLab/BEMGen";

    public override string AssemblyVersion => BuildInfo.InformationalVersion;
}

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

using System;
using System.Drawing;
using Grasshopper.Kernel;
using Lod.Core.Common;

namespace Lod.Grasshopper.Components;

public sealed class BemGenInfoComponent : GH_Component
{
    public BemGenInfoComponent()
        : base("BEMGen Info", "Info", "Version of the loaded BEMGen plugin, including the commit it was built from.", ComponentCategories.Category, ComponentCategories.Info)
    {
    }

    public override Guid ComponentGuid => new("b1d4f0a2-6c3e-4f7a-8e21-5a9c0d7e3b14");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddTextParameter("Version", "V", "Informational version; the part after '+' is the commit SHA.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        DA.SetData(0, BuildInfo.InformationalVersion);
    }
}

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

using System;
using System.Drawing;
using System.Linq;
using Grasshopper;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Data;
using Lod.Core.Common;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Validation;
using Lod.Grasshopper.Convert;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
using Rhino.Geometry;
using Surface = Lod.Core.Model.Surface;

namespace Lod.Grasshopper.Components;

/// <summary>
/// Converts a building into neutral Grasshopper data for a ClimateStudio definition (D-023, provisional): one tree branch per zone,
/// in building zone order. The output layout is documented in docs/architecture/convert2bem.md. The building is validated first; a failed
/// validation blocks conversion unless Override is set, and the override is recorded in the Provenance output (GLOBAL.md, scientific rule 6).
/// </summary>
public sealed class Convert2BemComponent : GH_Component
{
    public Convert2BemComponent()
        : base(
            "Convert2BEM",
            "2BEM",
            "Building to zone geometry, programs, and surfaces as data trees (branch {i} = zone i), ready to wire into a ClimateStudio definition. Provisional layout (D-023).",
            ComponentCategories.Category,
            ComponentCategories.Convert)
    {
    }

    public override Guid ComponentGuid => new("d091d70c-4de3-4f29-a900-ea4c9b777ba3");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddParameter(new BuildingParameter(), "Building", "Bldg", "The building.", GH_ParamAccess.item);
        pManager.AddBooleanParameter("Override", "Ovr", "Convert even if validation fails; the override is recorded.", GH_ParamAccess.item, false);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddBrepParameter("Zones", "Z", "{i}: closed Brep of each part of zone i, model units.", GH_ParamAccess.tree);
        pManager.AddTextParameter("Names", "N", "{i}: zone ID.", GH_ParamAccess.tree);
        pManager.AddTextParameter("Space Types", "ST", "{i}: space type.", GH_ParamAccess.tree);
        pManager.AddIntegerParameter("Multipliers", "M", "{i}: zone multiplier.", GH_ParamAccess.tree);
        pManager.AddBooleanParameter("Conditioned", "Cd", "{i}: whether zone i is conditioned (D-038).", GH_ParamAccess.tree);
        pManager.AddTextParameter("Load Types", "LT", "{i}: load types, aligned with Load Values.", GH_ParamAccess.tree);
        pManager.AddTextParameter("Load Bases", "LB", "{i}: load bases.", GH_ParamAccess.tree);
        pManager.AddNumberParameter("Load Values", "LV", "{i}: design values in their basis.", GH_ParamAccess.tree);
        pManager.AddNumberParameter("Load Schedules", "LS", "{i;k}: 8760 fractions of load k of zone i.", GH_ParamAccess.tree);
        pManager.AddNumberParameter("Heating Setpoints", "HS", "{i}: 8760 heating setpoints, °C; empty for unconditioned zones.", GH_ParamAccess.tree);
        pManager.AddNumberParameter("Cooling Setpoints", "CS", "{i}: 8760 cooling setpoints, °C; empty for unconditioned zones.", GH_ParamAccess.tree);
        pManager.AddBrepParameter("Surfaces", "S", "{i}: walls, floors, and ceilings of zone i.", GH_ParamAccess.tree);
        pManager.AddTextParameter("Boundaries", "B", "{i}: boundary of each surface: Outdoors, Ground, Adiabatic, or Interzone:<zone ID>.", GH_ParamAccess.tree);
        pManager.AddBrepParameter("Windows", "W", "{i}: explicit windows of zone i, at their positions.", GH_ParamAccess.tree);
        pManager.AddNumberParameter("Internal Mass", "IM", "{i}: exposed internal-mass area of zone i (slab area × exposed faces), m².", GH_ParamAccess.tree);
        pManager.AddTextParameter("Provenance", "P", "Building provenance, validation report, and any override.", GH_ParamAccess.item);
    }

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

        IGeneratedBuilding building = goo.Value;
        ValidationReport report = new BuildingValidator(ToleranceSettings.Default).Validate(building);
        string record = building.Provenance.Describe() + report.Describe();
        if (!report.Passed && !overrideValidation)
        {
            ComponentSupport.Report(this, new[] { Diagnostic.Error(DiagnosticCodes.ValidationFailed, "Validation failed; conversion blocked. See Provenance, or set Override.") });
            DA.SetData(15, record);
            return;
        }

        if (!report.Passed)
        {
            ComponentSupport.Report(this, new[] { Diagnostic.Warning(DiagnosticCodes.ValidationOverridden, "Validation failed; converted because Override is set. The override is recorded in Provenance.") });
            record += "OVERRIDE: converted despite failed validation\n";
        }

        var zones = new DataTree<Brep>();
        var names = new DataTree<string>();
        var spaceTypes = new DataTree<string>();
        var multipliers = new DataTree<int>();
        var conditioned = new DataTree<bool>();
        var loadTypes = new DataTree<string>();
        var loadBases = new DataTree<string>();
        var loadValues = new DataTree<double>();
        var loadSchedules = new DataTree<double>();
        var heating = new DataTree<double>();
        var cooling = new DataTree<double>();
        var surfaces = new DataTree<Brep>();
        var boundaries = new DataTree<string>();
        var windows = new DataTree<Brep>();
        var mass = new DataTree<double>();

        for (int i = 0; i < building.Zones.Count; i++)
        {
            Zone zone = building.Zones[i];
            var path = new GH_Path(i);
            zones.AddRange(RhinoGeometry.ZoneBreps(zone), path);
            names.Add(zone.Id.Value, path);
            spaceTypes.Add(zone.SpaceType.ToString(), path);
            multipliers.Add(zone.Multiplier, path);
            conditioned.Add(zone.Program.IsConditioned, path);
            for (int k = 0; k < zone.Program.Loads.Count; k++)
            {
                LoadDefinition load = zone.Program.Loads[k];
                loadTypes.Add(load.Type.ToString(), path);
                loadBases.Add(load.Basis.ToString(), path);
                loadValues.Add(load.Value, path);
                loadSchedules.AddRange(load.Schedule.Values, new GH_Path(i, k));
            }

            heating.EnsurePath(path);
            cooling.EnsurePath(path);
            if (zone.Program.Thermostat is { } thermostat)
            {
                heating.AddRange(thermostat.HeatingSetpoint.Values, path);
                cooling.AddRange(thermostat.CoolingSetpoint.Values, path);
            }
            foreach (Surface surface in building.Surfaces.Where(s => s.Zone == zone.Id))
            {
                foreach (Brep brep in SurfaceBreps(surface))
                {
                    surfaces.Add(brep, path);
                    boundaries.Add(surface.AdjacentZone is { } adjacent ? $"{surface.Boundary}:{adjacent.Value}" : surface.Boundary.ToString(), path);
                }

                if (surface is WallSurface wall)
                {
                    windows.AddRange(RhinoGeometry.WindowBreps(wall), path);
                }
            }

            mass.Add(building.InternalMasses.Where(m => m.Zone == zone.Id).Sum(m => m.SlabArea * m.ExposedFaces), path);
        }

        DA.SetDataTree(0, zones);
        DA.SetDataTree(1, names);
        DA.SetDataTree(2, spaceTypes);
        DA.SetDataTree(3, multipliers);
        DA.SetDataTree(4, conditioned);
        DA.SetDataTree(5, loadTypes);
        DA.SetDataTree(6, loadBases);
        DA.SetDataTree(7, loadValues);
        DA.SetDataTree(8, loadSchedules);
        DA.SetDataTree(9, heating);
        DA.SetDataTree(10, cooling);
        DA.SetDataTree(11, surfaces);
        DA.SetDataTree(12, boundaries);
        DA.SetDataTree(13, windows);
        DA.SetDataTree(14, mass);
        DA.SetData(15, record);
    }

    private static Brep[] SurfaceBreps(Surface surface) => surface switch
    {
        WallSurface wall => RhinoGeometry.WallBrep(wall) is { } brep ? new[] { brep } : new Brep[0],
        HorizontalSurface horizontal => RhinoGeometry.HorizontalBreps(horizontal).ToArray(),
        _ => new Brep[0],
    };
}

src/Lod.Grasshopper/Components/PipelineComponents.cs (generator, No Simplification, FloorAggregatorComponent, Inspect):

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: west stair, double-loaded corridor, one zone per dwelling unit. All lengths in metres regardless of model units.",
            ComponentCategories.Category,
            ComponentCategories.Generate)
    {
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    protected abstract string MultiplierDescription { get; }

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

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

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

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

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

    protected abstract IFloorAggregator CreateAggregator();
}

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/PresetComponents.cs (DefaultPresetComponent):

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): 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.Program)
    {
    }

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

src/Lod.Grasshopper/Components/ProgramComponents.cs (Schedule, Load, Program Preset):

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.Core.Schedules;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

public sealed class ScheduleComponent : GH_Component
{
    public ScheduleComponent()
        : base("Schedule", "Sch", "An 8760-hour schedule from a 24-hour weekday and weekend profile (holidays not modelled).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("1be96980-e9eb-4636-9ce6-4e5b6a301d1f");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Schedule name.", GH_ParamAccess.item);
        pManager.AddTextParameter("Kind", "K", $"Value meaning: {ComponentSupport.Names<ScheduleKind>()}.", GH_ParamAccess.item, nameof(ScheduleKind.Fraction));
        pManager.AddNumberParameter("Weekday", "Wd", "24 hourly values for Monday to Friday.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Weekend", "We", "24 hourly values for Saturday and Sunday.", GH_ParamAccess.list);
        pManager.AddTextParameter("First Day", "D", "Weekday of 1 January.", GH_ParamAccess.item, nameof(DayOfWeek.Monday));
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "The schedule.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string name = string.Empty;
        string kindText = string.Empty;
        string dayText = string.Empty;
        var weekday = new List<double>();
        var weekend = new List<double>();
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref kindText) || !DA.GetDataList(2, weekday) || !DA.GetDataList(3, weekend) || !DA.GetData(4, ref dayText))
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, kindText, out ScheduleKind kind) || !ComponentSupport.TryParse(this, dayText, out DayOfWeek firstDay))
        {
            return;
        }

        Schedule? schedule = ComponentSupport.Unwrap(this, Schedule.FromDailyProfiles(name, kind, weekday, weekend, firstDay));
        if (schedule is not null)
        {
            DA.SetData(0, new ScheduleGoo(schedule));
        }
    }
}

public sealed class LoadComponent : GH_Component
{
    public LoadComponent()
        : base("Load", "Load", "A load: design value in its basis and a fraction schedule. Magnitudes are people, W, or m³/h.", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("c07304be-fe9c-4810-ba1d-1cac1710c386");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Type", "T", $"Load type: {ComponentSupport.Names<LoadType>()}.", GH_ParamAccess.item);
        pManager.AddTextParameter("Basis", "B", $"Basis: {ComponentSupport.Names<LoadBasis>()}.", GH_ParamAccess.item);
        pManager.AddNumberParameter("Value", "V", "Design value in the basis' unit.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Schedule", "Sch", "A fraction schedule.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddParameter(new LoadParameter(), "Load", "L", "The load.", GH_ParamAccess.item);
    }

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        string typeText = string.Empty;
        string basisText = string.Empty;
        double value = 0.0;
        ScheduleGoo? schedule = null;
        if (!DA.GetData(0, ref typeText) || !DA.GetData(1, ref basisText) || !DA.GetData(2, ref value) || !DA.GetData(3, ref schedule) || schedule is null)
        {
            return;
        }

        if (!ComponentSupport.TryParse(this, typeText, out LoadType type) || !ComponentSupport.TryParse(this, basisText, out LoadBasis basis))
        {
            return;
        }

        LoadDefinition? load = ComponentSupport.Unwrap(this, LoadDefinition.Create(type, basis, value, schedule.Value));
        if (load is not null)
        {
            DA.SetData(0, new LoadGoo(load));
        }
    }
}

public sealed class ProgramPresetComponent : GH_Component
{
    public ProgramPresetComponent()
        : base("Program Preset", "Preset", "Loads, conditioning and setpoints, and window-to-wall ratio for one space type (D-024, D-038).", ComponentCategories.Category, ComponentCategories.Program)
    {
    }

    public override Guid ComponentGuid => new("56ca1eba-abed-4dbb-a9a7-09d8b5806231");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddTextParameter("Name", "N", "Preset name; descriptive only (reports, provenance, messages), never used for matching.", GH_ParamAccess.item);
        pManager.AddTextParameter("Space Type", "ST", $"Space type: {ComponentSupport.Names<SpaceType>()}.", GH_ParamAccess.item);
        pManager.AddParameter(new LoadParameter(), "Loads", "L", "Loads; at most one per type and basis (D-047).", GH_ParamAccess.list);
        pManager.AddBooleanParameter("Conditioned", "Cond", "Whether the space is conditioned (D-038). Conditioned spaces need both setpoints.", GH_ParamAccess.item, true);
        pManager.AddParameter(new ScheduleParameter(), "Heating", "H", "Heating setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddParameter(new ScheduleParameter(), "Cooling", "C", "Cooling setpoint, a temperature schedule (°C); conditioned spaces only.", GH_ParamAccess.item);
        pManager.AddNumberParameter("WWR", "WWR", "Window-to-wall ratio in [0, 1) used by the plan generator for every exterior wall.", GH_ParamAccess.item);
        pManager[2].Optional = true;
        pManager[4].Optional = true;
        pManager[5].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)
    {
        string name = string.Empty;
        string typeText = string.Empty;
        var loads = new List<LoadGoo>();
        bool conditioned = true;
        ScheduleGoo? heating = null;
        ScheduleGoo? cooling = null;
        double wwr = 0.0;
        if (!DA.GetData(0, ref name) || !DA.GetData(1, ref typeText) || !DA.GetData(3, ref conditioned) || !DA.GetData(6, ref wwr))
        {
            return;
        }

        DA.GetDataList(2, loads);
        DA.GetData(4, ref heating);
        DA.GetData(5, ref cooling);
        if (!ComponentSupport.TryParse(this, typeText, out SpaceType spaceType))
        {
            return;
        }

        Thermostat? thermostat = null;
        if (conditioned)
        {
            if (heating is null || cooling is null)
            {
                AddRuntimeMessage(GH_RuntimeMessageLevel.Error, "A conditioned space needs both Heating and Cooling setpoints.");
                return;
            }

            thermostat = ComponentSupport.Unwrap(this, Thermostat.Create(heating.Value, cooling.Value));
            if (thermostat is null)
            {
                return;
            }
        }

        ZoneProgram? program = ComponentSupport.Unwrap(this, ZoneProgram.Create(loads.Where(l => l is not null).Select(l => l.Value), thermostat));
        ProgramPreset? preset = program is null ? null : ComponentSupport.Unwrap(this, ProgramPreset.Create(name, spaceType, program, wwr));
        if (preset is not null)
        {
            DA.SetData(0, new ProgramPresetGoo(preset));
        }
    }
}

src/Lod.Grasshopper/Components/SimplifierComponents.cs (PlanSimplifierComponent, Map Source to Target, Validate):

using System;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

/// <summary>Shared inputs and solving of the plan simplifier components.</summary>
public abstract class PlanSimplifierComponent : GH_Component
{
    protected PlanSimplifierComponent(string name, string nickname, string description)
        : base(name, nickname, description, ComponentCategories.Category, ComponentCategories.Simplify)
    {
    }

    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);
        RegisterOptions(pManager);
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

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

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

    /// <summary>Registers inputs after Plan; Preview Location is added after them.</summary>
    protected virtual void RegisterOptions(GH_InputParamManager pManager)
    {
    }

    protected abstract IPlanSimplifier? CreateSimplifier(IGH_DataAccess DA);
}

public sealed class SemanticMergeComponent : PlanSimplifierComponent
{
    public SemanticMergeComponent()
        : base("Semantic Merge", "Z1", "Merges connected zones of the same space type (Z1).")
    {
    }

    public override Guid ComponentGuid => new("7d36f1d3-2708-4b69-b1a7-cf294c186e9a");

    protected override IPlanSimplifier CreateSimplifier(IGH_DataAccess DA) => new SemanticMerge(ToleranceSettings.Default);
}

public sealed class PerimeterCoreComponent : PlanSimplifierComponent
{
    public PerimeterCoreComponent()
        : base("Perimeter Core", "Z2", "Perimeter zones per orientation plus a core (Z2); corners split on the bisectors (ADR-008).")
    {
    }

    public override Guid ComponentGuid => new("6a86b976-c838-427f-93a7-344dcc2c18ce");

    protected override void RegisterOptions(GH_InputParamManager pManager)
    {
        pManager.AddNumberParameter("Depth", "D", "Perimeter zone depth, m.", GH_ParamAccess.item, 4.57);
    }

    protected override IPlanSimplifier? CreateSimplifier(IGH_DataAccess DA)
    {
        double depth = 0.0;
        return DA.GetData(1, ref depth) ? new PerimeterCore(ToleranceSettings.Default, depth) : null;
    }
}

public sealed class SingleZonePerFloorComponent : PlanSimplifierComponent
{
    public SingleZonePerFloorComponent()
        : base("Single Zone per Floor", "Z3", "The whole floor as one zone (Z3).")
    {
    }

    public override Guid ComponentGuid => new("4ff83222-5b7d-4d1b-8f26-fd580893a5db");

    protected override IPlanSimplifier CreateSimplifier(IGH_DataAccess DA) => new SingleZonePerFloor(ToleranceSettings.Default);
}

public sealed class MapSourceToTargetComponent : GH_Component
{
    public MapSourceToTargetComponent()
        : base("Map Source to Target", "Map", "The source-to-target overlap rows of a floor (spec §11).", ComponentCategories.Category, ComponentCategories.Inspect)
    {
    }

    public override Guid ComponentGuid => new("eee37e01-28a2-4b5a-9c7d-7566cb9d93d4");

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        pManager.AddParameter(new FloorParameter(), "Floor", "Floor", "A simplified floor.", GH_ParamAccess.item);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddTextParameter("Source", "S", "Source zone of each row.", GH_ParamAccess.list);
        pManager.AddTextParameter("Target", "T", "Target zone of each row.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Overlap", "A", "Overlap area, m².", GH_ParamAccess.list);
        pManager.AddNumberParameter("Source Fraction", "SF", "Overlap / source area.", GH_ParamAccess.list);
        pManager.AddNumberParameter("Target Fraction", "TF", "Overlap / target area.", GH_ParamAccess.list);
    }

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

        ZoneMapping[] rows = floor.Value.Mapping.ToArray();
        DA.SetDataList(0, rows.Select(r => r.Source.Value));
        DA.SetDataList(1, rows.Select(r => r.Target.Value));
        DA.SetDataList(2, rows.Select(r => r.OverlapArea));
        DA.SetDataList(3, rows.Select(r => r.SourceFraction));
        DA.SetDataList(4, rows.Select(r => r.TargetFraction));
    }
}

public sealed class ValidateComponent : GH_Component
{
    public ValidateComponent()
        : base("Validate", "Validate", "Checks a floor or building against its prescribed conservation invariants.", ComponentCategories.Category, ComponentCategories.Inspect)
    {
    }

    public override Guid ComponentGuid => new("03558c0b-83e8-417d-b122-96ebde64c147");

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

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

    protected override void RegisterOutputParams(GH_OutputParamManager pManager)
    {
        pManager.AddBooleanParameter("Passed", "P", "Whether every enforced check passed.", GH_ParamAccess.item);
        pManager.AddTextParameter("Report", "R", "One line per check.", GH_ParamAccess.item);
    }

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

        ValidationReport? report = goo switch
        {
            FloorGoo floor => new FloorValidator(ToleranceSettings.Default).Validate(floor.Value),
            BuildingGoo building => new BuildingValidator(ToleranceSettings.Default).Validate(building.Value),
            _ => null,
        };
        if (report is null)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Expected a floor or building, got {goo.TypeName}.");
            return;
        }

        if (!report.Passed)
        {
            AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Validation failed; see Report.");
        }

        DA.SetData(0, report.Passed);
        DA.SetData(1, report.Describe());
    }
}

src/Lod.Grasshopper/Parameters/BemParameters.cs (BemParameter<TGoo>):

using System;
using System.Drawing;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
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)
        : base(name, nickname, description, ComponentCategories.Category, ComponentCategories.Info, 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.")
    {
    }

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

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 9: Build and check the embedded icons
dotnet build BEMGen.sln -c Release
git grep -n "Icon => null" -- src/Lod.Grasshopper
git grep -c "IconLibrary.For(this)" -- src/Lod.Grasshopper
powershell -NoProfile -Command "([Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path 'src/Lod.Grasshopper/bin/Release/net7.0/BEMGen.gha').Path)).GetManifestResourceNames() | Sort-Object"

Expected: Build succeeded., 0 Warning(s), 0 Error(s); no Icon => null line; counts BemGenAssemblyInfo.cs:1, BemGenInfoComponent.cs:1, Convert2BemComponent.cs:1, PipelineComponents.cs:4, PresetComponents.cs:1, ProgramComponents.cs:3, SimplifierComponents.cs:3, BemParameters.cs:1 (with their paths); and the 26 resource names Lod.Grasshopper.Icons.<name>.png, one for each PNG of Step 4, from Lod.Grasshopper.Icons.BemGenAssemblyInfo.png to Lod.Grasshopper.Icons.ValidateComponent.png.

  • [x] Step 10: Commit
git status --short
git add scripts/make_icons.py src/Lod.Grasshopper/Icons src/Lod.Grasshopper/IconLibrary.cs src/Lod.Grasshopper/Lod.Grasshopper.csproj src/Lod.Grasshopper/BemGenAssemblyInfo.cs src/Lod.Grasshopper/Components/BemGenInfoComponent.cs src/Lod.Grasshopper/Components/Convert2BemComponent.cs src/Lod.Grasshopper/Components/PipelineComponents.cs src/Lod.Grasshopper/Components/PresetComponents.cs src/Lod.Grasshopper/Components/ProgramComponents.cs src/Lod.Grasshopper/Components/SimplifierComponents.cs src/Lod.Grasshopper/Parameters/BemParameters.cs
git commit -m "feature(grasshopper): draw an icon for every component and parameter"

Expected before git add: the eleven paths above and the folder src/Lod.Grasshopper/Icons/; nothing under bin/. .gitattributes already marks *.png as binary.

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

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0. Do not push without the person's approval.


Slice F — docs/architecture-presets-preview-icons

Task 8: Architecture documents

Files: - Modify: docs/architecture/pipeline.md (S4), docs/architecture/domain-model.md (S1)

Interfaces: - Consumes: Tasks 1–7 (types, component names, inputs, GUIDs). - Produces: the pipeline and domain-model descriptions after S4.1; the anchor #grasshopper-components-through-s41 that Task 9 links to.

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

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

  1. Replace
At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan. 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. 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
program presets          ProgramPreset, ProgramPresetSet                    one preset per space type, conditioned or not
  → plan generator       LinearPlanGenerator : PlanGenerator<...>           → IGeneratedPlan        Z0: one zone per dwelling unit, explicit windows

with

program presets          ProgramPreset, PresetValues                        one preset per space type, conditioned or not
  → plan generator       LinearPlanGenerator : PlanGenerator<...>           → IGeneratedPlan        one preset input per space type; Z0: one zone per dwelling unit, explicit windows
  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).

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-s41)).
  1. Replace
| Types | `Schedule`, `LoadDefinition`, `Thermostat`, `ZoneProgram`, `ProgramPreset`, `ProgramPresetSet`, `ExampleResidentialPresets` |
| Components | *Schedule*, *Load*, *Program Preset* (input *Conditioned*), *Example Residential Presets* |
| Decisions | D-024, D-026, D-038, D-047; ADR-003, ADR-005, ADR-007 |

- 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). A preset set holds at most one preset per space type.

with

| Types | `Schedule`, `LoadDefinition`, `Thermostat`, `ZoneProgram`, `ProgramPreset`, `PresetValues`, `PresetLoadValue`, `ExampleResidentialPresets` |
| Components | *Schedule*, *Load*, *Program Preset* (input *Conditioned*), *Dwelling Unit Preset*, *Corridor Preset*, *Stair Preset* |
| Decisions | D-024, D-026, D-038, D-047, D-061; ADR-003, ADR-005, ADR-007 |

- 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).
- `PresetValues` holds the plain values a preset is built from: name, space type, WWR, *Conditioned*, constant heating and cooling setpoints in °C, and the loads (`PresetLoadValue`: type, basis, value, fraction schedule). `ToPreset()` builds the preset and reports every invalid value at once; a conditioned preset gets constant setpoint schedules named `"{Name} Heating Setpoint"` and `"{Name} Cooling Setpoint"`, and an unconditioned one ignores the setpoints.
  1. Replace
- `ExampleResidentialPresets` hold illustrative round numbers for development and tests, not DOE prototype values: dwelling unit and corridor conditioned, stair unconditioned.

with

- `ExampleResidentialPresets` hold illustrative round numbers for development and tests, not DOE prototype values: dwelling unit and corridor conditioned, stair unconditioned. Each is available as values (`DwellingUnitValues`, `CorridorValues`, `StairValues`) and as the preset built from them (`DwellingUnit`, `Corridor`, `Stair`). The default preset components *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* take these values as their input defaults, so each works with nothing connected, and each shows the remark "Illustrative values; not sourced from DOE prototypes or standards."
  1. Replace
| Type | `LinearPlanGenerator : PlanGenerator<LinearPlanParameters>` → `IGeneratedPlan` |
| Component | *Linear Plan Generator* |
| Decisions | D-009, D-015, D-026, D-039; ADR-002 |

with

| Type | `LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>` → `IGeneratedPlan` |
| Component | *Linear Plan Generator* |
| Decisions | D-009, D-015, D-026, D-039, D-061; ADR-002 |

- 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.
  1. Replace
| Program types (schedules, loads, programs, presets) | Schedule length, values, and kind; load values and allowed bases; one load per type and basis (D-047); per-person loads need an occupancy load; setpoints are temperature schedules; WWR in [0, 1); one preset per space type | Error; no object |
| Plan generator | Parameters (`InvalidParameter`); a preset for every space type used (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`); polygons (`DegeneratePolygon`) | Error; no plan |

with

| Program types (schedules, loads, programs, presets) | Schedule length, values, and kind; load values and allowed bases; one load per type and basis (D-047); per-person loads need an occupancy load; setpoints are temperature schedules; WWR in [0, 1) | Error; no object |
| Plan generator | Parameters (`InvalidParameter`); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`); polygons (`DegeneratePolygon`) | Error; no plan |
  1. Replace
## Grasshopper components through S4

All components are on the **BEMGen** tab. Component icons come in S9. GUIDs never change once merged.

with

## Grasshopper components through S4.1

All components are on the **BEMGen** tab. GUIDs never change once merged, and the GUID of a deleted component is never reused (D-064).

- **Icons (D-063).** Every component and parameter, and the plugin itself, has a 24 × 24 PNG icon: a dark glyph with one accent colour per panel (a parameter takes the colour of the panel that makes its object). `scripts/make_icons.py` (Python 3.10+, Pillow) draws them at 96 × 96, downsamples them, and writes `src/Lod.Grasshopper/Icons/<class name>.png`; it stops when a component or parameter class has no icon. The PNGs are embedded resources, loaded by `IconLibrary` by class name; a missing resource gives Grasshopper's default icon instead of an exception. S9 only polishes them.
- **Preview Location (D-062).** *Linear Plan Generator*, the four plan simplifiers, and the three 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*.
- *Example Residential Presets* (`0239f4c9-0f19-4cf1-9d90-08db878eac8a`, S2) was deleted in S4.1 (D-061); its GUID is retired.
  1. Replace
| 1 Program | *Example Residential Presets* (ExPresets) | — → Presets | `ExampleResidentialPresetsComponent` | `0239f4c9-0f19-4cf1-9d90-08db878eac8a` | S2 |
| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Presets → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 |
| 3 Simplify | *No Simplification* (Z0) | Plan → Floor | `NoSimplificationComponent` | `83270abe-a13f-4f4c-a893-79739ad6e006` | S2 |
| 3 Simplify | *Semantic Merge* (Z1) | Plan → Floor | `SemanticMergeComponent` | `7d36f1d3-2708-4b69-b1a7-cf294c186e9a` | S3 |
| 3 Simplify | *Perimeter Core* (Z2) | Plan, Depth → Floor | `PerimeterCoreComponent` | `6a86b976-c838-427f-93a7-344dcc2c18ce` | S3 |
| 3 Simplify | *Single Zone per Floor* (Z3) | Plan → Floor | `SingleZonePerFloorComponent` | `4ff83222-5b7d-4d1b-8f26-fd580893a5db` | S3 |
| 4 Aggregate | *Stack Floors* (Stack) | Floors, Multipliers → Building | `StackFloorsComponent` | `6ce6524d-09b0-49fc-9b88-4c15bdcc8797` | S2 |
| 4 Aggregate | *Floor Area Multiplier* (Mult) | Floors, Multipliers → Building | `FloorAreaMultiplierComponent` | `e4b44821-c9ed-49b9-9c4e-7c08355254dd` | S4 |
| 4 Aggregate | *Single Zone Merged* (1Zone) | Floors, Multipliers → Building | `SingleZoneMergedComponent` | `506d5037-de93-4aee-a6c4-47dcc97509e1` | S4 |

with

| 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 |
| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Dwelling Unit, Corridor, Stair, Preview Location → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 (inputs S4.1) |
| 3 Simplify | *No Simplification* (Z0) | Plan, Preview Location → Floor | `NoSimplificationComponent` | `83270abe-a13f-4f4c-a893-79739ad6e006` | S2 |
| 3 Simplify | *Semantic Merge* (Z1) | Plan, Preview Location → Floor | `SemanticMergeComponent` | `7d36f1d3-2708-4b69-b1a7-cf294c186e9a` | S3 |
| 3 Simplify | *Perimeter Core* (Z2) | Plan, Depth, Preview Location → Floor | `PerimeterCoreComponent` | `6a86b976-c838-427f-93a7-344dcc2c18ce` | S3 |
| 3 Simplify | *Single Zone per Floor* (Z3) | Plan, Preview Location → Floor | `SingleZonePerFloorComponent` | `4ff83222-5b7d-4d1b-8f26-fd580893a5db` | S3 |
| 4 Aggregate | *Stack Floors* (Stack) | Floors, Multipliers, Preview Location → Building | `StackFloorsComponent` | `6ce6524d-09b0-49fc-9b88-4c15bdcc8797` | S2 |
| 4 Aggregate | *Floor Area Multiplier* (Mult) | Floors, Multipliers, Preview Location → Building | `FloorAreaMultiplierComponent` | `e4b44821-c9ed-49b9-9c4e-7c08355254dd` | S4 |
| 4 Aggregate | *Single Zone Merged* (1Zone) | Floors, Multipliers, Preview Location → Building | `SingleZoneMergedComponent` | `506d5037-de93-4aee-a6c4-47dcc97509e1` | S4 |
  1. Replace
| Grasshopper UX (naming, input order, tooltips, icons), example definitions per typology, user guide, Yak package | S9 | — |

with

| Grasshopper UX (naming, input order, tooltips, icon polish), example definitions per typology, user guide, Yak package | S9 | — |
  • [x] Step 3: Edit docs/architecture/domain-model.md

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

  1. Replace
> **Status:** Current as of S1 (`v0.1.0`) · **Date:** 2026-10-01 · **Scope:** `Lod.Core.Common`, `Lod.Core.Schedules`, `Lod.Core.Loads`, `Lod.Core.Programs`, `Lod.Core.Aggregation`

with

> **Status:** Current as of S1 (`v0.1.0`); program presets updated in S4.1 (`v0.4.1`, D-061) · **Date:** 2026-10-01 · **Scope:** `Lod.Core.Common`, `Lod.Core.Schedules`, `Lod.Core.Loads`, `Lod.Core.Programs`, `Lod.Core.Aggregation`
  1. Replace
 ProgramPresetSet ─1..*─► ProgramPreset ─1─► ZoneProgram ─0..*─► LoadDefinition ─1─► Schedule (Fraction)
 (one per SpaceType)      Name, SpaceType,   │                   Type, Basis, Value
                          WindowToWallRatio  └─0..1─► Thermostat ─┬─1─► Schedule (Temperature)  heating setpoint
                                                                  └─1─► Schedule (Temperature)  cooling setpoint
                                              none = unconditioned zone, no setpoints

with

 PresetValues ─ToPreset()─► ProgramPreset ─1─► ZoneProgram ─0..*─► LoadDefinition ─1─► Schedule (Fraction)
 (plain values, S4.1)       Name, SpaceType,   │                   Type, Basis, Value
                            WindowToWallRatio  └─0..1─► Thermostat ─┬─1─► Schedule (Temperature)  heating setpoint
                                                                    └─1─► Schedule (Temperature)  cooling setpoint
                                                none = unconditioned zone, no setpoints
  1. Replace
**`Result<T>`** is the outcome of an operation whose failure is expected. A success carries a value and may carry `Info` and `Warning` diagnostics, never errors; a failure carries at least one `Error` and no value. Reading `Value` on a failure throws an `InvalidOperationException` listing the diagnostics. The static `Result` class creates results: `Success`, `Failure`, and `FromDiagnostics`, which builds the value only when the collected diagnostics contain no error. Every S1 factory that validates input (`Schedule.Create`, `Schedule.Constant`, `Schedule.FromDailyProfiles`, `LoadDefinition.Create`, `Thermostat.Create`, `ZoneProgram.Create`, `ProgramPreset.Create`, `ProgramPresetSet.Create`) returns a `Result<T>`, and so does the aggregator.

with

**`Result<T>`** is the outcome of an operation whose failure is expected. A success carries a value and may carry `Info` and `Warning` diagnostics, never errors; a failure carries at least one `Error` and no value. Reading `Value` on a failure throws an `InvalidOperationException` listing the diagnostics. The static `Result` class creates results: `Success`, `Failure`, and `FromDiagnostics`, which builds the value only when the collected diagnostics contain no error. Every S1 factory that validates input (`Schedule.Create`, `Schedule.Constant`, `Schedule.FromDailyProfiles`, `LoadDefinition.Create`, `Thermostat.Create`, `ZoneProgram.Create`, `ProgramPreset.Create`) returns a `Result<T>`, and so do `PresetValues.ToPreset` (S4.1) and the aggregator.
  1. Replace
**`ProgramPreset`** is the program of one space type (D-024): `Name`, `SpaceType`, `Program`, and `WindowToWallRatio` in [0, 1) (D-026; `EmptyName`, `WindowToWallRatio`). **`ProgramPresetSet`** holds at most one preset per space type (`DuplicateSpaceType`), ordered by space type, with `Find(SpaceType)`.

**`ExampleResidentialPresets`** provides illustrative presets for dwelling unit, corridor, and stair; the dwelling unit and corridor are conditioned and the stair is not. Their values and conditioning are round-number choices for development and tests, not DOE prototype values; see [program presets](../research/program-presets.md).

with

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

**`PresetValues`** (S4.1) holds the plain values a preset is built from: `Name`, `SpaceType`, `WindowToWallRatio`, `Conditioned`, constant `HeatingSetpoint` and `CoolingSetpoint` in °C, and `Loads`, each a **`PresetLoadValue`** (`Type`, `Basis`, `Value`, fraction `Schedule`). Change a value with a `with` expression. `ToPreset()` builds the loads, a thermostat of constant temperature schedules named `"{Name} Heating Setpoint"` and `"{Name} Cooling Setpoint"` when `Conditioned` (the setpoints are ignored otherwise), the zone program, and the preset, and returns every error of these factories together.

**`ExampleResidentialPresets`** provides illustrative presets for dwelling unit, corridor, and stair; the dwelling unit and corridor are conditioned and the stair is not. Each is available as `PresetValues` (`DwellingUnitValues`, `CorridorValues`, `StairValues`) and as the `ProgramPreset` built from them (`DwellingUnit`, `Corridor`, `Stair`). Their values and conditioning are round-number choices for development and tests, not DOE prototype values; see [program presets](../research/program-presets.md).
  1. Replace
| `DuplicateSpaceType` | Error | `ProgramPresetSet.Create` |
| `NoSources` | Error | `EquivalentPropertyAggregator` |

with

| `NoSources` | Error | `EquivalentPropertyAggregator` |
  1. Replace
- **Grasshopper components** for schedules, loads, program presets (with a *Conditioned* input), and the example presets.

with

- **Grasshopper components** for schedules, loads, program presets (with a *Conditioned* input), and the example presets (replaced in S4.1 by one default preset component per space type, D-061).
  1. Replace
Codes are never reused or renamed; later stages only append codes. §8 lists the S1 codes.

with

Codes are never reused or renamed; a removed code's name is not reused (S4.1 removed `DuplicateSpaceType`, D-064). §8 lists the S1 codes.
  • [x] Step 4: Check the component table against the code
git grep -n -E "ComponentGuid => new\(" src/Lod.Grasshopper
git grep -n -F "0239f4c9" -- docs/architecture/pipeline.md

Expected: 25 lines for the first command (19 components, 6 parameters), each GUID appearing once in the tables of pipeline.md; one line for the second, the sentence that retires the GUID of Example Residential Presets.

  • [x] Step 5: Commit
git add docs/architecture/pipeline.md docs/architecture/domain-model.md
git commit -m "docs(architecture): describe typed presets, preset components, preview location, and icons"

Task 9: Program presets document

Files: - Modify: docs/research/program-presets.md (S1)

Interfaces: - Consumes: Tasks 1–5; the anchor of Task 8. - Produces: the research description of preset values, typed generator presets, and the default preset components; the new setpoint schedule names in the example table.

  • [x] Step 1: Edit docs/research/program-presets.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file. Replacement 6 adds a using to the C# example of §5, and replacement 7 replaces its last statement.

  1. Replace
> **Status:** Current as of S1 (`v0.1.0`) · **Date:** 2026-10-01 · **Decisions:** D-009, D-024, D-026, D-038, D-039, D-047

with

> **Status:** Current as of S4.1 (`v0.4.1`) · **Date:** 2026-10-01 · **Decisions:** D-009, D-024, D-026, D-038, D-039, D-047, D-061
  1. Replace
| `Name` | Display name | `EmptyName` when empty or white space |

with

| `Name` | Display name; descriptive only: it appears in reports, provenance, and messages and is never used for matching (D-061) | `EmptyName` when empty or white space |
  1. Replace
Presets are grouped in a `ProgramPresetSet`, which holds at most one preset per space type (`DuplicateSpaceType` otherwise) and orders them by space type. From S2 on, the plan generator gives each zone the preset of its space type from such a set.

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. (Until S4.1 presets were passed as a `ProgramPresetSet`, a list with at most one preset per space type.)

A preset can also be described by its plain values, `PresetValues`: name, space type, WWR, *Conditioned*, constant heating and cooling setpoints (°C), and loads (`PresetLoadValue`: type, basis, value, fraction schedule). `PresetValues.ToPreset()` builds the `ProgramPreset`; for a conditioned preset the setpoint schedules are constant temperature schedules named `"{Name} Heating Setpoint"` and `"{Name} Cooling Setpoint"`, and for an unconditioned preset the setpoints are ignored. Every invalid value is reported at once, with the diagnostics of §1.1.
  1. Replace
[`ExampleResidentialPresets`](../../src/Lod.Core/Programs/ExampleResidentialPresets.cs) defines one preset for each space type that the S2 linear plan generator places: dwelling unit, corridor, and stair. `ExampleResidentialPresets.All` returns them as a `ProgramPresetSet`. Tests in S1 and in later stages use these presets, so changing a value changes test expectations and snapshots.

with

[`ExampleResidentialPresets`](../../src/Lod.Core/Programs/ExampleResidentialPresets.cs) defines one preset for each space type that the S2 linear plan generator places: dwelling unit, corridor, and stair, each as `PresetValues` (`DwellingUnitValues`, `CorridorValues`, `StairValues`) and as the preset built from them (`DwellingUnit`, `Corridor`, `Stair`). The Grasshopper components *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* use these values as the defaults of their inputs (§5). Tests in S1 and in later stages use these presets, so changing a value changes test expectations and snapshots.
  1. Replace
| `Example Dwelling Unit` | `DwellingUnit` | conditioned | 21 °C, constant (`Example Heating Setpoint`) | 24 °C, constant (`Example Cooling Setpoint`) | 0.3 |
| `Example Corridor` | `Corridor` | conditioned | 21 °C, constant | 24 °C, constant | 0.2 |

with

| `Example Dwelling Unit` | `DwellingUnit` | conditioned | 21 °C, constant (`Example Dwelling Unit Heating Setpoint`) | 24 °C, constant (`Example Dwelling Unit Cooling Setpoint`) | 0.3 |
| `Example Corridor` | `Corridor` | conditioned | 21 °C, constant (`Example Corridor Heating Setpoint`) | 24 °C, constant (`Example Corridor Cooling Setpoint`) | 0.2 |
  1. Replace
using Lod.Core.Schedules;

with

using Lod.Core.Schedules;
using Lod.Generators.Linear;
  1. Replace
Result<ProgramPresetSet> presets = ProgramPresetSet.Create(
    new[] { unit.Value, lobby.Value, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair });

with

// From S4.1: change values of an example preset and build a preset from them.
Result<ProgramPreset> corridor = (ExampleResidentialPresets.CorridorValues with { Name = "My Corridor", WindowToWallRatio = 0.25 }).ToPreset();

// The linear plan generator takes one preset per space type (D-061).
var presets = new LinearPlanPresets(unit.Value, corridor.Value, ExampleResidentialPresets.Stair);
  1. Replace
S2 adds components that wrap the same factories: *Schedule* (an 8760-hour schedule from a 24-hour weekday and weekend profile), *Load*, *Program Preset* (with a *Conditioned* input; conditioned presets need both setpoints), and *Example Residential Presets*. Their inputs and outputs are documented with S2. Invalid inputs surface the diagnostics listed in §1 as component runtime messages.

with

S2 adds components that wrap the same factories: *Schedule* (an 8760-hour schedule from a 24-hour weekday and weekend profile), *Load*, and *Program Preset* (any space type, with a *Conditioned* input; conditioned presets need both setpoints). Invalid inputs surface the diagnostics listed in §1 as component runtime messages.

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

Task 10: S4.1 smoke-test checklist

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

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

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

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

### S4.1 checklist — presets, preview location, and icons (`v0.4.1`)

Build the plugin with `dotnet build src/Lod.Grasshopper -c Release` (0 warnings), close Rhino, and start it again with the plugin loaded as described above. Definitions saved before S4.1 lose their preset wiring: *Example Residential Presets* no longer exists and the *Linear Plan Generator* has new inputs (D-064).

#### Toolbar and icons

- [ ] *1 Program* shows Schedule, Load, Program Preset, Dwelling Unit Preset, Corridor Preset, and Stair Preset; *Example Residential Presets* is gone.
- [ ] Every BEMGen component on the toolbar and on the canvas shows its own icon, none shows Grasshopper's generic icon, and the icons of one panel share one accent colour. The BEMGen parameters show their icons in their tooltips and when placed from the *Params* search.

#### Default presets

1. Place *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* with nothing connected.
   - [ ] Each shows the remark `Illustrative values; not sourced from DOE prototypes or standards.` and no warning or error.
   - [ ] Their *Preset* outputs read `Preset Example Dwelling Unit (DwellingUnit, WWR 0.3)`, `Preset Example Corridor (Corridor, WWR 0.2)`, and `Preset Example Stair (Stair, WWR 0.1)`.
   - [ ] *Dwelling Unit Preset* has the inputs Name, WWR, Conditioned, Heating, Cooling, Occupancy, Occupancy Schedule, Lighting, Lighting Schedule, Equipment, Equipment Schedule, Infiltration, and Infiltration Schedule; *Corridor Preset* and *Stair Preset* have Name, WWR, Conditioned, Heating, Cooling, Lighting, Lighting Schedule, Infiltration, and Infiltration Schedule. *Conditioned* defaults to `True` for the dwelling unit and the corridor and `False` for the stair.
2. Place *Linear Plan Generator* with every number input at its default and connect the three presets to *Dwelling Unit*, *Corridor*, and *Stair*.
   - [ ] No warning or error; the preview shows the canonical 24 m × 18 m plan of the S2 checklist.
   - [ ] *Inspect* on the plan gives a *Report* equal to `tests/Lod.Generators.Tests/Linear/Snapshots/linear-plan-canonical.txt`, and its *Provenance* contains `Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor;Stair:Example Stair`.
3. Disconnect *Stair*.
   - [ ] The generator shows Grasshopper's warning that the input *Stair* failed to collect data and outputs no plan. Reconnect it.
4. Connect *Corridor Preset* to *Dwelling Unit* as well (both *Dwelling Unit* and *Corridor* now get the corridor preset).
   - [ ] The generator turns red with `Error PresetSpaceTypeMismatch [DwellingUnit]: Input DwellingUnit needs a DwellingUnit preset, got 'Example Corridor' (Corridor).` and outputs no plan. Reconnect *Dwelling Unit Preset*.
5. Set *WWR* of *Dwelling Unit Preset* to 0.5.
   - [ ] The windows of the dwelling units grow; in *Inspect* the line of `US1/W1` reads `glazing=15.000000`. Set it back to 0.3 (or disconnect the panel).
6. Set *Conditioned* of *Dwelling Unit Preset* to `False`.
   - [ ] In *Inspect* every dwelling-unit zone shows `unconditioned` instead of a `setpoints` line. Set it back to `True`.
7. Connect a *Schedule* component (Kind `Temperature`) to *Lighting Schedule* of *Corridor Preset*.
   - [ ] *Corridor Preset* turns red with an `Error ScheduleKindMismatch` message and outputs no preset. Disconnect it.

#### Preview location

8. Connect the canonical plan to *No Simplification* and *Single Zone per Floor*, and give *Single Zone per Floor* the *Preview Location* `0,30,0` (a *Vector XYZ* or a point).
   - [ ] The Single Zone per Floor preview sits 30 m north of the plan preview (bounding box 0,30,0 to 24,48,3); the No Simplification preview stays on the plan (0,0,0 to 24,18,3).
   - [ ] *Inspect* and *Validate* on the moved floor give the same results as without the move (the surface coordinates in *Report* are unchanged, `Passed` is `True`).
9. Connect the moved floor to *Stack Floors* with *Preview Location* left at its default.
   - [ ] The building previews at the origin (0,0,0 to 24,18,3), not 30 m north: downstream components do not inherit the move. Give *Stack Floors* the *Preview Location* `30,0,0`: its preview moves 30 m east.
   - [ ] *Convert2BEM* on that building outputs zone Breps between 0,0,0 and 24,18,3, at the true coordinates.
10. Give *Linear Plan Generator* the *Preview Location* `0,-30,0`.
   - [ ] Only the plan preview moves 30 m south; the floors and the building do not move.
  • [x] Step 2: Commit
git add docs/development/grasshopper-smoke-test.md
git commit -m "docs(development): add the s4.1 smoke-test checklist"

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

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

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

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

  • [x] Step 2: Run the S4.1 checklist (person, Rhino 8)

Load the plugin as docs/development/grasshopper-smoke-test.md describes and tick every item of the S4.1 checklist. Agents cannot do this step. If the person agrees, the controller may instead run the checks that do not need a viewport as a scripted headless Grasshopper session (D-056: Rhino 8 /nosplash /netcore with RHINO_PACKAGE_DIRS pointing at src/Lod.Grasshopper/bin/Release/net7.0/, components placed by GUID, states, messages, outputs, and Goo bounding boxes written to a file). Either way, the following values are expected; they were observed in such a session on the verified reference (Rhino 8.25.25314.11001, .NET 8.0.31):

  1. Registration. 19 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. 1 Program: Schedule (Sch), Load (Load), Program Preset (Preset), Dwelling Unit Preset (Unit, b491330c-bc92-42e7-b494-6946e66b599a), Corridor Preset (Corr, 5947d3ea-25fe-4dc3-b388-384d8326826e), Stair Preset (Stair, 717ccc25-1097-4b66-addc-d2fcdd4e385a), all primary; the six parameters are hidden in 0 Info; no object has the GUID 0239f4c9-0f19-4cf1-9d90-08db878eac8a. Every other component keeps its panel, nickname, GUID, and primary exposure from S4.
  2. Inputs. Linear Plan Generator: Length … Orientation at 0–6 as before, then Dwelling Unit (DU), Corridor (Co), Stair (St), required item inputs of type Program Preset at 7–9, and Preview Location (PL), optional vector, persistent {0,0,0}, at 10. Dwelling Unit Preset: Name (Example Dwelling Unit), WWR (0.3), Conditioned (True), Heating (21), Cooling (24), Occupancy (0.03), Occupancy Schedule, Lighting (5), Lighting Schedule, Equipment (5), Equipment Schedule, Infiltration (0.3), Infiltration Schedule; the schedule inputs optional and empty. Corridor Preset: Example Corridor, 0.2, True, 21, 24, Lighting 5, Infiltration 0.3, each with its schedule input. Stair Preset: Example Stair, 0.1, False, 21, 24, Lighting 3, Infiltration 0.3, each with its schedule input. No Simplification, Semantic Merge, Single Zone per Floor: Plan, Preview Location; Perimeter Core: Plan, Depth (4.57), Preview Location; Stack Floors, Floor Area Multiplier, Single Zone Merged: Floors, Multipliers, Preview Location. Program Preset is unchanged (Name, Space Type, Loads, Conditioned, Heating, Cooling, WWR).
  3. Default presets. Each default preset component with nothing connected shows only the remark Illustrative values; not sourced from DOE prototypes or standards. and outputs Preset Example Dwelling Unit (DwellingUnit, WWR 0.3), Preset Example Corridor (Corridor, WWR 0.2), and Preset Example Stair (Stair, WWR 0.1).
  4. Generator. With the three presets connected and every number at its default, no message; Inspect Report equal to tests/Lod.Generators.Tests/Linear/Snapshots/linear-plan-canonical.txt; Inspect Provenance LinearPlanGenerator CorridorWidth=2 FloorHeight=3 Length=24 OrientationDegrees=0 Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor;Stair:Example Stair StairLength=4 TargetUnitWidth=10 UnitDepth=8 [<version>], where <version> is the BEMGen Info version text (0.4.0+<sha>; the bump to 0.4.1 comes in Task 12).
  5. Missing input. Stair disconnected: the warning Input parameter St failed to collect data and no plan.
  6. Mismatch. Corridor Preset on Dwelling Unit: the error Error PresetSpaceTypeMismatch [DwellingUnit]: Input DwellingUnit needs a DwellingUnit preset, got 'Example Corridor' (Corridor). and no plan.
  7. Overrides. Dwelling Unit Preset with Name My Unit, WWR 0.4, and Conditioned False outputs Preset My Unit (DwellingUnit, WWR 0.4); the plan's dwelling-unit zones show unconditioned in Inspect, and the provenance contains Presets=DwellingUnit:My Unit;Corridor:Example Corridor;Stair:Example Stair.
  8. Preview location (metres). Plan Goo with Preview Location 0,30,0: offset 0,30,0, preview box 0,30,0 to 24,48,3, kept by Duplicate; the same plan without it: 0,0,0 to 24,18,3. No Simplification without its own offset: 0,0,0 to 24,18,3. Perimeter Core with 40,0,0: box from about 39.76,-0.18,0 to 64.24,18.18,3 (the window Breps stand slightly proud of the walls). Stack Floors (three storeys) with 0,0,20: 0,0,20 to 24,18,29; without: 0,0,0 to 24,18,9. Validate on both buildings: True. Convert2BEM on both: the Zones Breps span 0,0,0 to 24,18,9, and the 30 window Breps span 0,0,0.678… to 24,18,8.322…, identical with and without the offset.
  9. Preview location (millimetres). In a millimetre document, Preview Location 0,30000,0 gives offset 0,30000,0 and preview box 0,30000,0 to 24000,48000,3000; without it 0,0,0 to 24000,18000,3000.

Items that a headless session cannot observe stay open for a person (D-056, D-058): the look of the previews in the viewport (colours, the moved previews side by side), the icons on the toolbar and the canvas, connecting a point instead of a vector to Preview Location, and the checklist items 5–7 (WWR 0.5, Conditioned False, temperature schedule on Lighting Schedule) if they were not scripted. No example definition is part of S4.1; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) is built by a person after S4.1 (D-064) and stays person-only (D-058).

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

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

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0. Do not push without the person's approval.


Stage close-out

Task 12: Version 0.4.1, S4.1 records, tag v0.4.1

Files: - Modify: Directory.Build.props, AGENTS.md, README.md, docs/plans/2026-09-30-implementation-roadmap.md, docs/decisions/decision-log.md, docs/plans/2026-10-01-s4.1-presets-preview-icons.md (this plan: checkboxes and status, D-051)

Interfaces: - Consumes: the merged Slices A–F; the smoke-test record of Task 11; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump. - Produces: tag v0.4.1; the S4.1 records.

The decision-log entry in Step 7 has two fields filled in when this task runs: <date>, the date the stage is closed (ISO format, YYYY-MM-DD), and <name>, the person who confirms it. The roadmap Progress line of Step 6 carries the same <date>. The commit body of Step 8 uses the values written down in Task 11. The close-out uses its own branch, as the S1–S4 close-outs do.

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

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0.

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

Replace

    <Version>0.4.0</Version>

with

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

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

Checkpoint 1 (D-030) is reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), and so is the revision stage S4.1 (tag `v0.4.1`, D-064). The pipeline runs end to end for the linear plan: program presets → `LinearPlanGenerator` (one typed preset per space type, D-061) → plan simplifiers (`NoSimplification`, `SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`) → floor aggregators (`Stack`, `FloorAreaMultiplier`, `SingleZoneMerged`) → validation → `Convert2BEM`, as described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md). Every zoning level keeps the generator's windows in place (W0, D-039); vertical aggregation follows [ADR-009](docs/decisions/ADR-009-vertical-aggregation.md). In Grasshopper every component has an icon drawn by `scripts/make_icons.py` (D-063), and the generator, simplifier, and aggregator components have a *Preview Location* input that moves only their preview (D-062). Until the owner says otherwise, changes need not keep backward compatibility, but component GUIDs never change (D-064).

Not implemented yet: window transformations W1–W5 (S5), `Lod.Export` and Convert2IDF (S6), the precedent study (S7), the refined linear, point, and courtyard typologies (S8), and Grasshopper UX and release (S9). The example program presets hold illustrative values, not DOE prototype values, and the `Convert2BEM` output layout stays provisional until a ClimateStudio reference definition exists (D-023).

In the ## Commands section of AGENTS.md, insert this bullet directly after the bullet that starts with - `scripts/verify.ps1` is the local merge gate:

- `python scripts/make_icons.py` redraws the Grasshopper icons into `src/Lod.Grasshopper/Icons/` (Python 3.10+ with Pillow, D-063). Run it after adding a component or parameter class and add a motif for it; it fails when a component or parameter class has no icon.

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

Checkpoint 1 reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), followed by the revision stage S4.1 (tag `v0.4.1`). In Rhino 8 the plugin runs the whole pipeline for a linear plan: program presets with conditioning (a default preset component per space type), the linear plan generator with one preset input per space type and explicit windows, four plan simplifiers (Z0–Z3) that keep every window in place, three floor aggregators (Stack, FloorAreaMultiplier, SingleZoneMerged), invariant validation, and `Convert2BEM`. Every component has an icon, and every component with a viewport preview has a *Preview Location* input that moves only its preview. The pipeline is described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md), and [examples/grasshopper/checkpoint-1-pipeline.gh](examples/grasshopper/checkpoint-1-pipeline.gh) wires every simplifier into every aggregator. Next: window transformations (S5), Convert2IDF (S6), and the precedent study (S7). The `Convert2BEM` output stays provisional until a ClimateStudio reference definition exists (D-023), and the example program presets hold illustrative values, not DOE prototype values.
  • [x] Step 6: Update the roadmap

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

>
> **Progress:** <date> · S4.1 Grasshopper inputs, presets, and icons complete (tag `v0.4.1`); next: S5, S6, and S7. The *Linear Plan Generator* takes one preset per space type (*Dwelling Unit*, *Corridor*, *Stair*; `LinearPlanPresets`, mismatches are `PresetSpaceTypeMismatch`) from the new default preset components, which replace *Example Residential Presets* (D-061); generator, simplifier, and aggregator components have a *Preview Location* input (D-062); every component and parameter has an icon drawn by `scripts/make_icons.py` (D-063); all seven snapshots unchanged; 120 core, 19 generator, and 63 integration tests pass on `net8.0`.

In §2 (target repository layout) of the same file, replace

    Programs/      SpaceType, Thermostat, ZoneProgram, ProgramPreset, ProgramPresetSet, ExampleResidentialPresets

with

    Programs/      SpaceType, Thermostat, ZoneProgram, ProgramPreset, PresetValues, ExampleResidentialPresets

The S4.1 row of the stage table, the diagram, and the revision note were committed with this plan and stay as they are.

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

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

### D-067 — S4.1 complete

- **Date:** <date> · **Decided by:** <name> · **Status:** Accepted
- The revision stage S4.1 (D-064) is complete and tagged `v0.4.1`. D-061: plan generators take a typed preset record with one preset per space type (`PlanPresets`, `PresetSlot`; for the linear plan `LinearPlanPresets`), and a preset of the wrong space type is the error `PresetSpaceTypeMismatch`; `ProgramPresetSet` and the code `DuplicateSpaceType` are deleted. The *Linear Plan Generator* has the inputs *Dwelling Unit*, *Corridor*, and *Stair*; *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* build presets from `PresetValues` with the illustrative example values as defaults; *Example Residential Presets* is deleted and its GUID `0239f4c9-0f19-4cf1-9d90-08db878eac8a` retired. D-062: the generator, the four plan simplifiers, and the three floor aggregators have a *Preview Location* input that moves only their preview. D-063: every component and parameter, and the plugin, has a 24 × 24 icon drawn by `scripts/make_icons.py`.
- Plans, floors, and buildings are unchanged: all seven snapshots are byte-identical. Setpoint schedules of presets built from `PresetValues` are named `"<preset name> Heating Setpoint"` and `"<preset name> Cooling Setpoint"`. 120 core, 19 generator, and 63 integration tests pass on `net8.0`.
- The S4.1 smoke test is recorded in the close-out commit; items it did not perform stay open for a person (D-058).
- S5 (window transformations, gated on ADR-012) can start; S6 and S7 depend on S4 only.

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

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

Expected: no output.

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

Tick every completed checkbox of this plan and set its status line to > **Status:** Done · … (D-051). Leave unticked any step that was not performed, and name it in the commit body. The second -m records the smoke test as docs/development/grasshopper-smoke-test.md prescribes ("Recording the result"); fill <tester and method>, <date>, <version>, <sha>, <version text>, and <items> with the values written down in Task 11.

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

Expected: VERIFY PASSED, with 120 core, 19 generators, and 63 integration tests passed on net8.0.

  • [ ] Step 10: Tag
git tag -a v0.4.1 -m "S4.1 Grasshopper inputs, presets, and icons complete"
  • [ ] Step 11: Push (only after the person approves)
git push origin main
git push origin v0.4.1

Exit criteria

  • D-061: LinearPlanGenerator takes LinearPlanPresets; every wrong slot is a PresetSpaceTypeMismatch and an empty slot a MissingPreset (PlanGeneratorTests, SwappedPresetsAreAnErrorPerWrongSlot, MissingPresetIsAnError); ProgramPresetSet and DuplicateSpaceType are gone; Linear Plan Generator has the inputs Dwelling Unit, Corridor, Stair and keeps its GUID; Dwelling Unit Preset, Corridor Preset, and Stair Preset work with nothing connected and show the illustrative-values remark; Example Residential Presets is deleted and its GUID retired.
  • PresetValues.ToPreset() reproduces the example presets (ExampleResidentialPresetsTests) and reports every invalid value at once (PresetValuesTests).
  • D-062: the eight previewing components have Preview Location as their last input; it moves only their preview, in model units, and nothing downstream (smoke test items 8 and 9).
  • D-063: 26 icons drawn by scripts/make_icons.py, embedded as resources, returned by every component and parameter; the script fails for a class without an icon.
  • All seven snapshots byte-identical; scripts/verify.ps1 prints VERIFY PASSED; tests: 120 core, 19 generators, 63 integration on net8.0; 0 warnings.
  • The S4.1 smoke test is recorded in the close-out commit, with the items left for a person named.
  • Version 0.4.1, tag v0.4.1; AGENTS.md, README, the roadmap Progress line, and the decision-log entry are written; this plan is ticked and marked Done.

Notes for the reviewer

  1. Source. Every code block was taken from a verified reference implementation built on main at 8c6feaa (D-061 to D-064), one commit per task, in the order of this plan. Each intermediate state was built and tested: the red errors of Tasks 1–3 (including line and column numbers), the intermediate errors of Task 3 Steps 11 and 14, the counts 109 / 117 / 120 core and 19 generators, and a 0-warning build after every task are observed results. The 26 PNGs were regenerated from the plan's make_icons.py with Pillow 12.3.0 and are byte-identical to the reference (Task 7, Step 4).
  2. Slices and branches. The roadmap has no slice list for S4.1; this plan uses five slices and a close-out, named after D-004: feature/programs-preset-values, feature/generators-typed-presets, feature/grasshopper-preset-components, feature/grasshopper-preview-location, feature/grasshopper-icons, docs/architecture-presets-preview-icons, and chore/repo-s4.1-close-out. Merges rebase onto local main and never push; the person approves every push (execution handoff §6). The unsupervised-run push rule of D-054 does not apply to S4.1.
  3. A null preset is a MissingPreset, not a new code. The design brief left the choice open. MissingPreset already means "no preset for what the generator needs"; its summary is widened to cover an empty slot. The subject is the input name (Stair), while a layout space type without a slot keeps the zone ID as subject (C1). The Grasshopper component never passes a null preset: an unconnected input stops the component before the core is called (note 4).
  4. Unconnected preset input: a warning in Grasshopper, not an error (D-065). The generator's preset inputs are required (not optional), so Grasshopper itself reports Input parameter St failed to collect data as a warning and the component outputs nothing, which is how every required input of every BEMGen component behaves since S2. There is no silent fallback. The owner confirmed this behaviour (D-065, refining D-061's "missing-input error").
  5. The mismatch message uses the core slot name. The error reads Input DwellingUnit needs a DwellingUnit preset, got 'Example Corridor' (Corridor).: the input name comes from LinearPlanPresets (nameof(DwellingUnit)), not from the Grasshopper input Dwelling Unit (DU). The message is the same in code and in Grasshopper; S9 (UX) may map it to the Grasshopper name.
  6. Setpoint schedule names changed. PresetValues names the constant setpoint schedules after the preset ("Example Dwelling Unit Heating Setpoint" instead of the shared "Example Heating Setpoint"). Values are unchanged; no snapshot, validation check, or Convert2BEM output contains the names. docs/research/program-presets.md §2 shows the new names (Task 9).
  7. A missing icon returns null. An exception in an Icon getter would surface while Grasshopper builds its toolbar, far from its cause, and could hide the whole plugin. Completeness is enforced where it can fail fast: make_icons.py exits with code 1 when a component or parameter class has no icon, an icon has no class, or a stale PNG remains. The check finds classes with the pattern public sealed class X : Y; a non-sealed component class would escape it (all are sealed today).
  8. Icons for parameters and the assembly info. D-063 asks for an icon per component; parameters (shown in tooltips and the Params search) and the plugin get one too, 26 in all. A parameter uses the accent of the panel that makes its object (Plan: Generate, Floor: Simplify, Building: Aggregate), although the parameters are registered, hidden, in 0 Info. The PNGs are generated files; the script's header names the Pillow version that produced them. check_plans.py flags the docstring line that names this Pillow version as a possible attribution; it is a known false positive (it names the image library, not an author).
  9. Default preset inputs have defaults but are not marked optional. The design brief asked for optional inputs with defaults; D-061 asks that every input has a default so the component works with nothing connected. The reference registers the inputs with persistent default values (not Optional), which satisfies D-061: an input with persistent data always collects data. Only the schedule inputs are optional, because they have no default.
  10. DuplicateSpaceType is deleted, not deprecated. DiagnosticCodes says "never reuse or rename a published code". Deleting the code is neither reuse nor rename, and D-064 allows deletion without migration. Task 8 rewords the domain-model sentence "later stages only append codes" to "a removed code's name is not reused" (owner's decision).
  11. Tests changed, not only added. Two S1 tests (PresetSetRejectsDuplicateSpaceTypes, ExamplePresetsCoverTheLinearPlanSpaceTypes) are deleted with the type they test, and MissingPresetIsAnError (S2) now uses a null stair and expects the subject Stair instead of ST. Their coverage moves to ExampleResidentialPresetsTests and PlanGeneratorTests. No snapshot changes.
  12. Task 3 touches three projects in one commit because deleting ProgramPresetSet breaks Lod.Generators and Lod.Grasshopper at once; splitting it would leave main unbuildable between commits. The intermediate Example Residential Presets change in Step 14 exists only to keep the plugin building until Task 5 deletes the component.
  13. Preview offset in model units. RhinoGeometry builds Breps in metres and scales them to model units; the offset is added after that, so Preview Location is in model units, as D-062 says (smoke test item 9, millimetre document). The offset is not stored in the plan, floor, or building, so a component downstream previews at the true position unless it has its own offset.
  14. Commit type of Task 5. Removing Example Residential Presets is committed as refactor(grasshopper): …, as in the reference; AGENTS.md has no type for a removal, and the default preset components that replace it come in Task 4 (feature).
  15. Documents describe S4.1 as done only in the close-out. README "Status", AGENTS.md "Project state" and the make_icons.py command, the roadmap Progress line and layout line, the decision-log entry, the version, the plan ticks (D-051), and the tag are all in Task 12. The roadmap's S4.1 stage row, S5's dependency on S4.1, the diagram, the sentence below it, the detailed-plans row, and the revision note v5 are committed with this plan.
  16. Smoke-test values. The expected values in Task 11 were observed in a scripted headless Rhino 8.25 session (D-056) on the reference build; the viewport look, the toolbar icons, a point wired into Preview Location, and the checklist steps 5–7 of the S4.1 checklist (WWR 0.5, Conditioned False, a temperature schedule) were not observed there. US1/W1 … glazing=15.000000 in checklist step 5 is 0.5 × 30 m², the wall area shown in the canonical snapshot.
  17. Earlier checklists. The S2–S4 checklists still start from Example Residential Presets. They record what was tested at those stages and are not rewritten (D-064); the S4.1 checklist's preamble says that definitions saved before S4.1 lose their preset wiring.