Skip to content

S8.2 — Non-rectangular typologies Implementation Plan

Status: Done (controller, D-084; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.2 · Decisions: D-009, D-026, D-041, D-061, D-065, D-076, D-079, D-082, D-083, D-084, D-087, D-088; ADR-006, ADR-011, ADR-002 (Tasks 2–4), ADR-013 (Task 4), ADR-008 (Task 7)

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

Goal: Implement S8.2 of D-084: plan generators for the three High-Density Housing families without holes that are not bars, as ADR-006 specifies them, with Grasshopper components and icons: HDHM-TYP-007 (Point Plate, the first family with a zone that has no outdoor wall), HDHM-TYP-003 (Winged Band, a comb), and HDHM-TYP-004 (Open Court, a U). Every new family validates with every plan simplifier and floor aggregator, on its canonical plan and rotated and moved by two placements. Running the pipeline on these families found three faults on rotated plans that also affect plans released up to v0.8.0; they are fixed test-first (D-088): Semantic Merge unites each group as tiles, the surface builder tests whether a vertex lies on an edge at the vertices' original positions, and unions of tiles keep the tiles' own vertices. Perimeter Core's PlanTooNarrow message states its precondition, that every part of a footprint be wider than twice the perimeter depth (D-087). Every existing plan, floor, building, and snapshot stays unchanged. S8.2 ends with version 0.8.1 and the local tag v0.8.1; nothing is pushed in this run (D-084).

Architecture: Each family is a folder Lod.Generators/<Family>/ with a parameter record (a static Default holding the ADR-006 defaults), a preset record with one slot per space type it places, and a generator whose Layout returns zone rectangles and the footprint, as in S8.1. The wings of the winged band and the open court are bars of stair bays: the internal StairBayLayout of S8.1 gains Wing (bays along plan x or plan y from any start, with a zone ID prefix), Fit (the bay count and adjusted dwelling width that fill a wing's length), and FitErrors (the InvalidParameter error when the stairs leave no dwelling width); Bays calls Wing with the same arithmetic, so the S8.1 plans are unchanged. In Lod.Core, SemanticMerge unites each group with PolygonOps.UnionTiles instead of Union; VertexGrid.IsStrictlyInside and IsOnSegment become instance methods that measure the cells' original points against the distance tolerance in metres, and LayoutSurfaceBuilder and UnionTiles call them on their grid; UnionTiles replaces each result vertex by the tile vertex of its grid cell (VertexGrid.Restore); PerimeterCore appends its precondition to both PlanTooNarrow messages. In Lod.Grasshopper three components derive from the S8.1 TypologyGeneratorComponent; scripts/make_icons.py draws three new icons in the Generate green.

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

Global Constraints

  • Gate: D-087 and D-088 are recorded on main (1794af1), and this plan is committed to main together with the roadmap's S8.2 stage edits (see Roadmap edits) before Task 1 starts. ADR-006 and ADR-011, which specify the three families and the minimum wall and window geometry, were accepted in S8.1 (roadmap S8 gate, D-084); S8.2 changes no record or default of ADR-006. S8.2 depends on S8.1, which is tagged v0.8.0 locally. Before Task 1 the working tree is clean (git status --short prints nothing).
  • Rhino 8 (8.19 or later) only (D-003); stages exchange Grasshopper objects, not JSON (D-017).
  • Lod.Core and Lod.Generators have no RhinoCommon or Grasshopper reference; their tests run with plain dotnet test.
  • Nullable reference types on; warnings are errors; .editorconfig rules are enforced in the build; every public member of Lod.Core and Lod.Generators has XML docs (CS1591 is an error).
  • Generation is deterministic; outputs have a stable order: the point plate lists the core ST, then U1…U4 counter-clockwise from the south-west; the winged band and the open court list their wings in a fixed order (band, then wings west to east; north, west, east), each wing stairs first, then dwellings, numbered along the wing's axis.
  • Numerical tolerances come only from ToleranceSettings (ADR-004); S8.2 adds none and changes none. Task 3 changes where the existing Distance tolerance is measured (between the original positions of the vertices instead of between their grid cells, D-088); its value is unchanged, and ToleranceSettings.cs is not touched. The minimum wall and window geometry stays in GeometryLimits (ADR-011), unchanged.
  • Existing outputs do not change. The twelve existing snapshots (linear-plan-canonical, stack-two-storeys, the three simplifier-*, the three aggregator-*, and the four S8.1 canonical snapshots stair-bay-bar-canonical, stair-pair-canonical, gallery-bar-canonical, terrace-row-canonical) stay byte-identical, and every existing test keeps passing. The three fixes change results only where they were wrong: rotated plans whose Semantic Merge or single-zone aggregation failed (D-088).
  • Never weaken a check (AGENTS.md rules 11, 12). Each fix is test-first: the new test fails the way the task says on the code before the fix, then passes with the fix. No tolerance is widened, no validation check is relaxed, and no test is weakened. Perimeter Core keeps its checks unchanged; only its error message gains the precondition (D-087).
  • Report, never repair (GLOBAL.md quality rule 6). A wall or window below the ADR-011 minimum keeps its geometry and gets no window, reported as the info WindowOmitted; the plan is produced. A parameter value that would remove an essential relationship of a family (fewer than two wings) or a length that is not positive and finite, and stairs that leave no dwelling width in a wing, are the error InvalidParameter, every one reported at once. A plan that Perimeter Core cannot zone fails with PlanTooNarrow; it is never repaired.
  • Typed records per family (AGENTS.md): each family has its own parameter and preset records; there is no catch-all parameter class and no dictionary of parameters. Space types are only the existing DwellingUnit and Stair, so the existing default preset components serve every new generator.
  • Source contract (D-082). No book text, page images, or copied drawings enter BEMGen; ADR-006 and the precedent page paraphrase the families with their stable IDs. The source repository is not opened or modified while this plan is executed; every text the tasks need is given here.
  • No backward compatibility (D-064). Three components are added with new GUIDs: Point Plate 8ac9044a-8fbe-4964-a9de-2ef8be35094a, Winged Band e285811d-2fdb-4d31-8b02-62c270b609cf, Open Court 223014ea-3c01-4d2b-b8ba-92ebdc4a7327. No existing component, input, output, or GUID changes.
  • Grasshopper components are thin adaptors (AGENTS.md): every rule lives in Lod.Core or Lod.Generators; the components read inputs, call a generator, and report its diagnostics.
  • Research equivalence rules are not changed (GLOBAL.md scientific rule 7): the three fixes make existing rules hold on rotated plans, and the documents that state the rules (ADR-002, ADR-013, ADR-008) are updated in the same task as the code.
  • No simulation inside the pipeline (D-016). No CI (D-005): scripts/verify.ps1 passes locally before every merge to main.
  • Commits: type(scope): summary in lower case, no agent, model, or tool attribution and no Co-Authored-By trailers (D-001, D-002); author identity per D-050.
  • Pushes (D-084). Nothing is pushed in this run: slices are rebased onto local main, and the stage is tagged v0.8.1 locally. Task 16 (the push) is not performed in this run; it waits for the owner.
  • .gh example definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.2 adds none.
  • S8.2 changes these earlier files: Strategies.cs (S3), SimplifierTests.cs (S3, last changed in S5), PolygonOps.cs, LayoutSurfaceBuilder.cs, GeometryTests.cs (S2, last changed in S4.2), VertexGrid.cs (S4.2), LayoutSurfaceBuilderTests.cs (S2, last changed in S8.1), AggregatorTests.cs (S4, last changed in S5), StairBayLayout.cs, TypologyGeneratorComponents.cs (S8.1), scripts/make_icons.py (S4.1, last changed in S8.1), and Directory.Build.props (S0, last changed in S8.1); and the documents ADR-002 (S0, last changed in S8.1), ADR-013 (S4.2, last changed in S5), ADR-008 (S3, last changed in S5), ADR-006 and docs/research/precedents/hdhm-typologies.md (S8.1), docs/architecture/pipeline.md (S4, last changed in S8.1), docs/research/program-presets.md and docs/development/grasshopper-smoke-test.md (last changed in S8.1).

Files

Path Change Responsibility
src/Lod.Generators/PointPlate/PointPlateGenerator.cs Create PointPlateParameters, PointPlatePresets, PointPlateGenerator (007)
src/Lod.Generators/WingedBand/WingedBandGenerator.cs Create WingedBandParameters, WingedBandPresets, WingedBandGenerator (003)
src/Lod.Generators/OpenCourt/OpenCourtGenerator.cs Create OpenCourtParameters, OpenCourtPresets, OpenCourtGenerator (004)
src/Lod.Generators/StairBayBar/StairBayLayout.cs Modify (S8.1 file) Wing, Fit, FitErrors; Bays on Wing
src/Lod.Core/Simplification/Strategies.cs Modify (S3 file) Semantic Merge unites groups as tiles; Perimeter Core states its precondition in PlanTooNarrow
src/Lod.Core/Geometry/VertexGrid.cs Modify (S4.2 file) Segment tests on original points against the distance tolerance; Restore
src/Lod.Core/Geometry/PolygonOps.cs Modify (S2 file, last changed in S4.2) UnionTiles with the grid's segment test; keeps the tiles' own vertices
src/Lod.Core/Layout/LayoutSurfaceBuilder.cs Modify (S2 file, last changed in S4.2) The grid's segment tests
tests/Lod.Generators.Tests/{PointPlate,WingedBand,OpenCourt}/*GeneratorTests.cs Create Defaults, layout and zone IDs, tiling, family relationships, windows, invalid parameters, presets, determinism, provenance, snapshot
tests/Lod.Generators.Tests/{PointPlate,WingedBand,OpenCourt}/Snapshots/*-canonical.txt Create Canonical plans of the three families
tests/Lod.Integration.Tests/SimplifierTests.cs Modify (S3 file, last changed in S5) Semantic Merge of the rotated point plate; the Perimeter Core precondition message
tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs Modify (S2 file, last changed in S8.1) Rotated T-junctions on the boundary and between zones
tests/Lod.Core.Tests/Geometry/GeometryTests.cs Modify (S2 file, last changed in S4.2) The union of tiles keeps the tiles' own vertices
tests/Lod.Integration.Tests/AggregatorTests.cs Modify (S4 file, last changed in S5) Single-zone methods on a rotated Semantic Merge floor of the gallery bar
tests/Lod.Integration.Tests/NonRectangularTypologyTests.cs Create Every family × simplifier × aggregator, canonical and at two placements; Semantic Merge and Perimeter Core zones of each family
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs Modify (S8.1 file) Point Plate, Winged Band, Open Court
scripts/make_icons.py Modify (S4.1 file, last changed in S8.1) Motifs for the three new components
src/Lod.Grasshopper/Icons/OpenCourtComponent.png, PointPlateComponent.png, WingedBandComponent.png Create (generated) 35 icons
docs/decisions/ADR-002-geometry.md Modify Edge matching on original positions; unions of zones as tiles keeping the tiles' vertices; status line
docs/decisions/ADR-013-vertical-aggregation-methods.md Modify The storey outline keeps the footprints' own vertices
docs/decisions/ADR-008-perimeter-core-corners.md Modify Non-rectangular plans and the width precondition
docs/decisions/ADR-006-plan-generation-mechanism.md Modify The S8.2 families as implemented; pipeline consequences
docs/architecture/pipeline.md, docs/research/precedents/hdhm-typologies.md, docs/research/program-presets.md Modify Generators, the fixes, the component table through S8.2
docs/development/grasshopper-smoke-test.md Modify Append the S8.2 checklist
Directory.Build.props Modify Version 0.8.1
AGENTS.md, README.md Modify "Project state" and "Status" after S8.2; README layout line
docs/plans/2026-09-30-implementation-roadmap.md Modify Progress line and detailed-plans row (the S8.2 stage edits are committed with this plan)
docs/decisions/decision-log.md Modify S8.2 completion entry D-089

Test counts on net8.0 at the start of S8.2: 152 core, 91 generators, 319 integration. After Task 1: 152 / 110 / 319. After Task 2: 152 / 110 / 320. After Task 3: 153 / 110 / 320. After Task 4: 154 / 110 / 322. After Task 5: 154 / 137 / 322. After Task 6: 154 / 159 / 322. After Task 7: 154 / 159 / 323. After Task 8 and at the end: 154 core, 159 generators, 474 integration (core: +1 LayoutSurfaceBuilderTests, +1 GeometryTests; generators: +19 PointPlateGeneratorTests, +27 WingedBandGeneratorTests, +22 OpenCourtGeneratorTests; integration: +2 SimplifierTests, +2 AggregatorTests, +151 NonRectangularTypologyTests). No test is removed or changed. Grasshopper code has no unit tests; Tasks 9 and 10 are checked by a 0-warning build and the smoke test of Task 14.

Roadmap edits

The roadmap edits that describe S8.2 as executed are committed together with this plan, before Task 1: the S8.2 bullet of the S8 section names the pipeline consequences (D-087, D-088), and the risk table gains a row on rounding of rotated geometry on the clipping grid. The close-out (Task 15) adds the Progress line and the S8.2 row of the detailed-plans table. The Lod.Generators/ lines of §2 (target repository layout) already name PointPlate/, WingedBand/, and OpenCourt/ (S8.1) and stay as they are. After both, the roadmap differs from the S8.1 close-out only by these edits.

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

Task Agent Why
1 Point plate (007) bemgen-complex New plan generator geometry, the first zone without an outdoor wall; new snapshot
2 Semantic Merge unites groups as tiles bemgen-complex Geometry fix in a plan simplifier, test-first; every existing snapshot must stay byte-identical
3 Vertices on edges at their original positions bemgen-complex Geometry fix in the surface builder and the union of tiles, test-first
4 Unions of tiles keep the tiles' vertices bemgen-complex Geometry fix in the union of tiles, test-first, with an aggregator regression case
5 Winged band (003) bemgen-complex New plan generator geometry (a non-convex outline) and the extended shared bay layout; new snapshot
6 Open court (004) bemgen-complex New plan generator geometry (a U) on the shared bay layout; new snapshot
7 Perimeter Core precondition message bemgen-complex A plan simplifier's error, test-first (handoff §6: plan simplifiers)
8 Non-rectangular family matrix bemgen-complex Every family with every simplifier and aggregator, rotated and moved (handoff §6: combination matrix)
9 Typology generator components bemgen-bounded Thin Grasshopper components
10 Icons bemgen-complex Generated binaries and a visual check
11 ADR-006 bemgen-bounded Document fully given by the plan
12 Architecture documents bemgen-bounded Documents
13 S8.2 smoke-test checklist bemgen-bounded Documents
14 Smoke test in Rhino 8 Manual (person), or the controller headless (D-056) Needs Rhino 8
15 Close-out bemgen-bounded Bookkeeping
16 Push Owner (not performed in this run, D-084) A push is never delegated, and D-084 leaves it to the owner

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


Slice A — feature/generators-point-plate

Task 1: Point plate generator (HDHM-TYP-007)

Files: - Create: src/Lod.Generators/PointPlate/PointPlateGenerator.cs - Test: create tests/Lod.Generators.Tests/PointPlate/PointPlateGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.txt

Interfaces: - Consumes (S2–S8.1): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings), Validate, Layout, Describe, protected static bool IsPositive(double)), PlanParameters(double FloorHeight, double OrientationDegrees), PlanPresets.Slots, PresetSlot(string Input, SpaceType SpaceType, ProgramPreset Preset), PlanLayout(Polygon2 Footprint, IReadOnlyList<PlannedZone> Zones), PlannedZone(ZoneId Id, string Name, SpaceType SpaceType, Polygon2 Footprint), Polygon2.Rectangle, Provenance.Format, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch; test support PlanAssertions (S8.1: TilesTheFootprint, HasOneCentredWindowPerOutdoorWall, Neighbours, OutdoorFacings), Snapshot.Match, TextReport.Describe, ExampleResidentialPresets. - Produces: public sealed record PointPlateParameters(double DwellingDepth, double CoreWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (8, 6, 3, 0); public sealed record PointPlatePresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets (slots DwellingUnit, Stair); public sealed class PointPlateGenerator(ToleranceSettings), Name = PointPlateGenerator: a square plate of side CoreWidth + 2 · DwellingDepth with the core ST (Core, Stair) at [d, d + c]² and the dwellings U1…U4 (Unit 1…4) in a pinwheel; InvalidParameter for each length that is not positive and finite ({name} must be positive, got {value}.); provenance keys CoreWidth, DwellingDepth.

  • [x] Step 1: Create the branch
git switch -c feature/generators-point-plate main
  • [x] Step 2: Write the failing tests — create tests/Lod.Generators.Tests/PointPlate/PointPlateGeneratorTests.cs (full file)

19 tests: the ADR-006 defaults; the canonical plate (zone IDs and names, 22 m × 22 m, the core 36 m² at x, y = 8–14 m, dwellings 112 m², every corner of every zone, no source zones); tiling for three depth and core combinations; every dwelling adjoining the core along a 6 m wall and facing two orientations; the core without an outdoor wall; the window rule (8 windows, no diagnostic); five invalid lengths; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.

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

namespace Lod.Generators.Tests.PointPlate;

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

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

    [Fact]
    public void DefaultsAreTheAdr006Defaults()
    {
        Assert.Equal(new PointPlateParameters(DwellingDepth: 8.0, CoreWidth: 6.0, FloorHeight: 3.0, OrientationDegrees: 0.0), PointPlateParameters.Default);
    }

    [Fact]
    public void CanonicalPlateIsFourDwellingsInAPinwheelAroundOneCore()
    {
        IGeneratedPlan plan = Generate(PointPlateParameters.Default);

        Assert.Equal(new[] { "ST", "U1", "U2", "U3", "U4" }, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal(new[] { "Core", "Unit 1", "Unit 2", "Unit 3", "Unit 4" }, plan.Zones.Select(z => z.Name));
        Assert.Equal(22.0 * 22.0, plan.Footprint.Area, 9);
        Assert.Equal(new[] { 36.0, 112.0, 112.0, 112.0, 112.0 }, plan.Zones.Select(z => z.FloorArea));
        Assert.Equal(new[] { new Point2(8.0, 8.0), new Point2(14.0, 8.0), new Point2(14.0, 14.0), new Point2(8.0, 14.0) }, Outer(plan, "ST"));
        Assert.Equal(new[] { new Point2(0.0, 0.0), new Point2(14.0, 0.0), new Point2(14.0, 8.0), new Point2(0.0, 8.0) }, Outer(plan, "U1"));
        Assert.Equal(new[] { new Point2(14.0, 0.0), new Point2(22.0, 0.0), new Point2(22.0, 14.0), new Point2(14.0, 14.0) }, Outer(plan, "U2"));
        Assert.Equal(new[] { new Point2(8.0, 14.0), new Point2(22.0, 14.0), new Point2(22.0, 22.0), new Point2(8.0, 22.0) }, Outer(plan, "U3"));
        Assert.Equal(new[] { new Point2(0.0, 8.0), new Point2(8.0, 8.0), new Point2(8.0, 22.0), new Point2(0.0, 22.0) }, Outer(plan, "U4"));
        Assert.Equal(SpaceType.Stair, plan.Zones[0].SpaceType);
        Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
    }

    [Theory]
    [InlineData(8.0, 6.0)]
    [InlineData(5.0, 9.5)]
    [InlineData(11.0, 2.5)]
    public void ZonesTileTheSquarePlate(double dwellingDepth, double coreWidth)
    {
        IGeneratedPlan plan = Generate(PointPlateParameters.Default with { DwellingDepth = dwellingDepth, CoreWidth = coreWidth });
        double side = coreWidth + (2.0 * dwellingDepth);

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(side * side, plan.Footprint.Area, 9);
        Assert.Equal(5, plan.Zones.Count);
    }

    [Fact]
    public void EveryDwellingAdjoinsTheCoreAndHasTwoFacades()
    {
        IGeneratedPlan plan = Generate(PointPlateParameters.Default);

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

    [Fact]
    public void TheCoreHasNoOutdoorWall()
    {
        IGeneratedPlan plan = Generate(PointPlateParameters.Default);

        Assert.Empty(PlanAssertions.OutdoorFacings(plan, "ST"));
        Assert.Equal(4, plan.Surfaces.OfType<WallSurface>().Count(w => w.Zone.Value == "ST" && w.Boundary == BoundaryCondition.Interzone));
    }

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

        Assert.Empty(result.Diagnostics);
        PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
        Assert.Equal(8, result.Value.Surfaces.OfType<WallSurface>().Count(w => w.Windows.Count == 1));
    }

    [Theory]
    [InlineData(0.0, 6.0, "DwellingDepth")]
    [InlineData(-8.0, 6.0, "DwellingDepth")]
    [InlineData(8.0, 0.0, "CoreWidth")]
    [InlineData(8.0, double.NaN, "CoreWidth")]
    [InlineData(double.PositiveInfinity, 6.0, "DwellingDepth")]
    public void InvalidParametersAreErrors(double dwellingDepth, double coreWidth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new PointPlateParameters(dwellingDepth, coreWidth, 3.0, 0.0), Examples);

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

    [Fact]
    public void EveryInvalidParameterIsReported()
    {
        Result<IGeneratedPlan> result = _generator.Generate(new PointPlateParameters(0.0, -6.0, 0.0, double.NaN), Examples);

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

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

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

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

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

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

        Assert.Equal(nameof(PointPlateGenerator), provenance.Operation);
        Assert.Equal(
            new[] { "CoreWidth=6", "DwellingDepth=8", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair" },
            provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
    }

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(PointPlateParameters.Default)), "point-plate-canonical");
    }

    private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();

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

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

tests\Lod.Generators.Tests\PointPlate\PointPlateGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'PointPlate' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\PointPlate\PointPlateGeneratorTests.cs(154,37): error CS0246: The type or namespace name 'PointPlateParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\PointPlate\PointPlateGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'PointPlatePresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\PointPlate\PointPlateGeneratorTests.cs(17,22): error CS0246: The type or namespace name 'PointPlateGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 4: Implement — create src/Lod.Generators/PointPlate/PointPlateGenerator.cs (full file)

The plate is one footprint rectangle; the four dwelling rectangles meet the core and each other at T-junctions, which the layout surface builder splits into the core's four interzone walls.

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

namespace Lod.Generators.PointPlate;

/// <summary>Parameters of the point-access plate (HDHM-TYP-007, ADR-006).</summary>
/// <param name="DwellingDepth">Depth of each dwelling from its façade to the core, m; also the plate's margin around the core.</param>
/// <param name="CoreWidth">Side of the square core, 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 PointPlateParameters(
    double DwellingDepth,
    double CoreWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 8 m deep dwellings around a 6 m core (a 22 m square plate), 3 m storeys, orientation 0°.</summary>
    public static PointPlateParameters Default { get; } = new(8.0, 6.0, 3.0, 0.0);
}

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

/// <summary>
/// Compact point-access tower plate (HDHM-TYP-007, ADR-006): a square plate (R001, aspect ratio 1 instead of a bar) with one central core, a
/// <see cref="SpaceType.Stair"/> zone without outdoor walls, around which four dwellings lie in a pinwheel, each adjoining the core along one of its
/// sides and having two façades (R002: every dwelling's access is the one core). The pinwheel fixes the unit count at four; the source supports no
/// count. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// Zone IDs: <c>ST</c> (the core), then <c>U1…U4</c> counter-clockwise from the south-west. With <c>d</c> the dwelling depth, <c>c</c> the core
/// width, and <c>s = c + 2d</c>: <c>U1</c> <c>[0, d + c] × [0, d]</c>, <c>U2</c> <c>[d + c, s] × [0, d + c]</c>, <c>U3</c> <c>[d, s] × [d + c, s]</c>,
/// <c>U4</c> <c>[0, d] × [d, s]</c>, and the core <c>[d, d + c]²</c>.
/// </remarks>
public sealed class PointPlateGenerator : PlanGenerator<PointPlateParameters, PointPlatePresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public PointPlateGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

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

    /// <inheritdoc />
    protected override PlanLayout Layout(PointPlateParameters p)
    {
        double d = p.DwellingDepth;
        double inner = d + p.CoreWidth;
        double side = inner + d;
        var zones = new[]
        {
            new PlannedZone(new ZoneId("ST"), "Core", SpaceType.Stair, Polygon2.Rectangle(d, d, inner, inner)),
            Unit(1, Polygon2.Rectangle(0.0, 0.0, inner, d)),
            Unit(2, Polygon2.Rectangle(inner, 0.0, side, inner)),
            Unit(3, Polygon2.Rectangle(d, inner, side, side)),
            Unit(4, Polygon2.Rectangle(0.0, d, d, side)),
        };
        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, side, side), zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(PointPlateParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.DwellingDepth), Provenance.Format(p.DwellingDepth)),
        new KeyValuePair<string, string>(nameof(p.CoreWidth), Provenance.Format(p.CoreWidth)),
    };

    private static PlannedZone Unit(int number, Polygon2 footprint) =>
        new(new ZoneId($"U{number}"), $"Unit {number}", SpaceType.DwellingUnit, footprint);
}
  • [x] Step 5: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.PointPlate.PointPlateGeneratorTests"

Expected on net8.0:

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

with the only failing test and its message:

Failed Lod.Generators.Tests.PointPlate.PointPlateGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'point-plate-canonical' is missing or differs. Review Snapshots/point-plate-canonical.received.txt and rename it to point-plate-canonical.txt to accept.

and the file tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.received.txt (git-ignored by *.received.*).

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

Review the received file against the content below; it must be identical: 63 lines, 5 zones, 34 surfaces (8 outdoor walls, each with one window; 16 interzone walls; 10 floors and ceilings), glazing 19.8 m² south, 19.8 m² north, 19.8 m² east, 19.8 m² west (79.2 m² in all). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.received.txt tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.txt

tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.txt:

footprint area=484.000000 floorHeight=3.000000 orientation=0.000000
zone ST Stair "Core" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone U1 DwellingUnit "Unit 1" area=112.000000 volume=336.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U2 DwellingUnit "Unit 2" area=112.000000 volume=336.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U3 DwellingUnit "Unit 3" area=112.000000 volume=336.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone U4 DwellingUnit "Unit 4" area=112.000000 volume=336.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface ST/W1 Interzone adjacent=U1 area=18.000000 wall (8.000000,8.000000)-(14.000000,8.000000) z=0.000000 facing=South glazing=0.000000
surface ST/W2 Interzone adjacent=U2 area=18.000000 wall (14.000000,8.000000)-(14.000000,14.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W3 Interzone adjacent=U3 area=18.000000 wall (14.000000,14.000000)-(8.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W4 Interzone adjacent=U4 area=18.000000 wall (8.000000,14.000000)-(8.000000,8.000000) z=0.000000 facing=West glazing=0.000000
surface ST/F Unresolved area=36.000000 Floor z=0.000000
surface ST/C Unresolved area=36.000000 Ceiling z=3.000000
surface U1/W1 Outdoors area=42.000000 wall (0.000000,0.000000)-(14.000000,0.000000) z=0.000000 facing=South glazing=12.600000 window(offset=3.165942 sill=0.678416 7.668116x1.643168)
surface U1/W2 Interzone adjacent=U2 area=24.000000 wall (14.000000,0.000000)-(14.000000,8.000000) z=0.000000 facing=East glazing=0.000000
surface U1/W3 Interzone adjacent=ST area=18.000000 wall (14.000000,8.000000)-(8.000000,8.000000) z=0.000000 facing=North glazing=0.000000
surface U1/W4 Interzone adjacent=U4 area=24.000000 wall (8.000000,8.000000)-(0.000000,8.000000) z=0.000000 facing=North glazing=0.000000
surface U1/W5 Outdoors area=24.000000 wall (0.000000,8.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=7.200000 window(offset=1.809110 sill=0.678416 4.381780x1.643168)
surface U1/F Unresolved area=112.000000 Floor z=0.000000
surface U1/C Unresolved area=112.000000 Ceiling z=3.000000
surface U2/W1 Outdoors area=24.000000 wall (14.000000,0.000000)-(22.000000,0.000000) z=0.000000 facing=South glazing=7.200000 window(offset=1.809110 sill=0.678416 4.381780x1.643168)
surface U2/W2 Outdoors area=42.000000 wall (22.000000,0.000000)-(22.000000,14.000000) z=0.000000 facing=East glazing=12.600000 window(offset=3.165942 sill=0.678416 7.668116x1.643168)
surface U2/W3 Interzone adjacent=U3 area=24.000000 wall (22.000000,14.000000)-(14.000000,14.000000) z=0.000000 facing=North glazing=0.000000
surface U2/W4 Interzone adjacent=ST area=18.000000 wall (14.000000,14.000000)-(14.000000,8.000000) z=0.000000 facing=West glazing=0.000000
surface U2/W5 Interzone adjacent=U1 area=24.000000 wall (14.000000,8.000000)-(14.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface U2/F Unresolved area=112.000000 Floor z=0.000000
surface U2/C Unresolved area=112.000000 Ceiling z=3.000000
surface U3/W1 Interzone adjacent=ST area=18.000000 wall (8.000000,14.000000)-(14.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface U3/W2 Interzone adjacent=U2 area=24.000000 wall (14.000000,14.000000)-(22.000000,14.000000) z=0.000000 facing=South glazing=0.000000
surface U3/W3 Outdoors area=24.000000 wall (22.000000,14.000000)-(22.000000,22.000000) z=0.000000 facing=East glazing=7.200000 window(offset=1.809110 sill=0.678416 4.381780x1.643168)
surface U3/W4 Outdoors area=42.000000 wall (22.000000,22.000000)-(8.000000,22.000000) z=0.000000 facing=North glazing=12.600000 window(offset=3.165942 sill=0.678416 7.668116x1.643168)
surface U3/W5 Interzone adjacent=U4 area=24.000000 wall (8.000000,22.000000)-(8.000000,14.000000) z=0.000000 facing=West glazing=0.000000
surface U3/F Unresolved area=112.000000 Floor z=0.000000
surface U3/C Unresolved area=112.000000 Ceiling z=3.000000
surface U4/W1 Interzone adjacent=U1 area=24.000000 wall (0.000000,8.000000)-(8.000000,8.000000) z=0.000000 facing=South glazing=0.000000
surface U4/W2 Interzone adjacent=ST area=18.000000 wall (8.000000,8.000000)-(8.000000,14.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W3 Interzone adjacent=U3 area=24.000000 wall (8.000000,14.000000)-(8.000000,22.000000) z=0.000000 facing=East glazing=0.000000
surface U4/W4 Outdoors area=24.000000 wall (8.000000,22.000000)-(0.000000,22.000000) z=0.000000 facing=North glazing=7.200000 window(offset=1.809110 sill=0.678416 4.381780x1.643168)
surface U4/W5 Outdoors area=42.000000 wall (0.000000,22.000000)-(0.000000,8.000000) z=0.000000 facing=West glazing=12.600000 window(offset=3.165942 sill=0.678416 7.668116x1.643168)
surface U4/F Unresolved area=112.000000 Floor z=0.000000
surface U4/C Unresolved area=112.000000 Ceiling z=3.000000
  • [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.PointPlate.PointPlateGeneratorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

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

  • [x] Step 8: Commit
git add src/Lod.Generators/PointPlate/PointPlateGenerator.cs tests/Lod.Generators.Tests/PointPlate/PointPlateGeneratorTests.cs tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.txt
git commit -m "feature(generators): add the point plate generator (hdhm-typ-007)"
  • [x] Step 9: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/generators-point-plate
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/generators-point-plate
git branch -d feature/generators-point-plate

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


Slice B — fix/geometry-rotated-plans

The three tasks of this slice repair rotated plans (D-088). Each starts from a test that fails on the code before its fix, with the message given; the fix makes it pass without changing any existing snapshot. The faults are older than S8.2: on v0.8.0 the linear plan and the S8.1 gallery bar already fail Semantic Merge at some rotated placements (see Notes for the reviewer, note 4).

Task 2: Semantic Merge unites its groups as tiles

Files: - Modify: src/Lod.Core/Simplification/Strategies.cs (S3 file), docs/decisions/ADR-002-geometry.md (S0 document, last changed in S8.1) - Test: modify tests/Lod.Integration.Tests/SimplifierTests.cs (S3 file, last changed in S5)

Interfaces: - Consumes: PolygonOps.UnionTiles(IReadOnlyList<Polygon2> tiles) : IReadOnlyList<Polygon2> (S4.2, D-076: vertices in one grid cell unified, T-junctions inserted, then a union); PlanTransform.Apply (S4.2); PointPlateGenerator (Task 1); SemanticMerge(ToleranceSettings).Simplify(IGeneratedPlan) : Result<IFloor>; DiagnosticCodes.DisconnectedGroup (S3). - Produces: SemanticMerge unites each connected group of zones of one space type with UnionTiles instead of Union; a merged zone may have holes (the point plate's dwellings around the core). The check that a group gives exactly one polygon (DisconnectedGroup) is unchanged.

  • [x] Step 1: Create the branch
git switch -c fix/geometry-rotated-plans main
  • [x] Step 2: Write the failing test — replace tests/Lod.Integration.Tests/SimplifierTests.cs (full file)

One new test, SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan: the default point plate rotated by 30° and moved by (5.3, −2.1) m merges into Stair-1 and DwellingUnit-1, a ring with one hole whose area is the four dwellings' 448 m². The usings Lod.Core.Plans and Lod.Generators.PointPlate are added; every other test is unchanged.

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.Linear;
using Lod.Generators.PointPlate;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Integration.Tests;

public sealed class SimplifierTests
{
    private const double PerimeterDepth = 4.57;

    [Fact]
    public void SemanticMergeJoinsConnectedZonesOfTheSameType()
    {
        IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));

        Assert.Equal(new[] { "Stair-1", "Corridor-1", "DwellingUnit-1", "DwellingUnit-2" }, floor.Zones.Select(z => z.Id.Value));
        Zone south = floor.Zones.Single(z => z.Id.Value == "DwellingUnit-1");
        Assert.Equal(160.0, south.FloorArea, 6);
        Assert.Equal(new[] { "US1", "US2" }, south.SourceZones.Select(s => s.Value));
        Assert.Equal(5.0, south.Program.Find(LoadType.Lighting, LoadBasis.PerFloorArea)!.Value, 9);
    }

    [Fact]
    public void MergedZonesLoseTheirSharedPartition()
    {
        IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));

        Assert.DoesNotContain(floor.Surfaces, s => s.Zone == s.AdjacentZone);
        Assert.Equal(2, floor.Surfaces.OfType<WallSurface>().Count(w => w.Zone.Value == "DwellingUnit-1" && w.Boundary == BoundaryCondition.Outdoors));
    }

    [Fact]
    public void SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan()
    {
        // The point plate's dwellings meet each other and the core at T-junctions; rotated off the axes, a plain union of their footprints leaves
        // slits where clipping-grid rounding moves a T-junction off its edge, so the zones are united as tiles (ADR-013).
        IGeneratedPlan plate = new PointPlateGenerator(Pipeline.Tolerances)
            .Generate(PointPlateParameters.Default, new PointPlatePresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;
        IGeneratedPlan plan = PlanTransform.Apply(plate, 30.0, 5.3, -2.1).Value;

        Result<IFloor> floor = new SemanticMerge(Pipeline.Tolerances).Simplify(plan);

        Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
        Assert.Equal(new[] { "Stair-1", "DwellingUnit-1" }, floor.Value.Zones.Select(z => z.Id.Value));
        Polygon2 ring = floor.Value.Zones.Single(z => z.Id.Value == "DwellingUnit-1").Parts[0].Footprint;
        Assert.Single(ring.Holes);
        Assert.True(Pipeline.Tolerances.AreaEquals(4 * 112.0, ring.Area), $"Area {ring.Area}");
    }

    [Fact]
    public void PerimeterCoreHasFourPerimeterZonesAndACore()
    {
        IFloor floor = Pipeline.Floor(new PerimeterCore(Pipeline.Tolerances, PerimeterDepth));

        Assert.Equal(new[] { "P-North", "P-East", "P-South", "P-West", "CORE" }, floor.Zones.Select(z => z.Id.Value));
        Assert.Equal((24.0 - (2 * PerimeterDepth)) * (18.0 - (2 * PerimeterDepth)), floor.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
        Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
    }

    [Fact]
    public void PerimeterSouthHasOneCentredWindowWithTheSouthFacadeGlazing()
    {
        IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Pipeline.Plan()).Value;

        WallSurface south = floor.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "P-South" && w.Boundary == BoundaryCondition.Outdoors);
        Window window = Assert.Single(south.Windows);
        Assert.Equal(1.2 + 9.0 + 9.0, window.Area, 9);
        Assert.Equal((24.0 - window.Width) / 2.0, window.Offset, 9);
        Assert.Equal((3.0 - window.Height) / 2.0, window.SillHeight, 9);
        Assert.Equal(window.Width / 24.0, window.Height / 3.0, 12);
    }

    [Fact]
    public void PerimeterDepthTooLargeIsAnError()
    {
        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, 10.0).Simplify(Pipeline.Plan());

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

    [Theory]
    [InlineData(0.0)]
    [InlineData(-1.0)]
    public void NonPositivePerimeterDepthIsAnError(double depth)
    {
        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, depth).Simplify(Pipeline.Plan());

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

    [Fact]
    public void FootprintsWithHolesAreNotSupportedYet()
    {
        Polygon2 courtyard = Polygon2.Create(
            new[] { new Point2(0, 0), new Point2(30, 0), new Point2(30, 30), new Point2(0, 30) },
            new[] { new[] { new Point2(10, 10), new Point2(20, 10), new Point2(20, 20), new Point2(10, 20) } }).Value;
        var plan = new GeneratedPlan(courtyard, 3.0, 0.0, new Zone[0], new Surface[0], Provenance.Of("Test", new KeyValuePair<string, string>[0]));

        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(plan);

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

    [Fact]
    public void SingleZonePerFloorHasOnlyOutdoorWalls()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));

        Zone zone = Assert.Single(floor.Zones);
        Assert.Equal(SpaceType.Mixed, zone.SpaceType);
        Assert.Equal(6, zone.SourceZones.Count);
        Assert.All(floor.Surfaces.OfType<WallSurface>(), w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
        Assert.Equal(4, floor.Surfaces.OfType<WallSurface>().Count());
    }

    [Fact]
    public void SetpointsAreFloorAreaWeightedAndRecorded()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));

        Thermostat thermostat = floor.Zones[0].Program.Thermostat!;
        Assert.Equal(21.0, thermostat.HeatingSetpoint[0], 9);
        Assert.Equal(Lod.Core.Aggregation.AggregationMethod.FloorAreaWeighted, thermostat.HeatingSetpoint.Aggregation!.Method);
        Assert.DoesNotContain(new ZoneId("ST"), thermostat.HeatingSetpoint.Aggregation.Sources);
        Assert.Equal(5, thermostat.HeatingSetpoint.Aggregation.Sources.Count);
    }

    [Theory]
    [InlineData("SemanticMerge", 1)]
    [InlineData("SemanticMerge", 2)]
    [InlineData("SemanticMerge", 3)]
    [InlineData("PerimeterCore", 1)]
    [InlineData("PerimeterCore", 2)]
    [InlineData("PerimeterCore", 3)]
    [InlineData("PerimeterCore", 4)]
    [InlineData("SingleZonePerFloor", 1)]
    [InlineData("SingleZonePerFloor", 2)]
    public void EverySimplifierSatisfiesTheInvariantsForRandomPlans(string simplifier, int seed)
    {
        LinearPlanParameters parameters = RandomParameters(new Random(seed));

        IFloor floor = Simplifier(simplifier).Simplify(Pipeline.Plan(parameters)).Value;
        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);

        Assert.True(report.Passed, report.Describe());
    }

    [Fact]
    public void MergingTheUnconditionedStairEnlargesConditionedAreaAndIsReported()
    {
        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances)));

        CheckResult conditioned = report.Checks.Single(c => c.Check == "ConditionedFloorArea");
        Assert.False(conditioned.Passed);
        Assert.False(conditioned.Enforced);
        Assert.True(report.Passed, report.Describe());
    }

    [Fact]
    public void ValidationCatchesAnUncoveredFacade()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
        Surface[] withoutSouthWall = floor.Surfaces.Where(s => !(s is WallSurface w && w.Start.Y == 0.0 && w.End.Y == 0.0)).ToArray();
        var broken = new Floor(floor.Source, floor.Zones, withoutSouthWall, floor.Mapping, floor.Provenance);

        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);

        Assert.Contains(report.Checks, c => c.Check == "FacadeCoverage" && !c.Passed && c.Enforced);
        Assert.False(report.Passed);
    }

    [Fact]
    public void NoSimplificationPassesValidation()
    {
        Assert.True(new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.DetailedFloor()).Passed);
    }

    [Fact]
    public void ValidationCatchesAChangedProgram()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
        Zone zone = floor.Zones[0];
        var tampered = new Zone(zone.Id, zone.Name, zone.SpaceType, zone.Parts, ExampleResidentialPresets.DwellingUnit.Program, zone.SourceZones);
        var broken = new Floor(floor.Source, new[] { tampered }, floor.Surfaces, floor.Mapping, floor.Provenance);

        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);

        Assert.False(report.Passed);
        Assert.Contains(report.Checks, c => c.Check == "Installed.Lighting" && !c.Passed);
        Assert.Contains(report.Checks, c => c.Check == "Traceability" && !c.Passed);
    }

    [Theory]
    [InlineData("SemanticMerge")]
    [InlineData("PerimeterCore")]
    [InlineData("SingleZonePerFloor")]
    public void SimplifiersAreDeterministic(string simplifier)
    {
        Assert.Equal(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))));
    }

    [Theory]
    [InlineData("SemanticMerge")]
    [InlineData("PerimeterCore")]
    [InlineData("SingleZonePerFloor")]
    public void CanonicalSnapshots(string simplifier)
    {
        Snapshot.Match(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), $"simplifier-{simplifier}");
    }

    private static IPlanSimplifier Simplifier(string name) => name switch
    {
        "SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
        "PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, PerimeterDepth),
        "SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
        _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
    };

    private static LinearPlanParameters RandomParameters(Random random) => new(
        Length: 18.0 + (random.NextDouble() * 42.0),
        UnitDepth: 6.0 + (random.NextDouble() * 4.0),
        CorridorWidth: 1.5 + (random.NextDouble() * 1.5),
        TargetUnitWidth: 5.0 + (random.NextDouble() * 7.0),
        StairLength: 3.0 + (random.NextDouble() * 3.0),
        FloorHeight: 2.8 + (random.NextDouble() * 0.7),
        OrientationDegrees: random.NextDouble() * 360.0);
}
  • [x] Step 3: Run the test and see it fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.SimplifierTests.SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan"

Expected on net8.0: Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: … - Lod.Integration.Tests.dll (net8.0), with the message (a plain union of the rotated dwellings is not one polygon):

Failed Lod.Integration.Tests.SimplifierTests.SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan
  Error DisconnectedGroup [U1]: Merged group U1,U2,U4,U3 is not one polygon.
  • [x] Step 4: Implement — replace src/Lod.Core/Simplification/Strategies.cs (full file)

Only the union of SemanticMerge and its summary change; PerimeterCore and SingleZonePerFloor are unchanged.

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

namespace Lod.Core.Simplification;

/// <summary>
/// Z1: merges zones of the same space type that are connected through shared walls. Each connected group becomes one zone, the union of its
/// footprints as tiles (<see cref="PolygonOps.UnionTiles"/>), so zones meeting at T-junctions unite exactly on rotated plans; the union may have
/// holes, where the group surrounds zones of another type.
/// </summary>
public sealed class SemanticMerge : PlanSimplifier
{
    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public SemanticMerge(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        Dictionary<ZoneId, Zone> byId = plan.Zones.ToDictionary(z => z.Id);
        ILookup<ZoneId, ZoneId> neighbours = plan.Surfaces
            .Where(s => s.Boundary == BoundaryCondition.Interzone)
            .ToLookup(s => s.Zone, s => s.AdjacentZone!.Value);
        var ops = new PolygonOps(Tolerances);
        var visited = new HashSet<ZoneId>();
        var targets = new List<TargetZone>();
        var counters = new Dictionary<SpaceType, int>();
        foreach (Zone seed in plan.Zones.Where(z => !visited.Contains(z.Id)))
        {
            var group = new List<Zone>();
            var queue = new Queue<Zone>(new[] { seed });
            visited.Add(seed.Id);
            while (queue.Count > 0)
            {
                Zone current = queue.Dequeue();
                group.Add(current);
                foreach (ZoneId next in neighbours[current.Id].Where(n => !visited.Contains(n) && byId[n].SpaceType == seed.SpaceType))
                {
                    visited.Add(next);
                    queue.Enqueue(byId[next]);
                }
            }

            IReadOnlyList<Polygon2> union = ops.UnionTiles(group.Select(z => z.Parts[0].Footprint).ToArray());
            if (union.Count != 1)
            {
                return Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(
                    DiagnosticCodes.DisconnectedGroup,
                    $"Merged group {string.Join(",", group.Select(z => z.Id.Value))} is not one polygon.",
                    seed.Id.Value));
            }

            int number = counters.TryGetValue(seed.SpaceType, out int n) ? n + 1 : 1;
            counters[seed.SpaceType] = number;
            targets.Add(new TargetZone(new ZoneId($"{seed.SpaceType}-{number}"), $"{seed.SpaceType} {number}", seed.SpaceType, union[0]));
        }

        return Result.Success<IReadOnlyList<TargetZone>>(targets);
    }

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

/// <summary>
/// Z2: perimeter zones of a configurable depth plus a core (ADR-008). Each footprint edge owns the trapezoid between the edge and its inward
/// mitred offset, so corners are split along the bisectors; trapezoids in the same orientation bin are merged. Footprints with holes are not supported yet.
/// </summary>
public sealed class PerimeterCore : PlanSimplifier
{
    private readonly double _depth;

    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    /// <param name="perimeterDepth">Perimeter zone depth, m.</param>
    public PerimeterCore(ToleranceSettings tolerances, double perimeterDepth)
        : base(tolerances)
    {
        _depth = perimeterDepth;
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        if (!(_depth > 0) || double.IsInfinity(_depth))
        {
            return Fail(DiagnosticCodes.InvalidParameter, $"Perimeter depth must be positive, got {_depth}.");
        }

        if (plan.Footprint.Holes.Count > 0)
        {
            return Fail(DiagnosticCodes.NotSupported, "Perimeter/core of footprints with holes is not supported yet (S8, typologies).");
        }

        IReadOnlyList<Point2> v = plan.Footprint.Outer;
        int n = v.Count;
        Point2[] offset = Enumerable.Range(0, n).Select(i => Mitre(v[(i + n - 1) % n], v[i], v[(i + 1) % n])).ToArray();
        Result<Polygon2> core = Polygon2.Create(offset);
        bool keepsDirections = Enumerable.Range(0, n).All(i =>
        {
            Point2 a = v[i];
            Point2 b = v[(i + 1) % n];
            Point2 c = offset[i];
            Point2 d = offset[(i + 1) % n];
            return ((b.X - a.X) * (d.X - c.X)) + ((b.Y - a.Y) * (d.Y - c.Y)) > 0;
        });
        if (!core.IsSuccess || !keepsDirections || Polygon2.SignedArea(offset) <= 0)
        {
            return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter depth {_depth} m leaves no valid core for this footprint.");
        }

        var ops = new PolygonOps(Tolerances);
        var trapezoids = new List<(OrientationBin Bin, Polygon2 Shape)>();
        for (int i = 0; i < n; i++)
        {
            Polygon2 shape = Polygon2.Create(new[] { v[i], v[(i + 1) % n], offset[(i + 1) % n], offset[i] }).Value;
            trapezoids.Add((Orientation.Bin(Orientation.OutwardAzimuth(v[i], v[(i + 1) % n]) + plan.OrientationDegrees), shape));
        }

        double tiled = trapezoids.Sum(t => t.Shape.Area) + core.Value.Area;
        if (!Tolerances.AreaEquals(plan.Footprint.Area, tiled))
        {
            return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter zones and core cover {tiled:0.###} m² of a {plan.Footprint.Area:0.###} m² footprint.");
        }

        var targets = new List<TargetZone>();
        foreach (IGrouping<OrientationBin, (OrientationBin Bin, Polygon2 Shape)> bin in trapezoids.GroupBy(t => t.Bin).OrderBy(g => g.Key))
        {
            IReadOnlyList<Polygon2> merged = ops.Union(bin.Select(t => t.Shape));
            for (int k = 0; k < merged.Count; k++)
            {
                string suffix = merged.Count == 1 ? string.Empty : $"-{k + 1}";
                targets.Add(new TargetZone(new ZoneId($"P-{bin.Key}{suffix}"), $"Perimeter {bin.Key}{suffix}", SpaceType.Mixed, merged[k]));
            }
        }

        targets.Add(new TargetZone(new ZoneId("CORE"), "Core", SpaceType.Mixed, core.Value));
        return Result.Success<IReadOnlyList<TargetZone>>(targets);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe() =>
        new[] { new KeyValuePair<string, string>("PerimeterDepth", Provenance.Format(_depth)) };

    private Point2 Mitre(Point2 previous, Point2 vertex, Point2 next)
    {
        (double X, double Y) a = InwardNormal(previous, vertex);
        (double X, double Y) b = InwardNormal(vertex, next);
        double scale = _depth / (1.0 + (a.X * b.X) + (a.Y * b.Y));
        return new Point2(vertex.X + ((a.X + b.X) * scale), vertex.Y + ((a.Y + b.Y) * scale));
    }

    private static (double X, double Y) InwardNormal(Point2 from, Point2 to)
    {
        double length = from.DistanceTo(to);
        return (-(to.Y - from.Y) / length, (to.X - from.X) / length);
    }

    private static Result<IReadOnlyList<TargetZone>> Fail(string code, string message) =>
        Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(code, message));
}

/// <summary>Z3: the whole floor as one zone.</summary>
public sealed class SingleZonePerFloor : PlanSimplifier
{
    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public SingleZonePerFloor(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        SpaceType[] types = plan.Zones.Select(z => z.SpaceType).Distinct().ToArray();
        SpaceType type = types.Length == 1 ? types[0] : SpaceType.Mixed;
        return Result.Success<IReadOnlyList<TargetZone>>(new[] { new TargetZone(new ZoneId("FLOOR"), "Floor", type, plan.Footprint) });
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
  • [x] Step 5: Edit docs/decisions/ADR-002-geometry.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied. The first is the status line; the second adds the paragraph Unions of zones (S8.2) after Edge matching.

  1. Replace
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window)

with

Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window); unions of zones refined in S8.2
  1. Replace
**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall.

with

**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall.

**Unions of zones (S8.2).** Wherever zones are united into one zone, their footprints are united as tiles (`PolygonOps.UnionTiles`, [ADR-013](ADR-013-vertical-aggregation-methods.md)): the groups of *Semantic Merge* (Z1) as well as the storey outlines of the single-zone floor aggregators. A plain union of footprints that meet at T-junctions, such as the pinwheel dwellings of the point plate (`HDHM-TYP-007`, [ADR-006](ADR-006-plan-generation-mechanism.md)), splits the group or leaves slits once the plan is rotated off the axes, because rounding on the clipping grid moves the T-junction off its edge. A united zone may have holes, where it surrounds zones of another type (the point plate's dwellings around the core).
  • [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.SimplifierTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 30, Skipped: 0, Total: 30, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 152 core, 110 generators, and 320 integration tests passed on net8.0; git status lists exactly M docs/decisions/ADR-002-geometry.md, M src/Lod.Core/Simplification/Strategies.cs, and M tests/Lod.Integration.Tests/SimplifierTests.cs, and no *.received.txt: simplifier-SemanticMerge.txt and every other snapshot are unchanged, because the zones of axis-aligned plans share their vertices exactly and UnionTiles gives the same union there.

  • [x] Step 7: Commit
git add src/Lod.Core/Simplification/Strategies.cs tests/Lod.Integration.Tests/SimplifierTests.cs docs/decisions/ADR-002-geometry.md
git commit -m "fix(simplifiers): unite semantic-merge groups as tiles"

Task 3: Vertices on edges are tested at their original positions

Files: - Modify: src/Lod.Core/Geometry/VertexGrid.cs (S4.2 file), src/Lod.Core/Geometry/PolygonOps.cs (S2 file, last changed in S4.2), src/Lod.Core/Layout/LayoutSurfaceBuilder.cs (S2 file, last changed in S4.2), docs/decisions/ADR-002-geometry.md (Task 2) - Test: modify tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs (S2 file, last changed in S8.1)

Interfaces: - Consumes: VertexGrid (S4.2, internal: Key, Original, Parameter, the static IsStrictlyInside and IsOnSegment on grid cells within one grid unit), LayoutSurfaceBuilder.Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height) : Result<IReadOnlyList<Surface>>, PlanMotion(double rotationDegrees, double translationX, double translationY) and Polygon2.Transform(PlanMotion) (S4.2), DiagnosticCodes.UnmatchedEdge (S2). - Produces: VertexGrid.IsStrictlyInside(GridPoint a, GridPoint b, GridPoint p) and IsOnSegment(…) are instance methods that measure the original points of the three cells: within the distance tolerance of the line (Distance, 1e-6 m, the grid's cell size), and strictly between the ends by more than half the tolerance, or within the segment extended by the tolerance; LayoutSurfaceBuilder (wall splitting and the boundary test OnRing) and PolygonOps.UnionTiles (T-junction insertion) call them on their grid. The tolerance value is unchanged.

  • [x] Step 1: Write the failing test — replace tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs (full file)

One new test, RotatedTJunctionsOnTheBoundaryAndBetweenZonesAreRecognised: the point plate's five zones built directly, rotated by 131.4° and moved by (15.08, −153.9) m; the build succeeds with 8 outdoor walls of 264 m² in all and 16 interzone walls. Every other test is 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 Xunit;

namespace Lod.Core.Tests.Layout;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    [Fact]
    public void RotatedTJunctionsOnTheBoundaryAndBetweenZonesAreRecognised()
    {
        // A pinwheel of four 8 m deep zones around a 6 m core, rotated by 131.4° and moved: the zone corners at (14, 0), (22, 14), (8, 22), and
        // (0, 8) lie on the boundary between its corners, and snapped to the distance grid the one at (8, 22) lies 1.10 grid units off the snapped
        // boundary edge although it is on the exact edge; the edge test measures the original points against the distance tolerance.
        var motion = new PlanMotion(131.4, 15.08, -153.9);
        LayoutZone Moved(string id, double x0, double y0, double x1, double y1) => new(new ZoneId(id), Polygon2.Rectangle(x0, y0, x1, y1).Transform(motion));

        Result<IReadOnlyList<Surface>> result = _builder.Build(
            Polygon2.Rectangle(0, 0, 22, 22).Transform(motion),
            new[] { Moved("ST", 8, 8, 14, 14), Moved("U1", 0, 0, 14, 8), Moved("U2", 14, 0, 22, 14), Moved("U3", 8, 14, 22, 22), Moved("U4", 0, 8, 8, 22) },
            3.0);

        Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
        WallSurface[] walls = result.Value.OfType<WallSurface>().ToArray();
        Assert.Equal(8, walls.Count(w => w.Boundary == BoundaryCondition.Outdoors));
        Assert.Equal(22.0 * 4 * 3.0, walls.Where(w => w.Boundary == BoundaryCondition.Outdoors).Sum(w => w.Area), 6);
        Assert.Equal(16, walls.Count(w => w.Boundary == BoundaryCondition.Interzone));
    }

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

    private Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, params LayoutZone[] zones) => _builder.Build(footprint, zones, 3.0);
}
  • [x] Step 2: Run the test and see it fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests.RotatedTJunctionsOnTheBoundaryAndBetweenZonesAreRecognised"

Expected on net8.0: Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: … - Lod.Core.Tests.dll (net8.0), with the message (the corner of U3 and U4 at (8, 22) lies on the boundary edge, but snapped to the grid it is 1.10 grid units off the snapped edge):

Failed Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests.RotatedTJunctionsOnTheBoundaryAndBetweenZonesAreRecognised
  Error UnmatchedEdge [U3]: Edge (-15.971, -151.946)-(-6.713, -162.448) is neither shared with another zone nor on the plan boundary (gap or overlap).; Error UnmatchedEdge [U4]: Edge (-6.713, -162.448)-(-1.422, -168.449) is neither shared with another zone nor on the plan boundary (gap or overlap).
  • [x] Step 3: Implement — replace src/Lod.Core/Geometry/VertexGrid.cs (full file)
using System;
using System.Collections.Generic;

namespace Lod.Core.Geometry;

/// <summary>A cell of the <see cref="VertexGrid"/>, in grid units.</summary>
/// <param name="X">X index.</param>
/// <param name="Y">Y index.</param>
internal readonly record struct GridPoint(long X, long Y);

/// <summary>
/// Snaps points to the distance grid, remembers the first original point of each grid cell, and tests whether a grid point lies on a segment
/// within the distance tolerance. Shared by the layout surface builder and the union of tiles, so both recognise the same vertices and T-junctions.
/// The segment tests measure the cells' original points, not the snapped cells: snapping a point and both ends of a segment it lies on moves the
/// point up to √2 grid units off the snapped segment, so on rotated plans a T-junction exactly on an edge could fail a test on snapped cells.
/// </summary>
internal sealed class VertexGrid
{
    private readonly double _cell;
    private readonly Dictionary<GridPoint, Point2> _originals = new();

    /// <summary>Creates an empty grid.</summary>
    /// <param name="cell">Grid spacing, m: the distance tolerance.</param>
    public VertexGrid(double cell)
    {
        _cell = cell;
    }

    /// <summary>Registered cells, in registration order.</summary>
    public IEnumerable<GridPoint> Keys => _originals.Keys;

    /// <summary>The cell of a point.</summary>
    /// <param name="p">The point.</param>
    /// <returns>The cell.</returns>
    public GridPoint Key(Point2 p) => new((long)Math.Round(p.X / _cell, MidpointRounding.AwayFromZero), (long)Math.Round(p.Y / _cell, MidpointRounding.AwayFromZero));

    /// <summary>Registers a point; the first point registered in a cell stands for the cell.</summary>
    /// <param name="p">The point.</param>
    public void Register(Point2 p)
    {
        GridPoint key = Key(p);
        if (!_originals.ContainsKey(key))
        {
            _originals.Add(key, p);
        }
    }

    /// <summary>The first point registered in a cell.</summary>
    /// <param name="key">The cell.</param>
    /// <returns>The point.</returns>
    public Point2 Original(GridPoint key) => _originals[key];

    /// <summary>Position of <paramref name="p"/> along <paramref name="a"/>–<paramref name="b"/>, 0 at <paramref name="a"/> and 1 at <paramref name="b"/>.</summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>The parameter.</returns>
    public static double Parameter(GridPoint a, GridPoint b, GridPoint p)
    {
        double dx = b.X - a.X;
        double dy = b.Y - a.Y;
        return (((p.X - a.X) * dx) + ((p.Y - a.Y) * dy)) / ((dx * dx) + (dy * dy));
    }

    /// <summary>
    /// Within the distance tolerance of the line and strictly between the end points (by more than half the tolerance), measured between the
    /// original points of the cells.
    /// </summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>Whether <paramref name="p"/> splits the segment.</returns>
    public bool IsStrictlyInside(GridPoint a, GridPoint b, GridPoint p)
    {
        (double distance, double along, double length) = Measure(a, b, p);
        return distance <= _cell && along > 0.5 * _cell && along < length - (0.5 * _cell);
    }

    /// <summary>Within the distance tolerance of the segment, end points included, measured between the original points of the cells.</summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>Whether <paramref name="p"/> lies on the segment.</returns>
    public bool IsOnSegment(GridPoint a, GridPoint b, GridPoint p)
    {
        if (p == a || p == b)
        {
            return true;
        }

        (double distance, double along, double length) = Measure(a, b, p);
        return distance <= _cell && along >= -_cell && along <= length + _cell;
    }

    /// <summary>Distance of the original point of <paramref name="p"/> from the line through the original points of <paramref name="a"/> and
    /// <paramref name="b"/>, its position along that segment from <paramref name="a"/>, and the segment's length, m.</summary>
    private (double Distance, double Along, double Length) Measure(GridPoint a, GridPoint b, GridPoint p)
    {
        Point2 start = Original(a);
        Point2 end = Original(b);
        Point2 point = Original(p);
        double dx = end.X - start.X;
        double dy = end.Y - start.Y;
        double length = Math.Sqrt((dx * dx) + (dy * dy));
        double px = point.X - start.X;
        double py = point.Y - start.Y;
        return (Math.Abs((dx * py) - (dy * px)) / length, ((px * dx) + (py * dy)) / length, length);
    }
}
  • [x] Step 4: Implement — replace src/Lod.Core/Geometry/PolygonOps.cs (full file)

Only the T-junction test of UnionTiles (now grid.IsStrictlyInside) and its summary change.

using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using Lod.Core.Common;

namespace Lod.Core.Geometry;

/// <summary>
/// Polygon boolean operations on Clipper2 (ADR-002). Results are snapped to the <see cref="ToleranceSettings.Distance"/> grid.
/// </summary>
public sealed class PolygonOps
{
    private readonly int _precision;
    private readonly double _distance;

    /// <summary>Creates the operations with the clipping precision of <paramref name="tolerances"/>.</summary>
    /// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.DecimalPrecision"/> sets the snapping grid.</param>
    public PolygonOps(ToleranceSettings tolerances)
    {
        _precision = tolerances.DecimalPrecision;
        _distance = tolerances.Distance;
    }

    /// <summary>Intersection of two polygons.</summary>
    /// <param name="a">First polygon.</param>
    /// <param name="b">Second polygon.</param>
    /// <returns>Zero or more polygons.</returns>
    public IReadOnlyList<Polygon2> Intersect(Polygon2 a, Polygon2 b) => Execute(ClipType.Intersection, new[] { a }, new[] { b });

    /// <summary>Union of polygons.</summary>
    /// <param name="polygons">Polygons to merge.</param>
    /// <returns>Disjoint polygons covering the union.</returns>
    public IReadOnlyList<Polygon2> Union(IEnumerable<Polygon2> polygons) => Execute(ClipType.Union, polygons, Enumerable.Empty<Polygon2>());

    /// <summary>
    /// Union of polygons that tile a region and share their edges up to the distance tolerance, such as the zones of a storey (ADR-013). Before
    /// clipping, vertices in the same cell of the distance grid are unified, and every vertex lying on another tile's edge within the distance
    /// tolerance (a T-junction) is inserted into that edge, as <see cref="Layout.LayoutSurfaceBuilder"/> splits walls. Shared edges then cancel exactly;
    /// a plain <see cref="Union"/> of tiles that are not axis-aligned can leave slits or splits where rounding on the clipping grid moves a
    /// T-junction off its edge. Inserted vertices lie on their edges within the distance tolerance, so the outline is not moved.
    /// </summary>
    /// <param name="tiles">Polygons tiling a region.</param>
    /// <returns>Disjoint polygons covering the union.</returns>
    public IReadOnlyList<Polygon2> UnionTiles(IReadOnlyList<Polygon2> tiles)
    {
        var grid = new VertexGrid(_distance);
        foreach (Point2 p in tiles.SelectMany(t => t.Rings).SelectMany(r => r))
        {
            grid.Register(p);
        }

        GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
        IEnumerable<Point2> Split(IReadOnlyList<Point2> ring)
        {
            for (int i = 0; i < ring.Count; i++)
            {
                GridPoint a = grid.Key(ring[i]);
                GridPoint b = grid.Key(ring[(i + 1) % ring.Count]);
                yield return grid.Original(a);
                if (a == b)
                {
                    continue;
                }

                foreach (GridPoint cut in vertices.Where(v => v != a && v != b && grid.IsStrictlyInside(a, b, v)).OrderBy(v => VertexGrid.Parameter(a, b, v)))
                {
                    yield return grid.Original(cut);
                }
            }
        }

        // A tile whose rings collapse on the grid cannot be split; it is clipped as given.
        Polygon2[] prepared = tiles
            .Select(t => Polygon2.Create(Split(t.Outer).ToArray(), t.Holes.Select(h => Split(h).ToArray()).ToArray()) is { IsSuccess: true } split ? split.Value : t)
            .ToArray();
        return Union(prepared);
    }

    /// <summary>Part of <paramref name="subject"/> not covered by <paramref name="clips"/>.</summary>
    /// <param name="subject">Polygon to cut.</param>
    /// <param name="clips">Polygons to remove.</param>
    /// <returns>Zero or more polygons.</returns>
    public IReadOnlyList<Polygon2> Difference(Polygon2 subject, IEnumerable<Polygon2> clips) => Execute(ClipType.Difference, new[] { subject }, clips);

    /// <summary>Area of the intersection of two polygons.</summary>
    /// <param name="a">First polygon.</param>
    /// <param name="b">Second polygon.</param>
    /// <returns>Overlap area, m².</returns>
    public double IntersectionArea(Polygon2 a, Polygon2 b) => Intersect(a, b).Sum(p => p.Area);

    private IReadOnlyList<Polygon2> Execute(ClipType clipType, IEnumerable<Polygon2> subjects, IEnumerable<Polygon2> clips)
    {
        var clipper = new ClipperD(_precision);
        clipper.AddSubject(ToPaths(subjects));
        clipper.AddClip(ToPaths(clips));
        var tree = new PolyTreeD();
        clipper.Execute(clipType, FillRule.NonZero, tree);
        var result = new List<Polygon2>();
        Collect(tree, result);
        return result;
    }

    private static PathsD ToPaths(IEnumerable<Polygon2> polygons)
    {
        var paths = new PathsD();
        foreach (IReadOnlyList<Point2> ring in polygons.SelectMany(p => p.Rings))
        {
            var path = new PathD(ring.Count);
            path.AddRange(ring.Select(p => new PointD(p.X, p.Y)));
            paths.Add(path);
        }

        return paths;
    }

    private static void Collect(PolyPathD parent, List<Polygon2> result)
    {
        for (int i = 0; i < parent.Count; i++)
        {
            PolyPathD outer = parent[i];
            var holes = new List<IEnumerable<Point2>>();
            for (int j = 0; j < outer.Count; j++)
            {
                PolyPathD hole = outer[j];
                holes.Add(ToPoints(hole.Polygon!));
                Collect(hole, result);
            }

            Result<Polygon2> polygon = Polygon2.Create(ToPoints(outer.Polygon!), holes);
            if (polygon.IsSuccess)
            {
                result.Add(polygon.Value);
            }
        }
    }

    private static IEnumerable<Point2> ToPoints(PathD path) => path.Select(p => new Point2(p.x, p.y));
}
  • [x] Step 5: Implement — replace src/Lod.Core/Layout/LayoutSurfaceBuilder.cs (full file)

The wall split calls grid.IsStrictlyInside, and OnRing takes the grid and calls grid.IsOnSegment.

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

namespace Lod.Core.Layout;

/// <summary>A zone footprint to build surfaces for.</summary>
/// <param name="Id">Zone ID.</param>
/// <param name="Footprint">Zone floor polygon.</param>
public sealed record LayoutZone(ZoneId Id, Polygon2 Footprint);

/// <summary>
/// Builds the surfaces of a single-storey layout: walls split wherever another zone's vertex touches them, paired across zones
/// (interzone), or on the plan boundary (outdoors), then merged into maximal collinear runs; plus one unresolved floor and ceiling per zone.
/// Edges that are neither shared nor on the boundary indicate gaps or overlaps and are errors.
/// </summary>
public sealed class LayoutSurfaceBuilder
{
    private readonly ToleranceSettings _tolerances;

    /// <summary>Creates the builder.</summary>
    /// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.Distance"/> is the vertex matching grid.</param>
    public LayoutSurfaceBuilder(ToleranceSettings tolerances)
    {
        _tolerances = tolerances;
    }

    /// <summary>Builds walls (without windows), floors, and ceilings.</summary>
    /// <param name="footprint">Plan boundary; walls on it are outdoors.</param>
    /// <param name="zones">Zones tiling the footprint.</param>
    /// <param name="height">Floor-to-floor height, m.</param>
    /// <returns>Surfaces grouped by zone (walls, floor, ceiling), or errors for gaps and overlaps.</returns>
    public Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height)
    {
        var grid = new VertexGrid(_tolerances.Distance);
        foreach (Point2 p in footprint.Rings.SelectMany(r => r).Concat(zones.SelectMany(z => z.Footprint.Rings.SelectMany(r => r))))
        {
            grid.Register(p);
        }

        GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
        var segments = new List<Segment>();
        for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
        {
            int ringIndex = 0;
            foreach (IReadOnlyList<Point2> ring in zones[zoneIndex].Footprint.Rings)
            {
                AddRingSegments(grid, vertices, zoneIndex, ringIndex++, ring, segments);
            }
        }

        var diagnostics = new List<Diagnostic>();
        Classify(grid, footprint, zones, segments, diagnostics);
        if (diagnostics.Count > 0)
        {
            return Result.Failure<IReadOnlyList<Surface>>(diagnostics);
        }

        var surfaces = new List<Surface>();
        for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
        {
            LayoutZone zone = zones[zoneIndex];
            int wallNumber = 1;
            foreach (IGrouping<int, Segment> ring in segments.Where(s => s.Zone == zoneIndex).GroupBy(s => s.Ring))
            {
                foreach (List<Segment> run in MergeRuns(ring.ToList()))
                {
                    Segment first = run[0];
                    surfaces.Add(new WallSurface(
                        new SurfaceId($"{zone.Id.Value}/W{wallNumber++}"),
                        zone.Id,
                        grid.Original(first.A),
                        grid.Original(run[run.Count - 1].B),
                        elevation: 0.0,
                        height,
                        first.Boundary,
                        first.Adjacent is int adjacent ? zones[adjacent].Id : null,
                        Array.Empty<Window>()));
                }
            }

            surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/F"), zone.Id, HorizontalKind.Floor, zone.Footprint, 0.0, BoundaryCondition.Unresolved, null));
            surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/C"), zone.Id, HorizontalKind.Ceiling, zone.Footprint, height, BoundaryCondition.Unresolved, null));
        }

        return Result.Success<IReadOnlyList<Surface>>(surfaces);
    }

    private static void AddRingSegments(VertexGrid grid, GridPoint[] vertices, int zone, int ring, IReadOnlyList<Point2> points, List<Segment> segments)
    {
        for (int i = 0; i < points.Count; i++)
        {
            GridPoint a = grid.Key(points[i]);
            GridPoint b = grid.Key(points[(i + 1) % points.Count]);
            if (a == b)
            {
                continue;
            }

            GridPoint[] cuts = vertices
                .Where(v => v != a && v != b && grid.IsStrictlyInside(a, b, v))
                .OrderBy(v => VertexGrid.Parameter(a, b, v))
                .ToArray();
            GridPoint previous = a;
            foreach (GridPoint cut in cuts.Concat(new[] { b }))
            {
                segments.Add(new Segment(zone, ring, previous, cut));
                previous = cut;
            }
        }
    }

    private static void Classify(VertexGrid grid, Polygon2 footprint, IReadOnlyList<LayoutZone> zones, List<Segment> segments, List<Diagnostic> diagnostics)
    {
        var byDirection = segments.GroupBy(s => (s.A, s.B)).ToDictionary(g => g.Key, g => g.ToList());
        GridPoint[][] boundaryRings = footprint.Rings.Select(r => r.Select(grid.Key).ToArray()).ToArray();
        foreach (Segment segment in segments)
        {
            string subject = zones[segment.Zone].Id.Value;
            if (byDirection[(segment.A, segment.B)].Count > 1)
            {
                diagnostics.Add(Diagnostic.Error(DiagnosticCodes.OverlappingZones, $"Edge {Describe(grid, segment)} is used by more than one zone in the same direction.", subject));
                continue;
            }

            if (byDirection.TryGetValue((segment.B, segment.A), out List<Segment>? opposite))
            {
                segment.Boundary = BoundaryCondition.Interzone;
                segment.Adjacent = opposite[0].Zone;
            }
            else if (boundaryRings.Any(ring => OnRing(grid, ring, segment.A, segment.B)))
            {
                segment.Boundary = BoundaryCondition.Outdoors;
            }
            else
            {
                diagnostics.Add(Diagnostic.Error(DiagnosticCodes.UnmatchedEdge, $"Edge {Describe(grid, segment)} is neither shared with another zone nor on the plan boundary (gap or overlap).", subject));
            }
        }
    }

    private IEnumerable<List<Segment>> MergeRuns(List<Segment> ring)
    {
        int start = Enumerable.Range(0, ring.Count).FirstOrDefault(i => !CanMerge(ring[(i + ring.Count - 1) % ring.Count], ring[i]));
        List<Segment>? run = null;
        for (int k = 0; k < ring.Count; k++)
        {
            Segment segment = ring[(start + k) % ring.Count];
            if (run is not null && CanMerge(run[run.Count - 1], segment))
            {
                run.Add(segment);
                continue;
            }

            if (run is not null)
            {
                yield return run;
            }

            run = new List<Segment> { segment };
        }

        if (run is not null)
        {
            yield return run;
        }
    }

    private bool CanMerge(Segment previous, Segment next)
    {
        if (previous.B != next.A || previous.Boundary != next.Boundary || previous.Adjacent != next.Adjacent)
        {
            return false;
        }

        double ax = previous.B.X - previous.A.X;
        double ay = previous.B.Y - previous.A.Y;
        double bx = next.B.X - next.A.X;
        double by = next.B.Y - next.A.Y;
        double lengths = Math.Sqrt((ax * ax) + (ay * ay)) * Math.Sqrt((bx * bx) + (by * by));
        double sine = ((ax * by) - (ay * bx)) / lengths;
        double cosine = ((ax * bx) + (ay * by)) / lengths;
        return cosine > 0 && Math.Abs(sine) <= Math.Sin(_tolerances.Angle);
    }

    private static bool OnRing(VertexGrid grid, GridPoint[] ring, GridPoint a, GridPoint b)
    {
        for (int i = 0; i < ring.Length; i++)
        {
            GridPoint p = ring[i];
            GridPoint q = ring[(i + 1) % ring.Length];
            if (grid.IsOnSegment(p, q, a) && grid.IsOnSegment(p, q, b))
            {
                return true;
            }
        }

        return false;
    }

    private static string Describe(VertexGrid grid, Segment segment)
    {
        Point2 a = grid.Original(segment.A);
        Point2 b = grid.Original(segment.B);
        return $"({a.X:0.###}, {a.Y:0.###})-({b.X:0.###}, {b.Y:0.###})";
    }

    private sealed class Segment
    {
        public Segment(int zone, int ring, GridPoint a, GridPoint b)
        {
            Zone = zone;
            Ring = ring;
            A = a;
            B = b;
        }

        public int Zone { get; }

        public int Ring { get; }

        public GridPoint A { get; }

        public GridPoint B { get; }

        public BoundaryCondition Boundary { get; set; } = BoundaryCondition.Unresolved;

        public int? Adjacent { get; set; }
    }
}
  • [x] Step 6: Edit docs/decisions/ADR-002-geometry.md

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

  1. Replace
**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall.

with

**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall. Whether a vertex lies on an edge, to split the edge there or to put a segment on the plan boundary, is measured between the original positions of the vertices against the `Distance` tolerance, not between their grid cells (S8.2): snapping a T-junction and both ends of its edge independently can move it up to √2 grid units off the snapped edge, so on a rotated plan a zone corner exactly on the boundary, such as a stair corner on a façade, was not recognised and the rebuilt surfaces of *Semantic Merge* failed with `UnmatchedEdge`.
  • [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8, Duration: … - Lod.Core.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 153 core, 110 generators, and 320 integration tests passed on net8.0; git status lists exactly the five modified files of this task and no *.received.txt.

  • [x] Step 8: Commit
git add src/Lod.Core/Geometry/VertexGrid.cs src/Lod.Core/Geometry/PolygonOps.cs src/Lod.Core/Layout/LayoutSurfaceBuilder.cs tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs docs/decisions/ADR-002-geometry.md
git commit -m "fix(geometry): test vertices on edges at their original positions"

Task 4: Unions of tiles keep the tiles' own vertices

Files: - Modify: src/Lod.Core/Geometry/PolygonOps.cs, src/Lod.Core/Geometry/VertexGrid.cs (Task 3), docs/decisions/ADR-002-geometry.md (Task 3), docs/decisions/ADR-013-vertical-aggregation-methods.md (S4.2 document, last changed in S5) - Test: modify tests/Lod.Core.Tests/Geometry/GeometryTests.cs (S2 file, last changed in S4.2), tests/Lod.Integration.Tests/AggregatorTests.cs (S4 file, last changed in S5)

Interfaces: - Consumes: PolygonOps.UnionTiles and VertexGrid (Task 3); GalleryBarGenerator, GalleryBarParameters.Default, GalleryBarPresets (S8.1); SemanticMerge (Task 2); test support Pipeline.Aggregator(string name), Pipeline.Tolerances (S4.2); FloorEntry(IFloor, int), BuildingValidator(ToleranceSettings).Validate; DiagnosticCodes.FacadeNotCovered (S3, D-041). - Produces: VertexGrid.Restore(Point2 p) : Point2 (the first point registered in the cell of p, or p when the cell is empty); UnionTiles maps every vertex of the clipped union through Restore, so the result lies on the tiles' own vertices. United outlines lie on their sources' façade lines, and uniting zones that were themselves united (the single-zone aggregators on a Semantic Merge floor) adds no rounding.

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

One new test, UnionOfTilesKeepsTheTilesOwnVertices: the four rotated and moved pinwheel tiles unite into one ring with one hole whose every vertex is one of the tiles' corners. Every other test is unchanged.

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

namespace Lod.Core.Tests.Geometry;

public sealed class GeometryTests
{
    private readonly PolygonOps _ops = new(ToleranceSettings.Default);

    [Fact]
    public void PolygonNormalisesOrientationAndComputesAreaWithHoles()
    {
        Polygon2 polygon = Polygon2.Create(
            new[] { new Point2(0, 0), new Point2(0, 10), new Point2(10, 10), new Point2(10, 0) },
            new[] { new[] { new Point2(2, 2), new Point2(4, 2), new Point2(4, 4), new Point2(2, 4) } }).Value;

        Assert.True(Polygon2.SignedArea(polygon.Outer) > 0);
        Assert.True(Polygon2.SignedArea(polygon.Holes[0]) < 0);
        Assert.Equal(96.0, polygon.Area, 12);
    }

    [Fact]
    public void PolygonDropsClosingAndRepeatedPoints()
    {
        Polygon2 polygon = Polygon2.Create(new[] { new Point2(0, 0), new Point2(1, 0), new Point2(1, 0), new Point2(1, 1), new Point2(0, 0) }).Value;

        Assert.Equal(3, polygon.Outer.Count);
    }

    [Fact]
    public void PolygonRejectsDegenerateRings()
    {
        Assert.Contains(
            Polygon2.Create(new[] { new Point2(0, 0), new Point2(1, 0), new Point2(2, 0) }).Diagnostics,
            d => d.Code == DiagnosticCodes.DegeneratePolygon);
    }

    [Fact]
    public void IntersectionOfOverlappingRectangles()
    {
        double area = _ops.IntersectionArea(Polygon2.Rectangle(0, 0, 10, 10), Polygon2.Rectangle(5, 5, 15, 15));

        Assert.Equal(25.0, area, 9);
    }

    [Fact]
    public void TouchingRectanglesDoNotIntersect()
    {
        Assert.Empty(_ops.Intersect(Polygon2.Rectangle(0, 0, 10, 10), Polygon2.Rectangle(10, 0, 20, 10)));
    }

    [Fact]
    public void UnionOfAdjacentRectanglesIsOnePolygon()
    {
        Polygon2 union = Assert.Single(_ops.Union(new[] { Polygon2.Rectangle(0, 0, 10, 5), Polygon2.Rectangle(10, 0, 20, 5) }));

        Assert.Equal(100.0, union.Area, 9);
    }

    [Fact]
    public void DifferenceKeepsHoles()
    {
        Polygon2 ring = Assert.Single(_ops.Difference(Polygon2.Rectangle(0, 0, 10, 10), new[] { Polygon2.Rectangle(3, 3, 7, 7) }));

        Assert.Single(ring.Holes);
        Assert.Equal(84.0, ring.Area, 9);
    }

    [Theory]
    [InlineData(0, 0, 10, 0, 180.0)]
    [InlineData(10, 0, 10, 10, 90.0)]
    [InlineData(10, 10, 0, 10, 0.0)]
    [InlineData(0, 10, 0, 0, 270.0)]
    public void OutwardAzimuthOfCounterClockwiseRectangleEdges(double x1, double y1, double x2, double y2, double azimuth)
    {
        Assert.Equal(azimuth, Orientation.OutwardAzimuth(new Point2(x1, y1), new Point2(x2, y2)), 9);
    }

    [Theory]
    [InlineData(0.0, OrientationBin.North)]
    [InlineData(44.9, OrientationBin.North)]
    [InlineData(45.0, OrientationBin.East)]
    [InlineData(180.0, OrientationBin.South)]
    [InlineData(270.0, OrientationBin.West)]
    [InlineData(315.0, OrientationBin.North)]
    [InlineData(-90.0, OrientationBin.West)]
    public void OrientationBins(double azimuth, OrientationBin bin)
    {
        Assert.Equal(bin, Orientation.Bin(azimuth));
    }

    [Fact]
    public void CenteredWindowScalesTheWallRectangle()
    {
        Window window = Window.Centered(10.0, 3.0, 0.25 * 30.0)!;

        Assert.Equal(5.0, window.Width, 12);
        Assert.Equal(1.5, window.Height, 12);
        Assert.Equal(0.75, window.SillHeight, 12);
        Assert.Equal(7.5, window.Area, 12);
    }

    [Fact]
    public void ZeroGlazingHasNoWindow()
    {
        Assert.Null(Window.Centered(10.0, 3.0, 0.0));
    }

    [Fact]
    public void GlazingMustBeSmallerThanTheWall()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => Window.Centered(10.0, 3.0, 30.0));
    }

    [Fact]
    public void TranslateMovesEveryRing()
    {
        Polygon2 moved = Polygon2.Rectangle(0, 0, 1, 1).Translate(2, 3);

        Assert.Equal(2.0, moved.Outer.Min(p => p.X));
        Assert.Equal(3.0, moved.Outer.Min(p => p.Y));
    }

    [Fact]
    public void UnionOfRotatedTilesWithTJunctionsIsOneOutline()
    {
        // A 4 m x 18 m strip and two 20 m x 9 m rectangles beside it: the rectangles' corners at (4, 9) lie on the strip's edge (a T-junction).
        // Rotated by 7.3°, rounding on the clipping grid moves that corner off the edge, which a plain union turns into a slit or a split.
        var motion = new PlanMotion(7.3, 0.0, 0.0);
        Polygon2[] tiles =
        {
            Polygon2.Rectangle(0, 0, 4, 18).Transform(motion),
            Polygon2.Rectangle(4, 0, 24, 9).Transform(motion),
            Polygon2.Rectangle(4, 9, 24, 18).Transform(motion),
        };

        Polygon2 outline = Assert.Single(_ops.UnionTiles(tiles));

        Assert.Empty(outline.Holes);
        Assert.Equal(432.0, outline.Area, 5);
        var back = new PlanMotion(-7.3, 0.0, 0.0);
        Assert.All(outline.Outer.Select(back.Apply), p => Assert.True(
            Math.Abs(p.X) < 1e-5 || Math.Abs(p.X - 24) < 1e-5 || Math.Abs(p.Y) < 1e-5 || Math.Abs(p.Y - 18) < 1e-5,
            $"({p.X}, {p.Y}) is not on the outline"));
    }

    [Fact]
    public void UnionOfTilesKeepsTheTilesOwnVertices()
    {
        // Rotated and moved pinwheel tiles around a core: clipping rounds every vertex to the 1e-6 m grid, up to 0.7 grid units off the true
        // corner; the union of tiles returns the tiles' own vertices, so repeated unions do not accumulate rounding.
        var motion = new PlanMotion(131.4, 15.08, -153.9);
        Polygon2[] tiles =
        {
            Polygon2.Rectangle(0, 0, 14, 8).Transform(motion),
            Polygon2.Rectangle(14, 0, 22, 14).Transform(motion),
            Polygon2.Rectangle(8, 14, 22, 22).Transform(motion),
            Polygon2.Rectangle(0, 8, 8, 22).Transform(motion),
        };
        Point2[] corners = tiles.SelectMany(t => t.Outer).ToArray();

        Polygon2 ring = Assert.Single(_ops.UnionTiles(tiles));

        Assert.Single(ring.Holes);
        Assert.All(ring.Rings.SelectMany(r => r), p => Assert.Contains(p, corners));
    }
}
  • [x] Step 2: Replace tests/Lod.Integration.Tests/AggregatorTests.cs (full file)

One new theory, SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor (2 cases): the default gallery bar of S8.1, rotated by 321.2° and moved by (136.4, −118.8) m, through Semantic Merge and, given three times (1, 3, 1), to Single Zone per Floor Type and Single Zone Building; each building is produced and passes validation. The usings Lod.Core.Plans, Lod.Core.Programs, and Lod.Generators.GalleryBar are added; every other test is unchanged.

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.GalleryBar;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Integration.Tests;

public sealed class AggregatorTests
{
    [Fact]
    public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
    {
        IFloor floor = Pipeline.DetailedFloor();

        IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;

        Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
        HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
        Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
        Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
        Assert.Equal(
            horizontal.Length - 12,
            horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
        Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
    }

    [Fact]
    public void RepresentativeStoreysKeepTheirTrueElevations()
    {
        IFloor floor = Pipeline.DetailedFloor();

        IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;

        // Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
        Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
        Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
        Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
        Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
    }

    [Fact]
    public void ZoneMultiplierListsTheStoreysItRepresentsButDoesNotModel()
    {
        IFloor floor = Pipeline.DetailedFloor();
        FloorEntry[] entries = { new(floor, 1), new(floor, 8), new(floor, 1) };

        IReadOnlyList<PlacedStorey> unmodelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(entries).Value;

        // Storey 5 represents storeys 1-8 (D-045); the other seven are represented but not modelled, at their true elevations.
        Assert.Equal(new[] { 1, 2, 3, 4, 6, 7, 8 }, unmodelled.Select(s => s.Index));
        Assert.Equal(new[] { 3.0, 6.0, 9.0, 12.0, 18.0, 21.0, 24.0 }, unmodelled.Select(s => s.Elevation));
        Assert.All(unmodelled, s => Assert.Equal(floor.Zones.Select(z => z.FloorArea), s.Zones.Select(z => z.FloorArea)));
        Assert.All(unmodelled.SelectMany(s => s.Zones), z => Assert.Equal(1, z.Multiplier));
    }

    [Fact]
    public void ZoneMultiplierWithoutMultipliedStoreysModelsEveryStorey()
    {
        IFloor floor = Pipeline.DetailedFloor();

        Assert.Empty(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new[] { new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value);
        Assert.Contains(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new FloorEntry[0]).Diagnostics, d => d.Code == DiagnosticCodes.NoFloors);
    }

    [Theory]
    [InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
    [InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
    [InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
    public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
    {
        IFloor floor = Pipeline.DetailedFloor();

        Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);

        Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
        Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
        Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
    }

    [Theory]
    [InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
    [InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
    public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
    {
        // Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
        IFloor wide = Pipeline.DetailedFloor();
        IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
        FloorEntry[] entries = types.Split(',')
            .Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
            .Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
            .ToArray();

        Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
        IGeneratedBuilding building = result.Value;
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);

        Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
        Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
        Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
        Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
        HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
        Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
        double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
        Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
    }

    [Fact]
    public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
    {
        // D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
        IFloor wide = Pipeline.DetailedFloor();
        IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
        FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };

        Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
            .Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
        HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
            .Surfaces.OfType<HorizontalSurface>().ToArray();

        Assert.NotEmpty(modelled);
        Assert.All(modelled, h =>
        {
            HorizontalSurface source = stacked[h.Id];
            Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
            Assert.Null(h.AdjacentZone);
            Assert.Equal(source.Zone, h.Zone);
            Assert.Equal(source.Kind, h.Kind);
            Assert.Equal(source.Elevation, h.Elevation);
            Assert.Equal(source.Area, h.Area, 9);
        });
    }

    [Theory]
    [InlineData("Stack")]
    [InlineData("StackedFloorZoneMultiplier")]
    [InlineData("SingleZonePerFloorType")]
    [InlineData("SingleZoneBuilding")]
    public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
    {
        // Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
        IFloor wide = Pipeline.DetailedFloor();
        IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });

        Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });

        Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
        Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
        Assert.Equal("L2", warning.Subject);
        Assert.Contains("storey 2", warning.Message);
        Assert.Contains("180.000000 m²", warning.Message);
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
        Assert.True(report.Passed, report.Describe());
    }

    [Theory]
    [InlineData("Stack")]
    [InlineData("StackedFloorZoneMultiplier")]
    [InlineData("SingleZonePerFloorType")]
    [InlineData("SingleZoneBuilding")]
    public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
    {
        IFloor wide = Pipeline.DetailedFloor();
        IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });

        Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });

        Assert.True(result.IsSuccess);
        Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
    }

    [Fact]
    public void BottomMiddleTopMultiplierMatchesTheStackExactly()
    {
        IFloor floor = Pipeline.DetailedFloor();

        IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);

        Assert.True(report.Passed, report.Describe());
        Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
    }

    [Fact]
    public void SingleZonePerFloorTypeHasOneZonePerFloorEntry()
    {
        IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;

        Assert.Equal(new[] { "E0", "E1" }, building.Zones.Select(z => z.Id.Value));
        Assert.Equal(new[] { "Floor type 0", "Floor type 1" }, building.Zones.Select(z => z.Name));
        Assert.Equal(new[] { 1, 3 }, building.Zones.Select(z => z.Parts.Count));
        Assert.Equal(new[] { 6, 18 }, building.Zones.Select(z => z.SourceZones.Count));
        Assert.All(building.Zones, z => Assert.Equal(1, z.Multiplier));
        Assert.Equal(3 * 432.0 * 3.0, building.Zones[1].Volume, 6);
        Assert.Equal(new[] { 0.0, 3.0, 6.0, 9.0 }, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation));
    }

    [Fact]
    public void SingleZonePerFloorTypeKeepsTheSlabsBetweenEntriesAsInterzonePairs()
    {
        IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;

        // Within E1 the slabs at 6 m and 9 m become internal mass (D-031); the slab at 3 m lies between E0 and E1 (D-068, D-071).
        Assert.Equal(new[] { ("E1", 6.0), ("E1", 9.0) }, building.InternalMasses.Select(m => (m.Zone.Value, m.Elevation)));
        Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
        HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
        Assert.All(interzone, h => Assert.Equal(3.0, h.Elevation));
        Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 6);
        Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E0").Sum(h => h.Area), 6);
        Assert.Equal(interzone.Length, interzone.Count(h => h.Kind == HorizontalKind.Ceiling) * 2);
        Assert.DoesNotContain(building.Surfaces.OfType<WallSurface>(), w => w.Boundary != BoundaryCondition.Outdoors);
    }

    [Fact]
    public void SingleZonePerFloorTypeConservesEveryInvariant()
    {
        IFloor wide = Pipeline.DetailedFloor();
        IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });

        IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(wide, 2), new FloorEntry(narrow, 3) }).Value;
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);

        Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
        Assert.All(building.Surfaces.OfType<WallSurface>(), w => Assert.Single(w.Windows));
    }

    [Fact]
    public void OneFloorEntryGivesTheSingleZoneBuildingUnderAnotherName()
    {
        FloorEntry[] entries = { new(Pipeline.DetailedFloor(), 3) };

        string perType = TextReport.Describe(new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(entries).Value);
        string building = TextReport.Describe(new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(entries).Value);

        Assert.Equal(building.Replace("BUILDING", "E0").Replace("\"Building\"", "\"Floor type 0\""), perType);
    }

    [Fact]
    public void SingleZoneBuildingHasOneZoneWithOnePartPerStorey()
    {
        IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;

        Zone zone = Assert.Single(building.Zones);
        Assert.Equal(3, zone.Parts.Count);
        Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
        Assert.Equal(18, zone.SourceZones.Count);
        Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
    }

    [Fact]
    public void SingleZoneBuildingTurnsSlabsIntoInternalMass()
    {
        IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;

        Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
        Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
        Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
        Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
    }

    [Fact]
    public void SingleZoneBuildingKeepsGroundRoofAndGlazing()
    {
        IFloor floor = Pipeline.DetailedFloor();
        IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;

        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);

        Assert.True(report.Passed, report.Describe());
        Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
        Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
        Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
        Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
        Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
        Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
        Assert.All(building.Surfaces.OfType<WallSurface>(), w => Assert.Single(w.Windows));
    }

    public static IEnumerable<object[]> Matrix() =>
        from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
        from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
        select new object[] { simplifier, aggregator };

    [Theory]
    [MemberData(nameof(Matrix))]
    public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
    {
        IFloor floor = Pipeline.Floor(Pipeline.Simplifier(simplifier));
        FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };

        IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(entries).Value;
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);

        Assert.True(report.Passed, report.Describe());
    }

    [Theory]
    [InlineData("SingleZonePerFloorType")]
    [InlineData("SingleZoneBuilding")]
    public void SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor(string aggregator)
    {
        // Rotated by 321.2° and moved, a merged zone's corners on the clipping grid lie up to a grid unit off the true façade line; the storey
        // outline, united from those zones again, must still lie on the façade lines of the merged zones' walls (D-041).
        IGeneratedPlan bar = new GalleryBarGenerator(Pipeline.Tolerances)
            .Generate(GalleryBarParameters.Default, new GalleryBarPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;
        IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(PlanTransform.Apply(bar, 321.2, 136.4, -118.8).Value).Value;

        Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) });

        Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
        ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
        Assert.True(report.Passed, report.Describe());
    }

    [Fact]
    public void MultiplierSnapshot()
    {
        IFloor floor = Pipeline.Floor(Pipeline.Simplifier("PerimeterCore"));
        IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
            .Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;

        Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
    }

    [Fact]
    public void SingleZoneBuildingSnapshot()
    {
        IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;

        Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneBuilding");
    }

    [Fact]
    public void SingleZonePerFloorTypeSnapshot()
    {
        IFloor floor = Pipeline.DetailedFloor();
        IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2) }).Value;

        Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZonePerFloorType");
    }
}
  • [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.GeometryTests.UnionOfTilesKeepsTheTilesOwnVertices"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests.SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor"

Expected on net8.0: the core test fails, Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: … - Lod.Core.Tests.dll (net8.0), because the union returns its vertices rounded to the clipping grid instead of the tiles' corners; the message begins with the lines below and repeats the same failure for the items [2] to [11]:

Failed Lod.Core.Tests.Geometry.GeometryTests.UnionOfTilesKeepsTheTilesOwnVertices
  Assert.All() Failure: 11 out of 12 items in the collection did not pass.
  [0]:  Item:  Point2 { X = 9.079111, Y = -159.190495 }
     Error: Assert.Contains() Failure: Item not found in collection
            Collection: [Point2 { X = 15.08, Y = -153.9 }, Point2 { X = 5.821633885468872, Y = -143.39844502517357 }, Point2 { X = -0.17925467157480313, Y = -148.68893994776278 }, Point2 { X = 9.079111442956325, Y = -159.1904949225892 }, Point2 { X = 5.821633885468872, Y = -143.39844502517357 }, ···]
            Not found:  Point2 { X = 9.079111, Y = -159.190495 }

and both integration cases fail, Failed! - Failed: 2, Passed: 0, Skipped: 0, Total: 2, Duration: … - Lod.Integration.Tests.dll (net8.0), because the storey outline united from the merged zones lies off the merged walls' façade lines:

Failed Lod.Integration.Tests.AggregatorTests.SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor(aggregator: "SingleZonePerFloorType")
  Error FacadeNotCovered [L0/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L0/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L1/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L1/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L2/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L2/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L3/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L3/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L4/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L4/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).
Failed Lod.Integration.Tests.AggregatorTests.SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor(aggregator: "SingleZoneBuilding")
  Error FacadeNotCovered [L0/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L0/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L1/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L1/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L2/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L2/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L3/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L3/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).; Error FacadeNotCovered [L4/Stair-2/W3]: Rebuilt façades cover 0 m of this 4 m wall (D-041).; Error FacadeNotCovered [L4/DwellingUnit-1/W3]: Rebuilt façades cover 0 m of this 36 m wall (D-041).
  • [x] Step 4: Implement — replace src/Lod.Core/Geometry/VertexGrid.cs (full file)

Restore is added; nothing else changes.

using System;
using System.Collections.Generic;

namespace Lod.Core.Geometry;

/// <summary>A cell of the <see cref="VertexGrid"/>, in grid units.</summary>
/// <param name="X">X index.</param>
/// <param name="Y">Y index.</param>
internal readonly record struct GridPoint(long X, long Y);

/// <summary>
/// Snaps points to the distance grid, remembers the first original point of each grid cell, and tests whether a grid point lies on a segment
/// within the distance tolerance. Shared by the layout surface builder and the union of tiles, so both recognise the same vertices and T-junctions.
/// The segment tests measure the cells' original points, not the snapped cells: snapping a point and both ends of a segment it lies on moves the
/// point up to √2 grid units off the snapped segment, so on rotated plans a T-junction exactly on an edge could fail a test on snapped cells.
/// </summary>
internal sealed class VertexGrid
{
    private readonly double _cell;
    private readonly Dictionary<GridPoint, Point2> _originals = new();

    /// <summary>Creates an empty grid.</summary>
    /// <param name="cell">Grid spacing, m: the distance tolerance.</param>
    public VertexGrid(double cell)
    {
        _cell = cell;
    }

    /// <summary>Registered cells, in registration order.</summary>
    public IEnumerable<GridPoint> Keys => _originals.Keys;

    /// <summary>The cell of a point.</summary>
    /// <param name="p">The point.</param>
    /// <returns>The cell.</returns>
    public GridPoint Key(Point2 p) => new((long)Math.Round(p.X / _cell, MidpointRounding.AwayFromZero), (long)Math.Round(p.Y / _cell, MidpointRounding.AwayFromZero));

    /// <summary>Registers a point; the first point registered in a cell stands for the cell.</summary>
    /// <param name="p">The point.</param>
    public void Register(Point2 p)
    {
        GridPoint key = Key(p);
        if (!_originals.ContainsKey(key))
        {
            _originals.Add(key, p);
        }
    }

    /// <summary>The first point registered in a cell.</summary>
    /// <param name="key">The cell.</param>
    /// <returns>The point.</returns>
    public Point2 Original(GridPoint key) => _originals[key];

    /// <summary>The first point registered in the cell of <paramref name="p"/>, or <paramref name="p"/> itself when its cell is empty.</summary>
    /// <param name="p">The point.</param>
    /// <returns>The registered point.</returns>
    public Point2 Restore(Point2 p) => _originals.TryGetValue(Key(p), out Point2 original) ? original : p;

    /// <summary>Position of <paramref name="p"/> along <paramref name="a"/>–<paramref name="b"/>, 0 at <paramref name="a"/> and 1 at <paramref name="b"/>.</summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>The parameter.</returns>
    public static double Parameter(GridPoint a, GridPoint b, GridPoint p)
    {
        double dx = b.X - a.X;
        double dy = b.Y - a.Y;
        return (((p.X - a.X) * dx) + ((p.Y - a.Y) * dy)) / ((dx * dx) + (dy * dy));
    }

    /// <summary>
    /// Within the distance tolerance of the line and strictly between the end points (by more than half the tolerance), measured between the
    /// original points of the cells.
    /// </summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>Whether <paramref name="p"/> splits the segment.</returns>
    public bool IsStrictlyInside(GridPoint a, GridPoint b, GridPoint p)
    {
        (double distance, double along, double length) = Measure(a, b, p);
        return distance <= _cell && along > 0.5 * _cell && along < length - (0.5 * _cell);
    }

    /// <summary>Within the distance tolerance of the segment, end points included, measured between the original points of the cells.</summary>
    /// <param name="a">Segment start.</param>
    /// <param name="b">Segment end.</param>
    /// <param name="p">The point.</param>
    /// <returns>Whether <paramref name="p"/> lies on the segment.</returns>
    public bool IsOnSegment(GridPoint a, GridPoint b, GridPoint p)
    {
        if (p == a || p == b)
        {
            return true;
        }

        (double distance, double along, double length) = Measure(a, b, p);
        return distance <= _cell && along >= -_cell && along <= length + _cell;
    }

    /// <summary>Distance of the original point of <paramref name="p"/> from the line through the original points of <paramref name="a"/> and
    /// <paramref name="b"/>, its position along that segment from <paramref name="a"/>, and the segment's length, m.</summary>
    private (double Distance, double Along, double Length) Measure(GridPoint a, GridPoint b, GridPoint p)
    {
        Point2 start = Original(a);
        Point2 end = Original(b);
        Point2 point = Original(p);
        double dx = end.X - start.X;
        double dy = end.Y - start.Y;
        double length = Math.Sqrt((dx * dx) + (dy * dy));
        double px = point.X - start.X;
        double py = point.Y - start.Y;
        return (Math.Abs((dx * py) - (dy * px)) / length, ((px * dx) + (py * dy)) / length, length);
    }
}
  • [x] Step 5: Implement — replace src/Lod.Core/Geometry/PolygonOps.cs (full file)

UnionTiles maps the union's vertices through grid.Restore; when a restored ring is not a valid polygon, the clipped one is kept.

using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using Lod.Core.Common;

namespace Lod.Core.Geometry;

/// <summary>
/// Polygon boolean operations on Clipper2 (ADR-002). Results are snapped to the <see cref="ToleranceSettings.Distance"/> grid.
/// </summary>
public sealed class PolygonOps
{
    private readonly int _precision;
    private readonly double _distance;

    /// <summary>Creates the operations with the clipping precision of <paramref name="tolerances"/>.</summary>
    /// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.DecimalPrecision"/> sets the snapping grid.</param>
    public PolygonOps(ToleranceSettings tolerances)
    {
        _precision = tolerances.DecimalPrecision;
        _distance = tolerances.Distance;
    }

    /// <summary>Intersection of two polygons.</summary>
    /// <param name="a">First polygon.</param>
    /// <param name="b">Second polygon.</param>
    /// <returns>Zero or more polygons.</returns>
    public IReadOnlyList<Polygon2> Intersect(Polygon2 a, Polygon2 b) => Execute(ClipType.Intersection, new[] { a }, new[] { b });

    /// <summary>Union of polygons.</summary>
    /// <param name="polygons">Polygons to merge.</param>
    /// <returns>Disjoint polygons covering the union.</returns>
    public IReadOnlyList<Polygon2> Union(IEnumerable<Polygon2> polygons) => Execute(ClipType.Union, polygons, Enumerable.Empty<Polygon2>());

    /// <summary>
    /// Union of polygons that tile a region and share their edges up to the distance tolerance, such as the zones of a storey (ADR-013). Before
    /// clipping, vertices in the same cell of the distance grid are unified, and every vertex lying on another tile's edge within the distance
    /// tolerance (a T-junction) is inserted into that edge, as <see cref="Layout.LayoutSurfaceBuilder"/> splits walls. Shared edges then cancel exactly;
    /// a plain <see cref="Union"/> of tiles that are not axis-aligned can leave slits or splits where rounding on the clipping grid moves a
    /// T-junction off its edge. Inserted vertices lie on their edges within the distance tolerance, so the outline is not moved. The result keeps the
    /// tiles' own vertices: clipping rounds every vertex to the grid, up to 0.7 grid units off, so each result vertex is replaced by the tile
    /// vertex of its grid cell. United outlines therefore lie on the tiles' edges, and uniting zones that were themselves united adds no rounding.
    /// </summary>
    /// <param name="tiles">Polygons tiling a region.</param>
    /// <returns>Disjoint polygons covering the union.</returns>
    public IReadOnlyList<Polygon2> UnionTiles(IReadOnlyList<Polygon2> tiles)
    {
        var grid = new VertexGrid(_distance);
        foreach (Point2 p in tiles.SelectMany(t => t.Rings).SelectMany(r => r))
        {
            grid.Register(p);
        }

        GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
        IEnumerable<Point2> Split(IReadOnlyList<Point2> ring)
        {
            for (int i = 0; i < ring.Count; i++)
            {
                GridPoint a = grid.Key(ring[i]);
                GridPoint b = grid.Key(ring[(i + 1) % ring.Count]);
                yield return grid.Original(a);
                if (a == b)
                {
                    continue;
                }

                foreach (GridPoint cut in vertices.Where(v => v != a && v != b && grid.IsStrictlyInside(a, b, v)).OrderBy(v => VertexGrid.Parameter(a, b, v)))
                {
                    yield return grid.Original(cut);
                }
            }
        }

        // A tile whose rings collapse on the grid cannot be split; it is clipped as given.
        Polygon2[] prepared = tiles
            .Select(t => Polygon2.Create(Split(t.Outer).ToArray(), t.Holes.Select(h => Split(h).ToArray()).ToArray()) is { IsSuccess: true } split ? split.Value : t)
            .ToArray();
        return Union(prepared)
            .Select(u => Polygon2.Create(u.Outer.Select(grid.Restore), u.Holes.Select(h => h.Select(grid.Restore))) is { IsSuccess: true } restored ? restored.Value : u)
            .ToArray();
    }

    /// <summary>Part of <paramref name="subject"/> not covered by <paramref name="clips"/>.</summary>
    /// <param name="subject">Polygon to cut.</param>
    /// <param name="clips">Polygons to remove.</param>
    /// <returns>Zero or more polygons.</returns>
    public IReadOnlyList<Polygon2> Difference(Polygon2 subject, IEnumerable<Polygon2> clips) => Execute(ClipType.Difference, new[] { subject }, clips);

    /// <summary>Area of the intersection of two polygons.</summary>
    /// <param name="a">First polygon.</param>
    /// <param name="b">Second polygon.</param>
    /// <returns>Overlap area, m².</returns>
    public double IntersectionArea(Polygon2 a, Polygon2 b) => Intersect(a, b).Sum(p => p.Area);

    private IReadOnlyList<Polygon2> Execute(ClipType clipType, IEnumerable<Polygon2> subjects, IEnumerable<Polygon2> clips)
    {
        var clipper = new ClipperD(_precision);
        clipper.AddSubject(ToPaths(subjects));
        clipper.AddClip(ToPaths(clips));
        var tree = new PolyTreeD();
        clipper.Execute(clipType, FillRule.NonZero, tree);
        var result = new List<Polygon2>();
        Collect(tree, result);
        return result;
    }

    private static PathsD ToPaths(IEnumerable<Polygon2> polygons)
    {
        var paths = new PathsD();
        foreach (IReadOnlyList<Point2> ring in polygons.SelectMany(p => p.Rings))
        {
            var path = new PathD(ring.Count);
            path.AddRange(ring.Select(p => new PointD(p.X, p.Y)));
            paths.Add(path);
        }

        return paths;
    }

    private static void Collect(PolyPathD parent, List<Polygon2> result)
    {
        for (int i = 0; i < parent.Count; i++)
        {
            PolyPathD outer = parent[i];
            var holes = new List<IEnumerable<Point2>>();
            for (int j = 0; j < outer.Count; j++)
            {
                PolyPathD hole = outer[j];
                holes.Add(ToPoints(hole.Polygon!));
                Collect(hole, result);
            }

            Result<Polygon2> polygon = Polygon2.Create(ToPoints(outer.Polygon!), holes);
            if (polygon.IsSuccess)
            {
                result.Add(polygon.Value);
            }
        }
    }

    private static IEnumerable<Point2> ToPoints(PathD path) => path.Select(p => new Point2(p.x, p.y));
}
  • [x] Step 6: Edit docs/decisions/ADR-002-geometry.md and docs/decisions/ADR-013-vertical-aggregation-methods.md

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

  1. Replace
**Unions of zones (S8.2).** Wherever zones are united into one zone, their footprints are united as tiles (`PolygonOps.UnionTiles`, [ADR-013](ADR-013-vertical-aggregation-methods.md)): the groups of *Semantic Merge* (Z1) as well as the storey outlines of the single-zone floor aggregators. A plain union of footprints that meet at T-junctions, such as the pinwheel dwellings of the point plate (`HDHM-TYP-007`, [ADR-006](ADR-006-plan-generation-mechanism.md)), splits the group or leaves slits once the plan is rotated off the axes, because rounding on the clipping grid moves the T-junction off its edge. A united zone may have holes, where it surrounds zones of another type (the point plate's dwellings around the core).

with

**Unions of zones (S8.2).** Wherever zones are united into one zone, their footprints are united as tiles (`PolygonOps.UnionTiles`, [ADR-013](ADR-013-vertical-aggregation-methods.md)): the groups of *Semantic Merge* (Z1) as well as the storey outlines of the single-zone floor aggregators. A plain union of footprints that meet at T-junctions, such as the pinwheel dwellings of the point plate (`HDHM-TYP-007`, [ADR-006](ADR-006-plan-generation-mechanism.md)), splits the group or leaves slits once the plan is rotated off the axes, because rounding on the clipping grid moves the T-junction off its edge. A united zone may have holes, where it surrounds zones of another type (the point plate's dwellings around the core). The union keeps the tiles' own vertices: clipping rounds every vertex to the grid, up to 0.7 grid units off its true position, so each vertex of the result is replaced by the tile vertex of its grid cell. United zones therefore lie on their sources' façade lines, and uniting zones that were themselves united, as the single-zone aggregators do with a *Semantic Merge* floor, adds no rounding; without this, a rotated *Semantic Merge* floor could fail *Single Zone per Floor Type* and *Single Zone Building* with `FacadeNotCovered` (S8.2).

In docs/decisions/ADR-013-vertical-aggregation-methods.md, make this replacement; the "Replace" text occurs exactly once in the file.

  1. Replace
- **Zone:** one zone with one `ZonePart` per storey outline (the union of the storey's zone footprints; a storey whose zones form several polygons gets one part per polygon). The union treats the footprints as tiles (`PolygonOps.UnionTiles`): vertices in one cell of the distance grid are unified and every vertex lying on another footprint's edge (a T-junction) is inserted into that edge before clipping, as `LayoutSurfaceBuilder` splits walls, so shared edges cancel exactly. A plain union of rotated footprints leaves slits or splits the outline where rounding on the clipping grid moves a T-junction off its edge. Its space type is the sources' shared space type, or `Mixed`. Its source zones are the stacked zones of the group, storey by storey.

with

- **Zone:** one zone with one `ZonePart` per storey outline (the union of the storey's zone footprints; a storey whose zones form several polygons gets one part per polygon). The union treats the footprints as tiles (`PolygonOps.UnionTiles`): vertices in one cell of the distance grid are unified and every vertex lying on another footprint's edge (a T-junction) is inserted into that edge before clipping, as `LayoutSurfaceBuilder` splits walls, so shared edges cancel exactly, and the result keeps the footprints' own vertices ([ADR-002](ADR-002-geometry.md), S8.2). A plain union of rotated footprints leaves slits or splits the outline where rounding on the clipping grid moves a T-junction off its edge. Its space type is the sources' shared space type, or `Mixed`. Its source zones are the stacked zones of the group, storey by storey.
  • [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.GeometryTests"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 24, Skipped: 0, Total: 24, Duration: … - Lod.Core.Tests.dll (net8.0) and Passed! - Failed: 0, Passed: 47, Skipped: 0, Total: 47, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 154 core, 110 generators, and 322 integration tests passed on net8.0; git status lists exactly the six modified files of this task and no *.received.txt.

  • [x] Step 8: Commit
git add src/Lod.Core/Geometry/PolygonOps.cs src/Lod.Core/Geometry/VertexGrid.cs tests/Lod.Core.Tests/Geometry/GeometryTests.cs tests/Lod.Integration.Tests/AggregatorTests.cs docs/decisions/ADR-002-geometry.md docs/decisions/ADR-013-vertical-aggregation-methods.md
git commit -m "fix(geometry): keep the tiles' own vertices in unions of tiles"
  • [x] Step 9: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch fix/geometry-rotated-plans
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only fix/geometry-rotated-plans
git branch -d fix/geometry-rotated-plans
git diff --name-status v0.8.0 main -- "tests/*/Snapshots/*"

Expected: VERIFY PASSED, with 154 core, 110 generators, and 322 integration tests passed on net8.0; the last command lists only A tests/Lod.Generators.Tests/PointPlate/Snapshots/point-plate-canonical.txt: the twelve existing snapshots are byte-identical after the three fixes. Do not push (D-084).


Slice C — feature/generators-non-rectangular-typologies

Task 5: Winged band generator (HDHM-TYP-003)

Files: - Create: src/Lod.Generators/WingedBand/WingedBandGenerator.cs - Modify: src/Lod.Generators/StairBayBar/StairBayLayout.cs (S8.1 file) - Test: create tests/Lod.Generators.Tests/WingedBand/WingedBandGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/WingedBand/Snapshots/winged-band-canonical.txt

Interfaces: - Consumes: StairBayLayout.Bays(int bays, double unitWidth, double depth, double stairWidth) : PlanLayout (S8.1, internal), the PlanGenerator base and records as in Task 1, Polygon2.Create(IEnumerable<Point2>) : Result<Polygon2>, GeometryLimits and the info WindowOmitted (S8.1, ADR-011), PlanAssertions (S8.1). - Produces: - In StairBayLayout (internal): Wing(string prefix, string namePrefix, double x0, double y0, double length, double depth, bool alongY, int bays, double unitWidth, double stairWidth) : IReadOnlyList<PlannedZone> (bays from (x0, y0) along +x, or +y when alongY; zones {prefix}ST1…, then {prefix}U1…, numbered along the wing; the last bay ends exactly at the wing's length); Fit(double length, double unitWidth, double stairWidth) : (int Bays, double UnitWidth) (n = max(1, round(L / (2U + S))), rounded half away from zero, and the adjusted width (L − n·S) / (2n)); Need(int bays, double stairWidth) : string (the stairs a fitted wing needs, for messages; replaced in Task 6). Bays returns the footprint rectangle and Wing(string.Empty, string.Empty, 0, 0, length, depth, alongY: false, …), the same zones as in S8.1. - public sealed record WingedBandParameters(int WingCount, double WingLength, double WingSpacing, double Depth, double UnitWidth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (3, 17, 17, 12, 7, 3, 3, 0); public sealed record WingedBandPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets; public sealed class WingedBandGenerator(ToleranceSettings), Name = WingedBandGenerator: a band [0, W·D + (W − 1)·g] × [0, D] of stair bays along plan x and WingCount wings [(i − 1)(D + g), (i − 1)(D + g) + D] × [D, D + WingLength] of stair bays projecting to plan north, the first flush with the band's west end and the last with its east end; zones B-ST1…, B-U1… (Band Stair 1, Band Unit 1, …), then per wing W{i}-ST1…, W{i}-U1… (Wing 2 Stair 1, …); the footprint is the comb outline; errors InvalidParameter for WingCount < 2 (WingCount must be at least 2 (the wing-to-band relation repeats), got {n}.), for each length that is not positive and finite, and, only when those pass, for the band or a wing whose stairs leave no dwelling width (StairWidth 10 m leaves no dwelling width in each 17 m wing: its 2 stair bays need 20 m of stairs; reduce StairWidth or UnitWidth.); provenance keys WingCount, WingLength, WingSpacing, Depth, UnitWidth, StairWidth.

  • [x] Step 1: Create the branch
git switch -c feature/generators-non-rectangular-typologies main
  • [x] Step 2: Write the failing tests — create tests/Lod.Generators.Tests/WingedBand/WingedBandGeneratorTests.cs (full file)

27 tests: the ADR-006 defaults; the canonical plan (21 zone IDs, 1452 m², names, stairs 36 m², band dwellings 7.25 m × 12 m, wing dwellings 84 m², corners of B-ST2, W2-U1, W2-ST1, W2-U2, no source zones); the comb outline without holes; tiling for four wing counts, lengths, and spacings; every wing adjoining the band along its whole 12 m root; every stair serving the dwellings of its bay; the band's north façade as eight fragments of 5.5, 7.25, 3, 1.25, 1.25, 3, 7.25, and 5.5 m; the window rule; with WingSpacing 14 m the two 0.5 m fragments without a window and their WindowOmitted infos; seven invalid parameters; one wing; stairs that leave no dwelling width; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.

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

namespace Lod.Generators.Tests.WingedBand;

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

    private static readonly string[] CanonicalZones =
    {
        "B-ST1", "B-ST2", "B-ST3", "B-ST4", "B-U1", "B-U2", "B-U3", "B-U4", "B-U5", "B-U6", "B-U7", "B-U8",
        "W1-ST1", "W1-U1", "W1-U2", "W2-ST1", "W2-U1", "W2-U2", "W3-ST1", "W3-U1", "W3-U2",
    };

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

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

    [Fact]
    public void CanonicalPlanIsABandOfFourStairBaysWithThreeOneBayWings()
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default);

        Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal((70.0 * 12.0) + (3 * 17.0 * 12.0), plan.Footprint.Area, 9);
        Assert.Equal(new[] { "Band Stair 2", "Band Unit 3", "Wing 2 Stair 1", "Wing 2 Unit 2" }, plan.Zones.Where(z => z.Id.Value is "B-ST2" or "B-U3" or "W2-ST1" or "W2-U2").Select(z => z.Name));
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.Stair), z => Assert.Equal(36.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => z.Id.Value.StartsWith("B-U")), z => Assert.Equal(7.25 * 12.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => z.Id.Value.StartsWith("W") && z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal(84.0, z.FloorArea, 9));
        Assert.Equal(new[] { new Point2(24.75, 0.0), new Point2(27.75, 0.0), new Point2(27.75, 12.0), new Point2(24.75, 12.0) }, Outer(plan, "B-ST2"));
        Assert.Equal(new[] { new Point2(29.0, 12.0), new Point2(41.0, 12.0), new Point2(41.0, 19.0), new Point2(29.0, 19.0) }, Outer(plan, "W2-U1"));
        Assert.Equal(new[] { new Point2(29.0, 19.0), new Point2(41.0, 19.0), new Point2(41.0, 22.0), new Point2(29.0, 22.0) }, Outer(plan, "W2-ST1"));
        Assert.Equal(new[] { new Point2(29.0, 22.0), new Point2(41.0, 22.0), new Point2(41.0, 29.0), new Point2(29.0, 29.0) }, Outer(plan, "W2-U2"));
        Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
    }

    [Fact]
    public void TheFootprintIsACombOpenToPlanNorth()
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default);

        Assert.Equal(
            new[]
            {
                new Point2(0, 0), new Point2(70, 0), new Point2(70, 29), new Point2(58, 29), new Point2(58, 12), new Point2(41, 12),
                new Point2(41, 29), new Point2(29, 29), new Point2(29, 12), new Point2(12, 12), new Point2(12, 29), new Point2(0, 29),
            },
            plan.Footprint.Outer);
        Assert.Empty(plan.Footprint.Holes);
    }

    [Theory]
    [InlineData(2, 17.0, 17.0)]
    [InlineData(3, 17.0, 17.0)]
    [InlineData(5, 30.0, 9.5)]
    [InlineData(4, 8.0, 40.0)]
    public void ZonesTileTheFootprint(int wings, double wingLength, double wingSpacing)
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default with { WingCount = wings, WingLength = wingLength, WingSpacing = wingSpacing });

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(wings, plan.Zones.Select(z => z.Id.Value.Split('-')[0]).Distinct().Count(p => p.StartsWith("W")));
    }

    [Fact]
    public void EveryWingAdjoinsTheBandAlongItsWholeRoot()
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default);

        foreach (string wing in new[] { "W1", "W2", "W3" })
        {
            double root = plan.Surfaces.OfType<WallSurface>()
                .Where(w => w.Zone.Value == $"{wing}-U1" && w.AdjacentZone is { } adjacent && adjacent.Value.StartsWith("B-"))
                .Sum(w => w.Length);
            Assert.Equal(12.0, root, 9);
        }

        Assert.Equal(new[] { "B-ST1", "B-U1", "B-U2", "W1-ST1" }, PlanAssertions.Neighbours(plan, "W1-U1"));
        Assert.Equal(new[] { "B-U4", "B-U5", "W2-ST1" }, PlanAssertions.Neighbours(plan, "W2-U1"));
    }

    [Fact]
    public void EveryStairServesTheDwellingsOfItsBay()
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default);

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

    [Fact]
    public void TheBandNorthFacadeIsFragmentsBetweenTheWings()
    {
        IGeneratedPlan plan = Generate(WingedBandParameters.Default);
        WallSurface[] north = plan.Surfaces.OfType<WallSurface>()
            .Where(w => w.Zone.Value.StartsWith("B-") && w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.North)
            .OrderBy(w => w.End.X)
            .ToArray();

        Assert.Equal(new[] { "B-U2", "B-U3", "B-ST2", "B-U4", "B-U5", "B-ST3", "B-U6", "B-U7" }, north.Select(w => w.Zone.Value));
        Assert.Equal(new[] { 5.5, 7.25, 3.0, 1.25, 1.25, 3.0, 7.25, 5.5 }, north.Select(w => System.Math.Round(w.Length, 9)));
    }

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

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

    [Fact]
    public void FragmentsBelowTheMinimumWallLengthGetNoWindow()
    {
        // 14 m between the wings: the band's bay boundaries leave 0.5 m of north façade to the dwellings beside the middle wing's root.
        Result<IGeneratedPlan> result = _generator.Generate(WingedBandParameters.Default with { WingSpacing = 14.0 }, Examples);

        Assert.True(result.IsSuccess);
        WallSurface[] fragments = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Length < 1.0).ToArray();
        Assert.Equal(new[] { "B-U4", "B-U5" }, fragments.Select(w => w.Zone.Value).OrderBy(z => z, System.StringComparer.Ordinal));
        Assert.All(fragments, w => Assert.Equal(0.5, w.Length, 9));
        Assert.All(fragments, w => Assert.Empty(w.Windows));
        Assert.Equal(fragments.Select(w => w.Id.Value), result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).Select(d => d.Subject));
    }

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

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

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

        Assert.Equal("WingCount must be at least 2 (the wing-to-band relation repeats), got 1.", Assert.Single(result.Diagnostics).Message);
    }

    [Fact]
    public void StairsThatLeaveNoRoomForDwellingsAreAnError()
    {
        // A 17 m wing with 10 m stairs: round(17 / 11) = 2 bays need 20 m of stairs, so no dwelling width is left; the band still fits.
        Result<IGeneratedPlan> result = _generator.Generate(WingedBandParameters.Default with { UnitWidth = 0.5, StairWidth = 10.0 }, Examples);

        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.Equal("StairWidth 10 m leaves no dwelling width in each 17 m wing: its 2 stair bays need 20 m of stairs; reduce StairWidth or UnitWidth.", error.Message);
    }

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

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

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

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

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

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

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

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

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(WingedBandParameters.Default)), "winged-band-canonical");
    }

    private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();

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

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

tests\Lod.Generators.Tests\WingedBand\WingedBandGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'WingedBand' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\WingedBand\WingedBandGeneratorTests.cs(233,37): error CS0246: The type or namespace name 'WingedBandParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\WingedBand\WingedBandGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'WingedBandPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\WingedBand\WingedBandGeneratorTests.cs(23,22): error CS0246: The type or namespace name 'WingedBandGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 4: Implement — replace src/Lod.Generators/StairBayBar/StairBayLayout.cs (full file)

Bays keeps its signature and its zones: the stair bay bar and the stair pair are unchanged, and their snapshots stay byte-identical.

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

namespace Lod.Generators.StairBayBar;

/// <summary>
/// The stair bay of ADR-006: dwelling | stair | dwelling side by side along a bar, each through the full depth, the stair serving the two dwellings
/// beside it. Shared by <see cref="StairBayBarGenerator"/> (HDHM-TYP-001), the stair pair (HDHM-TYP-008), and the wings of the winged band
/// (HDHM-TYP-003) and the open court (HDHM-TYP-004), so the bay geometry exists once.
/// </summary>
internal static class StairBayLayout
{
    /// <summary>
    /// Lays out <paramref name="bays"/> stair bays west to east from the origin. Zones: <c>ST1…STn</c> (stairs, west to east), then <c>U1…U2n</c>
    /// (dwellings, west to east); bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
    /// </summary>
    /// <param name="bays">Number of bays, at least 1.</param>
    /// <param name="unitWidth">Dwelling width along the bar, m.</param>
    /// <param name="depth">Bar depth, m.</param>
    /// <param name="stairWidth">Stair width along the bar, m.</param>
    /// <returns>The footprint and the zones.</returns>
    public static PlanLayout Bays(int bays, double unitWidth, double depth, double stairWidth)
    {
        double length = bays * ((2.0 * unitWidth) + stairWidth);
        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, length, depth), Wing(string.Empty, string.Empty, 0.0, 0.0, length, depth, alongY: false, bays, unitWidth, stairWidth));
    }

    /// <summary>
    /// The stair bays that fill a wing of length <paramref name="length"/> exactly (ADR-006): <c>n = max(1, round(L / (2U + S)))</c> bays, rounded
    /// half away from zero, with the dwelling width adjusted to <c>(L − n·S) / (2n)</c>, which is not positive when the stairs leave no room.
    /// </summary>
    /// <param name="length">Wing length, m; positive.</param>
    /// <param name="unitWidth">Target dwelling width, m; positive.</param>
    /// <param name="stairWidth">Stair width, m; positive.</param>
    /// <returns>The bay count and the adjusted dwelling width.</returns>
    public static (int Bays, double UnitWidth) Fit(double length, double unitWidth, double stairWidth)
    {
        double bays = Math.Max(1.0, Math.Round(length / ((2.0 * unitWidth) + stairWidth), MidpointRounding.AwayFromZero));
        int count = bays >= int.MaxValue ? int.MaxValue : (int)bays;
        return (count, (length - (count * stairWidth)) / (2.0 * count));
    }

    /// <summary>The stair length a fitted wing needs, for messages: "its 2 stair bays need 20 m of stairs".</summary>
    /// <param name="bays">Bay count.</param>
    /// <param name="stairWidth">Stair width, m.</param>
    /// <returns>The phrase.</returns>
    public static string Need(int bays, double stairWidth) => bays == 1
        ? $"its one stair bay needs {Provenance.Format(stairWidth)} m of stairs"
        : $"its {bays} stair bays need {Provenance.Format(bays * stairWidth)} m of stairs";

    /// <summary>
    /// Lays out <paramref name="bays"/> stair bays along a wing that starts at (<paramref name="x0"/>, <paramref name="y0"/>) and runs along +x, or
    /// along +y when <paramref name="alongY"/>; the last bay ends exactly at the wing's length. Zones: <c>{prefix}ST1…</c>, then <c>{prefix}U1…</c>,
    /// numbered along the wing's axis; bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
    /// </summary>
    /// <param name="prefix">Zone ID prefix, such as <c>B-</c>.</param>
    /// <param name="namePrefix">Display name prefix, such as <c>Band </c>.</param>
    /// <param name="x0">Wing start, plan x, m.</param>
    /// <param name="y0">Wing start, plan y, m.</param>
    /// <param name="length">Wing length along its axis, m.</param>
    /// <param name="depth">Wing depth across its axis, m.</param>
    /// <param name="alongY">Whether the wing runs along +y instead of +x.</param>
    /// <param name="bays">Number of bays, at least 1.</param>
    /// <param name="unitWidth">Dwelling width along the wing, m.</param>
    /// <param name="stairWidth">Stair width along the wing, m.</param>
    /// <returns>The stairs, then the dwellings.</returns>
    public static IReadOnlyList<PlannedZone> Wing(
        string prefix,
        string namePrefix,
        double x0,
        double y0,
        double length,
        double depth,
        bool alongY,
        int bays,
        double unitWidth,
        double stairWidth)
    {
        double bayWidth = (2.0 * unitWidth) + stairWidth;
        double start = alongY ? y0 : x0;
        Polygon2 Piece(double from, double to) => alongY ? Polygon2.Rectangle(x0, from, x0 + depth, to) : Polygon2.Rectangle(from, y0, to, y0 + depth);
        var stairs = new List<PlannedZone>();
        var units = new List<PlannedZone>();
        for (int i = 0; i < bays; i++)
        {
            double west = start + (i * bayWidth);
            double stairWest = west + unitWidth;
            double stairEast = stairWest + stairWidth;
            double east = i == bays - 1 ? start + length : west + bayWidth;
            stairs.Add(new PlannedZone(new ZoneId($"{prefix}ST{i + 1}"), $"{namePrefix}Stair {i + 1}", SpaceType.Stair, Piece(stairWest, stairEast)));
            units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 1}"), $"{namePrefix}Unit {(2 * i) + 1}", SpaceType.DwellingUnit, Piece(west, stairWest)));
            units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 2}"), $"{namePrefix}Unit {(2 * i) + 2}", SpaceType.DwellingUnit, Piece(stairEast, east)));
        }

        stairs.AddRange(units);
        return stairs;
    }
}
  • [x] Step 5: Implement — create src/Lod.Generators/WingedBand/WingedBandGenerator.cs (full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.StairBayBar;

namespace Lod.Generators.WingedBand;

/// <summary>Parameters of the winged band (HDHM-TYP-003, ADR-006).</summary>
/// <param name="WingCount">Number of wings projecting from the band; at least 2.</param>
/// <param name="WingLength">Length of each wing from the band to its tip, m.</param>
/// <param name="WingSpacing">Clear distance between neighbouring wings along the band, m.</param>
/// <param name="Depth">Depth of the band and of every wing, which every dwelling and stair spans, m.</param>
/// <param name="UnitWidth">Target dwelling width along the band and the wings, m; adjusted so the stair bays fill each exactly.</param>
/// <param name="StairWidth">Width of each stair along the band and the wings, 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 WingedBandParameters(
    int WingCount,
    double WingLength,
    double WingSpacing,
    double Depth,
    double UnitWidth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 3 wings of 17 m, 17 m apart, 12 m deep, 7 m dwellings and 3 m stairs, 3 m storeys, orientation 0°.</summary>
    public static WingedBandParameters Default { get; } = new(3, 17.0, 17.0, 12.0, 7.0, 3.0, 3.0, 0.0);
}

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

/// <summary>
/// Projecting wings joined by a common band (HDHM-TYP-003, ADR-006): a band of stair bays along plan x and <see cref="WingedBandParameters.WingCount"/>
/// wings of stair bays projecting to plan north, the first flush with the band's west end and the last with its east end. Every wing adjoins the band
/// along its whole root (R001), and at least two wings repeat the wing-to-band relation (R002); one wing is rejected (R003, R004). The outline is a
/// comb, so the band's north façade is fragments between the wings' roots and its bay boundaries; ADR-011 governs their windows. Each wing and the band
/// hold <c>n = max(1, round(L / (2U + S)))</c> bays with the dwelling width adjusted to fill their length <c>L</c>. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// With <c>D</c> the depth and <c>g</c> the wing spacing, the band is <c>[0, W·D + (W − 1)·g] × [0, D]</c> and wing <c>i</c> (from 1) is
/// <c>[(i − 1)(D + g), (i − 1)(D + g) + D] × [D, D + WingLength]</c>. Zone IDs: <c>B-ST1…</c>, <c>B-U1…</c> (band, west to east), then per wing
/// <c>W{i}-ST1…</c>, <c>W{i}-U1…</c> (south to north); display names <c>Band Unit 3</c>, <c>Wing 2 Stair 1</c>.
/// </remarks>
public sealed class WingedBandGenerator : PlanGenerator<WingedBandParameters, WingedBandPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public WingedBandGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(WingedBandParameters parameters)
    {
        var errors = new List<Diagnostic>();
        if (parameters.WingCount < 2)
        {
            errors.Add(Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(WingedBandParameters.WingCount)} must be at least 2 (the wing-to-band relation repeats), got {parameters.WingCount}."));
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(WingedBandParameters.WingLength), parameters.WingLength),
            (nameof(WingedBandParameters.WingSpacing), parameters.WingSpacing),
            (nameof(WingedBandParameters.Depth), parameters.Depth),
            (nameof(WingedBandParameters.UnitWidth), parameters.UnitWidth),
            (nameof(WingedBandParameters.StairWidth), parameters.StairWidth),
        };
        errors.AddRange(values.Where(v => !IsPositive(v.Value)).Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}.")));
        if (errors.Count == 0)
        {
            errors.AddRange(WingFits("the", "band", BandLength(parameters), parameters));
            errors.AddRange(WingFits("each", "wing", parameters.WingLength, parameters));
        }

        return errors;
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(WingedBandParameters p)
    {
        double bandLength = BandLength(p);
        double top = p.Depth + p.WingLength;
        (int bandBays, double bandUnit) = StairBayLayout.Fit(bandLength, p.UnitWidth, p.StairWidth);
        (int wingBays, double wingUnit) = StairBayLayout.Fit(p.WingLength, p.UnitWidth, p.StairWidth);
        var zones = new List<PlannedZone>(StairBayLayout.Wing("B-", "Band ", 0.0, 0.0, bandLength, p.Depth, alongY: false, bandBays, bandUnit, p.StairWidth));
        for (int i = 1; i <= p.WingCount; i++)
        {
            zones.AddRange(StairBayLayout.Wing($"W{i}-", $"Wing {i} ", WingWest(p, i), p.Depth, p.WingLength, p.Depth, alongY: true, wingBays, wingUnit, p.StairWidth));
        }

        var outline = new List<Point2> { new(0.0, 0.0), new(bandLength, 0.0) };
        for (int i = p.WingCount; i >= 1; i--)
        {
            double west = WingWest(p, i);
            outline.Add(new Point2(west + p.Depth, top));
            outline.Add(new Point2(west, top));
            if (i > 1)
            {
                outline.Add(new Point2(west, p.Depth));
                outline.Add(new Point2(WingWest(p, i - 1) + p.Depth, p.Depth));
            }
        }

        return new PlanLayout(Polygon2.Create(outline).Value, zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(WingedBandParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.WingCount), p.WingCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.WingLength), Provenance.Format(p.WingLength)),
        new KeyValuePair<string, string>(nameof(p.WingSpacing), Provenance.Format(p.WingSpacing)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };

    private static double BandLength(WingedBandParameters p) => WingWest(p, p.WingCount) + p.Depth;

    private static double WingWest(WingedBandParameters p, int wing) => (wing - 1) * (p.Depth + p.WingSpacing);

    private static IEnumerable<Diagnostic> WingFits(string article, string wing, double length, WingedBandParameters p)
    {
        (int bays, double unitWidth) = StairBayLayout.Fit(length, p.UnitWidth, p.StairWidth);
        if (!IsPositive(unitWidth))
        {
            yield return Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(WingedBandParameters.StairWidth)} {Provenance.Format(p.StairWidth)} m leaves no dwelling width in {article} {Provenance.Format(length)} m {wing}: "
                + $"{StairBayLayout.Need(bays, p.StairWidth)}; reduce {nameof(WingedBandParameters.StairWidth)} or {nameof(WingedBandParameters.UnitWidth)}.");
        }
    }
}
  • [x] Step 6: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.WingedBand.WingedBandGeneratorTests"

Expected on net8.0:

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

with the only failing test and its message:

Failed Lod.Generators.Tests.WingedBand.WingedBandGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'winged-band-canonical' is missing or differs. Review Snapshots/winged-band-canonical.received.txt and rename it to winged-band-canonical.txt to accept.

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

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

Review the received file against the content below; it must be identical: 248 lines, 21 zones, 135 surfaces (43 outdoor walls, each with one window; 50 interzone walls; 42 floors and ceilings), glazing 55.8 m² south, 59.4 m² north, 51.3 m² east, 51.3 m² west (217.8 m² in all). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/WingedBand/Snapshots/winged-band-canonical.received.txt tests/Lod.Generators.Tests/WingedBand/Snapshots/winged-band-canonical.txt

tests/Lod.Generators.Tests/WingedBand/Snapshots/winged-band-canonical.txt:

footprint area=1452.000000 floorHeight=3.000000 orientation=0.000000
zone B-ST1 Stair "Band Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone B-ST2 Stair "Band Stair 2" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone B-ST3 Stair "Band Stair 3" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone B-ST4 Stair "Band Stair 4" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone B-U1 DwellingUnit "Band Unit 1" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U2 DwellingUnit "Band Unit 2" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U3 DwellingUnit "Band Unit 3" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U4 DwellingUnit "Band Unit 4" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U5 DwellingUnit "Band Unit 5" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U6 DwellingUnit "Band Unit 6" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U7 DwellingUnit "Band Unit 7" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone B-U8 DwellingUnit "Band Unit 8" area=87.000000 volume=261.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W1-ST1 Stair "Wing 1 Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone W1-U1 DwellingUnit "Wing 1 Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W1-U2 DwellingUnit "Wing 1 Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W2-ST1 Stair "Wing 2 Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone W2-U1 DwellingUnit "Wing 2 Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W2-U2 DwellingUnit "Wing 2 Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W3-ST1 Stair "Wing 3 Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone W3-U1 DwellingUnit "Wing 3 Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W3-U2 DwellingUnit "Wing 3 Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface B-ST1/W1 Outdoors area=9.000000 wall (7.250000,0.000000)-(10.250000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST1/W2 Interzone adjacent=B-U2 area=36.000000 wall (10.250000,0.000000)-(10.250000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-ST1/W3 Interzone adjacent=W1-U1 area=9.000000 wall (10.250000,12.000000)-(7.250000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-ST1/W4 Interzone adjacent=B-U1 area=36.000000 wall (7.250000,12.000000)-(7.250000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface B-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface B-ST2/W1 Outdoors area=9.000000 wall (24.750000,0.000000)-(27.750000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST2/W2 Interzone adjacent=B-U4 area=36.000000 wall (27.750000,0.000000)-(27.750000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-ST2/W3 Outdoors area=9.000000 wall (27.750000,12.000000)-(24.750000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST2/W4 Interzone adjacent=B-U3 area=36.000000 wall (24.750000,12.000000)-(24.750000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-ST2/F Unresolved area=36.000000 Floor z=0.000000
surface B-ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface B-ST3/W1 Outdoors area=9.000000 wall (42.250000,0.000000)-(45.250000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST3/W2 Interzone adjacent=B-U6 area=36.000000 wall (45.250000,0.000000)-(45.250000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-ST3/W3 Outdoors area=9.000000 wall (45.250000,12.000000)-(42.250000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST3/W4 Interzone adjacent=B-U5 area=36.000000 wall (42.250000,12.000000)-(42.250000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-ST3/F Unresolved area=36.000000 Floor z=0.000000
surface B-ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface B-ST4/W1 Outdoors area=9.000000 wall (59.750000,0.000000)-(62.750000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface B-ST4/W2 Interzone adjacent=B-U8 area=36.000000 wall (62.750000,0.000000)-(62.750000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-ST4/W3 Interzone adjacent=W3-U1 area=9.000000 wall (62.750000,12.000000)-(59.750000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-ST4/W4 Interzone adjacent=B-U7 area=36.000000 wall (59.750000,12.000000)-(59.750000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-ST4/F Unresolved area=36.000000 Floor z=0.000000
surface B-ST4/C Unresolved area=36.000000 Ceiling z=3.000000
surface B-U1/W1 Outdoors area=21.750000 wall (0.000000,0.000000)-(7.250000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U1/W2 Interzone adjacent=B-ST1 area=36.000000 wall (7.250000,0.000000)-(7.250000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U1/W3 Interzone adjacent=W1-U1 area=21.750000 wall (7.250000,12.000000)-(0.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U1/W4 Outdoors area=36.000000 wall (0.000000,12.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface B-U1/F Unresolved area=87.000000 Floor z=0.000000
surface B-U1/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U2/W1 Outdoors area=21.750000 wall (10.250000,0.000000)-(17.500000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U2/W2 Interzone adjacent=B-U3 area=36.000000 wall (17.500000,0.000000)-(17.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U2/W3 Outdoors area=16.500000 wall (17.500000,12.000000)-(12.000000,12.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface B-U2/W4 Interzone adjacent=W1-U1 area=5.250000 wall (12.000000,12.000000)-(10.250000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U2/W5 Interzone adjacent=B-ST1 area=36.000000 wall (10.250000,12.000000)-(10.250000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U2/F Unresolved area=87.000000 Floor z=0.000000
surface B-U2/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U3/W1 Outdoors area=21.750000 wall (17.500000,0.000000)-(24.750000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U3/W2 Interzone adjacent=B-ST2 area=36.000000 wall (24.750000,0.000000)-(24.750000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U3/W3 Outdoors area=21.750000 wall (24.750000,12.000000)-(17.500000,12.000000) z=0.000000 facing=North glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U3/W4 Interzone adjacent=B-U2 area=36.000000 wall (17.500000,12.000000)-(17.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U3/F Unresolved area=87.000000 Floor z=0.000000
surface B-U3/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U4/W1 Outdoors area=21.750000 wall (27.750000,0.000000)-(35.000000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U4/W2 Interzone adjacent=B-U5 area=36.000000 wall (35.000000,0.000000)-(35.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U4/W3 Interzone adjacent=W2-U1 area=18.000000 wall (35.000000,12.000000)-(29.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U4/W4 Outdoors area=3.750000 wall (29.000000,12.000000)-(27.750000,12.000000) z=0.000000 facing=North glazing=1.125000 window(offset=0.282673 sill=0.678416 0.684653x1.643168)
surface B-U4/W5 Interzone adjacent=B-ST2 area=36.000000 wall (27.750000,12.000000)-(27.750000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U4/F Unresolved area=87.000000 Floor z=0.000000
surface B-U4/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U5/W1 Outdoors area=21.750000 wall (35.000000,0.000000)-(42.250000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U5/W2 Interzone adjacent=B-ST3 area=36.000000 wall (42.250000,0.000000)-(42.250000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U5/W3 Outdoors area=3.750000 wall (42.250000,12.000000)-(41.000000,12.000000) z=0.000000 facing=North glazing=1.125000 window(offset=0.282673 sill=0.678416 0.684653x1.643168)
surface B-U5/W4 Interzone adjacent=W2-U1 area=18.000000 wall (41.000000,12.000000)-(35.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U5/W5 Interzone adjacent=B-U4 area=36.000000 wall (35.000000,12.000000)-(35.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U5/F Unresolved area=87.000000 Floor z=0.000000
surface B-U5/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U6/W1 Outdoors area=21.750000 wall (45.250000,0.000000)-(52.500000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U6/W2 Interzone adjacent=B-U7 area=36.000000 wall (52.500000,0.000000)-(52.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U6/W3 Outdoors area=21.750000 wall (52.500000,12.000000)-(45.250000,12.000000) z=0.000000 facing=North glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U6/W4 Interzone adjacent=B-ST3 area=36.000000 wall (45.250000,12.000000)-(45.250000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U6/F Unresolved area=87.000000 Floor z=0.000000
surface B-U6/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U7/W1 Outdoors area=21.750000 wall (52.500000,0.000000)-(59.750000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U7/W2 Interzone adjacent=B-ST4 area=36.000000 wall (59.750000,0.000000)-(59.750000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B-U7/W3 Interzone adjacent=W3-U1 area=5.250000 wall (59.750000,12.000000)-(58.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U7/W4 Outdoors area=16.500000 wall (58.000000,12.000000)-(52.500000,12.000000) z=0.000000 facing=North glazing=4.950000 window(offset=1.243763 sill=0.678416 3.012474x1.643168)
surface B-U7/W5 Interzone adjacent=B-U6 area=36.000000 wall (52.500000,12.000000)-(52.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U7/F Unresolved area=87.000000 Floor z=0.000000
surface B-U7/C Unresolved area=87.000000 Ceiling z=3.000000
surface B-U8/W1 Outdoors area=21.750000 wall (62.750000,0.000000)-(70.000000,0.000000) z=0.000000 facing=South glazing=6.525000 window(offset=1.639506 sill=0.678416 3.970989x1.643168)
surface B-U8/W2 Outdoors area=36.000000 wall (70.000000,0.000000)-(70.000000,12.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface B-U8/W3 Interzone adjacent=W3-U1 area=21.750000 wall (70.000000,12.000000)-(62.750000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B-U8/W4 Interzone adjacent=B-ST4 area=36.000000 wall (62.750000,12.000000)-(62.750000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B-U8/F Unresolved area=87.000000 Floor z=0.000000
surface B-U8/C Unresolved area=87.000000 Ceiling z=3.000000
surface W1-ST1/W1 Interzone adjacent=W1-U1 area=36.000000 wall (0.000000,19.000000)-(12.000000,19.000000) z=0.000000 facing=South glazing=0.000000
surface W1-ST1/W2 Outdoors area=9.000000 wall (12.000000,19.000000)-(12.000000,22.000000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W1-ST1/W3 Interzone adjacent=W1-U2 area=36.000000 wall (12.000000,22.000000)-(0.000000,22.000000) z=0.000000 facing=North glazing=0.000000
surface W1-ST1/W4 Outdoors area=9.000000 wall (0.000000,22.000000)-(0.000000,19.000000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W1-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface W1-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface W1-U1/W1 Interzone adjacent=B-U1 area=21.750000 wall (0.000000,12.000000)-(7.250000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W1-U1/W2 Interzone adjacent=B-ST1 area=9.000000 wall (7.250000,12.000000)-(10.250000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W1-U1/W3 Interzone adjacent=B-U2 area=5.250000 wall (10.250000,12.000000)-(12.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W1-U1/W4 Outdoors area=21.000000 wall (12.000000,12.000000)-(12.000000,19.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W1-U1/W5 Interzone adjacent=W1-ST1 area=36.000000 wall (12.000000,19.000000)-(0.000000,19.000000) z=0.000000 facing=North glazing=0.000000
surface W1-U1/W6 Outdoors area=21.000000 wall (0.000000,19.000000)-(0.000000,12.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W1-U1/F Unresolved area=84.000000 Floor z=0.000000
surface W1-U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface W1-U2/W1 Interzone adjacent=W1-ST1 area=36.000000 wall (0.000000,22.000000)-(12.000000,22.000000) z=0.000000 facing=South glazing=0.000000
surface W1-U2/W2 Outdoors area=21.000000 wall (12.000000,22.000000)-(12.000000,29.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W1-U2/W3 Outdoors area=36.000000 wall (12.000000,29.000000)-(0.000000,29.000000) z=0.000000 facing=North glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface W1-U2/W4 Outdoors area=21.000000 wall (0.000000,29.000000)-(0.000000,22.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W1-U2/F Unresolved area=84.000000 Floor z=0.000000
surface W1-U2/C Unresolved area=84.000000 Ceiling z=3.000000
surface W2-ST1/W1 Interzone adjacent=W2-U1 area=36.000000 wall (29.000000,19.000000)-(41.000000,19.000000) z=0.000000 facing=South glazing=0.000000
surface W2-ST1/W2 Outdoors area=9.000000 wall (41.000000,19.000000)-(41.000000,22.000000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W2-ST1/W3 Interzone adjacent=W2-U2 area=36.000000 wall (41.000000,22.000000)-(29.000000,22.000000) z=0.000000 facing=North glazing=0.000000
surface W2-ST1/W4 Outdoors area=9.000000 wall (29.000000,22.000000)-(29.000000,19.000000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W2-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface W2-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface W2-U1/W1 Interzone adjacent=B-U4 area=18.000000 wall (29.000000,12.000000)-(35.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W2-U1/W2 Interzone adjacent=B-U5 area=18.000000 wall (35.000000,12.000000)-(41.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W2-U1/W3 Outdoors area=21.000000 wall (41.000000,12.000000)-(41.000000,19.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W2-U1/W4 Interzone adjacent=W2-ST1 area=36.000000 wall (41.000000,19.000000)-(29.000000,19.000000) z=0.000000 facing=North glazing=0.000000
surface W2-U1/W5 Outdoors area=21.000000 wall (29.000000,19.000000)-(29.000000,12.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W2-U1/F Unresolved area=84.000000 Floor z=0.000000
surface W2-U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface W2-U2/W1 Interzone adjacent=W2-ST1 area=36.000000 wall (29.000000,22.000000)-(41.000000,22.000000) z=0.000000 facing=South glazing=0.000000
surface W2-U2/W2 Outdoors area=21.000000 wall (41.000000,22.000000)-(41.000000,29.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W2-U2/W3 Outdoors area=36.000000 wall (41.000000,29.000000)-(29.000000,29.000000) z=0.000000 facing=North glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface W2-U2/W4 Outdoors area=21.000000 wall (29.000000,29.000000)-(29.000000,22.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W2-U2/F Unresolved area=84.000000 Floor z=0.000000
surface W2-U2/C Unresolved area=84.000000 Ceiling z=3.000000
surface W3-ST1/W1 Interzone adjacent=W3-U1 area=36.000000 wall (58.000000,19.000000)-(70.000000,19.000000) z=0.000000 facing=South glazing=0.000000
surface W3-ST1/W2 Outdoors area=9.000000 wall (70.000000,19.000000)-(70.000000,22.000000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W3-ST1/W3 Interzone adjacent=W3-U2 area=36.000000 wall (70.000000,22.000000)-(58.000000,22.000000) z=0.000000 facing=North glazing=0.000000
surface W3-ST1/W4 Outdoors area=9.000000 wall (58.000000,22.000000)-(58.000000,19.000000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W3-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface W3-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface W3-U1/W1 Interzone adjacent=B-U7 area=5.250000 wall (58.000000,12.000000)-(59.750000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W3-U1/W2 Interzone adjacent=B-ST4 area=9.000000 wall (59.750000,12.000000)-(62.750000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W3-U1/W3 Interzone adjacent=B-U8 area=21.750000 wall (62.750000,12.000000)-(70.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W3-U1/W4 Outdoors area=21.000000 wall (70.000000,12.000000)-(70.000000,19.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W3-U1/W5 Interzone adjacent=W3-ST1 area=36.000000 wall (70.000000,19.000000)-(58.000000,19.000000) z=0.000000 facing=North glazing=0.000000
surface W3-U1/W6 Outdoors area=21.000000 wall (58.000000,19.000000)-(58.000000,12.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W3-U1/F Unresolved area=84.000000 Floor z=0.000000
surface W3-U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface W3-U2/W1 Interzone adjacent=W3-ST1 area=36.000000 wall (58.000000,22.000000)-(70.000000,22.000000) z=0.000000 facing=South glazing=0.000000
surface W3-U2/W2 Outdoors area=21.000000 wall (70.000000,22.000000)-(70.000000,29.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W3-U2/W3 Outdoors area=36.000000 wall (70.000000,29.000000)-(58.000000,29.000000) z=0.000000 facing=North glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface W3-U2/W4 Outdoors area=21.000000 wall (58.000000,29.000000)-(58.000000,22.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W3-U2/F Unresolved area=84.000000 Floor z=0.000000
surface W3-U2/C Unresolved area=84.000000 Ceiling z=3.000000
  • [x] Step 8: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.WingedBand.WingedBandGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairBayBar|FullyQualifiedName~Lod.Generators.Tests.StairPair"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 27, Skipped: 0, Total: 27, Duration: … - Lod.Generators.Tests.dll (net8.0); the stair bay bar and stair pair tests on the changed StairBayLayout, Passed! - Failed: 0, Passed: 34, Skipped: 0, Total: 34, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 154 core, 137 generators, and 322 integration tests passed on net8.0; git status lists M src/Lod.Generators/StairBayBar/StairBayLayout.cs and the three new files of this task, and no *.received.txt.

  • [x] Step 9: Commit
git add src/Lod.Generators/StairBayBar/StairBayLayout.cs src/Lod.Generators/WingedBand/WingedBandGenerator.cs tests/Lod.Generators.Tests/WingedBand/WingedBandGeneratorTests.cs tests/Lod.Generators.Tests/WingedBand/Snapshots/winged-band-canonical.txt
git commit -m "feature(generators): add the winged band generator (hdhm-typ-003)"

Task 6: Open court generator (HDHM-TYP-004)

Files: - Create: src/Lod.Generators/OpenCourt/OpenCourtGenerator.cs - Modify: src/Lod.Generators/StairBayBar/StairBayLayout.cs, src/Lod.Generators/WingedBand/WingedBandGenerator.cs (Task 5) - Test: create tests/Lod.Generators.Tests/OpenCourt/OpenCourtGeneratorTests.cs - Snapshot: create tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.txt

Interfaces: - Consumes: StairBayLayout.Wing, Fit (Task 5); the PlanGenerator base and records as in Task 1; PolygonOps.IntersectionArea (in the test that the court is empty); PlanAssertions (S8.1). - Produces: - In StairBayLayout: FitErrors(string wing, double length, double unitWidth, double stairWidth) : IEnumerable<Diagnostic> (zero or one InvalidParameter error; the message names the wing, such as each 17 m wing, and the stairs it would need) replaces Need; WingedBandGenerator calls it for the band and the wings, with messages unchanged, and loses its private WingFits. - public sealed record OpenCourtParameters(double CourtWidth, double CourtDepth, double Depth, double UnitWidth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (24, 17, 12, 7, 3, 3, 0); public sealed record OpenCourtPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets; public sealed class OpenCourtGenerator(ToleranceSettings), Name = OpenCourtGenerator: west wing [0, D] × [0, c], east wing [D + w, 2D + w] × [0, c], north wing [0, 2D + w] × [c, c + D], each a bar of stair bays, around a court [D, D + w] × [0, c] open to plan south; zones N-ST1…, N-U1… (North Stair 1, …, west to east), then W-… and E-… (West Unit 1, …, south to north); the footprint is the U outline; InvalidParameter for each length that is not positive and finite and, only when those pass, for the north wing or the side wings whose stairs leave no dwelling width (StairWidth 20 m leaves no dwelling width in each 17 m side wing: its one stair bay needs 20 m of stairs; reduce StairWidth or UnitWidth.); provenance keys CourtWidth, CourtDepth, Depth, UnitWidth, StairWidth.

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

22 tests: the ADR-006 defaults; the canonical plan (15 zone IDs, 984 m², names, stairs 36 m², north dwellings 6.5 m × 12 m, side dwellings 84 m², corners of N-ST2, W-ST1, E-U2, no source zones); the U outline without holes and the empty court; tiling for three court sizes; dwellings on three sides of the court (24 m of the north wing's south façade, the inner façades of the side wings); every dwelling adjoining a stair; the window rule; six invalid parameters; stairs that leave no dwelling width; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.

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

namespace Lod.Generators.Tests.OpenCourt;

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

    private static readonly string[] CanonicalZones =
    {
        "N-ST1", "N-ST2", "N-ST3", "N-U1", "N-U2", "N-U3", "N-U4", "N-U5", "N-U6",
        "W-ST1", "W-U1", "W-U2", "E-ST1", "E-U1", "E-U2",
    };

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

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

    [Fact]
    public void CanonicalPlanIsThreeStairBayWingsAroundACourt()
    {
        IGeneratedPlan plan = Generate(OpenCourtParameters.Default);

        Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
        Assert.Equal((48.0 * 29.0) - (24.0 * 17.0), plan.Footprint.Area, 9);
        Assert.Equal(new[] { "North Stair 2", "North Unit 3", "West Unit 2", "East Stair 1" }, plan.Zones.Where(z => z.Id.Value is "N-ST2" or "N-U3" or "W-U2" or "E-ST1").Select(z => z.Name));
        Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.Stair), z => Assert.Equal(36.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => z.Id.Value.StartsWith("N-U")), z => Assert.Equal(6.5 * 12.0, z.FloorArea, 9));
        Assert.All(plan.Zones.Where(z => !z.Id.Value.StartsWith("N-") && z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal(84.0, z.FloorArea, 9));
        Assert.Equal(new[] { new Point2(22.5, 17.0), new Point2(25.5, 17.0), new Point2(25.5, 29.0), new Point2(22.5, 29.0) }, Outer(plan, "N-ST2"));
        Assert.Equal(new[] { new Point2(0.0, 7.0), new Point2(12.0, 7.0), new Point2(12.0, 10.0), new Point2(0.0, 10.0) }, Outer(plan, "W-ST1"));
        Assert.Equal(new[] { new Point2(36.0, 10.0), new Point2(48.0, 10.0), new Point2(48.0, 17.0), new Point2(36.0, 17.0) }, Outer(plan, "E-U2"));
        Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
    }

    [Fact]
    public void TheFootprintIsAUOpenToPlanSouth()
    {
        IGeneratedPlan plan = Generate(OpenCourtParameters.Default);

        Assert.Equal(
            new[] { new Point2(0, 0), new Point2(12, 0), new Point2(12, 17), new Point2(36, 17), new Point2(36, 0), new Point2(48, 0), new Point2(48, 29), new Point2(0, 29) },
            plan.Footprint.Outer);
        Assert.Empty(plan.Footprint.Holes);
        Assert.Equal(0.0, new PolygonOps(ToleranceSettings.Default).IntersectionArea(plan.Footprint, Polygon2.Rectangle(12, 0, 36, 17)), 9);
    }

    [Theory]
    [InlineData(24.0, 17.0)]
    [InlineData(6.0, 40.0)]
    [InlineData(50.0, 4.0)]
    public void ZonesTileTheFootprint(double courtWidth, double courtDepth)
    {
        IGeneratedPlan plan = Generate(OpenCourtParameters.Default with { CourtWidth = courtWidth, CourtDepth = courtDepth });

        PlanAssertions.TilesTheFootprint(plan);
        Assert.Equal(new[] { "N", "W", "E" }, plan.Zones.Select(z => z.Id.Value.Split('-')[0]).Distinct());
    }

    [Fact]
    public void DwellingsLieOnThreeSidesOfTheCourt()
    {
        IGeneratedPlan plan = Generate(OpenCourtParameters.Default);
        WallSurface[] court = plan.Surfaces.OfType<WallSurface>()
            .Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Start.X is >= 12.0 and <= 36.0 && w.End.X is >= 12.0 and <= 36.0 && w.Start.Y <= 17.0 && w.End.Y <= 17.0)
            .ToArray();

        Assert.Equal(new[] { "N-U2", "N-U3", "N-ST2", "N-U4", "N-U5" }, court.Where(w => Orientation.Bin(w.PlanAzimuth) == OrientationBin.South).OrderBy(w => w.End.X).Select(w => w.Zone.Value));
        Assert.Equal(new[] { "W-U1", "W-ST1", "W-U2" }, court.Where(w => Orientation.Bin(w.PlanAzimuth) == OrientationBin.East).OrderBy(w => w.Start.Y).Select(w => w.Zone.Value));
        Assert.Equal(new[] { "E-U1", "E-ST1", "E-U2" }, court.Where(w => Orientation.Bin(w.PlanAzimuth) == OrientationBin.West).OrderBy(w => w.End.Y).Select(w => w.Zone.Value));
        Assert.Equal(24.0, court.Where(w => Orientation.Bin(w.PlanAzimuth) == OrientationBin.South).Sum(w => w.Length), 9);
    }

    [Fact]
    public void EveryDwellingAdjoinsAStair()
    {
        IGeneratedPlan plan = Generate(OpenCourtParameters.Default);

        Assert.All(
            plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit),
            z => Assert.Contains(PlanAssertions.Neighbours(plan, z.Id.Value), n => plan.Zones.Single(s => s.Id.Value == n).SpaceType == SpaceType.Stair));
        Assert.Equal(new[] { "N-ST1", "N-U1", "N-U2", "W-ST1" }, PlanAssertions.Neighbours(plan, "W-U2"));
        Assert.Equal(new[] { "E-ST1", "N-ST3", "N-U5", "N-U6" }, PlanAssertions.Neighbours(plan, "E-U2"));
        Assert.Equal(new[] { "W-U1", "W-U2" }, PlanAssertions.Neighbours(plan, "W-ST1"));
        Assert.Equal(new[] { OrientationBin.East, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "W-U1"));
    }

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

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

    [Theory]
    [InlineData(0.0, 17.0, 12.0, 7.0, 3.0, "CourtWidth")]
    [InlineData(24.0, -17.0, 12.0, 7.0, 3.0, "CourtDepth")]
    [InlineData(24.0, 17.0, 0.0, 7.0, 3.0, "Depth")]
    [InlineData(24.0, 17.0, 12.0, double.NaN, 3.0, "UnitWidth")]
    [InlineData(24.0, 17.0, 12.0, 7.0, -3.0, "StairWidth")]
    [InlineData(double.PositiveInfinity, 17.0, 12.0, 7.0, 3.0, "CourtWidth")]
    public void InvalidParametersAreErrors(double courtWidth, double courtDepth, double depth, double unitWidth, double stairWidth, string name)
    {
        Result<IGeneratedPlan> result = _generator.Generate(new OpenCourtParameters(courtWidth, courtDepth, depth, unitWidth, stairWidth, 3.0, 0.0), Examples);

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

    [Fact]
    public void StairsThatLeaveNoRoomForDwellingsAreAnError()
    {
        // 17 m side wings with 20 m stairs: one bay needs more than the wing's length; the 48 m north wing (round(48 / 21) = 2 bays) still fits.
        Result<IGeneratedPlan> result = _generator.Generate(OpenCourtParameters.Default with { UnitWidth = 0.5, StairWidth = 20.0 }, Examples);

        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
        Assert.Equal("StairWidth 20 m leaves no dwelling width in each 17 m side wing: its one stair bay needs 20 m of stairs; reduce StairWidth or UnitWidth.", error.Message);
    }

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

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

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

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

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

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

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

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

    [Fact]
    public void CanonicalPlanSnapshot()
    {
        Snapshot.Match(TextReport.Describe(Generate(OpenCourtParameters.Default)), "open-court-canonical");
    }

    private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();

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

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

tests\Lod.Generators.Tests\OpenCourt\OpenCourtGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'OpenCourt' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\OpenCourt\OpenCourtGeneratorTests.cs(187,37): error CS0246: The type or namespace name 'OpenCourtParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\OpenCourt\OpenCourtGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'OpenCourtPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\OpenCourt\OpenCourtGeneratorTests.cs(23,22): error CS0246: The type or namespace name 'OpenCourtGenerator' could not be found (are you missing a using directive or an assembly reference?)
  • [x] Step 3: Implement — replace src/Lod.Generators/StairBayBar/StairBayLayout.cs (full file)

Need becomes FitErrors, which builds the whole error; Wing, Fit, and Bays are unchanged.

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

namespace Lod.Generators.StairBayBar;

/// <summary>
/// The stair bay of ADR-006: dwelling | stair | dwelling side by side along a bar, each through the full depth, the stair serving the two dwellings
/// beside it. Shared by <see cref="StairBayBarGenerator"/> (HDHM-TYP-001), the stair pair (HDHM-TYP-008), and the wings of the winged band
/// (HDHM-TYP-003) and the open court (HDHM-TYP-004), so the bay geometry exists once.
/// </summary>
internal static class StairBayLayout
{
    /// <summary>
    /// Lays out <paramref name="bays"/> stair bays west to east from the origin. Zones: <c>ST1…STn</c> (stairs, west to east), then <c>U1…U2n</c>
    /// (dwellings, west to east); bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
    /// </summary>
    /// <param name="bays">Number of bays, at least 1.</param>
    /// <param name="unitWidth">Dwelling width along the bar, m.</param>
    /// <param name="depth">Bar depth, m.</param>
    /// <param name="stairWidth">Stair width along the bar, m.</param>
    /// <returns>The footprint and the zones.</returns>
    public static PlanLayout Bays(int bays, double unitWidth, double depth, double stairWidth)
    {
        double length = bays * ((2.0 * unitWidth) + stairWidth);
        return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, length, depth), Wing(string.Empty, string.Empty, 0.0, 0.0, length, depth, alongY: false, bays, unitWidth, stairWidth));
    }

    /// <summary>
    /// The stair bays that fill a wing of length <paramref name="length"/> exactly (ADR-006): <c>n = max(1, round(L / (2U + S)))</c> bays, rounded
    /// half away from zero, with the dwelling width adjusted to <c>(L − n·S) / (2n)</c>, which is not positive when the stairs leave no room.
    /// </summary>
    /// <param name="length">Wing length, m; positive.</param>
    /// <param name="unitWidth">Target dwelling width, m; positive.</param>
    /// <param name="stairWidth">Stair width, m; positive.</param>
    /// <returns>The bay count and the adjusted dwelling width.</returns>
    public static (int Bays, double UnitWidth) Fit(double length, double unitWidth, double stairWidth)
    {
        double bays = Math.Max(1.0, Math.Round(length / ((2.0 * unitWidth) + stairWidth), MidpointRounding.AwayFromZero));
        int count = bays >= int.MaxValue ? int.MaxValue : (int)bays;
        return (count, (length - (count * stairWidth)) / (2.0 * count));
    }

    /// <summary>
    /// The error for a wing whose fitted stair bays leave no dwelling width (<see cref="Fit"/>), or none; the message names the wing, such as
    /// <c>each 17 m wing</c>, and the stairs it would need.
    /// </summary>
    /// <param name="wing">The wing as named in the message.</param>
    /// <param name="length">Wing length, m.</param>
    /// <param name="unitWidth">Target dwelling width, m.</param>
    /// <param name="stairWidth">Stair width, m.</param>
    /// <returns>Zero or one <see cref="DiagnosticCodes.InvalidParameter"/> error.</returns>
    public static IEnumerable<Diagnostic> FitErrors(string wing, double length, double unitWidth, double stairWidth)
    {
        (int bays, double width) = Fit(length, unitWidth, stairWidth);
        if (width > 0)
        {
            yield break;
        }

        string need = bays == 1
            ? $"its one stair bay needs {Provenance.Format(stairWidth)} m of stairs"
            : $"its {bays} stair bays need {Provenance.Format(bays * stairWidth)} m of stairs";
        yield return Diagnostic.Error(
            DiagnosticCodes.InvalidParameter,
            $"{nameof(StairBayBarParameters.StairWidth)} {Provenance.Format(stairWidth)} m leaves no dwelling width in {wing}: {need}; "
            + $"reduce {nameof(StairBayBarParameters.StairWidth)} or {nameof(StairBayBarParameters.UnitWidth)}.");
    }

    /// <summary>
    /// Lays out <paramref name="bays"/> stair bays along a wing that starts at (<paramref name="x0"/>, <paramref name="y0"/>) and runs along +x, or
    /// along +y when <paramref name="alongY"/>; the last bay ends exactly at the wing's length. Zones: <c>{prefix}ST1…</c>, then <c>{prefix}U1…</c>,
    /// numbered along the wing's axis; bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
    /// </summary>
    /// <param name="prefix">Zone ID prefix, such as <c>B-</c>.</param>
    /// <param name="namePrefix">Display name prefix, such as <c>Band </c>.</param>
    /// <param name="x0">Wing start, plan x, m.</param>
    /// <param name="y0">Wing start, plan y, m.</param>
    /// <param name="length">Wing length along its axis, m.</param>
    /// <param name="depth">Wing depth across its axis, m.</param>
    /// <param name="alongY">Whether the wing runs along +y instead of +x.</param>
    /// <param name="bays">Number of bays, at least 1.</param>
    /// <param name="unitWidth">Dwelling width along the wing, m.</param>
    /// <param name="stairWidth">Stair width along the wing, m.</param>
    /// <returns>The stairs, then the dwellings.</returns>
    public static IReadOnlyList<PlannedZone> Wing(
        string prefix,
        string namePrefix,
        double x0,
        double y0,
        double length,
        double depth,
        bool alongY,
        int bays,
        double unitWidth,
        double stairWidth)
    {
        double bayWidth = (2.0 * unitWidth) + stairWidth;
        double start = alongY ? y0 : x0;
        Polygon2 Piece(double from, double to) => alongY ? Polygon2.Rectangle(x0, from, x0 + depth, to) : Polygon2.Rectangle(from, y0, to, y0 + depth);
        var stairs = new List<PlannedZone>();
        var units = new List<PlannedZone>();
        for (int i = 0; i < bays; i++)
        {
            double west = start + (i * bayWidth);
            double stairWest = west + unitWidth;
            double stairEast = stairWest + stairWidth;
            double east = i == bays - 1 ? start + length : west + bayWidth;
            stairs.Add(new PlannedZone(new ZoneId($"{prefix}ST{i + 1}"), $"{namePrefix}Stair {i + 1}", SpaceType.Stair, Piece(stairWest, stairEast)));
            units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 1}"), $"{namePrefix}Unit {(2 * i) + 1}", SpaceType.DwellingUnit, Piece(west, stairWest)));
            units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 2}"), $"{namePrefix}Unit {(2 * i) + 2}", SpaceType.DwellingUnit, Piece(stairEast, east)));
        }

        stairs.AddRange(units);
        return stairs;
    }
}
  • [x] Step 4: Implement — replace src/Lod.Generators/WingedBand/WingedBandGenerator.cs (full file)

The band and the wings are checked with StairBayLayout.FitErrors; the private WingFits is removed. The messages are unchanged.

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

namespace Lod.Generators.WingedBand;

/// <summary>Parameters of the winged band (HDHM-TYP-003, ADR-006).</summary>
/// <param name="WingCount">Number of wings projecting from the band; at least 2.</param>
/// <param name="WingLength">Length of each wing from the band to its tip, m.</param>
/// <param name="WingSpacing">Clear distance between neighbouring wings along the band, m.</param>
/// <param name="Depth">Depth of the band and of every wing, which every dwelling and stair spans, m.</param>
/// <param name="UnitWidth">Target dwelling width along the band and the wings, m; adjusted so the stair bays fill each exactly.</param>
/// <param name="StairWidth">Width of each stair along the band and the wings, 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 WingedBandParameters(
    int WingCount,
    double WingLength,
    double WingSpacing,
    double Depth,
    double UnitWidth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: 3 wings of 17 m, 17 m apart, 12 m deep, 7 m dwellings and 3 m stairs, 3 m storeys, orientation 0°.</summary>
    public static WingedBandParameters Default { get; } = new(3, 17.0, 17.0, 12.0, 7.0, 3.0, 3.0, 0.0);
}

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

/// <summary>
/// Projecting wings joined by a common band (HDHM-TYP-003, ADR-006): a band of stair bays along plan x and <see cref="WingedBandParameters.WingCount"/>
/// wings of stair bays projecting to plan north, the first flush with the band's west end and the last with its east end. Every wing adjoins the band
/// along its whole root (R001), and at least two wings repeat the wing-to-band relation (R002); one wing is rejected (R003, R004). The outline is a
/// comb, so the band's north façade is fragments between the wings' roots and its bay boundaries; ADR-011 governs their windows. Each wing and the band
/// hold <c>n = max(1, round(L / (2U + S)))</c> bays with the dwelling width adjusted to fill their length <c>L</c>. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// With <c>D</c> the depth and <c>g</c> the wing spacing, the band is <c>[0, W·D + (W − 1)·g] × [0, D]</c> and wing <c>i</c> (from 1) is
/// <c>[(i − 1)(D + g), (i − 1)(D + g) + D] × [D, D + WingLength]</c>. Zone IDs: <c>B-ST1…</c>, <c>B-U1…</c> (band, west to east), then per wing
/// <c>W{i}-ST1…</c>, <c>W{i}-U1…</c> (south to north); display names <c>Band Unit 3</c>, <c>Wing 2 Stair 1</c>.
/// </remarks>
public sealed class WingedBandGenerator : PlanGenerator<WingedBandParameters, WingedBandPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public WingedBandGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(WingedBandParameters parameters)
    {
        var errors = new List<Diagnostic>();
        if (parameters.WingCount < 2)
        {
            errors.Add(Diagnostic.Error(
                DiagnosticCodes.InvalidParameter,
                $"{nameof(WingedBandParameters.WingCount)} must be at least 2 (the wing-to-band relation repeats), got {parameters.WingCount}."));
        }

        var values = new (string Name, double Value)[]
        {
            (nameof(WingedBandParameters.WingLength), parameters.WingLength),
            (nameof(WingedBandParameters.WingSpacing), parameters.WingSpacing),
            (nameof(WingedBandParameters.Depth), parameters.Depth),
            (nameof(WingedBandParameters.UnitWidth), parameters.UnitWidth),
            (nameof(WingedBandParameters.StairWidth), parameters.StairWidth),
        };
        errors.AddRange(values.Where(v => !IsPositive(v.Value)).Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}.")));
        if (errors.Count == 0)
        {
            errors.AddRange(StairBayLayout.FitErrors($"the {Provenance.Format(BandLength(parameters))} m band", BandLength(parameters), parameters.UnitWidth, parameters.StairWidth));
            errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(parameters.WingLength)} m wing", parameters.WingLength, parameters.UnitWidth, parameters.StairWidth));
        }

        return errors;
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(WingedBandParameters p)
    {
        double bandLength = BandLength(p);
        double top = p.Depth + p.WingLength;
        (int bandBays, double bandUnit) = StairBayLayout.Fit(bandLength, p.UnitWidth, p.StairWidth);
        (int wingBays, double wingUnit) = StairBayLayout.Fit(p.WingLength, p.UnitWidth, p.StairWidth);
        var zones = new List<PlannedZone>(StairBayLayout.Wing("B-", "Band ", 0.0, 0.0, bandLength, p.Depth, alongY: false, bandBays, bandUnit, p.StairWidth));
        for (int i = 1; i <= p.WingCount; i++)
        {
            zones.AddRange(StairBayLayout.Wing($"W{i}-", $"Wing {i} ", WingWest(p, i), p.Depth, p.WingLength, p.Depth, alongY: true, wingBays, wingUnit, p.StairWidth));
        }

        var outline = new List<Point2> { new(0.0, 0.0), new(bandLength, 0.0) };
        for (int i = p.WingCount; i >= 1; i--)
        {
            double west = WingWest(p, i);
            outline.Add(new Point2(west + p.Depth, top));
            outline.Add(new Point2(west, top));
            if (i > 1)
            {
                outline.Add(new Point2(west, p.Depth));
                outline.Add(new Point2(WingWest(p, i - 1) + p.Depth, p.Depth));
            }
        }

        return new PlanLayout(Polygon2.Create(outline).Value, zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(WingedBandParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.WingCount), p.WingCount.ToString(CultureInfo.InvariantCulture)),
        new KeyValuePair<string, string>(nameof(p.WingLength), Provenance.Format(p.WingLength)),
        new KeyValuePair<string, string>(nameof(p.WingSpacing), Provenance.Format(p.WingSpacing)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };

    private static double BandLength(WingedBandParameters p) => WingWest(p, p.WingCount) + p.Depth;

    private static double WingWest(WingedBandParameters p, int wing) => (wing - 1) * (p.Depth + p.WingSpacing);
}
  • [x] Step 5: Implement — create src/Lod.Generators/OpenCourt/OpenCourtGenerator.cs (full file)
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 Lod.Generators.StairBayBar;

namespace Lod.Generators.OpenCourt;

/// <summary>Parameters of the open court (HDHM-TYP-004, ADR-006).</summary>
/// <param name="CourtWidth">Width of the court between the side wings, m.</param>
/// <param name="CourtDepth">Depth of the court from its open side to the north wing, which is also the side wings' length, m.</param>
/// <param name="Depth">Depth of every wing, which every dwelling and stair spans, m.</param>
/// <param name="UnitWidth">Target dwelling width along the wings, m; adjusted so the stair bays fill each wing exactly.</param>
/// <param name="StairWidth">Width of each stair along the wings, 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 OpenCourtParameters(
    double CourtWidth,
    double CourtDepth,
    double Depth,
    double UnitWidth,
    double StairWidth,
    double FloorHeight,
    double OrientationDegrees)
    : PlanParameters(FloorHeight, OrientationDegrees)
{
    /// <summary>The ADR-006 defaults: a 24 m × 17 m court, 12 m deep wings, 7 m dwellings and 3 m stairs, 3 m storeys, orientation 0°.</summary>
    public static OpenCourtParameters Default { get; } = new(24.0, 17.0, 12.0, 7.0, 3.0, 3.0, 0.0);
}

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

/// <summary>
/// Open U-shaped court with stair-served wings (HDHM-TYP-004, ADR-006): three wings of stair bays, west, east, and north, around a court open to plan
/// south. Dwellings lie on three sides of the court (R001), the fourth side stays open (R002), and every dwelling adjoins a stair of its bay (R003). The
/// north wing runs the full width and holds the corners; the side wings run from plan south to it. Each wing holds
/// <c>n = max(1, round(L / (2U + S)))</c> bays with the dwelling width adjusted to fill its length <c>L</c>. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// With <c>D</c> the depth, <c>w</c> the court width, and <c>c</c> the court depth: west wing <c>[0, D] × [0, c]</c>, east wing
/// <c>[D + w, 2D + w] × [0, c]</c>, north wing <c>[0, 2D + w] × [c, c + D]</c>. Zone IDs: <c>N-ST1…</c>, <c>N-U1…</c> (north wing, west to east), then
/// <c>W-…</c> and <c>E-…</c> (side wings, south to north); display names <c>North Unit 3</c>, <c>West Stair 1</c>.
/// </remarks>
public sealed class OpenCourtGenerator : PlanGenerator<OpenCourtParameters, OpenCourtPresets>
{
    /// <summary>Creates the generator.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public OpenCourtGenerator(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override IEnumerable<Diagnostic> Validate(OpenCourtParameters parameters)
    {
        var values = new (string Name, double Value)[]
        {
            (nameof(OpenCourtParameters.CourtWidth), parameters.CourtWidth),
            (nameof(OpenCourtParameters.CourtDepth), parameters.CourtDepth),
            (nameof(OpenCourtParameters.Depth), parameters.Depth),
            (nameof(OpenCourtParameters.UnitWidth), parameters.UnitWidth),
            (nameof(OpenCourtParameters.StairWidth), parameters.StairWidth),
        };
        var errors = values
            .Where(v => !IsPositive(v.Value))
            .Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
            .ToList();
        if (errors.Count == 0)
        {
            double north = NorthLength(parameters);
            errors.AddRange(StairBayLayout.FitErrors($"the {Provenance.Format(north)} m north wing", north, parameters.UnitWidth, parameters.StairWidth));
            errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(parameters.CourtDepth)} m side wing", parameters.CourtDepth, parameters.UnitWidth, parameters.StairWidth));
        }

        return errors;
    }

    /// <inheritdoc />
    protected override PlanLayout Layout(OpenCourtParameters p)
    {
        double d = p.Depth;
        double east = d + p.CourtWidth;
        double width = NorthLength(p);
        double top = p.CourtDepth + d;
        (int northBays, double northUnit) = StairBayLayout.Fit(width, p.UnitWidth, p.StairWidth);
        (int sideBays, double sideUnit) = StairBayLayout.Fit(p.CourtDepth, p.UnitWidth, p.StairWidth);
        var zones = new List<PlannedZone>();
        zones.AddRange(StairBayLayout.Wing("N-", "North ", 0.0, p.CourtDepth, width, d, alongY: false, northBays, northUnit, p.StairWidth));
        zones.AddRange(StairBayLayout.Wing("W-", "West ", 0.0, 0.0, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
        zones.AddRange(StairBayLayout.Wing("E-", "East ", east, 0.0, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
        Polygon2 footprint = Polygon2.Create(new[]
        {
            new Point2(0.0, 0.0), new Point2(d, 0.0), new Point2(d, p.CourtDepth), new Point2(east, p.CourtDepth),
            new Point2(east, 0.0), new Point2(east + d, 0.0), new Point2(east + d, top), new Point2(0.0, top),
        }).Value;
        return new PlanLayout(footprint, zones);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe(OpenCourtParameters p) => new[]
    {
        new KeyValuePair<string, string>(nameof(p.CourtWidth), Provenance.Format(p.CourtWidth)),
        new KeyValuePair<string, string>(nameof(p.CourtDepth), Provenance.Format(p.CourtDepth)),
        new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
        new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
        new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
    };

    private static double NorthLength(OpenCourtParameters p) => p.Depth + p.CourtWidth + p.Depth;
}
  • [x] Step 6: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.OpenCourt.OpenCourtGeneratorTests"

Expected on net8.0:

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

with the only failing test and its message:

Failed Lod.Generators.Tests.OpenCourt.OpenCourtGeneratorTests.CanonicalPlanSnapshot
  Snapshot 'open-court-canonical' is missing or differs. Review Snapshots/open-court-canonical.received.txt and rename it to open-court-canonical.txt to accept.

and the file tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.received.txt (git-ignored by *.received.*).

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

Review the received file against the content below; it must be identical: 177 lines, 15 zones, 96 surfaces (30 outdoor walls, each with one window; 36 interzone walls; 30 floors and ceilings), glazing 41.4 m² south, 37.8 m² north, 37.8 m² east, 37.8 m² west (154.8 m² in all). Accept it:

Move-Item -Force tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.received.txt tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.txt

tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.txt:

footprint area=984.000000 floorHeight=3.000000 orientation=0.000000
zone N-ST1 Stair "North Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone N-ST2 Stair "North Stair 2" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone N-ST3 Stair "North Stair 3" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone N-U1 DwellingUnit "North Unit 1" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U2 DwellingUnit "North Unit 2" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U3 DwellingUnit "North Unit 3" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U4 DwellingUnit "North Unit 4" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U5 DwellingUnit "North Unit 5" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U6 DwellingUnit "North Unit 6" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W-ST1 Stair "West Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone W-U1 DwellingUnit "West Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone W-U2 DwellingUnit "West Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone E-ST1 Stair "East Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
  load Lighting PerFloorArea value=3.000000 annual=8760.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  unconditioned
zone E-U1 DwellingUnit "East Unit 1" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
zone E-U2 DwellingUnit "East Unit 2" area=84.000000 volume=252.000000 parts=1 multiplier=1 sources=[]
  load Occupancy PerFloorArea value=0.030000 annual=6391.400000
  load Lighting PerFloorArea value=5.000000 annual=2627.800000
  load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
  load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
  setpoints heatingMean=21.000000 coolingMean=24.000000
surface N-ST1/W1 Interzone adjacent=W-U2 area=9.000000 wall (6.500000,17.000000)-(9.500000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST1/W2 Interzone adjacent=N-U2 area=36.000000 wall (9.500000,17.000000)-(9.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST1/W3 Outdoors area=9.000000 wall (9.500000,29.000000)-(6.500000,29.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST1/W4 Interzone adjacent=N-U1 area=36.000000 wall (6.500000,29.000000)-(6.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-ST2/W1 Outdoors area=9.000000 wall (22.500000,17.000000)-(25.500000,17.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST2/W2 Interzone adjacent=N-U4 area=36.000000 wall (25.500000,17.000000)-(25.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST2/W3 Outdoors area=9.000000 wall (25.500000,29.000000)-(22.500000,29.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST2/W4 Interzone adjacent=N-U3 area=36.000000 wall (22.500000,29.000000)-(22.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST2/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-ST3/W1 Interzone adjacent=E-U2 area=9.000000 wall (38.500000,17.000000)-(41.500000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST3/W2 Interzone adjacent=N-U6 area=36.000000 wall (41.500000,17.000000)-(41.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST3/W3 Outdoors area=9.000000 wall (41.500000,29.000000)-(38.500000,29.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST3/W4 Interzone adjacent=N-U5 area=36.000000 wall (38.500000,29.000000)-(38.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST3/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-U1/W1 Interzone adjacent=W-U2 area=19.500000 wall (0.000000,17.000000)-(6.500000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-U1/W2 Interzone adjacent=N-ST1 area=36.000000 wall (6.500000,17.000000)-(6.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-U1/W3 Outdoors area=19.500000 wall (6.500000,29.000000)-(0.000000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U1/W4 Outdoors area=36.000000 wall (0.000000,29.000000)-(0.000000,17.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface N-U1/F Unresolved area=78.000000 Floor z=0.000000
surface N-U1/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U2/W1 Interzone adjacent=W-U2 area=7.500000 wall (9.500000,17.000000)-(12.000000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-U2/W2 Outdoors area=12.000000 wall (12.000000,17.000000)-(16.000000,17.000000) z=0.000000 facing=South glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface N-U2/W3 Interzone adjacent=N-U3 area=36.000000 wall (16.000000,17.000000)-(16.000000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-U2/W4 Outdoors area=19.500000 wall (16.000000,29.000000)-(9.500000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U2/W5 Interzone adjacent=N-ST1 area=36.000000 wall (9.500000,29.000000)-(9.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-U2/F Unresolved area=78.000000 Floor z=0.000000
surface N-U2/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U3/W1 Outdoors area=19.500000 wall (16.000000,17.000000)-(22.500000,17.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U3/W2 Interzone adjacent=N-ST2 area=36.000000 wall (22.500000,17.000000)-(22.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-U3/W3 Outdoors area=19.500000 wall (22.500000,29.000000)-(16.000000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U3/W4 Interzone adjacent=N-U2 area=36.000000 wall (16.000000,29.000000)-(16.000000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-U3/F Unresolved area=78.000000 Floor z=0.000000
surface N-U3/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U4/W1 Outdoors area=19.500000 wall (25.500000,17.000000)-(32.000000,17.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U4/W2 Interzone adjacent=N-U5 area=36.000000 wall (32.000000,17.000000)-(32.000000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-U4/W3 Outdoors area=19.500000 wall (32.000000,29.000000)-(25.500000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U4/W4 Interzone adjacent=N-ST2 area=36.000000 wall (25.500000,29.000000)-(25.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-U4/F Unresolved area=78.000000 Floor z=0.000000
surface N-U4/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U5/W1 Outdoors area=12.000000 wall (32.000000,17.000000)-(36.000000,17.000000) z=0.000000 facing=South glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface N-U5/W2 Interzone adjacent=E-U2 area=7.500000 wall (36.000000,17.000000)-(38.500000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-U5/W3 Interzone adjacent=N-ST3 area=36.000000 wall (38.500000,17.000000)-(38.500000,29.000000) z=0.000000 facing=East glazing=0.000000
surface N-U5/W4 Outdoors area=19.500000 wall (38.500000,29.000000)-(32.000000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U5/W5 Interzone adjacent=N-U4 area=36.000000 wall (32.000000,29.000000)-(32.000000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-U5/F Unresolved area=78.000000 Floor z=0.000000
surface N-U5/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U6/W1 Interzone adjacent=E-U2 area=19.500000 wall (41.500000,17.000000)-(48.000000,17.000000) z=0.000000 facing=South glazing=0.000000
surface N-U6/W2 Outdoors area=36.000000 wall (48.000000,17.000000)-(48.000000,29.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface N-U6/W3 Outdoors area=19.500000 wall (48.000000,29.000000)-(41.500000,29.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U6/W4 Interzone adjacent=N-ST3 area=36.000000 wall (41.500000,29.000000)-(41.500000,17.000000) z=0.000000 facing=West glazing=0.000000
surface N-U6/F Unresolved area=78.000000 Floor z=0.000000
surface N-U6/C Unresolved area=78.000000 Ceiling z=3.000000
surface W-ST1/W1 Interzone adjacent=W-U1 area=36.000000 wall (0.000000,7.000000)-(12.000000,7.000000) z=0.000000 facing=South glazing=0.000000
surface W-ST1/W2 Outdoors area=9.000000 wall (12.000000,7.000000)-(12.000000,10.000000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W-ST1/W3 Interzone adjacent=W-U2 area=36.000000 wall (12.000000,10.000000)-(0.000000,10.000000) z=0.000000 facing=North glazing=0.000000
surface W-ST1/W4 Outdoors area=9.000000 wall (0.000000,10.000000)-(0.000000,7.000000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface W-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface W-U1/W1 Outdoors area=36.000000 wall (0.000000,0.000000)-(12.000000,0.000000) z=0.000000 facing=South glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface W-U1/W2 Outdoors area=21.000000 wall (12.000000,0.000000)-(12.000000,7.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W-U1/W3 Interzone adjacent=W-ST1 area=36.000000 wall (12.000000,7.000000)-(0.000000,7.000000) z=0.000000 facing=North glazing=0.000000
surface W-U1/W4 Outdoors area=21.000000 wall (0.000000,7.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W-U1/F Unresolved area=84.000000 Floor z=0.000000
surface W-U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface W-U2/W1 Interzone adjacent=W-ST1 area=36.000000 wall (0.000000,10.000000)-(12.000000,10.000000) z=0.000000 facing=South glazing=0.000000
surface W-U2/W2 Outdoors area=21.000000 wall (12.000000,10.000000)-(12.000000,17.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W-U2/W3 Interzone adjacent=N-U2 area=7.500000 wall (12.000000,17.000000)-(9.500000,17.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W4 Interzone adjacent=N-ST1 area=9.000000 wall (9.500000,17.000000)-(6.500000,17.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W5 Interzone adjacent=N-U1 area=19.500000 wall (6.500000,17.000000)-(0.000000,17.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W6 Outdoors area=21.000000 wall (0.000000,17.000000)-(0.000000,10.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface W-U2/F Unresolved area=84.000000 Floor z=0.000000
surface W-U2/C Unresolved area=84.000000 Ceiling z=3.000000
surface E-ST1/W1 Interzone adjacent=E-U1 area=36.000000 wall (36.000000,7.000000)-(48.000000,7.000000) z=0.000000 facing=South glazing=0.000000
surface E-ST1/W2 Outdoors area=9.000000 wall (48.000000,7.000000)-(48.000000,10.000000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface E-ST1/W3 Interzone adjacent=E-U2 area=36.000000 wall (48.000000,10.000000)-(36.000000,10.000000) z=0.000000 facing=North glazing=0.000000
surface E-ST1/W4 Outdoors area=9.000000 wall (36.000000,10.000000)-(36.000000,7.000000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface E-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface E-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface E-U1/W1 Outdoors area=36.000000 wall (36.000000,0.000000)-(48.000000,0.000000) z=0.000000 facing=South glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface E-U1/W2 Outdoors area=21.000000 wall (48.000000,0.000000)-(48.000000,7.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface E-U1/W3 Interzone adjacent=E-ST1 area=36.000000 wall (48.000000,7.000000)-(36.000000,7.000000) z=0.000000 facing=North glazing=0.000000
surface E-U1/W4 Outdoors area=21.000000 wall (36.000000,7.000000)-(36.000000,0.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface E-U1/F Unresolved area=84.000000 Floor z=0.000000
surface E-U1/C Unresolved area=84.000000 Ceiling z=3.000000
surface E-U2/W1 Interzone adjacent=E-ST1 area=36.000000 wall (36.000000,10.000000)-(48.000000,10.000000) z=0.000000 facing=South glazing=0.000000
surface E-U2/W2 Outdoors area=21.000000 wall (48.000000,10.000000)-(48.000000,17.000000) z=0.000000 facing=East glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface E-U2/W3 Interzone adjacent=N-U6 area=19.500000 wall (48.000000,17.000000)-(41.500000,17.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W4 Interzone adjacent=N-ST3 area=9.000000 wall (41.500000,17.000000)-(38.500000,17.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W5 Interzone adjacent=N-U5 area=7.500000 wall (38.500000,17.000000)-(36.000000,17.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W6 Outdoors area=21.000000 wall (36.000000,17.000000)-(36.000000,10.000000) z=0.000000 facing=West glazing=6.300000 window(offset=1.582971 sill=0.678416 3.834058x1.643168)
surface E-U2/F Unresolved area=84.000000 Floor z=0.000000
surface E-U2/C Unresolved area=84.000000 Ceiling z=3.000000
  • [x] Step 8: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.OpenCourt.OpenCourtGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.WingedBand"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22, Duration: … - Lod.Generators.Tests.dll (net8.0); the winged band on the shared FitErrors, with its message test, Passed! - Failed: 0, Passed: 27, Skipped: 0, Total: 27, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 154 core, 159 generators, and 322 integration tests passed on net8.0; git status lists the two modified and three new files of this task and no *.received.txt.

  • [x] Step 9: Commit
git add src/Lod.Generators/StairBayBar/StairBayLayout.cs src/Lod.Generators/WingedBand/WingedBandGenerator.cs src/Lod.Generators/OpenCourt/OpenCourtGenerator.cs tests/Lod.Generators.Tests/OpenCourt/OpenCourtGeneratorTests.cs tests/Lod.Generators.Tests/OpenCourt/Snapshots/open-court-canonical.txt
git commit -m "feature(generators): add the open court generator (hdhm-typ-004)"

Task 7: Perimeter Core states its width precondition

Files: - Modify: src/Lod.Core/Simplification/Strategies.cs (Task 2), docs/decisions/ADR-008-perimeter-core-corners.md (S3 document, last changed in S5) - Test: modify tests/Lod.Integration.Tests/SimplifierTests.cs (Task 2)

Interfaces: - Consumes: PerimeterCore(ToleranceSettings, double depth).Simplify (S3) and its two PlanTooNarrow failures (no valid core, or trapezoids and core that do not tile the footprint); OpenCourtGenerator and OpenCourtParameters.Default (Task 6). - Produces: both PlanTooNarrow messages end with the precondition (D-087): …: every wing and every part of the plan must be wider than twice the depth ({2d} m). Use a smaller depth or another simplifier. (a private Precondition()). The checks themselves are unchanged.

  • [x] Step 1: Write the failing test — replace tests/Lod.Integration.Tests/SimplifierTests.cs (full file)

One new test, WingsNarrowerThanTwiceThePerimeterDepthAreAnActionableError: the open court with 8 m deep wings through Perimeter Core at its 4.57 m depth gives exactly one PlanTooNarrow error with the full message. The using Lod.Generators.OpenCourt is added; every other test, including Task 2's, is unchanged.

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.Linear;
using Lod.Generators.OpenCourt;
using Lod.Generators.PointPlate;
using Lod.Tests.Shared;
using Xunit;

namespace Lod.Integration.Tests;

public sealed class SimplifierTests
{
    private const double PerimeterDepth = 4.57;

    [Fact]
    public void SemanticMergeJoinsConnectedZonesOfTheSameType()
    {
        IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));

        Assert.Equal(new[] { "Stair-1", "Corridor-1", "DwellingUnit-1", "DwellingUnit-2" }, floor.Zones.Select(z => z.Id.Value));
        Zone south = floor.Zones.Single(z => z.Id.Value == "DwellingUnit-1");
        Assert.Equal(160.0, south.FloorArea, 6);
        Assert.Equal(new[] { "US1", "US2" }, south.SourceZones.Select(s => s.Value));
        Assert.Equal(5.0, south.Program.Find(LoadType.Lighting, LoadBasis.PerFloorArea)!.Value, 9);
    }

    [Fact]
    public void MergedZonesLoseTheirSharedPartition()
    {
        IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));

        Assert.DoesNotContain(floor.Surfaces, s => s.Zone == s.AdjacentZone);
        Assert.Equal(2, floor.Surfaces.OfType<WallSurface>().Count(w => w.Zone.Value == "DwellingUnit-1" && w.Boundary == BoundaryCondition.Outdoors));
    }

    [Fact]
    public void SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan()
    {
        // The point plate's dwellings meet each other and the core at T-junctions; rotated off the axes, a plain union of their footprints leaves
        // slits where clipping-grid rounding moves a T-junction off its edge, so the zones are united as tiles (ADR-013).
        IGeneratedPlan plate = new PointPlateGenerator(Pipeline.Tolerances)
            .Generate(PointPlateParameters.Default, new PointPlatePresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;
        IGeneratedPlan plan = PlanTransform.Apply(plate, 30.0, 5.3, -2.1).Value;

        Result<IFloor> floor = new SemanticMerge(Pipeline.Tolerances).Simplify(plan);

        Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
        Assert.Equal(new[] { "Stair-1", "DwellingUnit-1" }, floor.Value.Zones.Select(z => z.Id.Value));
        Polygon2 ring = floor.Value.Zones.Single(z => z.Id.Value == "DwellingUnit-1").Parts[0].Footprint;
        Assert.Single(ring.Holes);
        Assert.True(Pipeline.Tolerances.AreaEquals(4 * 112.0, ring.Area), $"Area {ring.Area}");
    }

    [Fact]
    public void PerimeterCoreHasFourPerimeterZonesAndACore()
    {
        IFloor floor = Pipeline.Floor(new PerimeterCore(Pipeline.Tolerances, PerimeterDepth));

        Assert.Equal(new[] { "P-North", "P-East", "P-South", "P-West", "CORE" }, floor.Zones.Select(z => z.Id.Value));
        Assert.Equal((24.0 - (2 * PerimeterDepth)) * (18.0 - (2 * PerimeterDepth)), floor.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
        Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
    }

    [Fact]
    public void PerimeterSouthHasOneCentredWindowWithTheSouthFacadeGlazing()
    {
        IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Pipeline.Plan()).Value;

        WallSurface south = floor.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "P-South" && w.Boundary == BoundaryCondition.Outdoors);
        Window window = Assert.Single(south.Windows);
        Assert.Equal(1.2 + 9.0 + 9.0, window.Area, 9);
        Assert.Equal((24.0 - window.Width) / 2.0, window.Offset, 9);
        Assert.Equal((3.0 - window.Height) / 2.0, window.SillHeight, 9);
        Assert.Equal(window.Width / 24.0, window.Height / 3.0, 12);
    }

    [Fact]
    public void PerimeterDepthTooLargeIsAnError()
    {
        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, 10.0).Simplify(Pipeline.Plan());

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

    [Fact]
    public void WingsNarrowerThanTwiceThePerimeterDepthAreAnActionableError()
    {
        // 8 m deep wings around an open court: the 4.57 m perimeters of a wing's two façades would overlap (ADR-008 precondition).
        IGeneratedPlan court = new OpenCourtGenerator(Pipeline.Tolerances)
            .Generate(OpenCourtParameters.Default with { Depth = 8.0 }, new OpenCourtPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;

        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(court);

        Diagnostic error = Assert.Single(result.Diagnostics);
        Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
        Assert.Equal(
            "Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.",
            error.Message);
    }

    [Theory]
    [InlineData(0.0)]
    [InlineData(-1.0)]
    public void NonPositivePerimeterDepthIsAnError(double depth)
    {
        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, depth).Simplify(Pipeline.Plan());

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

    [Fact]
    public void FootprintsWithHolesAreNotSupportedYet()
    {
        Polygon2 courtyard = Polygon2.Create(
            new[] { new Point2(0, 0), new Point2(30, 0), new Point2(30, 30), new Point2(0, 30) },
            new[] { new[] { new Point2(10, 10), new Point2(20, 10), new Point2(20, 20), new Point2(10, 20) } }).Value;
        var plan = new GeneratedPlan(courtyard, 3.0, 0.0, new Zone[0], new Surface[0], Provenance.Of("Test", new KeyValuePair<string, string>[0]));

        Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(plan);

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

    [Fact]
    public void SingleZonePerFloorHasOnlyOutdoorWalls()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));

        Zone zone = Assert.Single(floor.Zones);
        Assert.Equal(SpaceType.Mixed, zone.SpaceType);
        Assert.Equal(6, zone.SourceZones.Count);
        Assert.All(floor.Surfaces.OfType<WallSurface>(), w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
        Assert.Equal(4, floor.Surfaces.OfType<WallSurface>().Count());
    }

    [Fact]
    public void SetpointsAreFloorAreaWeightedAndRecorded()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));

        Thermostat thermostat = floor.Zones[0].Program.Thermostat!;
        Assert.Equal(21.0, thermostat.HeatingSetpoint[0], 9);
        Assert.Equal(Lod.Core.Aggregation.AggregationMethod.FloorAreaWeighted, thermostat.HeatingSetpoint.Aggregation!.Method);
        Assert.DoesNotContain(new ZoneId("ST"), thermostat.HeatingSetpoint.Aggregation.Sources);
        Assert.Equal(5, thermostat.HeatingSetpoint.Aggregation.Sources.Count);
    }

    [Theory]
    [InlineData("SemanticMerge", 1)]
    [InlineData("SemanticMerge", 2)]
    [InlineData("SemanticMerge", 3)]
    [InlineData("PerimeterCore", 1)]
    [InlineData("PerimeterCore", 2)]
    [InlineData("PerimeterCore", 3)]
    [InlineData("PerimeterCore", 4)]
    [InlineData("SingleZonePerFloor", 1)]
    [InlineData("SingleZonePerFloor", 2)]
    public void EverySimplifierSatisfiesTheInvariantsForRandomPlans(string simplifier, int seed)
    {
        LinearPlanParameters parameters = RandomParameters(new Random(seed));

        IFloor floor = Simplifier(simplifier).Simplify(Pipeline.Plan(parameters)).Value;
        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);

        Assert.True(report.Passed, report.Describe());
    }

    [Fact]
    public void MergingTheUnconditionedStairEnlargesConditionedAreaAndIsReported()
    {
        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances)));

        CheckResult conditioned = report.Checks.Single(c => c.Check == "ConditionedFloorArea");
        Assert.False(conditioned.Passed);
        Assert.False(conditioned.Enforced);
        Assert.True(report.Passed, report.Describe());
    }

    [Fact]
    public void ValidationCatchesAnUncoveredFacade()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
        Surface[] withoutSouthWall = floor.Surfaces.Where(s => !(s is WallSurface w && w.Start.Y == 0.0 && w.End.Y == 0.0)).ToArray();
        var broken = new Floor(floor.Source, floor.Zones, withoutSouthWall, floor.Mapping, floor.Provenance);

        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);

        Assert.Contains(report.Checks, c => c.Check == "FacadeCoverage" && !c.Passed && c.Enforced);
        Assert.False(report.Passed);
    }

    [Fact]
    public void NoSimplificationPassesValidation()
    {
        Assert.True(new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.DetailedFloor()).Passed);
    }

    [Fact]
    public void ValidationCatchesAChangedProgram()
    {
        IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
        Zone zone = floor.Zones[0];
        var tampered = new Zone(zone.Id, zone.Name, zone.SpaceType, zone.Parts, ExampleResidentialPresets.DwellingUnit.Program, zone.SourceZones);
        var broken = new Floor(floor.Source, new[] { tampered }, floor.Surfaces, floor.Mapping, floor.Provenance);

        ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);

        Assert.False(report.Passed);
        Assert.Contains(report.Checks, c => c.Check == "Installed.Lighting" && !c.Passed);
        Assert.Contains(report.Checks, c => c.Check == "Traceability" && !c.Passed);
    }

    [Theory]
    [InlineData("SemanticMerge")]
    [InlineData("PerimeterCore")]
    [InlineData("SingleZonePerFloor")]
    public void SimplifiersAreDeterministic(string simplifier)
    {
        Assert.Equal(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))));
    }

    [Theory]
    [InlineData("SemanticMerge")]
    [InlineData("PerimeterCore")]
    [InlineData("SingleZonePerFloor")]
    public void CanonicalSnapshots(string simplifier)
    {
        Snapshot.Match(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), $"simplifier-{simplifier}");
    }

    private static IPlanSimplifier Simplifier(string name) => name switch
    {
        "SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
        "PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, PerimeterDepth),
        "SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
        _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
    };

    private static LinearPlanParameters RandomParameters(Random random) => new(
        Length: 18.0 + (random.NextDouble() * 42.0),
        UnitDepth: 6.0 + (random.NextDouble() * 4.0),
        CorridorWidth: 1.5 + (random.NextDouble() * 1.5),
        TargetUnitWidth: 5.0 + (random.NextDouble() * 7.0),
        StairLength: 3.0 + (random.NextDouble() * 3.0),
        FloorHeight: 2.8 + (random.NextDouble() * 0.7),
        OrientationDegrees: random.NextDouble() * 360.0);
}
  • [x] Step 2: Run the test and see it fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.SimplifierTests.WingsNarrowerThanTwiceThePerimeterDepthAreAnActionableError"

Expected on net8.0: Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: … - Lod.Integration.Tests.dll (net8.0), with the message (the existing message stops after "for this footprint."):

Failed Lod.Integration.Tests.SimplifierTests.WingsNarrowerThanTwiceThePerimeterDepthAreAnActionableError
  Assert.Equal() Failure: Strings differ
                                 ↓ (pos 62)
  Expected: ···"e for this footprint: every wing and ever"···
  Actual:   ···" leaves no valid core for this footprint."
                                                     ↑ (pos 62)
  • [x] Step 3: Implement — replace src/Lod.Core/Simplification/Strategies.cs (full file)

Both PlanTooNarrow messages of PerimeterCore append Precondition(); nothing else changes.

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

namespace Lod.Core.Simplification;

/// <summary>
/// Z1: merges zones of the same space type that are connected through shared walls. Each connected group becomes one zone, the union of its
/// footprints as tiles (<see cref="PolygonOps.UnionTiles"/>), so zones meeting at T-junctions unite exactly on rotated plans; the union may have
/// holes, where the group surrounds zones of another type.
/// </summary>
public sealed class SemanticMerge : PlanSimplifier
{
    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public SemanticMerge(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        Dictionary<ZoneId, Zone> byId = plan.Zones.ToDictionary(z => z.Id);
        ILookup<ZoneId, ZoneId> neighbours = plan.Surfaces
            .Where(s => s.Boundary == BoundaryCondition.Interzone)
            .ToLookup(s => s.Zone, s => s.AdjacentZone!.Value);
        var ops = new PolygonOps(Tolerances);
        var visited = new HashSet<ZoneId>();
        var targets = new List<TargetZone>();
        var counters = new Dictionary<SpaceType, int>();
        foreach (Zone seed in plan.Zones.Where(z => !visited.Contains(z.Id)))
        {
            var group = new List<Zone>();
            var queue = new Queue<Zone>(new[] { seed });
            visited.Add(seed.Id);
            while (queue.Count > 0)
            {
                Zone current = queue.Dequeue();
                group.Add(current);
                foreach (ZoneId next in neighbours[current.Id].Where(n => !visited.Contains(n) && byId[n].SpaceType == seed.SpaceType))
                {
                    visited.Add(next);
                    queue.Enqueue(byId[next]);
                }
            }

            IReadOnlyList<Polygon2> union = ops.UnionTiles(group.Select(z => z.Parts[0].Footprint).ToArray());
            if (union.Count != 1)
            {
                return Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(
                    DiagnosticCodes.DisconnectedGroup,
                    $"Merged group {string.Join(",", group.Select(z => z.Id.Value))} is not one polygon.",
                    seed.Id.Value));
            }

            int number = counters.TryGetValue(seed.SpaceType, out int n) ? n + 1 : 1;
            counters[seed.SpaceType] = number;
            targets.Add(new TargetZone(new ZoneId($"{seed.SpaceType}-{number}"), $"{seed.SpaceType} {number}", seed.SpaceType, union[0]));
        }

        return Result.Success<IReadOnlyList<TargetZone>>(targets);
    }

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

/// <summary>
/// Z2: perimeter zones of a configurable depth plus a core (ADR-008). Each footprint edge owns the trapezoid between the edge and its inward
/// mitred offset, so corners are split along the bisectors; trapezoids in the same orientation bin are merged. Footprints with holes are not supported yet.
/// </summary>
public sealed class PerimeterCore : PlanSimplifier
{
    private readonly double _depth;

    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    /// <param name="perimeterDepth">Perimeter zone depth, m.</param>
    public PerimeterCore(ToleranceSettings tolerances, double perimeterDepth)
        : base(tolerances)
    {
        _depth = perimeterDepth;
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        if (!(_depth > 0) || double.IsInfinity(_depth))
        {
            return Fail(DiagnosticCodes.InvalidParameter, $"Perimeter depth must be positive, got {_depth}.");
        }

        if (plan.Footprint.Holes.Count > 0)
        {
            return Fail(DiagnosticCodes.NotSupported, "Perimeter/core of footprints with holes is not supported yet (S8, typologies).");
        }

        IReadOnlyList<Point2> v = plan.Footprint.Outer;
        int n = v.Count;
        Point2[] offset = Enumerable.Range(0, n).Select(i => Mitre(v[(i + n - 1) % n], v[i], v[(i + 1) % n])).ToArray();
        Result<Polygon2> core = Polygon2.Create(offset);
        bool keepsDirections = Enumerable.Range(0, n).All(i =>
        {
            Point2 a = v[i];
            Point2 b = v[(i + 1) % n];
            Point2 c = offset[i];
            Point2 d = offset[(i + 1) % n];
            return ((b.X - a.X) * (d.X - c.X)) + ((b.Y - a.Y) * (d.Y - c.Y)) > 0;
        });
        if (!core.IsSuccess || !keepsDirections || Polygon2.SignedArea(offset) <= 0)
        {
            return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter depth {_depth} m leaves no valid core for this footprint: {Precondition()}");
        }

        var ops = new PolygonOps(Tolerances);
        var trapezoids = new List<(OrientationBin Bin, Polygon2 Shape)>();
        for (int i = 0; i < n; i++)
        {
            Polygon2 shape = Polygon2.Create(new[] { v[i], v[(i + 1) % n], offset[(i + 1) % n], offset[i] }).Value;
            trapezoids.Add((Orientation.Bin(Orientation.OutwardAzimuth(v[i], v[(i + 1) % n]) + plan.OrientationDegrees), shape));
        }

        double tiled = trapezoids.Sum(t => t.Shape.Area) + core.Value.Area;
        if (!Tolerances.AreaEquals(plan.Footprint.Area, tiled))
        {
            return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter zones and core cover {tiled:0.###} m² of a {plan.Footprint.Area:0.###} m² footprint: {Precondition()}");
        }

        var targets = new List<TargetZone>();
        foreach (IGrouping<OrientationBin, (OrientationBin Bin, Polygon2 Shape)> bin in trapezoids.GroupBy(t => t.Bin).OrderBy(g => g.Key))
        {
            IReadOnlyList<Polygon2> merged = ops.Union(bin.Select(t => t.Shape));
            for (int k = 0; k < merged.Count; k++)
            {
                string suffix = merged.Count == 1 ? string.Empty : $"-{k + 1}";
                targets.Add(new TargetZone(new ZoneId($"P-{bin.Key}{suffix}"), $"Perimeter {bin.Key}{suffix}", SpaceType.Mixed, merged[k]));
            }
        }

        targets.Add(new TargetZone(new ZoneId("CORE"), "Core", SpaceType.Mixed, core.Value));
        return Result.Success<IReadOnlyList<TargetZone>>(targets);
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe() =>
        new[] { new KeyValuePair<string, string>("PerimeterDepth", Provenance.Format(_depth)) };

    /// <summary>The precondition a <see cref="DiagnosticCodes.PlanTooNarrow"/> plan breaks, with what the user can do (ADR-008).</summary>
    private string Precondition() =>
        $"every wing and every part of the plan must be wider than twice the depth ({2.0 * _depth} m). Use a smaller depth or another simplifier.";

    private Point2 Mitre(Point2 previous, Point2 vertex, Point2 next)
    {
        (double X, double Y) a = InwardNormal(previous, vertex);
        (double X, double Y) b = InwardNormal(vertex, next);
        double scale = _depth / (1.0 + (a.X * b.X) + (a.Y * b.Y));
        return new Point2(vertex.X + ((a.X + b.X) * scale), vertex.Y + ((a.Y + b.Y) * scale));
    }

    private static (double X, double Y) InwardNormal(Point2 from, Point2 to)
    {
        double length = from.DistanceTo(to);
        return (-(to.Y - from.Y) / length, (to.X - from.X) / length);
    }

    private static Result<IReadOnlyList<TargetZone>> Fail(string code, string message) =>
        Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(code, message));
}

/// <summary>Z3: the whole floor as one zone.</summary>
public sealed class SingleZonePerFloor : PlanSimplifier
{
    /// <summary>Creates the simplifier.</summary>
    /// <param name="tolerances">Tolerances.</param>
    public SingleZonePerFloor(ToleranceSettings tolerances)
        : base(tolerances)
    {
    }

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

    /// <inheritdoc />
    protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
    {
        SpaceType[] types = plan.Zones.Select(z => z.SpaceType).Distinct().ToArray();
        SpaceType type = types.Length == 1 ? types[0] : SpaceType.Mixed;
        return Result.Success<IReadOnlyList<TargetZone>>(new[] { new TargetZone(new ZoneId("FLOOR"), "Floor", type, plan.Footprint) });
    }

    /// <inheritdoc />
    protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
  • [x] Step 4: Edit docs/decisions/ADR-008-perimeter-core-corners.md

Make this replacement; the "Replace" text occurs exactly once in the file. It adds the consequence for non-rectangular plans after the one on plans narrower than 2d.

  1. Replace
- A plan narrower than `2d` fails: the canonical plan with `d` = 10 m returns `PlanTooNarrow`. The user chooses a smaller depth; the simplifier never chooses one.

with

- A plan narrower than `2d` fails: the canonical plan with `d` = 10 m returns `PlanTooNarrow`. The user chooses a smaller depth; the simplifier never chooses one.
- Non-rectangular plans (S8.2, [ADR-006](ADR-006-plan-generation-mechanism.md)): the bisector rule holds unchanged at re-entrant corners, where the two trapezoids meet on the diagonal of the inside corner, so the comb of the winged band and the U of the open court pass the checks at their default 12 m wing depth with the default 4.57 m depth, and a façade direction with several runs (the court's three south-facing façades) gives numbered zones such as `P-South-1`. Every wing, plate, and other part of the footprint must be wider than `2d`: the open court with 8 m wings fails with `PlanTooNarrow`, whose message states this precondition and suggests a smaller depth or another simplifier. Supporting narrower wings would need a different partition, such as the straight skeleton (option 3), and a new ADR.
  • [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.SimplifierTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all

Expected: Passed! - Failed: 0, Passed: 31, Skipped: 0, Total: 31, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 154 core, 159 generators, and 323 integration tests passed on net8.0; git status lists exactly M docs/decisions/ADR-008-perimeter-core-corners.md, M src/Lod.Core/Simplification/Strategies.cs, and M tests/Lod.Integration.Tests/SimplifierTests.cs, and no *.received.txt (no snapshot holds a PlanTooNarrow message).

  • [x] Step 6: Commit
git add src/Lod.Core/Simplification/Strategies.cs tests/Lod.Integration.Tests/SimplifierTests.cs docs/decisions/ADR-008-perimeter-core-corners.md
git commit -m "fix(simplifiers): state the perimeter-core width precondition in its error"

Task 8: Every non-rectangular family with every simplifier and aggregator

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

Interfaces: - Consumes: the three generators and their Default records (Tasks 1, 5, 6); test support Pipeline.Simplifier(string name), Pipeline.Aggregator(string name), Pipeline.Tolerances (S4.2); PlanTransform.Apply(IGeneratedPlan plan, double rotationDegrees, double translationX, double translationY) : Result<IGeneratedPlan> (S4.2); SemanticMerge (Task 2), PerimeterCore(ToleranceSettings, double) (Task 7), FloorEntry(IFloor, int), BuildingValidator(ToleranceSettings).Validate(IGeneratedBuilding) : ValidationReport. - Produces: the S8 test of the roadmap: each of the three families × four simplifiers × four aggregators (floors 1, 3, 1) builds and validates without warnings, on the canonical plan and on the plan rotated by 30° and moved by (5.3, −2.1) m and rotated by 131.4° and moved by (15.08, −153.9) m; Semantic Merge joins only connected zones of one space type and unites the point plate's dwellings into a ring around the core; Perimeter Core gives each façade run its own zone, also at the re-entrant corners (ADR-008). No production code changes.

This task has no red phase: it checks that the simplifiers and aggregators, with the fixes of Tasks 2–4, accept the new families. If any case fails, stop and report: a further pipeline change needs its own decision and test-first task.

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

151 cases: EveryCombinationValidates (48), EveryCombinationValidatesOnARotatedAndTranslatedPlan (96), SemanticMergeJoinsOnlyConnectedZonesOfOneType (3: 2 zones for the point plate, 13 for the winged band, 9 for the open court), ThePointPlateDwellingsMergeIntoARingAroundTheCore (1), and PerimeterCoreGivesEachFacadeRunItsOwnZone (3: P-North, P-East, P-South, P-West, CORE for the point plate; 13 zones for the winged band; 9 for the open court).

using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.OpenCourt;
using Lod.Generators.PointPlate;
using Lod.Generators.WingedBand;
using Xunit;

namespace Lod.Integration.Tests;

/// <summary>The S8.2 families without holes (ADR-006): the point plate, the winged band, and the open court through every simplifier and aggregator.</summary>
public sealed class NonRectangularTypologyTests
{
    private static readonly string[] Families = { "PointPlate", "WingedBand", "OpenCourt" };
    private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
    private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };

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

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

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

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

    [Theory]
    [MemberData(nameof(PlacedMatrix))]
    public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(string family, string simplifier, string aggregator, int placement)
    {
        double[] p = Placements[placement];

        AssertValidates(PlanTransform.Apply(Plan(family), p[0], p[1], p[2]).Value, simplifier, aggregator);
    }

    [Theory]
    [InlineData("PointPlate", "DwellingUnit-1,Stair-1")]
    [InlineData("WingedBand", "DwellingUnit-1,DwellingUnit-2,DwellingUnit-3,DwellingUnit-4,DwellingUnit-5,DwellingUnit-6,Stair-1,Stair-2,Stair-3,Stair-4,Stair-5,Stair-6,Stair-7")]
    [InlineData("OpenCourt", "DwellingUnit-1,DwellingUnit-2,DwellingUnit-3,DwellingUnit-4,Stair-1,Stair-2,Stair-3,Stair-4,Stair-5")]
    public void SemanticMergeJoinsOnlyConnectedZonesOfOneType(string family, string zones)
    {
        // A wing's root dwelling joins the band or north-wing dwellings it adjoins; stairs never adjoin each other and stay apart.
        IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(family)).Value;

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

    [Fact]
    public void ThePointPlateDwellingsMergeIntoARingAroundTheCore()
    {
        IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan("PointPlate")).Value;

        Polygon2 ring = floor.Zones.Single(z => z.Id.Value == "DwellingUnit-1").Parts[0].Footprint;
        Assert.Equal(22.0 * 22.0, ring.Area + floor.Zones.Single(z => z.Id.Value == "Stair-1").FloorArea, 9);
        Assert.Equal(new[] { new Point2(8, 14), new Point2(14, 14), new Point2(14, 8), new Point2(8, 8) }.OrderBy(p => p.X).ThenBy(p => p.Y), Assert.Single(ring.Holes).OrderBy(p => p.X).ThenBy(p => p.Y));
        Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "Stair-1" && w.Boundary == BoundaryCondition.Outdoors);
        Assert.Equal(4, floor.Surfaces.OfType<WallSurface>().Count(w => w.Zone.Value == "DwellingUnit-1" && w.AdjacentZone?.Value == "Stair-1"));
    }

    [Theory]
    [InlineData("PointPlate", "P-North,P-East,P-South,P-West,CORE")]
    [InlineData("WingedBand", "P-North-1,P-North-2,P-North-3,P-North-4,P-North-5,P-East-1,P-East-2,P-East-3,P-South,P-West-1,P-West-2,P-West-3,CORE")]
    [InlineData("OpenCourt", "P-North,P-East-1,P-East-2,P-South-1,P-South-2,P-South-3,P-West-1,P-West-2,CORE")]
    public void PerimeterCoreGivesEachFacadeRunItsOwnZone(string family, string zones)
    {
        // Bisector trapezoids also at the re-entrant corners (ADR-008); a direction with several separate façade runs gives numbered zones.
        IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan(family)).Value;

        Assert.Equal(zones, string.Join(",", floor.Zones.Select(z => z.Id.Value)));
        Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
        Assert.True(Pipeline.Tolerances.AreaEquals(Plan(family).Footprint.Area, floor.Zones.Sum(z => z.FloorArea)));
    }

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

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

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

    /// <summary>The canonical plan of a family: ADR-006 defaults and the example presets.</summary>
    private static IGeneratedPlan Plan(string family)
    {
        ProgramPreset unit = ExampleResidentialPresets.DwellingUnit;
        ProgramPreset stair = ExampleResidentialPresets.Stair;
        return family switch
        {
            "PointPlate" => new PointPlateGenerator(Pipeline.Tolerances).Generate(PointPlateParameters.Default, new PointPlatePresets(unit, stair)).Value,
            "WingedBand" => new WingedBandGenerator(Pipeline.Tolerances).Generate(WingedBandParameters.Default, new WingedBandPresets(unit, stair)).Value,
            "OpenCourt" => new OpenCourtGenerator(Pipeline.Tolerances).Generate(OpenCourtParameters.Default, new OpenCourtPresets(unit, stair)).Value,
            _ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown family."),
        };
    }
}
  • [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.NonRectangularTypologyTests"

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

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

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

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

Expected: VERIFY PASSED, with 154 core, 159 generators, and 474 integration tests passed on net8.0; the last command lists exactly three added (A) files, the canonical snapshots of Tasks 1, 5, and 6, and nothing modified: the twelve existing snapshots are byte-identical. Do not push (D-084).


Slice D — feature/grasshopper-non-rectangular-typologies

Task 9: Typology generator components

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

Files: - Modify: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file)

Interfaces: - Consumes: the three generators and their records (Tasks 1, 5, 6); the S8.1 base TypologyGeneratorComponent (panel 2 Generate, one Plan output, AddPreset, TryNumbers, Preset, Output, FloorHeightDescription, OrientationDescription); ComponentSupport.PreviewLocationParameter() (S4.1); IconLibrary.For (a missing icon gives Grasshopper's default icon until Task 10). - Produces: three components after Terrace Row in the file, each with its number inputs defaulting to the family's Default record, Floor Height, Orientation, one required preset input per space type (an unconnected one is Grasshopper's missing-input warning, D-065), and Preview Location last (D-062):

Component (nickname) Inputs → output Class GUID
Point Plate (PPlate) Dwelling Depth (DD, 8), Core Width (CW, 6), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Stair (St), Preview Location (PL) → Plan PointPlateComponent 8ac9044a-8fbe-4964-a9de-2ef8be35094a
Winged Band (WBand) Wing Count (WC, integer, 3), Wing Length (WL, 17), Wing Spacing (WS, 17), Depth (D, 12), Unit Width (UW, 7), Stair Width (SW, 3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan WingedBandComponent e285811d-2fdb-4d31-8b02-62c270b609cf
Open Court (OCourt) Court Width (CW, 24), Court Depth (CD, 17), Depth (D, 12), Unit Width (UW, 7), Stair Width (SW, 3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan OpenCourtComponent 223014ea-3c01-4d2b-b8ba-92ebdc4a7327

The four S8.1 components and their base class are unchanged.

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

Three usings and the three component classes at the end are added; nothing else changes.

using System;
using System.Drawing;
using Grasshopper.Kernel;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.GalleryBar;
using Lod.Generators.OpenCourt;
using Lod.Generators.PointPlate;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.TerraceRow;
using Lod.Generators.WingedBand;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;

namespace Lod.Grasshopper.Components;

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

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

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

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

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

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

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

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

        return true;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        var parameters = new TerraceRowParameters(plots, v[0], v[1], v[2], v[3]);
        Output(DA, new TerraceRowGenerator(ToleranceSettings.Default).Generate(parameters, new TerraceRowPresets(dwellingUnit)));
    }
}

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

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        PointPlateParameters d = PointPlateParameters.Default;
        pManager.AddNumberParameter("Dwelling Depth", "DD", "Depth of each dwelling from its façade to the core, m; the plate side is Core Width + 2 × Dwelling Depth.", GH_ParamAccess.item, d.DwellingDepth);
        pManager.AddNumberParameter("Core Width", "CW", "Side of the square core, m.", GH_ParamAccess.item, d.CoreWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the four dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the core");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

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

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

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        WingedBandParameters d = WingedBandParameters.Default;
        pManager.AddIntegerParameter("Wing Count", "WC", "Number of wings projecting from the band; at least 2.", GH_ParamAccess.item, d.WingCount);
        pManager.AddNumberParameter("Wing Length", "WL", "Length of each wing from the band to its tip, m.", GH_ParamAccess.item, d.WingLength);
        pManager.AddNumberParameter("Wing Spacing", "WS", "Clear distance between neighbouring wings along the band, m.", GH_ParamAccess.item, d.WingSpacing);
        pManager.AddNumberParameter("Depth", "D", "Depth of the band and of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill the band and each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the band and the wings, m.", GH_ParamAccess.item, d.StairWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

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

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

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

    protected override void RegisterInputParams(GH_InputParamManager pManager)
    {
        OpenCourtParameters d = OpenCourtParameters.Default;
        pManager.AddNumberParameter("Court Width", "CW", "Width of the court between the side wings, m.", GH_ParamAccess.item, d.CourtWidth);
        pManager.AddNumberParameter("Court Depth", "CD", "Depth of the court from its open side to the north wing, which is also the side wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
        pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
        pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
        pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, m.", GH_ParamAccess.item, d.StairWidth);
        pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
        pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
        AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
        AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
        pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
    }

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

        var parameters = new OpenCourtParameters(v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
        Output(DA, new OpenCourtGenerator(ToleranceSettings.Default).Generate(parameters, new OpenCourtPresets(dwellingUnit, stair)));
    }
}
  • [x] Step 3: Build
dotnet build BEMGen.sln -c Release
git status --short --untracked-files=all

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

  • [x] Step 4: Commit
git add src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs
git commit -m "feature(grasshopper): add the point plate, winged band, and open court components"

Task 10: Icons for the three generator components

Files: - Modify: scripts/make_icons.py (S4.1 file, last changed in S8.1) - Create (generated): src/Lod.Grasshopper/Icons/PointPlateComponent.png, WingedBandComponent.png, OpenCourtComponent.png

Interfaces: - Consumes: the component classes of Task 9 (28 components, 6 parameters, 1 assembly info); IconLibrary.For and the Icons\*.png embedding of Lod.Grasshopper.csproj (S4.1, unchanged). - Produces: motifs point_plate (a square plate in the accent colour with a dark central core and four dark walls in a pinwheel), winged_band (a comb in plan: a band with three wings to plan north, dark stair bands across the wings and dark stairs in the band), and open_court (a U in plan open to the south, dark stairs in the north and side wings), all in the Generate green; the icon table maps PointPlateComponent, WingedBandComponent, and OpenCourtComponent. 35 icons.

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

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

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

The three motifs are added after terrace_row, and the three icon entries after Terrace Row in the Generate group; every other motif and entry is unchanged.

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

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

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

from __future__ import annotations

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

from PIL import Image, ImageDraw

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

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

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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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

    return paint


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

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

    return paint


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


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


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


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

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

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


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

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

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

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

  • [x] Step 4: Look at the icons

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

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

Expected: 35 icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png. Open the image. In the order of sorted() on Windows (case-insensitive), six per row, the 32 icons of S8.1 look as in the S8.1 plan (Task 13, Step 4) and the three new ones are added, which moves the icons after them. Every icon keeps its usual motif; the three new ones are Open Court (a green U open to the south, with dark stairs in the north and both side wings), Point Plate (a green square with a dark central core and four dark walls in a pinwheel), and Winged Band (a green comb of three wings to the north, with dark stair bands across the wings and dark stairs in the band).

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

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

Expected: Build succeeded., 0 Warning(s), 0 Error(s); counts BemGenAssemblyInfo.cs:1, BemGenInfoComponent.cs:1, Convert2BemComponent.cs:1, PipelineComponents.cs:4, PresetComponents.cs:1, ProgramComponents.cs:3, SimplifierComponents.cs:3, TransformPlanComponent.cs:1, TypologyGeneratorComponents.cs:1, BemParameters.cs:1 (with their paths; the three new components inherit the icon of TypologyGeneratorComponent, as the S8.1 ones do); and the 35 resource names Lod.Grasshopper.Icons.<name>.png, one for each PNG of Step 3, including Lod.Grasshopper.Icons.OpenCourtComponent.png, Lod.Grasshopper.Icons.PointPlateComponent.png, and Lod.Grasshopper.Icons.WingedBandComponent.png.

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

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

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

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


Slice E — docs/architecture-non-rectangular-typologies

Task 11: ADR-006 records the S8.2 families as implemented

Files: - Modify: docs/decisions/ADR-006-plan-generation-mechanism.md (S8.1 document)

Interfaces: - Consumes: Tasks 1–10 (zone IDs, defaults, messages, the three fixes, the Perimeter Core precondition). - Produces: ADR-006 after S8.2: the decisions line applies ADR-008; the stair bay paragraph names Wing, Fit, and FitErrors; the section Families implemented in S8.2 and specified for S8.3 with one Implemented (S8.2) item per family; the section Pipeline consequences of S8.2; and a consequence on wings narrower than twice the perimeter depth. No record, default, or rule of ADR-006 changes; ADR-006 stays accepted by the controller under D-084, pending the owner's reading.

  • [x] Step 1: Create the branch
git switch -c docs/architecture-non-rectangular-typologies main
  • [x] Step 2: Edit docs/decisions/ADR-006-plan-generation-mechanism.md

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

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

with

Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084; applies [ADR-002](ADR-002-geometry.md), [ADR-008](ADR-008-perimeter-core-corners.md), and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)
  1. Replace
A **stair bay** is dwelling | stair | dwelling side by side along a bar, each spanning the full bar depth: the stair serves the two dwellings beside it on every storey, and every dwelling has two opposite outdoor façades. `StairBayLayout` (in `Lod.Generators/StairBayBar/`, internal) lays out *n* bays from given widths; `HDHM-TYP-001`, `HDHM-TYP-008`, and later the wings of 003, 004, and 005 use it, so the bay geometry exists once. In a bay of width `2·UnitWidth + StairWidth`, the west dwelling is `[x₀, x₀ + U]`, the stair `[x₀ + U, x₀ + U + S]`, and the east dwelling `[x₀ + U + S, x₀ + 2U + S]`.

with

A **stair bay** is dwelling | stair | dwelling side by side along a bar, each spanning the full bar depth: the stair serves the two dwellings beside it on every storey, and every dwelling has two opposite outdoor façades. `StairBayLayout` (in `Lod.Generators/StairBayBar/`, internal) lays out *n* bays from given widths, along plan x from the origin or, for a wing, from any start along plan x or plan y (`Wing`), and fits the bays to a wing's length (`Fit`, `FitErrors`); `HDHM-TYP-001`, `HDHM-TYP-008`, the wings of 003 and 004, and later 005 use it, so the bay geometry exists once. In a bay of width `2·UnitWidth + StairWidth`, the west dwelling is `[x₀, x₀ + U]`, the stair `[x₀ + U, x₀ + U + S]`, and the east dwelling `[x₀ + U + S, x₀ + 2U + S]`.
  1. Replace
### Families specified for S8.2 and S8.3

with

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

with

Records, defaults, and zoning are fixed here; the layouts are implemented, and may be refined, in their sub-stage, which records any change here. S8.2 implemented 007, 003, and 004 as specified, without changing a record or a default; the details it settled are recorded below each family. Each wing below is a bar of stair bays: `n = max(1, round(L / (2U + S)))` bays, rounded half away from zero, with the unit width adjusted to `(L − n·S) / (2n)` so the bays fill the wing's length `L` exactly. Validation additionally requires that adjusted width to be positive: stairs that leave no room for dwellings are `InvalidParameter`, for example `StairWidth 10 m leaves no dwelling width in each 17 m wing: its 2 stair bays need 20 m of stairs; reduce StairWidth or UnitWidth.` Within a wing, zones are listed stairs first, then dwellings, numbered along the wing's axis, and each wing's last bay ends exactly at the wing's end.
  1. Replace
#### `HDHM-TYP-007` Compact point-access tower plate: `PointPlateGenerator` (S8.2)

with

#### `HDHM-TYP-007` Compact point-access tower plate: `PointPlateGenerator` (S8.2, implemented)
  1. Replace
- Realisation: `R001` a compact plate (aspect ratio 1) instead of a bar; `R002` every dwelling's access is the one central core. Validation: both lengths positive. The unit count is fixed at four by the pinwheel; the source supports no count.

with

- Realisation: `R001` a compact plate (aspect ratio 1) instead of a bar; `R002` every dwelling's access is the one central core. Validation: both lengths positive. The unit count is fixed at four by the pinwheel; the source supports no count.
- Implemented (S8.2): zones `ST` (*Core*), then `U1…U4` (*Unit 1…*); the default plate is 484 m², the core 36 m², each dwelling 112 m², with 8 windows. The core is the first zone of any family without an outdoor wall. *Semantic Merge* unites the four dwellings into one zone with a hole around the core, which needed the union of zones as tiles ([ADR-002](ADR-002-geometry.md)).
  1. Replace
#### `HDHM-TYP-003` Projecting wings joined by a common band: `WingedBandGenerator` (S8.2)

with

#### `HDHM-TYP-003` Projecting wings joined by a common band: `WingedBandGenerator` (S8.2, implemented)
  1. Replace
- Realisation: `R001` every wing adjoins the band along its whole root; `R002` `WingCount` ≥ 2 repeats the relation. The comb outline gives the band's north façade short fragments between the wings' roots and its bay boundaries; ADR-011 governs their windows.

with

- Realisation: `R001` every wing adjoins the band along its whole root; `R002` `WingCount` ≥ 2 repeats the relation. The comb outline gives the band's north façade short fragments between the wings' roots and its bay boundaries; ADR-011 governs their windows.
- Implemented (S8.2): the first wing is flush with the band's west end and the last with its east end. Zones `B-ST1…B-ST4`, `B-U1…B-U8` (*Band Stair 1*, *Band Unit 1*, …; four bays of 7.25 m dwellings in the 70 m band), then per wing `W{i}-ST1`, `W{i}-U1`, `W{i}-U2` (*Wing 2 Stair 1*, …; one bay of 7 m dwellings); 1452 m², 43 windows. The band's north fragments are 5.5, 7.25, 3, 1.25, 1.25, 3, 7.25, and 5.5 m by default, so every one keeps its window; with `WingSpacing` 14 m two fragments are 0.5 m and get no window (`WindowOmitted`). `WingCount` < 2 is `WingCount must be at least 2 (the wing-to-band relation repeats), got 1.`
  1. Replace
#### `HDHM-TYP-004` Open U-shaped court with stair-served wings: `OpenCourtGenerator` (S8.2)

with

#### `HDHM-TYP-004` Open U-shaped court with stair-served wings: `OpenCourtGenerator` (S8.2, implemented)
  1. Replace
- Realisation: `R001` dwellings on three sides of the court; `R002` the fourth (south) side stays open; `R003` every dwelling adjoins a stair. Validation: lengths positive; `CourtWidth` and `CourtDepth` positive (an open court exists).
with
- Realisation: `R001` dwellings on three sides of the court; `R002` the fourth (south) side stays open; `R003` every dwelling adjoins a stair. Validation: lengths positive; `CourtWidth` and `CourtDepth` positive (an open court exists).
- Implemented (S8.2): zones `N-ST1…N-ST3`, `N-U1…N-U6` (*North Stair 1*, *North Unit 1*, …; three bays of 6.5 m dwellings in the 48 m north wing), then `W-ST1`, `W-U1`, `W-U2` and `E-ST1`, `E-U1`, `E-U2` (*West Unit 1*, …; one bay of 7 m dwellings, south to north); 984 m², 30 windows. The court's façades are the north wing's south façade between the side wings and the side wings' inner façades.
  1. Replace
- Realisation: `R001` dwellings on every edge of the court; `R002` the court is closed on all four sides (the footprint has exactly one hole). Validation: lengths positive. Requires footprints with holes in every plan simplifier and floor aggregator (S8.3, D-084).
with
- Realisation: `R001` dwellings on every edge of the court; `R002` the court is closed on all four sides (the footprint has exactly one hole). Validation: lengths positive. Requires footprints with holes in every plan simplifier and floor aggregator (S8.3, D-084).

### Pipeline consequences of S8.2

Running every plan simplifier and floor aggregator on the non-rectangular families, canonical and rotated, found three faults on rotated plans that also affected the S8.1 bars and the linear plan at some placements; each is fixed and documented where it belongs, and no tolerance or check is relaxed:

- *Semantic Merge* united its groups with a plain union, which splits a group whose zones meet at T-junctions once rotated (`DisconnectedGroup`); it now unites them as tiles ([ADR-002](ADR-002-geometry.md)).
- The surface builder tested whether a vertex lies on an edge between snapped grid cells, which can put a T-junction exactly on an edge up to √2 grid units off it (`UnmatchedEdge`); it now measures the original positions against the distance tolerance (ADR-002).
- A union of tiles returned vertices rounded to the clipping grid, so a storey outline united from a *Semantic Merge* floor could lie off the merged walls' façade lines (`FacadeNotCovered` in the single-zone aggregators); it now keeps the tiles' own vertices (ADR-002, [ADR-013](ADR-013-vertical-aggregation-methods.md)).

The bisector rule of *Perimeter Core* needed no change: it handles the re-entrant corners of the comb and the U. Its precondition, that every wing or other part of the footprint be wider than twice the perimeter depth, is stated in its `PlanTooNarrow` message and in [ADR-008](ADR-008-perimeter-core-corners.md); with the defaults (12 m deep wings and plates of 22 m, 4.57 m depth) every family passes.
  1. Replace
- The source's abstract validation records are not used as tests (D-083); a sub-stage that refines a layout updates the realisation text here.
with
- The source's abstract validation records are not used as tests (D-083); a sub-stage that refines a layout updates the realisation text here.
- Wings and plates narrower than twice the perimeter depth cannot be zoned by *Perimeter Core* (`PlanTooNarrow`); the user chooses a smaller depth or another simplifier.
  • [x] Step 3: Check and commit
git grep -c -e "(S8.2, implemented)" -e "### Pipeline consequences of S8.2" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git add docs/decisions/ADR-006-plan-generation-mechanism.md
git commit -m "docs(decisions): record the s8.2 generators and pipeline consequences in adr-006"

Expected: docs/decisions/ADR-006-plan-generation-mechanism.md:4 (three family headings and the new section).

Task 12: Architecture documents

Files: - Modify: docs/architecture/pipeline.md (S4, last changed in S8.1), docs/research/precedents/hdhm-typologies.md (S8.1), docs/research/program-presets.md (S1, last changed in S8.1)

Interfaces: - Consumes: Tasks 1–11 (families, fixes, components). - Produces: the pipeline description after S8.2; the heading ## Grasshopper components through S8.2 (anchor #grasshopper-components-through-s82), which the overview of pipeline.md and program-presets.md link to; the S8.2 row removed from ## Not implemented yet; the precedent page naming the three generators as implemented.

  • [x] Step 1: Edit docs/architecture/pipeline.md

Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied. Replacement 3 points the Preview Location link at the renamed heading of replacement 8; replacement 12 removes the S8.2 row from the open items.

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

with

At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan; the revision stage S4.1 (D-061 to D-064) gave the plan generator one preset input per space type and added default preset components, a *Preview Location* input, and component icons; the revision stage S4.2 (D-068 to D-075, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md)) added *Transform Plan*, made the floor aggregators designate floors and ceilings on the assembled stack, turned them into four methods, and made heat transfer between zones a conversion option; stage S5 (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)) gives every rebuilt outdoor wall one centred window with the glazed area it covers; stage S8.1 (D-082 to D-084, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md)) adds a plan generator for each bar family of the *High-Density Housing* precedent study and the minimum wall and window geometry of every generator; stage S8.2 (D-084) adds the non-rectangular families without holes, the point plate, the winged band, and the open court, and makes the united zones and the edge tests of the surface builder exact on rotated plans. 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
                         StairBayBar | StairPair | GalleryBar | TerraceRow

with

                         StairBayBar | StairPair | GalleryBar | TerraceRow |
                         PointPlate | WingedBand | OpenCourt
  1. Replace
- All logic lives in `Lod.Core` and `Lod.Generators`, which reference neither Rhino nor Grasshopper; their tests run with `dotnet test`. `Lod.Grasshopper` wraps each pipeline type in a Goo with a viewport preview and exposes one component per step (D-017, D-018). Every component that previews a plan, floor, or building has a last input *Preview Location* that moves only its preview (D-062, [below](#grasshopper-components-through-s81)).

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-s82)).
  1. Replace
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Transform Plan* |

with

| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Point Plate*, *Winged Band*, *Open Court*, *Transform Plan* |
  1. Replace
- Surfaces come from `LayoutSurfaceBuilder`: an edge shared by two zones becomes an interzone wall, an edge on the plan boundary an outdoor wall; any other edge (a gap or an overlap) is an error. Floors and ceilings stay `Unresolved` until floors are aggregated.

with

- Point plate (`HDHM-TYP-007`): a square plate of side `CoreWidth + 2 · DwellingDepth` (22 m by default) with a central core `ST` (*Core*, a `Stair` zone, 6 m square) and four dwellings `U1…U4` of `DwellingDepth` 8 m in a pinwheel around it, counter-clockwise from the south-west; every dwelling adjoins the core along one of the core's sides and has two façades; the core has no outdoor wall, the first zone of any family without one.
- Winged band (`HDHM-TYP-003`): a band of stair bays along plan x and `WingCount` (≥ 2, default 3) wings of stair bays projecting to plan north, `WingLength` 17 m long and `WingSpacing` 17 m apart, all `Depth` 12 m deep; the first wing is flush with the band's west end, the last with its east end, so the outline is a comb. Every wing adjoins the band along its whole root. The band and each wing hold `n = max(1, round(L / (2U + S)))` bays with the dwelling width adjusted to fill their length `L` (the default 70 m band has four bays of 7.25 m dwellings, the wings one bay each); stairs that leave no dwelling width are the error `InvalidParameter`. Zones `B-ST1…`, `B-U1…` (band, west to east), then `W{i}-ST1…`, `W{i}-U1…` (wing `i`, south to north). The band's north façade is fragments between the wings' roots and its bay boundaries (1.25 m to 7.25 m by default); a fragment shorter than 1 m gets no window (`WindowOmitted`, ADR-011).
- Open court (`HDHM-TYP-004`): three wings of stair bays, `Depth` 12 m deep, around a court `CourtWidth` 24 m wide and `CourtDepth` 17 m deep that is open to plan south: west and east wings of length `CourtDepth` and a north wing across the full width `2 · Depth + CourtWidth` that holds the corners. Bays are fitted to each wing as in the winged band. Zones `N-…` (west to east), then `W-…` and `E-…` (south to north); every dwelling adjoins a stair of its bay.
- Surfaces come from `LayoutSurfaceBuilder`: an edge shared by two zones becomes an interzone wall, an edge on the plan boundary an outdoor wall; any other edge (a gap or an overlap) is an error. Whether a vertex lies on an edge, to split it there or to place it on the boundary, is measured between the vertices' original positions against the distance tolerance, not between their snapped grid cells, so T-junctions are recognised on rotated plans (ADR-002, S8.2). Floors and ceilings stay `Unresolved` until floors are aggregated.
  1. Replace
- *Semantic Merge* merges connected zones of the same space type. *Perimeter Core* makes one perimeter zone per orientation plus a core, with corners split on the bisectors (ADR-008). *Single Zone per Floor* makes the floor one zone.

with

- *Semantic Merge* merges connected zones of the same space type; each group's footprint is the union of its zones as tiles (`PolygonOps.UnionTiles`), which keeps the zones' own vertices and may have holes: the four dwellings of the point plate become one ring around the core (ADR-002). *Perimeter Core* makes one perimeter zone per orientation plus a core, with corners split on the bisectors (ADR-008), also at re-entrant corners; an orientation with several separate façade runs, such as the open court's three south façades, gives the zones `P-South-1…`. Every wing or other part of the footprint must be wider than twice the depth, or the simplifier fails with `PlanTooNarrow`, whose message says so. *Single Zone per Floor* makes the floor one zone.
  1. Replace
The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines. Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).

with

The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines whose vertices are the zones' own vertices (S8.2). Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).
  1. Replace
## Grasshopper components through S8.1

with

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

with

- **Preview Location (D-062).** The eight plan generators, *Transform Plan*, the four plan simplifiers, and the four floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.
  1. Replace
- **Typology generators (S8.1, ADR-006).** *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* follow *Linear Plan Generator*: their number inputs (counts are integers) default to the family's `Default` record, then *Floor Height* (3 m) and *Orientation* (0°), then one required preset input per space type they place (*Dwelling Unit*, and *Stair* except for *Terrace Row*; an unconnected one is Grasshopper's missing-input warning, D-065), then *Preview Location*. Their descriptions name the family and what is not modelled. `WindowOmitted` shows as a remark. *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged.
with
- **Typology generators (S8.1, ADR-006).** *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* follow *Linear Plan Generator*: their number inputs (counts are integers) default to the family's `Default` record, then *Floor Height* (3 m) and *Orientation* (0°), then one required preset input per space type they place (*Dwelling Unit*, and *Stair* except for *Terrace Row*; an unconnected one is Grasshopper's missing-input warning, D-065), then *Preview Location*. Their descriptions name the family and what is not modelled. `WindowOmitted` shows as a remark. *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged. *Point Plate*, *Winged Band*, and *Open Court* (S8.2) follow the same pattern with the preset inputs *Dwelling Unit* and *Stair*; on *Point Plate* the *Stair* preset is the core's.
  1. Replace
| 2 Generate | *Terrace Row* (Terr) | Plot Count (6), Plot Width (5.5), Depth (10), Floor Height (3), Orientation (0), Dwelling Unit, Preview Location → Plan | `TerraceRowComponent` | `f694e595-dba2-4b98-9d1e-f1492e3edf39` | S8.1 |
with
| 2 Generate | *Terrace Row* (Terr) | Plot Count (6), Plot Width (5.5), Depth (10), Floor Height (3), Orientation (0), Dwelling Unit, Preview Location → Plan | `TerraceRowComponent` | `f694e595-dba2-4b98-9d1e-f1492e3edf39` | S8.1 |
| 2 Generate | *Point Plate* (PPlate) | Dwelling Depth (8), Core Width (6), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `PointPlateComponent` | `8ac9044a-8fbe-4964-a9de-2ef8be35094a` | S8.2 |
| 2 Generate | *Winged Band* (WBand) | Wing Count (3), Wing Length (17), Wing Spacing (17), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `WingedBandComponent` | `e285811d-2fdb-4d31-8b02-62c270b609cf` | S8.2 |
| 2 Generate | *Open Court* (OCourt) | Court Width (24), Court Depth (17), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `OpenCourtComponent` | `223014ea-3c01-4d2b-b8ba-92ebdc4a7327` | S8.2 |
  1. Replace
| Convert2IDF: an EnergyPlus-ready IDF from `IGeneratedBuilding`, including constructions, unconditioned zones, and how zone multipliers and internal mass are written | S6 | ADR-010 |
| Non-rectangular typologies without holes: *Point Plate* (`HDHM-TYP-007`), *Winged Band* (003), *Open Court* (004), specified in ADR-006 | S8.2 | ADR-006, ADR-011 |
with
| Convert2IDF: an EnergyPlus-ready IDF from `IGeneratedBuilding`, including constructions, unconditioned zones, and how zone multipliers and internal mass are written | S6 | ADR-010 |
  • [x] Step 2: Edit docs/research/precedents/hdhm-typologies.md

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

  1. Replace
| `HDHM-TYP-003` | Projecting wings joined by a common band | recurrent (3 projects) | `WingedBandGenerator` (specified in ADR-006) | S8.2 |
| `HDHM-TYP-004` | Open U-shaped court with stair-served wings | recurrent (2 projects) | `OpenCourtGenerator` (specified in ADR-006) | S8.2 |

with

| `HDHM-TYP-003` | Projecting wings joined by a common band | recurrent (3 projects) | `WingedBandGenerator` (*Winged Band*) | S8.2 |
| `HDHM-TYP-004` | Open U-shaped court with stair-served wings | recurrent (2 projects) | `OpenCourtGenerator` (*Open Court*) | S8.2 |
  1. Replace
| `HDHM-TYP-007` | Compact point-access tower plate | provisional | `PointPlateGenerator` (specified in ADR-006) | S8.2 |

with

| `HDHM-TYP-007` | Compact point-access tower plate | provisional | `PointPlateGenerator` (*Point Plate*) | S8.2 |
  • [x] Step 3: Edit docs/research/program-presets.md

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

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

with

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

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

Task 13: S8.2 smoke-test checklist

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

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

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

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

### S8.2 checklist — non-rectangular typologies (`v0.8.1`)

S8.2 adds three plan generator components in *2 Generate* for the families without holes ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)) and makes *Semantic Merge* and the single-zone aggregators exact on rotated plans ([ADR-002](../decisions/ADR-002-geometry.md)). No existing component, input, output, or GUID changes. Use *Dwelling Unit Preset* and *Stair Preset* with nothing connected.

#### Toolbar and icons

- [ ] *2 Generate* shows *Point Plate*, *Winged Band*, and *Open Court* next to the S8.1 generators, each with its own green icon (a square pinwheel around a dark core, a comb, and a U).
- [ ] Inputs, in order, with their defaults: *Point Plate* (PPlate) Dwelling Depth 8, Core Width 6, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; *Winged Band* (WBand) Wing Count 3, Wing Length 17, Wing Spacing 17, Depth 12, Unit Width 7, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; *Open Court* (OCourt) Court Width 24, Court Depth 17, Depth 12, Unit Width 7, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location. Each has one output, *Plan*.
- [ ] With a preset input unconnected, the component shows Grasshopper's missing-input warning and outputs nothing (D-065).

#### Default plans

1. Place each new generator with its presets connected and nothing else changed; connect *Inspect* to each *Plan*.
   - [ ] *Point Plate*: no runtime message; five zones `ST` (*Core*), `U1`–`U4`; footprint 22 m × 22 m (484 m²), core 36 m² at x, y = 8–14 m with no outdoor wall, dwellings 112 m² in a pinwheel; 8 windows, 79.2 m² of glazing (19.8 m² per orientation in *Validate* on its *No Simplification* floor).
   - [ ] *Winged Band*: no runtime message; 21 zones `B-ST1`–`B-ST4`, `B-U1`–`B-U8`, `W1-ST1`, `W1-U1`, `W1-U2`, … `W3-U2`; a comb in a 70 m × 29 m box (1452 m²) with three 12 m wings to plan north; 43 windows, 217.8 m² of glazing (59.4 north, 55.8 south, 51.3 east, 51.3 west); the band's north façade between the wings carries a window on every fragment, the shortest 1.25 m.
   - [ ] *Open Court*: no runtime message; 15 zones `N-ST1`–`N-ST3`, `N-U1`–`N-U6`, `W-ST1`, `W-U1`, `W-U2`, `E-ST1`, `E-U1`, `E-U2`; a U in a 48 m × 29 m box (984 m²) whose 24 m × 17 m court is open to plan south; 30 windows, 154.8 m² of glazing (41.4 south, 37.8 north, 37.8 east, 37.8 west).
   - [ ] Each *Inspect* report equals the snapshot `tests/Lod.Generators.Tests/<Family>/Snapshots/<family>-canonical.txt`.

#### Combinations

2. Connect each new plan to the four plan simplifiers (*Perimeter Core* Depth 4.57) and each floor three times, Multipliers 1, 3, 1, to the four floor aggregators.
   - [ ] *Semantic Merge* gives 2 zones for *Point Plate* (`DwellingUnit-1`, a ring around the core, previewed with the hole, and `Stair-1`), 13 for *Winged Band*, and 9 for *Open Court*.
   - [ ] *Perimeter Core* gives `P-North`, `P-East`, `P-South`, `P-West`, `CORE` for *Point Plate*; 13 zones for *Winged Band* (`P-North-1`–`P-North-5`, `P-East-1`–`P-East-3`, `P-South`, `P-West-1`–`P-West-3`, `CORE`) and 9 for *Open Court* (`P-North`, `P-East-1`, `P-East-2`, `P-South-1`–`P-South-3`, `P-West-1`, `P-West-2`, `CORE`), with diagonal walls also at the re-entrant corners.
   - [ ] For all 16 combinations of each generator, *Validate* gives Passed = True (only `ConditionedFloorArea` may read `note`), no aggregator shows a warning, and *Convert2BEM* with Override = False converts without an error.

#### Rotated placement

3. Put each new plan through *Transform Plan* with a 30° rotation about the world z axis and a move by 5.3, −2.1, 0, then through each simplifier and each aggregator (Multipliers 1, 3, 1); repeat with a 131.4° rotation and a move by 15.08, −153.9, 0.
   - [ ] *Validate* gives Passed = True for all 16 combinations of each generator and placement, with the same total glazing as the unrotated plan.
   - [ ] *Gallery Bar* (S8.1) through *Transform Plan* with a 321.2° rotation and a move by 136.4, −118.8, 0, then *Semantic Merge*, then *Single Zone Building* and *Single Zone per Floor Type*: no error and *Validate* passes (a regression check of the S8.2 geometry fixes; the integration test `SingleZoneMethodsCoverTheFacadesOfARotatedSemanticMergeFloor` covers the same case).

#### Parameters, preconditions, and the minimum geometry

4. Change inputs one at a time and set each back afterwards.
   - [ ] *Winged Band* Wing Count 1: `Error InvalidParameter: WingCount must be at least 2 (the wing-to-band relation repeats), got 1.` and no plan. A length of 0 on any of the three generators gives `… must be positive, got 0.`, for example *Point Plate* Core Width 0: `Error InvalidParameter: CoreWidth must be positive, got 0.`
   - [ ] *Open Court* Unit Width 0.5 and Stair Width 20: `Error InvalidParameter: StairWidth 20 m leaves no dwelling width in each 17 m side wing: its one stair bay needs 20 m of stairs; reduce StairWidth or UnitWidth.`
   - [ ] *Winged Band* Wing Spacing 14: the plan is produced with two remarks, `Info WindowOmitted [B-U4/W4]: Outdoor wall B-U4/W4 is 0.5 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.45 m² of glazing not placed).` and the same for `B-U5/W3`; those two fragments show no window.
   - [ ] *Open Court* Depth 8 through *Perimeter Core* (Depth 4.57): `Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.` and no floor; the other three simplifiers still work.
  • [x] Step 2: Commit
git add docs/development/grasshopper-smoke-test.md
git commit -m "docs(development): add the s8.2 smoke-test checklist"

Task 14: 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 15, Step 8)

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

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

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

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

  1. Registration. 28 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, all primary: Gallery Bar GBar a58df462-b3c2-46b7-be3d-7ea9f4629280, Linear Plan Generator Linear a4a9131e-0a0f-49e0-99a4-f26760f6810f, Open Court OCourt 223014ea-3c01-4d2b-b8ba-92ebdc4a7327, Point Plate PPlate 8ac9044a-8fbe-4964-a9de-2ef8be35094a, Stair Bay Bar SBar 911c3204-42bb-4023-bdb7-2931ea1caad3, Stair Pair SPair 5669d39b-45e6-47ea-9250-f2304d005335, Terrace Row Terr f694e595-dba2-4b98-9d1e-f1492e3edf39, Transform Plan TPlan 57c17b9c-04b4-4595-9150-bb923877d95e, Winged Band WBand e285811d-2fdb-4d31-8b02-62c270b609cf. Every other component and parameter as in S8.1.
  2. Inputs. In order, with their persistent defaults (model in metres): Point Plate Dwelling Depth (DD) 8, Core Width (CW) 6, Floor Height (FH) 3, Orientation (O) 0, Dwelling Unit (DU), Stair (St), Preview Location (PL, optional) {0,0,0}; Winged Band Wing Count (WC, integer) 3, Wing Length (WL) 17, Wing Spacing (WS) 17, Depth (D) 12, Unit Width (UW) 7, Stair Width (SW) 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; Open Court Court Width (CW) 24, Court Depth (CD) 17, Depth (D) 12, Unit Width (UW) 7, Stair Width (SW) 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location. Every input except Preview Location is required. With the preset inputs unconnected each component is in the warning state with Input parameter DU failed to collect data and Input parameter St failed to collect data and outputs nothing.
  3. Defaults. Each new generator with the presets → Inspect; → No Simplification → Stack Floors (one floor) → Convert2BEM; and → Semantic Merge, Perimeter Core (default depth), and Single Zone per Floor: no runtime message on the generator, Inspect, a simplifier, Stack Floors, or Convert2BEM (the preset components show only their remark).
Generator Plan bounding box (m) footprint area= Boundaries Windows (Breps in branches) Floors of Semantic Merge, Perimeter Core, Single Zone per Floor
Point Plate (0, 0, 0)–(22, 22, 3) 484.000000 Adiabatic 16, Ground 5, Outdoors 13 8 in 5 (0, 2, 2, 2, 2) 2, 5, 1 zones
Winged Band (0, 0, 0)–(70, 29, 3) 1452.000000 Adiabatic 50, Ground 21, Outdoors 64 43 in 21 (1, 2, 2, 1, then 2 in each of branches 4–13, then 3, 2, 2, 3, 2, 2, 3) 13, 13, 1 zones
Open Court (0, 0, 0)–(48, 29, 3) 984.000000 Adiabatic 36, Ground 15, Outdoors 45 30 in 15 (1, 2, 1, then 2 in each of branches 3–9, then 3, 2, 2, 3, 2) 9, 9, 1 zones
  1. Every combination. Each new generator's plan through each of the four plan simplifiers (Perimeter Core with its default depth), each floor given 1, 3, 1 to each of the four aggregators, each building to Validate: no runtime message on any simplifier, aggregator, or Validate, and Validate Passed = True for all 48 buildings.
  2. Rotated plan. Each new plan through Transform Plan with the transform of a 30° rotation about the world z axis followed by a move by (5.3, −2.1, 0), then the 16 combinations of item 4; and again with a 131.4° rotation and a move by (15.08, −153.9, 0): no runtime message (including Transform Plan) and Validate Passed = True for all 96.
  3. Regression of the S8.2 fixes. Gallery Bar with the presets through Transform Plan (321.2°, then a move by (136.4, −118.8, 0)) → Semantic Merge (three times) → Single Zone per Floor Type and Single Zone Building (1, 3, 1) → Validate: no runtime message and Passed = True for both.
  4. Parameters, preconditions, and the minimum geometry. Winged Band with Wing Count 1: error state, Error InvalidParameter: WingCount must be at least 2 (the wing-to-band relation repeats), got 1., no plan. Point Plate with Core Width 0: error state, Error InvalidParameter: CoreWidth must be positive, got 0. Open Court with Unit Width 0.5 and Stair Width 20: error state, Error InvalidParameter: StairWidth 20 m leaves no dwelling width in each 17 m side wing: its one stair bay needs 20 m of stairs; reduce StairWidth or UnitWidth. Winged Band with Wing Spacing 14: remark state with the two remarks Info WindowOmitted [B-U4/W4]: Outdoor wall B-U4/W4 is 0.5 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.45 m² of glazing not placed). and the same for B-U5/W3, and a plan. Open Court with Depth 8: the generator in the remark state with the two remarks Info WindowOmitted [N-U1/W2]: Outdoor wall N-U1/W2 is 0.5 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.45 m² of glazing not placed). and the same for N-U4/W1, and a plan; Perimeter Core on it in the error state with Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.

Items that a headless session cannot observe stay open for a person (D-056, D-058): the toolbar and canvas icons, the viewport previews (the point plate's Semantic Merge ring with its hole, the diagonal walls of Perimeter Core at the re-entrant corners, the fragments without a window), the Inspect reports compared with the three canonical snapshots (the generator tests check the snapshots), the zone IDs of Semantic Merge and Perimeter Core (NonRectangularTypologyTests checks them; the headless session recorded the zone counts), the glazing values of Validate and the totals of the rotated plans (only Passed was recorded; the generator tests check the glazing of every wall), Convert2BEM on the 16 combinations (the headless session converted the one-storey default plans only), and the other three simplifiers on the 8 m open court. No example definition is part of S8.2; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).

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

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

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


Stage close-out

Task 15: Version 0.8.1, S8.2 records, tag v0.8.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-02-s8.2-non-rectangular-typologies.md (this plan: checkboxes and status, D-051)

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

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

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

Expected: VERIFY PASSED, with 154 core, 159 generators, and 474 integration tests passed on net8.0.

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

Replace

    <Version>0.8.0</Version>

with

    <Version>0.8.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.8.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.8.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 are the revision stages S4.1 (tag `v0.4.1`, D-064) and S4.2 (tag `v0.4.2`, D-073), stage S5 (tag `v0.5.0`, D-079), and sub-stages S8.1 (tag `v0.8.0`) and S8.2 (tag `v0.8.1`, D-084). The pipeline runs end to end for eight plan generators: program presets → a plan generator (one `PlanGenerator` subclass per *High-Density Housing* typology family with typed parameter and preset records, [ADR-006](docs/decisions/ADR-006-plan-generation-mechanism.md): `LinearPlanGenerator`, the double-sided internal corridor bar `HDHM-TYP-009`; the bar families `StairBayBarGenerator` 001, `StairPairGenerator` 008, `GalleryBarGenerator` 002 with its gallery not modelled, and `TerraceRowGenerator` 006 with one dwelling zone per plot and storey, D-085; and the non-rectangular families `PointPlateGenerator` 007, a square plate whose core has no outdoor wall, `WingedBandGenerator` 003, a comb, and `OpenCourtGenerator` 004, a U; one typed preset per space type, D-061; optionally rotated and moved by `PlanTransform`, D-070) → plan simplifiers (`NoSimplification`, `SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`) → floor aggregators (`Stack`, `StackedFloorZoneMultiplier`, `SingleZonePerFloorType`, `SingleZoneBuilding`, D-071; floors and ceilings designated on the assembled stack, D-068) → validation → `Convert2BEM` (heat transfer between zones an export option, off by default, D-069), as described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md). Every generator places one centred window per outdoor wall (D-026), except on a wall shorter than 1 m or where the window would be narrower or lower than 0.3 m (`GeometryLimits`, info `WindowOmitted`, [ADR-011](docs/decisions/ADR-011-minimum-wall-and-window-geometry.md)). Wherever zones are merged and walls are rebuilt (`SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`, `SingleZonePerFloorType`, `SingleZoneBuilding`), every outdoor wall carries one centred window with the glazed area of the source windows it covers (`CentredWindows`, D-079, [ADR-012](docs/decisions/ADR-012-centred-windows.md)); the other steps keep their input's windows. Merged zones are unions of their sources as tiles that keep the sources' own vertices, so they may have holes and stay exact on rotated plans, and vertices on edges are recognised at their original positions (D-088, [ADR-002](docs/decisions/ADR-002-geometry.md)); `PerimeterCore` needs every part of a footprint to be wider than twice its depth and otherwise fails with `PlanTooNarrow` (D-087, [ADR-008](docs/decisions/ADR-008-perimeter-core-corners.md)). Vertical aggregation follows [ADR-013](docs/decisions/ADR-013-vertical-aggregation-methods.md), which supersedes [ADR-009](docs/decisions/ADR-009-vertical-aggregation.md) where it differs. ADR-006, ADR-011, ADR-012, ADR-013, and the S4.2, S5, S8.1, and S8.2 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084). In Grasshopper every component has an icon drawn by `scripts/make_icons.py` (D-063), and the generator, *Transform Plan*, simplifier, and aggregator components have a *Preview Location* input that moves only their preview (D-062). Until the owner says otherwise, changes need not keep backward compatibility, but component GUIDs never change (D-064).

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

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

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

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

  1. Replace
  Lod.Generators/    plan generators, one per typology family (the linear plan and the S8.1 bar families; the others in S8.2 and S8.3)

with

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

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

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

with

> **Progress:** 2026-10-02 · S8.1 Plan generation mechanism and bar typologies complete (tag `v0.8.0`, local until the owner pushes it, D-084); next: S6 and S8.2. ADR-006 fixes one `PlanGenerator` subclass per *High-Density Housing* family, with typed parameter and preset records and design defaults for all nine families; ADR-011 adds `GeometryLimits` (no window on an outdoor wall shorter than 1 m or for a window narrower or lower than 0.3 m, info `WindowOmitted`); `LinearPlanGenerator` checked as `HDHM-TYP-009` and kept; new generators and components *Stair Bay Bar* (001), *Stair Pair* (008), *Gallery Bar* (002, gallery not modelled), and *Terrace Row* (006, one zone per plot and storey, D-085); every bar family validates with every simplifier and aggregator, also rotated and moved; existing snapshots byte-identical, four new; 152 core, 91 generator, and 319 integration tests pass on `net8.0`.
>
> **Progress:** <date> · S8.2 Non-rectangular typologies without holes complete (tag `v0.8.1`, local until the owner pushes it, D-084); next: S6 and S8.3. New generators and components *Point Plate* (007, a square plate whose core has no outdoor wall), *Winged Band* (003, a comb), and *Open Court* (004, a U), their wings on the shared stair-bay layout; *Semantic Merge* unites its groups as tiles, the surface builder tests vertices on edges at their original positions, and unions of tiles keep the tiles' own vertices, which repairs rotated plans released up to `v0.8.0` (D-088); *Perimeter Core* states its width precondition in `PlanTooNarrow` (D-087); every non-rectangular family validates with every simplifier and aggregator, also rotated and moved; existing snapshots byte-identical, three new; 154 core, 159 generator, and 474 integration tests pass on `net8.0`.
  1. Replace
| S8.1 | [2026-10-02-s8.1-bar-typologies.md](2026-10-02-s8.1-bar-typologies.md) | 152 core, 91 generators, 319 integration |

with

| S8.1 | [2026-10-02-s8.1-bar-typologies.md](2026-10-02-s8.1-bar-typologies.md) | 152 core, 91 generators, 319 integration |
| S8.2 | [2026-10-02-s8.2-non-rectangular-typologies.md](2026-10-02-s8.2-non-rectangular-typologies.md) | 154 core, 159 generators, 474 integration |

The S8.2 stage edits of the plan commit (the S8.2 bullet and the risk row) stay as they are; §2 (target repository layout) already names the S8.2 folders.

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

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

### D-089 — S8.2 complete

- **Date:** <date> · **Decided by:** controller (unsupervised run, D-084) · **Status:** Provisional
- Sub-stage S8.2 (D-084) is complete and tagged `v0.8.1` locally; pushing `main` and the tags `v0.8.0` and `v0.8.1` waits for the owner (D-084). The three families without holes that ADR-006 specifies for S8.2 are implemented as specified, with no parameter record, default, or rule of ADR-006 changed: `PointPlateGenerator` (`HDHM-TYP-007`: a square plate with the core `ST`, a `Stair` zone without outdoor walls, and the dwellings `U1…U4` in a pinwheel), `WingedBandGenerator` (`HDHM-TYP-003`: a comb of a band and at least two wings of stair bays, zones `B-…` and `W{i}-…`), and `OpenCourtGenerator` (`HDHM-TYP-004`: a U of north, west, and east wings of stair bays around a court open to plan south, zones `N-…`, `W-…`, `E-…`); the wings share `StairBayLayout` (`Wing`, `Fit`, `FitErrors`), and the S8.1 bars are unchanged. New components in *2 Generate*, with icons: *Point Plate* (PPlate, `8ac9044a-8fbe-4964-a9de-2ef8be35094a`), *Winged Band* (WBand, `e285811d-2fdb-4d31-8b02-62c270b609cf`), and *Open Court* (OCourt, `223014ea-3c01-4d2b-b8ba-92ebdc4a7327`). ADR-006 records the details settled in S8.2 and the pipeline consequences.
- D-088 is implemented test-first: *Semantic Merge* unites each group as tiles, so a merged zone may have holes; the surface builder and the union of tiles test whether a vertex lies on an edge at the vertices' original positions against the unchanged distance tolerance; and unions of tiles keep the tiles' own vertices ([ADR-002](ADR-002-geometry.md), [ADR-013](ADR-013-vertical-aggregation-methods.md)). Each fix repairs rotated plans that already failed on `v0.8.0` (the linear plan and the S8.1 bars through *Semantic Merge*). D-087: *Perimeter Core*'s `PlanTooNarrow` message states that every part of the footprint must be wider than twice the perimeter depth ([ADR-008](ADR-008-perimeter-core-corners.md)); its checks are unchanged. No tolerance or validation check changed.
- The twelve existing snapshots are byte-identical; three canonical snapshots are new. 154 core, 159 generator, and 474 integration tests pass on `net8.0`, including every new family with every simplifier and aggregator on its canonical plan and on the plan rotated by 30° and moved by (5.3, −2.1) m and rotated by 131.4° and moved by (15.08, −153.9) m.
- The S8.2 smoke test is recorded in the close-out commit; items it did not perform stay open for a person (D-058).
- S8.3 (the enclosed courtyard 005, footprints with holes in every simplifier and aggregator) can start; S6 (Convert2IDF) is unaffected.

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

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

Expected: no output.

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

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

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

Expected: VERIFY PASSED, with 154 core, 159 generators, and 474 integration tests passed on net8.0.

  • [ ] Step 10: Tag (local)
git tag -a v0.8.1 -m "S8.2 non-rectangular typologies complete"
git describe --tags

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

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

Files: none

Not performed in this run. D-084 leaves pushing to the owner: the controller does not run this task, and its steps stay unticked when the stage is closed. They are listed so that the owner, or the controller once the owner authorises it, pushes the stage the way S5 was pushed (D-080); never a force push. The S8.1 push (S8.1 plan, Task 18) was not performed either, so this task pushes main with both sub-stage tags, v0.8.0 and v0.8.1; v0.4.2 and v0.5.0 were pushed after S5 and are not part of it.

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

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

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

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

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

Expected: each push succeeds without --force (main as a fast-forward, the tags as [new tag]); ls-remote lists both tags (each with its peeled ^{} line); git status -sb shows ## main...origin/main with nothing ahead or behind. A rejected push is not retried with force: stop and report.

Exit criteria

  • PointPlateGenerator (007, the core ST without an outdoor wall and four dwellings in a pinwheel), WingedBandGenerator (003, a comb of a band and at least two wings), and OpenCourtGenerator (004, a U open to plan south) implement ADR-006 with its defaults, zone IDs, and validation; each has tiling, window, relationship, invalid-parameter, determinism, provenance, and snapshot tests; StairBayLayout lays out the wings (Wing, Fit, FitErrors) and the S8.1 bars unchanged.
  • Semantic Merge unites its groups as tiles, the surface builder tests vertices on edges at their original positions against the unchanged distance tolerance, and unions of tiles keep the tiles' own vertices (D-088); each fix has a test that failed on the code before it (SimplifierTests, LayoutSurfaceBuilderTests, GeometryTests, AggregatorTests), and ADR-002 and ADR-013 describe the rules.
  • Perimeter Core's PlanTooNarrow message states the width precondition (D-087), tested by SimplifierTests; ADR-008 records it.
  • Every new family validates with every plan simplifier and floor aggregator, on its canonical plan and at two rotated and moved placements (NonRectangularTypologyTests, 151 cases).
  • Snapshots: the twelve existing snapshots byte-identical; three new canonical snapshots.
  • Three new components in 2 Generate with the GUIDs of this plan, their ADR-006 defaults, required preset inputs, Preview Location last, and icons (35 icons); no existing component, input, output, or GUID changed.
  • ADR-006 records the S8.2 families as implemented and the pipeline consequences; pipeline.md, the precedent page, and program-presets.md describe S8.2; the S8.2 smoke-test checklist is in the guide.
  • scripts/verify.ps1 prints VERIFY PASSED; tests: 154 core, 159 generators, 474 integration on net8.0; 0 warnings.
  • The S8.2 smoke test is recorded in the close-out commit, with the items left for a person named.
  • Version 0.8.1, local tag v0.8.1; AGENTS.md, README, the roadmap Progress line and detailed-plans row, and the decision-log entry D-089 are written; this plan is ticked and marked Done.
  • Nothing is pushed in this run (D-084); Task 16 is left for the owner.

Notes for the reviewer

  1. Source. Every code block, snapshot, and documentation replacement was extracted programmatically from a verified reference implementation built on the S8.1 reference (d4aae8e), 12 commits ending at 09c0ac3, each passing scripts/verify.ps1 with 0 warnings. Local main is 1794af1: S8.1 executed (v0.8.0, 4e89b4e; src/, tests/, and scripts/ identical to the S8.1 reference, Directory.Build.props at 0.8.0) plus the entries D-087 and D-088, so every file the reference touches is identical on both. The reference's commit order is kept with one exception: its component commit is split into Task 9 (the components) and Task 10 (the icons and make_icons.py), as S8.1 did with its Tasks 11 and 13. The reference has no roadmap commit; the roadmap edits are the plan commit's and the close-out's. Every task was then replayed on a clone of 1794af1, taking each file content and each replacement from this plan's text: the red errors of Tasks 1, 5, and 6 (with line and column numbers and in the compiler's order), the failing tests and messages of Tasks 2, 3, 4, and 7, the single failing snapshot test of Tasks 1, 5, and 6 and the three received snapshots (byte-identical to the expected contents), the failure of make_icons.py before Task 10 and the 35 hashes after it (all 35 PNGs byte-identical to the reference's), the counts after every task, a 0-warning build after every task, every documentation replacement (each "Replace" text occurs exactly once when it is applied, and the result equals the reference), the close-out edits, and VERIFY PASSED on a fresh clone of the replayed v0.8.1 (154 core, 159 generators, 474 integration) are observed results. The replayed end state equals the reference apart from line endings, the version (the reference keeps 0.5.0), and the files the reference holds in their S8.1 form or not at all (the decision log, the roadmap, AGENTS.md, README.md, the plans, and the .gitignore lines main gained in S8.1).
  2. Slices and branches. The roadmap names no slices for S8.2; this plan uses five slices and a close-out, named after D-004: feature/generators-point-plate, fix/geometry-rotated-plans (the three D-088 fixes, type fix), feature/generators-non-rectangular-typologies (which also holds the fix(simplifiers) commit of Task 7, because its test needs the open court of Task 6), feature/grasshopper-non-rectangular-typologies, docs/architecture-non-rectangular-typologies, and chore/repo-s8.2-close-out. Merges rebase onto local main. Like S8.1, this run ends without a push (D-084); Task 16 is written out for the owner, pushes both v0.8.0 and v0.8.1, and stays unticked.
  3. D-087, D-088, and the reference documents. D-087 and D-088 were logged on main after the reference's documents were settled. The rules are the same, and D-088's "Details in ADR-002 and ADR-013" is where Tasks 2–4 put them, but the reference texts of ADR-002, ADR-006, ADR-008, and ADR-013 do not cite D-087 or D-088. The plan keeps the reference text unchanged; the plan commit's roadmap bullet, the close-out's AGENTS.md, README.md, roadmap Progress line, and D-089 cite them. Citing the two entries in the ADRs is a one-line follow-up for the owner's reading.
  4. The fixes repair behaviour released up to v0.8.0. On the code of 1794af1 (that is, v0.8.0), the reference build's sweep probe (not committed: seed 12345, four fixed and 40 random rotations and moves, the linear plan and the four S8.1 bars, every simplifier with every aggregator, floors 1, 3, 1) gave total 3520, failing 16, every failure in Semantic Merge with UnmatchedEdge (4 on the linear plan, 12 on the gallery bar). Task 4's regression test fails on that code already in Semantic Merge, with Error UnmatchedEdge [Stair-2]: Edge (167.574, -143.864)-(170.691, -146.371) is neither shared with another zone nor on the plan boundary (gap or overlap). and one more for DwellingUnit-1; with Task 3 applied it fails as Task 4, Step 3 shows (FacadeNotCovered), and with Task 4 it passes. DisconnectedGroup (Task 2) was observed only on the point plate, which is new in S8.2. The same probe on the replayed end state, with all eight families, gave total 5632, failing 0; the reference build reported total 9408, failing 0 and total 8960, failing 0 for its own sweeps, which were not re-run. ADR-006's new section (Task 11) says that the three faults "also affected the S8.1 bars and the linear plan"; what was observed on v0.8.0 is the UnmatchedEdge fault, with the FacadeNotCovered fault behind it on the gallery bar. The plan keeps the reference text.
  5. The reverted alternative fix. While the reference was built, a fix that restored the original vertices after every clipping operation (in the shared PolygonOps.Execute) was tried first; it changed two existing snapshots and was reverted, and it is in no reference commit (reference build report; not replayed). The adopted fix restores vertices only in UnionTiles, whose results are the outlines of united zones; Intersect, Difference, and Union keep their grid-rounded results, so overlap mapping, the perimeter/core shapes, and all twelve existing snapshots are unchanged.
  6. The small-zone clipping limit (not fixed). Clipping rounds every vertex to the 1e-6 m grid (ADR-002, ADR-004), an absolute area error of the order of the perimeter times 1e-6 m, which for a zone of about 1 m² can exceed the relative area tolerance of 1e-6. Observed on the replayed end state: the point plate with CoreWidth 1 m (a 1 m² core, other parameters at their defaults), rotated by 30° and moved by (5.3, −2.1) m, fails validation after Semantic Merge, Perimeter Core, and Single Zone per Floor with FAIL Floor0.SourceCoverage: not fully mapped: ST (No Simplification passes); in the probe of note 4 with 150 random placements, 6 of 154 placements fail every merging combination for that plate. The reference build reported the same limit at 124.1°, which was not reproduced at the three moves tried. S8.2 does not fix it: no tolerance may be widened (AGENTS.md rule 12), and a different treatment of small zones would change ADR-004 and needs its own decision. Every family's defaults validate at every tested placement. The plan commit adds a risk row to the roadmap.
  7. The VertexGrid tolerance is measured on unsnapped coordinates. Before Task 3, IsStrictlyInside and IsOnSegment compared snapped grid cells: within 1 grid unit of the line, and more than half a unit from the ends. After it they compare the original point of each cell (the first point registered in it) in metres, against the grid's cell size, which is ToleranceSettings.Distance (new VertexGrid(_tolerances.Distance) and new VertexGrid(_distance)): within 1e-6 m of the line and more than 0.5e-6 m from the ends. The value is unchanged and no constant is added. Snapping a T-junction and both ends of its edge independently can move it up to √2 grid units off the snapped edge (1.10 units in Task 3's test), so the old test rejected vertices that lie exactly on the edge; the new one measures the true positions. On axis-aligned plans with vertices on the grid both tests agree, which is why every existing snapshot is unchanged.
  8. Zones with holes in S8.2. D-084 assigns footprints with holes to S8.3. S8.2 already produces a zone with a hole: Semantic Merge unites the point plate's four dwellings into a ring around the core, as D-088 allows and as the types support since S2 (ADR-002). Task 8 validates that floor with every aggregator. Plan footprints with holes (the enclosed court) and Perimeter Core on them stay in S8.3.
  9. Perimeter Core and D-087. The bisector rule of ADR-008 is unchanged; the numbered zones such as P-South-1 come from its S3 rule for several façade runs in one direction. Task 7 changes only the two PlanTooNarrow messages; the precondition is not checked separately, because the existing checks (a valid inward offset that keeps every direction, trapezoids and core that tile the footprint) already reject such plans.
  10. Tasks without a red phase. Task 8 adds verification tests only; it passed at once in the reference and in the replay, and the task says to stop if a case fails. Tasks 9 and 10 are Grasshopper code. The build-error red phases of Tasks 1, 5, and 6 list only declaration-level errors (four each): the compiler stops before binding method bodies.
  11. ADR-006 after the code. Task 11 records the S8.2 details in ADR-006 after the code tasks, as the reference did. No record, default, or rule of ADR-006 changes, so the S8 gate (ADR-006 and ADR-011 accepted before any S8 code, satisfied in S8.1) is not affected.
  12. Icons. The 32 existing PNGs are byte-identical after Task 10 (Pillow 12.3.0); the three new ones equal the reference's bytes. The contact-sheet description is from the replay's sheet.
  13. Smoke-test values. The expected values of Task 14 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit: registration and inputs, the default plans (bounding boxes, the footprint line of Inspect, Boundaries, Windows, and the zone counts of three simplifiers), the 48 canonical and 96 rotated combinations (states and Validate Passed), the regression case, and five parameter scenarios. Unlike S8.1, the provenance texts were not recorded; the generator tests check them. Inspect texts beyond the footprint line, Validate values, Convert2BEM on the combinations, and the viewport were not observed. The reference assembly reported 0.5.0+<sha> because the reference keeps the S5 version number.
  14. Clean working tree. The git status expectations assume a working tree with no untracked files outside .gitignore before Task 1.
  15. check_plans.py false positives. The Global Constraints line that states the no-attribution rule is flagged as a possible attribution; it states the rule. The make_icons.py docstring line that names the Pillow version of the icons is flagged, as in S4.1, S4.2, and S8.1.