S8.6 — Operating suite and court clusters Implementation Plan¶
Status: Done (controller, D-098; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.6 (section S8, Typologies) · Decisions: D-009, D-026, D-061, D-062, D-063, D-064, D-065, D-079, D-085, D-087, D-088, D-090, D-091, D-092, D-097, D-098, D-099, D-102, D-103; ADR-006 (the specification of the two families, Task 4), ADR-008 and ADR-002 (several holes, Task 4), ADR-011, ADR-014
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.6 of D-098: the last two families of the four-source set with one plan for every storey, as ADR-006 specifies them: SYN-TYP-015 (Operating Suite, theatre banks between alternating clean and dirty corridors, every bank and corridor adjoining the support rooms across the suite's west end, one zone per department, D-097) and SYN-TYP-011 (Court Cluster, wings of stair bays around a row of at least two courts, each middle wing bordering two courts, the first footprint with several holes), with their Grasshopper components and icons. The theatre preset's WWR of 0 leaves the banks' east ends without a window and without WindowOmitted (ADR-011, rule 1), which the generator tests' window check now states. Every simplifier, aggregator, and validation already handles several holes; Perimeter Core gives each court its own court zones per orientation, which ADR-008 records. Both families validate with every simplifier and aggregator on their canonical plans, rotated and moved by two placements, with the fewest and the most tested counts, and with the most at the second placement. Every existing plan, floor, building, and snapshot stays unchanged; no Lod.Core file changes. S8.6 ends with version 0.8.5 and the tag v0.8.5; after the stage gate and a fresh clone of the tag pass scripts/verify.ps1, the controller pushes main and v0.8.5 (D-098). The close-out also removes two stray code-fence lines that the S8.5 close-out left in the decision log, and checks the code fences of every Markdown file it touches.
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, and a generator on the PlanGenerator base. OperatingSuiteGenerator stacks rectangular bands from south to north east of one support zone, so every shared edge is bit-identical. CourtClusterGenerator places its wings with StairBayLayout (Wing, Fit, FitErrors), as EnclosedCourtGenerator does, and stores one clockwise hole per court; no code is shared with EnclosedCourtGenerator beyond StairBayLayout, whose summary names the new user. The test helper PlanAssertions.HasOneCentredWindowPerOutdoorWall expects no window on a wall whose zone's preset has WWR 0. Two components derive from the S8.1 TypologyGeneratorComponent, and scripts/make_icons.py draws two new icons.
Tech Stack: C# 12 (LangVersion 12.0); .NET SDK ≥ 8 (global.json pins 8.0.100 with rollForward: latestMajor); netstandard2.0 core libraries; Clipper2 2.0.0; RhinoCommon and Grasshopper 8.19.25132.1001 (plugin targets net7.0); xUnit 2.9.3, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 17.14.1 (tests target net8.0); Windows PowerShell 5.1 for scripts/verify.ps1; Python 3.10 or later with Pillow for scripts/make_icons.py (the icons in this plan were drawn with Pillow 12.3.0).
Global Constraints¶
- Gate: S8.5 is complete on
main(16ead1f, tagv0.8.4, D-103), with D-102 and D-103 recorded; this plan is committed tomainbefore Task 1 ("docs(plans): add the s8.6 operating suite and court cluster plan"); it is the only file of that commit. ADR-006 (with the S8.4 specification of the two families, their defaults, zone IDs, order, and validation), ADR-008, ADR-011, and ADR-014 are accepted. Before Task 1 the working tree is clean (git status --shortprints nothing). - Rhino 8 (8.19 or later) only (D-003); stages exchange Grasshopper objects, not JSON (D-017).
Lod.CoreandLod.Generatorshave no RhinoCommon or Grasshopper reference; their tests run with plaindotnet test.- Nullable reference types on; warnings are errors;
.editorconfigrules are enforced in the build; every public member ofLod.CoreandLod.Generatorshas XML docs (CS1591 is an error). - Generation is deterministic; outputs have a stable order: the operating suite lists
SUP, then its bands from south to north,DC1,TB1,CC1,TB2,DC2,TB3,CC2, … (clean and dirty corridors numbered separately); the court cluster lists the south wing (S-ST1…, thenS-U1…, west to east), the north wing, then the west wing, the middle wingsM1,M2, …, and the east wing (south to north), each wing its stairs first, and stores its courts west to east. - Counts. The operating suite needs at least 2 theatre banks and the court cluster at least 2 courts (one court is
SYN-TYP-005); neither has an upper bound (ADR-006). A count below its minimum and every length that is not positive and finite are the errorInvalidParameter, every error reported at once; the court cluster reports a stair bay that leaves no dwelling width withStairBayLayout.FitErrors, for its south and north wings only when the count is valid (their length depends on it). - Numerical tolerances come only from
ToleranceSettings(ADR-004); S8.6 adds none and changes none, andToleranceSettings.csis not touched. The minimum wall and window geometry stays inGeometryLimits(ADR-011): a wall whose zone's preset has WWR 0 has no glazed area, so it gets no window and noWindowOmitted(rule 1), unchanged. - Existing outputs do not change. The twenty-two existing snapshots (
linear-plan-canonical,stack-two-storeys, the threesimplifier-*, the threeaggregator-*, the S8.1 snapshotsstair-bay-bar-canonical,stair-pair-canonical,gallery-bar-canonical,terrace-row-canonical, the S8.2 snapshotspoint-plate-canonical,winged-band-canonical,open-court-canonical, the S8.3 snapshotenclosed-court-canonical, the S8.4 snapshotsoffice-plate-canonicalandcafeteria-canonical, and the S8.5 snapshotsradial-lobes-canonical,branching-mall-canonical,care-hub-canonical, andfoyer-halls-canonical) stay byte-identical, and every existing test keeps passing; no test is removed, and no existing icon PNG changes.EnclosedCourtGeneratoris not touched. - Never weaken a check (AGENTS.md rules 11, 12). Tasks 1 and 2 start from tests that fail to build, the way each task says; the integration matrices pass at once, and each task says to stop if a case fails. The change to
PlanAssertionsof Task 1 is stricter, not weaker: a wall whose zone's preset has WWR 0 must have no window, where the helper before would have required one; for every other wall the check is unchanged, and Task 1 runs every earlier generator test against it before the new tests are added. No tolerance is widened, no validation check is relaxed, and no test is weakened or removed. - Report, never repair (GLOBAL.md quality rule 6). A plan that Perimeter Core cannot zone fails with
PlanTooNarrow(D-087), never repaired; the generators do not impose that precondition, and Task 2 tests it on 9 m wings. Zones about 1 m wide on rotated plans remain the known limit of D-090; S8.6 does not change it. - 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. The presets are
OperatingTheatre,CleanCorridor,DirtyCorridor, andClinicalSupport(015), andDwellingUnitandStair(011), each checked against its slot's space type (PresetSpaceTypeMismatch). No space type and no preset is added (ADR-014 already holds them). - Department zoning (D-097). A non-residential zone is one department: each theatre bank, each corridor, and the support rooms together are one zone each. Housing keeps one zone per dwelling and storey (D-009, D-085).
- Source contract (D-093). ADR-006 and
docs/research/precedents/syn-typologies.mdhold everything this plan needs;typology-synthesizeris not opened while this plan is executed. - No backward compatibility (D-064). Two components are added with new GUIDs: Operating Suite
33323afd-abf1-4ecc-bf54-5d67c411c962and Court Cluster01fc2ca9-3ec0-4142-b827-2519a4523d36. No existing component, input, output, or GUID changes. - Grasshopper components are thin adaptors (AGENTS.md): every rule lives in
Lod.CoreorLod.Generators; a component reads inputs, calls the generator, and reports its diagnostics. - Research equivalence rules are not changed (GLOBAL.md scientific rule 7): S8.6 adds generators and changes no step of the pipeline, so the research documentation needs no edit; ADR-008 and ADR-002 record that several holes need no change.
- Code fences. Every Markdown file this plan touches keeps balanced code fences: every opening fence line carries a language, every closing fence line is bare, and no file ends inside a block. Text this plan appends to a file is the content of a code block, never the fence lines around it; Tasks 4 and 6 run a check that prints each file's state.
- No simulation inside the pipeline (D-016). No CI (D-005):
scripts/verify.ps1passes locally before every merge tomain. - Commits:
type(scope): summaryin lower case, no agent, model, or tool attribution and noCo-Authored-Bytrailers (D-001, D-002); author identity per D-050. - Pushes (D-098). Slices are rebased onto local
mainand nothing is pushed while the stage runs. After the close-out (Task 6), its tag, and a fresh clone ofv0.8.5that passesscripts/verify.ps1, the controller pushesmainandv0.8.5(Task 7); never a force push. .ghexample definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.6 adds none.- S8.6 changes these earlier files:
PlanAssertions.cs(S8.1, unchanged since),StairBayLayout.cs(S8.1, last changed in S8.3),TypologyGeneratorComponents.cs(S8.1, last changed in S8.5),scripts/make_icons.py(S4.1, last changed in S8.5), andDirectory.Build.props(S0, last changed in S8.5); and the documents AGENTS.md, README.md, ADR-006, ADR-008, ADR-002,pipeline.md,program-presets.md,syn-typologies.md, the roadmap,docs/development/grasshopper-smoke-test.md, and the decision log (all last changed in S8.5).
Files¶
| Path | Change | Responsibility |
|---|---|---|
tests/Lod.Generators.Tests/PlanAssertions.cs |
Modify (S8.1 file) | No window on an outdoor wall whose zone's preset has WWR 0 |
src/Lod.Generators/OperatingSuite/OperatingSuiteGenerator.cs |
Create | OperatingSuiteParameters, OperatingSuitePresets, OperatingSuiteGenerator (015) |
tests/Lod.Generators.Tests/OperatingSuite/OperatingSuiteGeneratorTests.cs, …/Snapshots/operating-suite-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/OperatingSuiteTypologyTests.cs |
Create | Every simplifier × aggregator, canonical, at two placements, with 2 and 5 banks, and with 5 banks at 131.4°; Semantic Merge, Perimeter Core |
src/Lod.Generators/StairBayBar/StairBayLayout.cs |
Modify (S8.1 file, last changed in S8.3) | Summary names the court cluster (no code change) |
src/Lod.Generators/CourtCluster/CourtClusterGenerator.cs |
Create | CourtClusterParameters, CourtClusterPresets, CourtClusterGenerator (011) |
tests/Lod.Generators.Tests/CourtCluster/CourtClusterGeneratorTests.cs, …/Snapshots/court-cluster-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/CourtClusterTypologyTests.cs |
Create | Every simplifier × aggregator, canonical, at two placements, with 2 and 4 courts, and with 4 courts at 131.4°; Semantic Merge, Perimeter Core with 2–4 courts, 9 m wings PlanTooNarrow, Single Zone Building |
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs |
Modify (S8.1 file, last changed in S8.5) | Operating Suite, Court Cluster |
scripts/make_icons.py |
Modify (S4.1 file, last changed in S8.5) | Two new motifs |
src/Lod.Grasshopper/Icons/OperatingSuiteComponent.png, CourtClusterComponent.png |
Create (generated) | 59 icons |
docs/decisions/ADR-006-plan-generation-mechanism.md, docs/decisions/ADR-008-perimeter-core-corners.md, docs/decisions/ADR-002-geometry.md, docs/architecture/pipeline.md, docs/research/precedents/syn-typologies.md, docs/research/program-presets.md |
Modify | The two families as implemented, several holes in Perimeter Core, the components, the renamed components heading |
docs/development/grasshopper-smoke-test.md |
Modify | Remove the stray fence line of S8.5, append the S8.6 checklist (Task 4) and its result (Task 6) |
Directory.Build.props |
Modify | Version 0.8.5 |
AGENTS.md, README.md |
Modify | "Project state" and "Status" after S8.6; README layout line |
docs/plans/2026-09-30-implementation-roadmap.md |
Modify | Progress line and detailed-plans row |
docs/decisions/decision-log.md |
Modify | Remove the two stray fence lines of S8.5; D-104 (the layout choices of S8.6), D-105 (S8.6 complete) |
Test counts on net8.0 at the start of S8.6: 193 core, 360 generators, 1184 integration (D-103). After Task 1: 193 / 396 / 1283. After Task 2 and at the end: 193 core, 427 generators, 1385 integration (generators: +36 OperatingSuiteGeneratorTests, +31 CourtClusterGeneratorTests; integration: +99 OperatingSuiteTypologyTests, +102 CourtClusterTypologyTests). No test is removed or changed; the helper PlanAssertions of Task 1 is test support, not a test. Grasshopper code has no unit tests; Task 3 is checked by a 0-warning build, the embedded icons, and the smoke test of Task 5.
Task assignment (D-048, execution handoff §6)¶
| Task | Agent | Why |
|---|---|---|
| 1 Operating suite (015) | bemgen-complex |
New plan generator geometry, a stricter test helper; new snapshot; combination matrix |
| 2 Court cluster (011) | bemgen-complex |
New plan generator geometry with several holes; new snapshot; combination matrix with Perimeter Core on several courts |
| 3 Generator components and icons | bemgen-complex |
Thin components with generated icons and a visual check |
| 4 Architecture documents and smoke-test checklist | bemgen-bounded |
Documents, exact replacements, a fence repair |
| 5 Smoke test | The controller headless (D-056); person for the viewport items | Needs Rhino 8 |
| 6 Close-out | bemgen-bounded |
Bookkeeping, a fence repair |
| 7 Fresh-clone gate and push | Controller (D-098) | A push is never delegated |
The end-of-stage review is done by bemgen-complex. A bounded task escalates to bemgen-complex after two failures or any deviation from this plan's expected output.
Slice A — feature/operating-suite-court-cluster¶
The two families, one task each: the generator tests first (they fail to build), the generator, the snapshot reviewed and accepted, then the integration matrix, which passes at once; if a case of the matrix fails, stop and report: a pipeline change needs its own test-first task. Task 1 first makes the generator tests' window check state the WWR-0 rule the operating suite relies on, and runs every earlier generator test against it.
Task 1: Operating suite with separated clean and dirty routes (SYN-TYP-015)¶
Files:
- Modify: tests/Lod.Generators.Tests/PlanAssertions.cs (S8.1 file)
- Create: src/Lod.Generators/OperatingSuite/OperatingSuiteGenerator.cs
- Test: create tests/Lod.Generators.Tests/OperatingSuite/OperatingSuiteGeneratorTests.cs, tests/Lod.Integration.Tests/OperatingSuiteTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt
Interfaces:
- Consumes (S2–S8.5): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings), Validate, Layout, Describe, protected static bool IsPositive(double)), PlanParameters, PlanPresets.Slots, PresetSlot, PlanLayout, PlannedZone(ZoneId, string, SpaceType, Polygon2), Polygon2.Rectangle, Provenance.Format, Diagnostic.Error, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch; SpaceType.OperatingTheatre, CleanCorridor, DirtyCorridor, and ClinicalSupport, ExampleNonResidentialPresets.OperatingTheatre, CleanCorridor, DirtyCorridor, and ClinicalSupport (S8.4, the theatre's WWR 0), and ExampleNonResidentialPresets.OperatingTheatreValues for the glazed theatre preset of the tests; Window.Centered returning no window for a glazed area of 0 (ADR-011, rule 1); test support PlanAssertions (TilesTheFootprint, HasOneCentredWindowPerOutdoorWall, Neighbours, OutdoorFacings), Snapshot.Match, TextReport.Describe, Pipeline.Simplifier, Pipeline.Aggregator, Pipeline.Tolerances, PlanTransform.Apply, SemanticMerge, PerimeterCore(ToleranceSettings, double), FloorEntry, BuildingValidator.
- Produces: PlanAssertions.HasOneCentredWindowPerOutdoorWall expects no window on an outdoor wall whose zone's preset has WWR 0, and one centred window on every other outdoor wall, as before. public sealed record OperatingSuiteParameters(int BankCount, double BankLength, double TheatreDepth, double CleanCorridorWidth, double DirtyCorridorWidth, double SupportLength, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (2, 36, 7, 3, 2.4, 12, 4.2, 0); public sealed record OperatingSuitePresets(ProgramPreset OperatingTheatre, ProgramPreset CleanCorridor, ProgramPreset DirtyCorridor, ProgramPreset ClinicalSupport) : PlanPresets (slots in that order); public sealed class OperatingSuiteGenerator(ToleranceSettings), Name = OperatingSuiteGenerator, public const int MinimumBanks = 2: SUP (Support, ClinicalSupport) is [0, SupportLength] across the full depth; east of it, bands of BankLength from y = 0: the dirty corridor DC1, then per bank k the bank TB{k} (Theatre Bank k, OperatingTheatre) and the corridor k, clean when k is odd (CC…, Clean Corridor i, CleanCorridor) and dirty otherwise (DC…, Dirty Corridor i, DirtyCorridor), so n banks have ⌈n/2⌉ clean and ⌊n/2⌋ + 1 dirty corridors (D-104); y is accumulated band by band and the footprint and SUP use the final y, so shared edges are bit-identical; errors InvalidParameter for fewer than 2 banks (BankCount must be at least 2 (the theatre banks repeat beside the support rooms), got 1.) and for each length that is not positive and finite ({name} must be positive, got {value}.), all at once; no upper bound; provenance keys BankCount, BankLength, TheatreDepth, CleanCorridorWidth, DirtyCorridorWidth, SupportLength. By default a rectangle of 48 m × 21.8 m (1046.4 m²): SUP 261.6 m², DC1 and DC2 86.4 m², TB1 and TB2 252 m², CC1 108 m²; the outdoor walls are SUP's west, south, and north sides, every band's east end, DC1's south side, and DC2's north side; 8 windows with 71.988 m² of glazing (three on SUP, two on each dirty corridor, one at the clean corridor's end), none on a bank and no diagnostic.
- [x] Step 1: Create the branch
git switch -c feature/operating-suite-court-cluster main
- [x] Step 2: Make the window check state the WWR-0 rule — replace
tests/Lod.Generators.Tests/PlanAssertions.cs(full file)
HasOneCentredWindowPerOutdoorWall looks up the zone's WWR before it checks the wall, and treats a wall whose zone's preset has WWR 0 like a wall that is not outdoor: it must have no window. Nothing else changes.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Xunit;
namespace Lod.Generators.Tests;
/// <summary>Checks every plan generator must pass (ADR-006): tiling, the D-026 window rule, and adjacency queries.</summary>
internal static class PlanAssertions
{
/// <summary>The zones cover the footprint's area and no two zones overlap.</summary>
public static void TilesTheFootprint(IGeneratedPlan plan)
{
var ops = new PolygonOps(ToleranceSettings.Default);
Assert.True(ToleranceSettings.Default.AreaEquals(plan.Footprint.Area, plan.Zones.Sum(z => z.FloorArea)));
for (int i = 0; i < plan.Zones.Count; i++)
{
for (int j = i + 1; j < plan.Zones.Count; j++)
{
Assert.Equal(0.0, ops.IntersectionArea(plan.Zones[i].Parts[0].Footprint, plan.Zones[j].Parts[0].Footprint), 9);
}
}
}
/// <summary>
/// Every outdoor wall has one window centred on it with area WWR × wall area of its zone's preset (D-026), except that a zone whose preset has
/// WWR 0 has no window (ADR-011, rule 1); other walls have none.
/// </summary>
public static void HasOneCentredWindowPerOutdoorWall(IGeneratedPlan plan, PlanPresets presets)
{
foreach (WallSurface wall in plan.Surfaces.OfType<WallSurface>())
{
Zone zone = plan.Zones.Single(z => z.Id == wall.Zone);
double ratio = presets.Slots.Single(s => s.SpaceType == zone.SpaceType).Preset.WindowToWallRatio;
if (wall.Boundary != BoundaryCondition.Outdoors || ratio == 0.0)
{
Assert.Empty(wall.Windows);
continue;
}
Window window = Assert.Single(wall.Windows);
Assert.Equal(ratio * wall.Area, window.Area, 9);
Assert.Equal((wall.Length - window.Width) / 2.0, window.Offset, 12);
Assert.Equal((wall.Height - window.Height) / 2.0, window.SillHeight, 12);
}
}
/// <summary>IDs of the zones that share an interzone wall with a zone, in ordinal order.</summary>
public static IReadOnlyList<string> Neighbours(IGeneratedPlan plan, string zone) =>
plan.Surfaces.OfType<WallSurface>()
.Where(w => w.Zone.Value == zone && w.Boundary == BoundaryCondition.Interzone)
.Select(w => w.AdjacentZone!.Value.Value)
.Distinct()
.OrderBy(id => id, System.StringComparer.Ordinal)
.ToArray();
/// <summary>Plan orientation bins of a zone's outdoor walls, in ordinal order of their names.</summary>
public static IReadOnlyList<OrientationBin> OutdoorFacings(IGeneratedPlan plan, string zone) =>
plan.Surfaces.OfType<WallSurface>()
.Where(w => w.Zone.Value == zone && w.Boundary == BoundaryCondition.Outdoors)
.Select(w => Orientation.Bin(w.PlanAzimuth))
.Distinct()
.OrderBy(b => b.ToString(), System.StringComparer.Ordinal)
.ToArray();
}
- [x] Step 3: Run every earlier generator test against the stricter check
dotnet test tests/Lod.Generators.Tests -c Release
Expected: Passed! - Failed: 0, Passed: 360, Skipped: 0, Total: 360, Duration: … - Lod.Generators.Tests.dll (net8.0). Every earlier generator has a WWR above 0 on every preset, so nothing changes for them; if a test fails, stop and report.
- [x] Step 4: Write the failing tests — create
tests/Lod.Generators.Tests/OperatingSuite/OperatingSuiteGeneratorTests.cs(full file)
36 tests: the ADR-006 defaults; the canonical plan (zone IDs, names, space types, rectangles, areas); the band order for 2 to 5 banks; tiling for five parameter sets; every band adjoining SUP over its full depth (R001, 2 to 5 banks); every bank between exactly one clean and one dirty corridor and no other bank, and every corridor adjoining only banks and SUP (R002, 2 to 5 banks); the corridors shared by consecutive banks; the outdoor facings; the windows and glazing, none on the banks and no diagnostic; a glazed theatre preset (WWR 0.2) giving 10 windows; a bank count of 1; invalid lengths; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
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;
using Lod.Generators.OperatingSuite;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.OperatingSuite;
public sealed class OperatingSuiteGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly OperatingSuitePresets Examples = new(
ExampleNonResidentialPresets.OperatingTheatre,
ExampleNonResidentialPresets.CleanCorridor,
ExampleNonResidentialPresets.DirtyCorridor,
ExampleNonResidentialPresets.ClinicalSupport);
private readonly OperatingSuiteGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new OperatingSuiteParameters(
BankCount: 2,
BankLength: 36.0,
TheatreDepth: 7.0,
CleanCorridorWidth: 3.0,
DirtyCorridorWidth: 2.4,
SupportLength: 12.0,
FloorHeight: 4.2,
OrientationDegrees: 0.0),
OperatingSuiteParameters.Default);
}
[Fact]
public void CanonicalPlanIsASupportZoneBesideBandsOfBanksAndCorridors()
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default);
Assert.Equal(new[] { "SUP", "DC1", "TB1", "CC1", "TB2", "DC2" }, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { "Support", "Dirty Corridor 1", "Theatre Bank 1", "Clean Corridor 1", "Theatre Bank 2", "Dirty Corridor 2" }, plan.Zones.Select(z => z.Name));
Assert.Equal(
new[] { SpaceType.ClinicalSupport, SpaceType.DirtyCorridor, SpaceType.OperatingTheatre, SpaceType.CleanCorridor, SpaceType.OperatingTheatre, SpaceType.DirtyCorridor },
plan.Zones.Select(z => z.SpaceType));
Assert.Equal(48.0 * 21.8, plan.Footprint.Area, 9);
double[] areas = { 12.0 * 21.8, 36.0 * 2.4, 36.0 * 7.0, 36.0 * 3.0, 36.0 * 7.0, 36.0 * 2.4 };
Assert.All(plan.Zones.Zip(areas, (z, a) => (z.FloorArea, a)), p => Assert.Equal(p.a, p.FloorArea, 9));
AssertRectangle(0, 0, 12, 21.8, Outer(plan, "SUP"));
AssertRectangle(12, 0, 48, 2.4, Outer(plan, "DC1"));
AssertRectangle(12, 2.4, 48, 9.4, Outer(plan, "TB1"));
AssertRectangle(12, 9.4, 48, 12.4, Outer(plan, "CC1"));
AssertRectangle(12, 12.4, 48, 19.4, Outer(plan, "TB2"));
AssertRectangle(12, 19.4, 48, 21.8, Outer(plan, "DC2"));
AssertRectangle(0, 0, 48, 21.8, plan.Footprint.Outer.ToArray());
Assert.Empty(plan.Footprint.Holes);
Assert.All(plan.Zones, z => Assert.Equal(4.2, z.Parts[0].Height));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Theory]
[InlineData(2, new[] { "SUP", "DC1", "TB1", "CC1", "TB2", "DC2" })]
[InlineData(3, new[] { "SUP", "DC1", "TB1", "CC1", "TB2", "DC2", "TB3", "CC2" })]
[InlineData(4, new[] { "SUP", "DC1", "TB1", "CC1", "TB2", "DC2", "TB3", "CC2", "TB4", "DC3" })]
[InlineData(5, new[] { "SUP", "DC1", "TB1", "CC1", "TB2", "DC2", "TB3", "CC2", "TB4", "DC3", "TB5", "CC3" })]
public void BandsAlternateFromADirtyCorridorInTheSouth(int banks, string[] zones)
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default with { BankCount = banks });
Assert.Equal(zones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(plan.Zones.Skip(1).OrderBy(z => z.Parts[0].Footprint.Outer.Min(p => p.Y)).Select(z => z.Id.Value), plan.Zones.Skip(1).Select(z => z.Id.Value));
int clean = (banks + 1) / 2;
int dirty = banks + 1 - clean;
Assert.Equal((banks * 7.0) + (clean * 3.0) + (dirty * 2.4), plan.Footprint.Outer.Max(p => p.Y), 9);
}
[Theory]
[InlineData(2, 36.0, 7.0, 3.0, 2.4, 12.0)]
[InlineData(3, 36.0, 7.0, 3.0, 2.4, 12.0)]
[InlineData(5, 36.0, 7.0, 3.0, 2.4, 12.0)]
[InlineData(2, 20.5, 6.3, 2.2, 1.8, 7.7)]
[InlineData(4, 50.0, 9.0, 4.0, 3.0, 20.0)]
public void ZonesTileTheFootprint(int banks, double length, double theatre, double clean, double dirty, double support)
{
IGeneratedPlan plan = Generate(new OperatingSuiteParameters(banks, length, theatre, clean, dirty, support, 4.2, 0.0));
PlanAssertions.TilesTheFootprint(plan);
int cleanCount = (banks + 1) / 2;
double depth = (banks * theatre) + (cleanCount * clean) + ((banks + 1 - cleanCount) * dirty);
Assert.Equal((support + length) * depth, plan.Footprint.Area, 9);
Assert.Equal((2 * banks) + 2, plan.Zones.Count);
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void EveryBankAndCorridorAdjoinsTheSupportRooms(int banks)
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default with { BankCount = banks });
string[] bands = plan.Zones.Skip(1).Select(z => z.Id.Value).ToArray();
Assert.Equal(bands.OrderBy(id => id, StringComparer.Ordinal), PlanAssertions.Neighbours(plan, "SUP"));
foreach (string band in bands)
{
WallSurface root = Assert.Single(plan.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == band && w.AdjacentZone?.Value == "SUP");
Assert.Equal(Depth(plan, band), root.Length, 9);
}
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void EveryBankLiesBetweenOneCleanAndOneDirtyCorridorAndTheRoutesNeverMeet(int banks)
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default with { BankCount = banks });
Dictionary<string, SpaceType> types = plan.Zones.ToDictionary(z => z.Id.Value, z => z.SpaceType);
foreach (Zone bank in plan.Zones.Where(z => z.SpaceType == SpaceType.OperatingTheatre))
{
IReadOnlyList<string> neighbours = PlanAssertions.Neighbours(plan, bank.Id.Value);
Assert.Single(neighbours, n => types[n] == SpaceType.CleanCorridor);
Assert.Single(neighbours, n => types[n] == SpaceType.DirtyCorridor);
Assert.DoesNotContain(neighbours, n => types[n] == SpaceType.OperatingTheatre);
Assert.All(
plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone == bank.Id && w.AdjacentZone is { } other && types[other.Value] != SpaceType.ClinicalSupport),
w => Assert.Equal(36.0, w.Length, 9));
}
foreach (Zone corridor in plan.Zones.Where(z => z.SpaceType is SpaceType.CleanCorridor or SpaceType.DirtyCorridor))
{
Assert.All(PlanAssertions.Neighbours(plan, corridor.Id.Value), n => Assert.Contains(types[n], new[] { SpaceType.OperatingTheatre, SpaceType.ClinicalSupport }));
}
}
[Fact]
public void ConsecutiveBanksShareTheCorridorBetweenThem()
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default with { BankCount = 3 });
Assert.Equal(new[] { "CC1", "DC1", "SUP" }, PlanAssertions.Neighbours(plan, "TB1"));
Assert.Equal(new[] { "CC1", "DC2", "SUP" }, PlanAssertions.Neighbours(plan, "TB2"));
Assert.Equal(new[] { "CC2", "DC2", "SUP" }, PlanAssertions.Neighbours(plan, "TB3"));
Assert.Equal(new[] { "SUP", "TB1", "TB2" }, PlanAssertions.Neighbours(plan, "CC1"));
Assert.Equal(new[] { "SUP", "TB3" }, PlanAssertions.Neighbours(plan, "CC2"));
}
[Fact]
public void OnlyTheSupportsWestEndTheBandsEastEndsAndTheOuterBandsAreOutdoor()
{
IGeneratedPlan plan = Generate(OperatingSuiteParameters.Default);
Assert.Equal(new[] { OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "SUP"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "DC1"));
Assert.Equal(new[] { OrientationBin.East }, PlanAssertions.OutdoorFacings(plan, "TB1"));
Assert.Equal(new[] { OrientationBin.East }, PlanAssertions.OutdoorFacings(plan, "CC1"));
Assert.Equal(new[] { OrientationBin.East }, PlanAssertions.OutdoorFacings(plan, "TB2"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North }, PlanAssertions.OutdoorFacings(plan, "DC2"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North }, PlanAssertions.OutdoorFacings(Generate(OperatingSuiteParameters.Default with { BankCount = 3 }), "CC2"));
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPresetAndTheTheatresNone()
{
Result<IGeneratedPlan> result = _generator.Generate(OperatingSuiteParameters.Default, Examples);
// The theatre preset's WWR is 0, so the banks' east ends get no window and no WindowOmitted info (ADR-011 rule 1).
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
WallSurface[] walls = result.Value.Surfaces.OfType<WallSurface>().ToArray();
Assert.All(walls.Where(w => w.Zone.Value.StartsWith("TB", StringComparison.Ordinal)), w => Assert.Empty(w.Windows));
Assert.Equal(8, walls.Sum(w => w.Windows.Count));
double support = 0.2 * (21.8 + 12.0 + 12.0) * 4.2;
double dirty = 2 * 0.1 * (36.0 + 2.4) * 4.2;
double clean = 0.1 * 3.0 * 4.2;
Assert.Equal(support + dirty + clean, walls.Sum(w => w.GlazedArea), 9);
}
[Fact]
public void ATheatrePresetWithGlazingGivesTheBanksEastWindows()
{
ProgramPreset glazed = (ExampleNonResidentialPresets.OperatingTheatreValues with { WindowToWallRatio = 0.2 }).ToPreset().Value;
OperatingSuitePresets presets = Examples with { OperatingTheatre = glazed };
Result<IGeneratedPlan> result = _generator.Generate(OperatingSuiteParameters.Default, presets);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, presets);
Assert.Equal(10, result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
}
[Fact]
public void ABankCountBelowTwoIsAnError()
{
Result<IGeneratedPlan> result = _generator.Generate(OperatingSuiteParameters.Default with { BankCount = 1 }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal("BankCount must be at least 2 (the theatre banks repeat beside the support rooms), got 1.", error.Message);
}
[Theory]
[InlineData(0.0, 7.0, 3.0, 2.4, 12.0, "BankLength")]
[InlineData(36.0, -7.0, 3.0, 2.4, 12.0, "TheatreDepth")]
[InlineData(36.0, 7.0, double.NaN, 2.4, 12.0, "CleanCorridorWidth")]
[InlineData(36.0, 7.0, 3.0, 0.0, 12.0, "DirtyCorridorWidth")]
[InlineData(36.0, 7.0, 3.0, 2.4, -12.0, "SupportLength")]
[InlineData(double.PositiveInfinity, 7.0, 3.0, 2.4, 12.0, "BankLength")]
public void InvalidParametersAreErrors(double length, double theatre, double clean, double dirty, double support, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new OperatingSuiteParameters(2, length, theatre, clean, dirty, support, 4.2, 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 OperatingSuiteParameters(0, 0.0, -7.0, 0.0, 0.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(8, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(
new[]
{
("OperatingTheatre", SpaceType.OperatingTheatre), ("CleanCorridor", SpaceType.CleanCorridor), ("DirtyCorridor", SpaceType.DirtyCorridor),
("ClinicalSupport", SpaceType.ClinicalSupport),
},
Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.OperatingTheatre, Examples.CleanCorridor, Examples.DirtyCorridor, Examples.ClinicalSupport }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(
OperatingSuiteParameters.Default,
new OperatingSuitePresets(
ExampleNonResidentialPresets.OperatingTheatre,
ExampleNonResidentialPresets.DirtyCorridor,
ExampleNonResidentialPresets.CleanCorridor,
ExampleNonResidentialPresets.ClinicalSupport));
Assert.Equal(new[] { "CleanCorridor", "DirtyCorridor" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
}
[Fact]
public void GenerationIsDeterministic()
{
Assert.Equal(TextReport.Describe(Generate(OperatingSuiteParameters.Default)), TextReport.Describe(Generate(OperatingSuiteParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(OperatingSuiteParameters.Default).Provenance;
Assert.Equal(nameof(OperatingSuiteGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"BankCount=2", "BankLength=36", "CleanCorridorWidth=3", "DirtyCorridorWidth=2.4", "FloorHeight=4.2", "OrientationDegrees=0",
"Presets=OperatingTheatre:Example Operating Theatre;CleanCorridor:Example Clean Corridor;DirtyCorridor:Example Dirty Corridor;ClinicalSupport:Example Clinical Support",
"SupportLength=12", "TheatreDepth=7",
},
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(OperatingSuiteParameters.Default)), "operating-suite-canonical");
}
private static void AssertRectangle(double minX, double minY, double maxX, double maxY, Point2[] actual)
{
Point2[] expected = Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
Assert.Equal(expected.Length, actual.Length);
for (int i = 0; i < expected.Length; i++)
{
Assert.Equal(expected[i].X, actual[i].X, 9);
Assert.Equal(expected[i].Y, actual[i].Y, 9);
}
}
private static double Depth(IGeneratedPlan plan, string zone)
{
Point2[] outer = Outer(plan, zone);
return outer.Max(p => p.Y) - outer.Min(p => p.Y);
}
private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();
private IGeneratedPlan Generate(OperatingSuiteParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
- [x] Step 5: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.OperatingSuite.OperatingSuiteGeneratorTests"
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\OperatingSuite\OperatingSuiteGeneratorTests.cs(8,22): error CS0234: The type or namespace name 'OperatingSuite' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\OperatingSuite\OperatingSuiteGeneratorTests.cs(309,37): error CS0246: The type or namespace name 'OperatingSuiteParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\OperatingSuite\OperatingSuiteGeneratorTests.cs(17,28): error CS0246: The type or namespace name 'OperatingSuitePresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\OperatingSuite\OperatingSuiteGeneratorTests.cs(23,22): error CS0246: The type or namespace name 'OperatingSuiteGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 6: Implement — create
src/Lod.Generators/OperatingSuite/OperatingSuiteGenerator.cs(full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
namespace Lod.Generators.OperatingSuite;
/// <summary>Parameters of the operating suite with separated clean and dirty routes (SYN-TYP-015, ADR-006).</summary>
/// <param name="BankCount">Number of theatre banks; at least 2.</param>
/// <param name="BankLength">Length of every bank and corridor along plan x, from the support rooms to the east façade, m.</param>
/// <param name="TheatreDepth">Depth of every theatre bank across the bands, m.</param>
/// <param name="CleanCorridorWidth">Width of every clean corridor, m.</param>
/// <param name="DirtyCorridorWidth">Width of every dirty corridor, m.</param>
/// <param name="SupportLength">Length of the support rooms along plan x at the suite's west end, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record OperatingSuiteParameters(
int BankCount,
double BankLength,
double TheatreDepth,
double CleanCorridorWidth,
double DirtyCorridorWidth,
double SupportLength,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>
/// The ADR-006 defaults: 2 banks of 36 m × 7 m, a 3 m clean and 2.4 m dirty corridors, 12 m of support rooms, 4.2 m storeys, orientation 0°.
/// </summary>
public static OperatingSuiteParameters Default { get; } = new(2, 36.0, 7.0, 3.0, 2.4, 12.0, 4.2, 0.0);
}
/// <summary>The program presets of the operating suite, one per space type it places (D-061).</summary>
/// <param name="OperatingTheatre">Preset of the theatre banks; space type <see cref="SpaceType.OperatingTheatre"/>.</param>
/// <param name="CleanCorridor">Preset of the clean corridors; space type <see cref="SpaceType.CleanCorridor"/>.</param>
/// <param name="DirtyCorridor">Preset of the dirty corridors; space type <see cref="SpaceType.DirtyCorridor"/>.</param>
/// <param name="ClinicalSupport">Preset of the support rooms; space type <see cref="SpaceType.ClinicalSupport"/>.</param>
public sealed record OperatingSuitePresets(ProgramPreset OperatingTheatre, ProgramPreset CleanCorridor, ProgramPreset DirtyCorridor, ProgramPreset ClinicalSupport)
: PlanPresets
{
/// <inheritdoc />
public override IReadOnlyList<PresetSlot> Slots => new[]
{
new PresetSlot(nameof(OperatingTheatre), SpaceType.OperatingTheatre, OperatingTheatre),
new PresetSlot(nameof(CleanCorridor), SpaceType.CleanCorridor, CleanCorridor),
new PresetSlot(nameof(DirtyCorridor), SpaceType.DirtyCorridor, DirtyCorridor),
new PresetSlot(nameof(ClinicalSupport), SpaceType.ClinicalSupport, ClinicalSupport),
};
}
/// <summary>
/// Operating suite with separated clean and dirty routes (SYN-TYP-015, ADR-006): repeated theatre banks beside the support rooms, every bank and
/// corridor adjoining them (R001), and distinct clean and dirty routes (R002): every bank lies between one clean and one dirty corridor, and no clean
/// corridor adjoins a dirty one. Zones are departments (D-097, ADR-014): each theatre bank, each corridor, and the support rooms together.
/// </summary>
/// <remarks>
/// The support rooms <c>SUP</c> span the suite's full depth at its west end, <c>[0, SupportLength]</c>; east of them, bands of length
/// <c>BankLength</c> along plan x are stacked from south to north: a dirty corridor, then for each bank the bank and the next corridor, the corridors
/// alternating clean and dirty, so consecutive banks share a corridor. Zone IDs: <c>SUP</c>, then the bands south to north, <c>DC1</c>,
/// <c>TB1</c>, <c>CC1</c>, <c>TB2</c>, <c>DC2</c>, <c>TB3</c>, <c>CC2</c>, …; display names <c>Support</c>, <c>Dirty Corridor 1</c>,
/// <c>Theatre Bank 1</c>, <c>Clean Corridor 1</c>. Only the support rooms' west, south, and north façades, the bands' east ends, and the outer
/// side of the southmost and northmost corridors are outdoor walls; a bank's only façade is its east end, which the theatre preset's WWR of 0 leaves
/// without a window.
/// </remarks>
public sealed class OperatingSuiteGenerator : PlanGenerator<OperatingSuiteParameters, OperatingSuitePresets>
{
/// <summary>The fewest theatre banks: the family repeats them.</summary>
public const int MinimumBanks = 2;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public OperatingSuiteGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(OperatingSuiteGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(OperatingSuiteParameters parameters)
{
if (parameters.BankCount < MinimumBanks)
{
yield return Diagnostic.Error(
DiagnosticCodes.InvalidParameter,
$"{nameof(OperatingSuiteParameters.BankCount)} must be at least {MinimumBanks} (the theatre banks repeat beside the support rooms), got {parameters.BankCount}.");
}
var values = new (string Name, double Value)[]
{
(nameof(OperatingSuiteParameters.BankLength), parameters.BankLength),
(nameof(OperatingSuiteParameters.TheatreDepth), parameters.TheatreDepth),
(nameof(OperatingSuiteParameters.CleanCorridorWidth), parameters.CleanCorridorWidth),
(nameof(OperatingSuiteParameters.DirtyCorridorWidth), parameters.DirtyCorridorWidth),
(nameof(OperatingSuiteParameters.SupportLength), parameters.SupportLength),
};
foreach ((string name, double value) in values.Where(v => !IsPositive(v.Value)))
{
yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be positive, got {value}.");
}
}
/// <inheritdoc />
protected override PlanLayout Layout(OperatingSuiteParameters p)
{
double west = p.SupportLength;
double east = p.SupportLength + p.BankLength;
var bands = new List<PlannedZone>();
double y = 0.0;
int clean = 0;
int dirty = 0;
for (int corridor = 0; corridor <= p.BankCount; corridor++)
{
if (corridor > 0)
{
double top = y + p.TheatreDepth;
bands.Add(new PlannedZone(new ZoneId($"TB{corridor}"), $"Theatre Bank {corridor}", SpaceType.OperatingTheatre, Polygon2.Rectangle(west, y, east, top)));
y = top;
}
// Corridors alternate from a dirty corridor in the south, so every bank lies between one clean and one dirty corridor.
bool isClean = corridor % 2 == 1;
double next = y + (isClean ? p.CleanCorridorWidth : p.DirtyCorridorWidth);
Polygon2 shape = Polygon2.Rectangle(west, y, east, next);
if (isClean)
{
clean++;
bands.Add(new PlannedZone(new ZoneId($"CC{clean}"), $"Clean Corridor {clean}", SpaceType.CleanCorridor, shape));
}
else
{
dirty++;
bands.Add(new PlannedZone(new ZoneId($"DC{dirty}"), $"Dirty Corridor {dirty}", SpaceType.DirtyCorridor, shape));
}
y = next;
}
var zones = new List<PlannedZone> { new(new ZoneId("SUP"), "Support", SpaceType.ClinicalSupport, Polygon2.Rectangle(0.0, 0.0, west, y)) };
zones.AddRange(bands);
return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, east, y), zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(OperatingSuiteParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.BankCount), p.BankCount.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>(nameof(p.BankLength), Provenance.Format(p.BankLength)),
new KeyValuePair<string, string>(nameof(p.TheatreDepth), Provenance.Format(p.TheatreDepth)),
new KeyValuePair<string, string>(nameof(p.CleanCorridorWidth), Provenance.Format(p.CleanCorridorWidth)),
new KeyValuePair<string, string>(nameof(p.DirtyCorridorWidth), Provenance.Format(p.DirtyCorridorWidth)),
new KeyValuePair<string, string>(nameof(p.SupportLength), Provenance.Format(p.SupportLength)),
};
}
- [x] Step 7: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.OperatingSuite.OperatingSuiteGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 35, Skipped: 0, Total: 36, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.OperatingSuite.OperatingSuiteGeneratorTests.CanonicalPlanSnapshot
Snapshot 'operating-suite-canonical' is missing or differs. Review Snapshots/operating-suite-canonical.received.txt and rename it to operating-suite-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.received.txt (git-ignored by *.received.*).
- [x] Step 8: Review and accept the received file
Review the received file against the content below; it must be identical: 73 lines, 6 zones, 40 surfaces (10 outdoor walls, 8 with one window; 18 interzone walls; 12 floors and ceilings), glazing 25.2 m² south, 25.2 m² north, 3.276 m² east, 18.312 m² west (71.988 m² in all); the outdoor walls without a window are the banks' east ends, and every band adjoins SUP along its west end. Accept it:
Move-Item -Force tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.received.txt tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt
tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt:
footprint area=1046.400000 floorHeight=4.200000 orientation=0.000000
zone SUP ClinicalSupport "Support" area=261.600000 volume=1098.720000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.050000 annual=2871.000000
load Lighting PerFloorArea value=12.000000 annual=4048.800000
load ElectricEquipment PerFloorArea value=10.000000 annual=4048.800000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone DC1 DirtyCorridor "Dirty Corridor 1" area=86.400000 volume=362.880000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=8.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone TB1 OperatingTheatre "Theatre Bank 1" area=252.000000 volume=1058.400000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2871.000000
load Lighting PerFloorArea value=25.000000 annual=4048.800000
load ElectricEquipment PerFloorArea value=30.000000 annual=4048.800000
load Ventilation AirChangesPerHour value=20.000000 annual=5946.000000
load Infiltration AirChangesPerHour value=0.100000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=22.000000
zone CC1 CleanCorridor "Clean Corridor 1" area=108.000000 volume=453.600000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=8.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone TB2 OperatingTheatre "Theatre Bank 2" area=252.000000 volume=1058.400000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2871.000000
load Lighting PerFloorArea value=25.000000 annual=4048.800000
load ElectricEquipment PerFloorArea value=30.000000 annual=4048.800000
load Ventilation AirChangesPerHour value=20.000000 annual=5946.000000
load Infiltration AirChangesPerHour value=0.100000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=22.000000
zone DC2 DirtyCorridor "Dirty Corridor 2" area=86.400000 volume=362.880000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=8.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
surface SUP/W1 Outdoors area=50.400000 wall (0.000000,0.000000)-(12.000000,0.000000) z=0.000000 facing=South glazing=10.080000 window(offset=3.316718 sill=1.160851 5.366563x1.878297)
surface SUP/W2 Interzone adjacent=DC1 area=10.080000 wall (12.000000,0.000000)-(12.000000,2.400000) z=0.000000 facing=East glazing=0.000000
surface SUP/W3 Interzone adjacent=TB1 area=29.400000 wall (12.000000,2.400000)-(12.000000,9.400000) z=0.000000 facing=East glazing=0.000000
surface SUP/W4 Interzone adjacent=CC1 area=12.600000 wall (12.000000,9.400000)-(12.000000,12.400000) z=0.000000 facing=East glazing=0.000000
surface SUP/W5 Interzone adjacent=TB2 area=29.400000 wall (12.000000,12.400000)-(12.000000,19.400000) z=0.000000 facing=East glazing=0.000000
surface SUP/W6 Interzone adjacent=DC2 area=10.080000 wall (12.000000,19.400000)-(12.000000,21.800000) z=0.000000 facing=East glazing=0.000000
surface SUP/W7 Outdoors area=50.400000 wall (12.000000,21.800000)-(0.000000,21.800000) z=0.000000 facing=North glazing=10.080000 window(offset=3.316718 sill=1.160851 5.366563x1.878297)
surface SUP/W8 Outdoors area=91.560000 wall (0.000000,21.800000)-(0.000000,0.000000) z=0.000000 facing=West glazing=18.312000 window(offset=6.025372 sill=1.160851 9.749256x1.878297)
surface SUP/F Unresolved area=261.600000 Floor z=0.000000
surface SUP/C Unresolved area=261.600000 Ceiling z=4.200000
surface DC1/W1 Outdoors area=151.200000 wall (12.000000,0.000000)-(48.000000,0.000000) z=0.000000 facing=South glazing=15.120000 window(offset=12.307900 sill=1.435922 11.384200x1.328157)
surface DC1/W2 Outdoors area=10.080000 wall (48.000000,0.000000)-(48.000000,2.400000) z=0.000000 facing=East glazing=1.008000 window(offset=0.820527 sill=1.435922 0.758947x1.328157)
surface DC1/W3 Interzone adjacent=TB1 area=151.200000 wall (48.000000,2.400000)-(12.000000,2.400000) z=0.000000 facing=North glazing=0.000000
surface DC1/W4 Interzone adjacent=SUP area=10.080000 wall (12.000000,2.400000)-(12.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface DC1/F Unresolved area=86.400000 Floor z=0.000000
surface DC1/C Unresolved area=86.400000 Ceiling z=4.200000
surface TB1/W1 Interzone adjacent=DC1 area=151.200000 wall (12.000000,2.400000)-(48.000000,2.400000) z=0.000000 facing=South glazing=0.000000
surface TB1/W2 Outdoors area=29.400000 wall (48.000000,2.400000)-(48.000000,9.400000) z=0.000000 facing=East glazing=0.000000
surface TB1/W3 Interzone adjacent=CC1 area=151.200000 wall (48.000000,9.400000)-(12.000000,9.400000) z=0.000000 facing=North glazing=0.000000
surface TB1/W4 Interzone adjacent=SUP area=29.400000 wall (12.000000,9.400000)-(12.000000,2.400000) z=0.000000 facing=West glazing=0.000000
surface TB1/F Unresolved area=252.000000 Floor z=0.000000
surface TB1/C Unresolved area=252.000000 Ceiling z=4.200000
surface CC1/W1 Interzone adjacent=TB1 area=151.200000 wall (12.000000,9.400000)-(48.000000,9.400000) z=0.000000 facing=South glazing=0.000000
surface CC1/W2 Outdoors area=12.600000 wall (48.000000,9.400000)-(48.000000,12.400000) z=0.000000 facing=East glazing=1.260000 window(offset=1.025658 sill=1.435922 0.948683x1.328157)
surface CC1/W3 Interzone adjacent=TB2 area=151.200000 wall (48.000000,12.400000)-(12.000000,12.400000) z=0.000000 facing=North glazing=0.000000
surface CC1/W4 Interzone adjacent=SUP area=12.600000 wall (12.000000,12.400000)-(12.000000,9.400000) z=0.000000 facing=West glazing=0.000000
surface CC1/F Unresolved area=108.000000 Floor z=0.000000
surface CC1/C Unresolved area=108.000000 Ceiling z=4.200000
surface TB2/W1 Interzone adjacent=CC1 area=151.200000 wall (12.000000,12.400000)-(48.000000,12.400000) z=0.000000 facing=South glazing=0.000000
surface TB2/W2 Outdoors area=29.400000 wall (48.000000,12.400000)-(48.000000,19.400000) z=0.000000 facing=East glazing=0.000000
surface TB2/W3 Interzone adjacent=DC2 area=151.200000 wall (48.000000,19.400000)-(12.000000,19.400000) z=0.000000 facing=North glazing=0.000000
surface TB2/W4 Interzone adjacent=SUP area=29.400000 wall (12.000000,19.400000)-(12.000000,12.400000) z=0.000000 facing=West glazing=0.000000
surface TB2/F Unresolved area=252.000000 Floor z=0.000000
surface TB2/C Unresolved area=252.000000 Ceiling z=4.200000
surface DC2/W1 Interzone adjacent=TB2 area=151.200000 wall (12.000000,19.400000)-(48.000000,19.400000) z=0.000000 facing=South glazing=0.000000
surface DC2/W2 Outdoors area=10.080000 wall (48.000000,19.400000)-(48.000000,21.800000) z=0.000000 facing=East glazing=1.008000 window(offset=0.820527 sill=1.435922 0.758947x1.328157)
surface DC2/W3 Outdoors area=151.200000 wall (48.000000,21.800000)-(12.000000,21.800000) z=0.000000 facing=North glazing=15.120000 window(offset=12.307900 sill=1.435922 11.384200x1.328157)
surface DC2/W4 Interzone adjacent=SUP area=10.080000 wall (12.000000,21.800000)-(12.000000,19.400000) z=0.000000 facing=West glazing=0.000000
surface DC2/F Unresolved area=86.400000 Floor z=0.000000
surface DC2/C Unresolved area=86.400000 Ceiling z=4.200000
- [x] Step 9: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.OperatingSuite.OperatingSuiteGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 36, Skipped: 0, Total: 36, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 10: Write the integration tests — create
tests/Lod.Integration.Tests/OperatingSuiteTypologyTests.cs(full file)
99 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32, rotated by 30° and moved by (5.3, −2.1) m, and rotated by 131.4° and moved by (15.08, −153.9) m), EveryCombinationValidatesWithTheFewestAndTheMostBanks (32, 2 and 5 banks), EveryCombinationValidatesWithTheMostBanksOnARotatedAndTranslatedPlan (16, 5 banks at 131.4°), SemanticMergeKeepsEveryDepartmentBecauseNoTwoOfOneTypeTouch (2 and 5 banks), and PerimeterCoreKeepsTheBandsEastWindowsOnPerimeterEast (P-North, P-East, P-South, P-West, CORE; P-East carries all 3.276 m² of east glazing; CORE from CC1, SUP, TB1, and TB2). No production code changes.
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.OperatingSuite;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.6 operating suite (SYN-TYP-015, ADR-006): theatre banks between alternating clean and dirty corridors beside the support rooms, through
/// every simplifier and aggregator, canonical and placed, with the fewest and the most tested banks.
/// </summary>
public sealed class OperatingSuiteTypologyTests
{
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 } };
/// <summary>The fewest banks (ADR-006) and the most tested; the family has no upper bound.</summary>
private static readonly int[] Counts = { OperatingSuiteGenerator.MinimumBanks, 5 };
public static IEnumerable<object[]> Matrix() =>
from simplifier in Simplifiers
from aggregator in Aggregators
select new object[] { simplifier, aggregator };
public static IEnumerable<object[]> PlacedMatrix() =>
from combination in Matrix()
from placement in Enumerable.Range(0, Placements.Length)
select combination.Append(placement).ToArray();
public static IEnumerable<object[]> CountMatrix() =>
from combination in Matrix()
from count in Counts
select combination.Append(count).ToArray();
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidates(string simplifier, string aggregator)
{
AssertValidates(Plan(), simplifier, aggregator);
}
[Theory]
[MemberData(nameof(PlacedMatrix))]
public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(string simplifier, string aggregator, int placement)
{
double[] p = Placements[placement];
AssertValidates(PlanTransform.Apply(Plan(), p[0], p[1], p[2]).Value, simplifier, aggregator);
}
[Theory]
[MemberData(nameof(CountMatrix))]
public void EveryCombinationValidatesWithTheFewestAndTheMostBanks(string simplifier, string aggregator, int banks)
{
AssertValidates(Plan(OperatingSuiteParameters.Default with { BankCount = banks }), simplifier, aggregator);
}
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidatesWithTheMostBanksOnARotatedAndTranslatedPlan(string simplifier, string aggregator)
{
double[] p = Placements[1];
AssertValidates(PlanTransform.Apply(Plan(OperatingSuiteParameters.Default with { BankCount = Counts[1] }), p[0], p[1], p[2]).Value, simplifier, aggregator);
}
[Theory]
[InlineData(2)]
[InlineData(5)]
public void SemanticMergeKeepsEveryDepartmentBecauseNoTwoOfOneTypeTouch(int banks)
{
IGeneratedPlan plan = Plan(OperatingSuiteParameters.Default with { BankCount = banks });
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(plan).Value;
Assert.Equal(plan.Zones.Select(z => z.SpaceType), floor.Zones.Select(z => z.SpaceType));
Assert.Equal(plan.Zones.Select(z => z.Id.Value), floor.Zones.Select(z => Assert.Single(z.SourceZones).Value));
}
[Fact]
public void PerimeterCoreKeepsTheBandsEastWindowsOnPerimeterEast()
{
IGeneratedPlan plan = Plan();
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(plan).Value;
Assert.Equal("P-North,P-East,P-South,P-West,CORE", string.Join(",", floor.Zones.Select(z => z.Id.Value)));
double east = plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.East).Sum(w => w.GlazedArea);
Assert.Equal(0.1 * (2.4 + 3.0 + 2.4) * 4.2, east, 9);
Assert.Equal(east, floor.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "P-East").Sum(w => w.GlazedArea), 9);
Assert.Equal(
new[] { "CC1", "SUP", "TB1", "TB2" },
floor.Zones.Single(z => z.Id.Value == "CORE").SourceZones.Select(s => s.Value).OrderBy(id => id, StringComparer.Ordinal));
}
private static void AssertValidates(IGeneratedPlan plan, string simplifier, string aggregator)
{
Result<IFloor> floor = Pipeline.Simplifier(simplifier).Simplify(plan);
Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
FloorEntry[] entries = { new(floor.Value, 1), new(floor.Value, 3), new(floor.Value, 1) };
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(entries);
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
}
private static OperatingSuitePresets Presets() => new(
ExampleNonResidentialPresets.OperatingTheatre,
ExampleNonResidentialPresets.CleanCorridor,
ExampleNonResidentialPresets.DirtyCorridor,
ExampleNonResidentialPresets.ClinicalSupport);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(OperatingSuiteParameters? parameters = null) =>
new OperatingSuiteGenerator(Pipeline.Tolerances).Generate(parameters ?? OperatingSuiteParameters.Default, Presets()).Value;
}
- [x] Step 11: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.OperatingSuiteTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 99, Skipped: 0, Total: 99, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 396 generators, and 1283 integration tests passed on net8.0; git status lists M tests/Lod.Generators.Tests/PlanAssertions.cs and the four new files of this task, and no *.received.txt.
- [x] Step 12: Commit
git add tests/Lod.Generators.Tests/PlanAssertions.cs src/Lod.Generators/OperatingSuite/OperatingSuiteGenerator.cs tests/Lod.Generators.Tests/OperatingSuite/OperatingSuiteGeneratorTests.cs tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt tests/Lod.Integration.Tests/OperatingSuiteTypologyTests.cs
git commit -m "feature(generators): add the operating suite with separated clean and dirty routes"
Task 2: Linked housing clusters around several courts (SYN-TYP-011)¶
Files:
- Modify: src/Lod.Generators/StairBayBar/StairBayLayout.cs (S8.1 file, last changed in S8.3; summary only)
- Create: src/Lod.Generators/CourtCluster/CourtClusterGenerator.cs
- Test: create tests/Lod.Generators.Tests/CourtCluster/CourtClusterGeneratorTests.cs, tests/Lod.Integration.Tests/CourtClusterTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt
Interfaces:
- Consumes: as Task 1, with Polygon2.Create (an outer ring and holes), StairBayLayout.Wing, Fit, and FitErrors (S8.1–S8.3, internal), SpaceType.DwellingUnit and Stair, ExampleResidentialPresets.DwellingUnit and Stair, PolygonOps(ToleranceSettings.Default) in the tests, and SingleZoneBuilding in the integration tests; Perimeter Core on footprints with holes (S8.3, ADR-008).
- Produces: public sealed record CourtClusterParameters(int CourtCount, double CourtWidth, double CourtDepth, double Depth, double UnitWidth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (2, 24, 24, 12, 7, 3, 3, 0); public sealed record CourtClusterPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets (slots DwellingUnit, Stair); public sealed class CourtClusterGenerator(ToleranceSettings), Name = CourtClusterGenerator, public const int MinimumCourts = 2: with depth D, court width w, court depth c, and k courts, the plan is (k + 1)·D + k·w wide and 2D + c deep; the south and north wings span the full width, the west wing, the middle wings i at x = i·(D + w) (Middle i, IDs M{i}-…), and the east wing at x = k·(D + w) lie between them over [D, D + c]; court j is [(j − 1)(D + w) + D, j(D + w)] × [D, D + c], a clockwise hole, west to east; every wing is filled with stair bays by StairBayLayout as in EnclosedCourtGenerator, which is not changed (D-104); errors InvalidParameter for fewer than 2 courts (CourtCount must be at least 2 (one court is SYN-TYP-005, Enclosed Court), got 1.), for each length that is not positive and finite, and FitErrors for each {W} m south and north wing (only when the count is valid) and each {c} m west, middle, and east wing; no upper bound; provenance keys CourtCount, CourtWidth, CourtDepth, Depth, UnitWidth, StairWidth. By default 84 m × 48 m less two courts (2880 m²), 39 zones: the long wings five bays of 6.9 m dwellings, the side wings one bay of 10.5 m dwellings; 72 windows with 374.4 m² of glazing, 32 of them on the court façades (158.4 m², 16 per court); the shortest outdoor walls are 2.4 m court-edge fragments, all with windows; no diagnostic.
- [x] Step 1: Name the court cluster in the stair bay's summary — replace
src/Lod.Generators/StairBayBar/StairBayLayout.cs(full file)
The summary lists the court cluster (SYN-TYP-011) among the users of the stair bay; no code changes.
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), the open court (HDHM-TYP-004), the enclosed court (HDHM-TYP-005), and the court cluster (SYN-TYP-011), 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 2: Write the failing tests — create
tests/Lod.Generators.Tests/CourtCluster/CourtClusterGeneratorTests.cs(full file)
31 tests: the ADR-006 defaults; the canonical zones, names, areas, and rectangles; the footprint with two holes (exact rings) and no overlap with either court; tiling and the hole count for 2, 3, 4, and 6 courts and odd dimensions; every middle wing with only court façades, 24 m into each of its two courts (2 to 4 courts); every court with 24 m of façade per side from the right wings, each wall with a window (2 and 3 courts); a stair in every wing and every dwelling adjoining one; the windows (72, 32 on the courts); a court count of 1; invalid lengths; the stair-fit errors (exact messages); every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
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;
using Lod.Generators.CourtCluster;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.CourtCluster;
public sealed class CourtClusterGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly CourtClusterPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);
private static readonly string[] CanonicalZones =
{
"S-ST1", "S-ST2", "S-ST3", "S-ST4", "S-ST5", "S-U1", "S-U2", "S-U3", "S-U4", "S-U5", "S-U6", "S-U7", "S-U8", "S-U9", "S-U10",
"N-ST1", "N-ST2", "N-ST3", "N-ST4", "N-ST5", "N-U1", "N-U2", "N-U3", "N-U4", "N-U5", "N-U6", "N-U7", "N-U8", "N-U9", "N-U10",
"W-ST1", "W-U1", "W-U2", "M1-ST1", "M1-U1", "M1-U2", "E-ST1", "E-U1", "E-U2",
};
private readonly CourtClusterGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new CourtClusterParameters(CourtCount: 2, CourtWidth: 24.0, CourtDepth: 24.0, Depth: 12.0, UnitWidth: 7.0, StairWidth: 3.0, FloorHeight: 3.0, OrientationDegrees: 0.0),
CourtClusterParameters.Default);
}
[Fact]
public void CanonicalPlanIsFiveStairBayWingsAroundTwoCourts()
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default);
Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal((84.0 * 48.0) - (2 * 24.0 * 24.0), plan.Footprint.Area, 9);
Assert.Equal(
new[] { "South Stair 2", "North Unit 3", "West Unit 2", "Middle 1 Stair 1", "Middle 1 Unit 2", "East Stair 1" },
plan.Zones.Where(z => z.Id.Value is "S-ST2" or "N-U3" or "W-U2" or "M1-ST1" or "M1-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("S-U", StringComparison.Ordinal) || z.Id.Value.StartsWith("N-U", StringComparison.Ordinal)), z => Assert.Equal(6.9 * 12.0, z.FloorArea, 9));
Assert.All(plan.Zones.Where(z => z.Id.Value is "W-U1" or "W-U2" or "M1-U1" or "M1-U2" or "E-U1" or "E-U2"), z => Assert.Equal(10.5 * 12.0, z.FloorArea, 9));
AssertRectangle(23.7, 0.0, 26.7, 12.0, Outer(plan, "S-ST2"));
AssertRectangle(16.8, 36.0, 23.7, 48.0, Outer(plan, "N-U3"));
AssertRectangle(0.0, 22.5, 12.0, 25.5, Outer(plan, "W-ST1"));
AssertRectangle(36.0, 12.0, 48.0, 22.5, Outer(plan, "M1-U1"));
AssertRectangle(36.0, 22.5, 48.0, 25.5, Outer(plan, "M1-ST1"));
AssertRectangle(72.0, 25.5, 84.0, 36.0, Outer(plan, "E-U2"));
Assert.All(plan.Zones, z => Assert.Equal(3.0, z.Parts[0].Height));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Fact]
public void TheFootprintIsARectangleWithOneHolePerCourt()
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default);
Assert.Equal(new[] { new Point2(0, 0), new Point2(84, 0), new Point2(84, 48), new Point2(0, 48) }, plan.Footprint.Outer);
Assert.Equal(2, plan.Footprint.Holes.Count);
Assert.Equal(new[] { new Point2(12, 12), new Point2(12, 36), new Point2(36, 36), new Point2(36, 12) }, plan.Footprint.Holes[0]);
Assert.Equal(new[] { new Point2(48, 12), new Point2(48, 36), new Point2(72, 36), new Point2(72, 12) }, plan.Footprint.Holes[1]);
var ops = new PolygonOps(ToleranceSettings.Default);
Assert.Equal(0.0, ops.IntersectionArea(plan.Footprint, Polygon2.Rectangle(12, 12, 36, 36)), 9);
Assert.Equal(0.0, ops.IntersectionArea(plan.Footprint, Polygon2.Rectangle(48, 12, 72, 36)), 9);
}
[Theory]
[InlineData(2, 24.0, 24.0, 12.0)]
[InlineData(3, 24.0, 24.0, 12.0)]
[InlineData(4, 24.0, 24.0, 12.0)]
[InlineData(6, 24.0, 24.0, 12.0)]
[InlineData(2, 6.0, 40.0, 12.0)]
[InlineData(3, 50.0, 4.0, 10.0)]
[InlineData(4, 17.5, 30.5, 9.0)]
public void ZonesTileTheFootprint(int courts, double courtWidth, double courtDepth, double depth)
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default with { CourtCount = courts, CourtWidth = courtWidth, CourtDepth = courtDepth, Depth = depth });
PlanAssertions.TilesTheFootprint(plan);
Assert.Equal(courts, plan.Footprint.Holes.Count);
double width = ((courts + 1) * depth) + (courts * courtWidth);
Assert.Equal((width * ((2 * depth) + courtDepth)) - (courts * courtWidth * courtDepth), plan.Footprint.Area, 9);
string[] wings = new[] { "S", "N", "W" }.Concat(Enumerable.Range(1, courts - 1).Select(i => $"M{i}")).Append("E").ToArray();
Assert.Equal(wings, plan.Zones.Select(z => z.Id.Value.Split('-')[0]).Distinct());
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void EveryMiddleWingBordersTwoCourts(int courts)
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default with { CourtCount = courts });
for (int i = 1; i < courts; i++)
{
string prefix = $"M{i}-";
Zone[] wing = plan.Zones.Where(z => z.Id.Value.StartsWith(prefix, StringComparison.Ordinal)).ToArray();
Assert.Equal(new[] { $"{prefix}ST1", $"{prefix}U1", $"{prefix}U2" }, wing.Select(z => z.Id.Value));
Assert.All(wing, z => Assert.Equal(new[] { OrientationBin.East, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, z.Id.Value)));
WallSurface[] facades = plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Zone.Value.StartsWith(prefix, StringComparison.Ordinal)).ToArray();
Assert.Equal(24.0, facades.Where(w => OnCourt(w, i)).Sum(w => w.Length), 9);
Assert.Equal(24.0, facades.Where(w => OnCourt(w, i + 1)).Sum(w => w.Length), 9);
Assert.Equal(facades.Length, facades.Count(w => OnCourt(w, i) || OnCourt(w, i + 1)));
}
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void EveryCourtIsClosedByDwellingsWithWindowsFacingIntoIt(int courts)
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default with { CourtCount = courts });
WallSurface[] outdoor = plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
for (int court = 1; court <= courts; court++)
{
WallSurface[] facades = outdoor.Where(w => OnCourt(w, court)).ToArray();
Assert.Equal(24.0, Facing(facades, OrientationBin.North).Sum(w => w.Length), 9);
Assert.Equal(24.0, Facing(facades, OrientationBin.South).Sum(w => w.Length), 9);
Assert.Equal(24.0, Facing(facades, OrientationBin.East).Sum(w => w.Length), 9);
Assert.Equal(24.0, Facing(facades, OrientationBin.West).Sum(w => w.Length), 9);
Assert.All(Facing(facades, OrientationBin.North), w => Assert.StartsWith("S-", w.Zone.Value, StringComparison.Ordinal));
Assert.All(Facing(facades, OrientationBin.South), w => Assert.StartsWith("N-", w.Zone.Value, StringComparison.Ordinal));
Assert.All(Facing(facades, OrientationBin.East), w => Assert.StartsWith(court == 1 ? "W-" : $"M{court - 1}-", w.Zone.Value, StringComparison.Ordinal));
Assert.All(Facing(facades, OrientationBin.West), w => Assert.StartsWith(court == courts ? "E-" : $"M{court}-", w.Zone.Value, StringComparison.Ordinal));
Assert.All(facades, w => Assert.Single(w.Windows));
}
}
[Fact]
public void EveryWingHasItsOwnStairsAndEveryDwellingAdjoinsAStair()
{
IGeneratedPlan plan = Generate(CourtClusterParameters.Default with { CourtCount = 3 });
Assert.All(
plan.Zones.GroupBy(z => z.Id.Value.Split('-')[0]),
wing => Assert.Contains(wing, z => z.SpaceType == SpaceType.Stair));
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));
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(CourtClusterParameters.Default, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
WallSurface[] walls = result.Value.Surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(72, walls.Sum(w => w.Windows.Count));
Assert.Equal(32, walls.Where(w => OnCourt(w, 1) || OnCourt(w, 2)).Sum(w => w.Windows.Count));
}
[Fact]
public void ACourtCountBelowTwoIsAnError()
{
Result<IGeneratedPlan> result = _generator.Generate(CourtClusterParameters.Default with { CourtCount = 1 }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal("CourtCount must be at least 2 (one court is SYN-TYP-005, Enclosed Court), got 1.", error.Message);
}
[Theory]
[InlineData(0.0, 24.0, 12.0, 7.0, 3.0, "CourtWidth")]
[InlineData(24.0, -24.0, 12.0, 7.0, 3.0, "CourtDepth")]
[InlineData(24.0, 24.0, 0.0, 7.0, 3.0, "Depth")]
[InlineData(24.0, 24.0, 12.0, double.NaN, 3.0, "UnitWidth")]
[InlineData(24.0, 24.0, 12.0, 7.0, -3.0, "StairWidth")]
[InlineData(double.PositiveInfinity, 24.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 CourtClusterParameters(2, 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()
{
// 24 m west, middle, and east wings with 30 m stairs: one bay needs more than the wing's length; the 84 m south and north wings
// (round(84 / 31) = 3 bays, 90 m of stairs) do not fit either.
Result<IGeneratedPlan> result = _generator.Generate(CourtClusterParameters.Default with { UnitWidth = 0.5, StairWidth = 30.0 }, Examples);
Assert.Equal(
new[]
{
"StairWidth 30 m leaves no dwelling width in each 84 m south and north wing: its 3 stair bays need 90 m of stairs; reduce StairWidth or UnitWidth.",
"StairWidth 30 m leaves no dwelling width in each 24 m west, middle, and east wing: its one stair bay needs 30 m of stairs; reduce StairWidth or UnitWidth.",
},
result.Diagnostics.Select(d => d.Message));
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.InvalidParameter, d.Code));
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new CourtClusterParameters(0, 0.0, 0.0, -12.0, 0.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(8, 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(CourtClusterParameters.Default, new CourtClusterPresets(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(CourtClusterParameters.Default)), TextReport.Describe(Generate(CourtClusterParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(CourtClusterParameters.Default).Provenance;
Assert.Equal(nameof(CourtClusterGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"CourtCount=2", "CourtDepth=24", "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(CourtClusterParameters.Default)), "court-cluster-canonical");
}
/// <summary>Whether a wall lies on the edge of court <paramref name="court"/> (from 1, west to east) of a plan with the default dimensions.</summary>
private static bool OnCourt(WallSurface wall, int court)
{
double west = (court * 12.0) + ((court - 1) * 24.0);
return new[] { wall.Start, wall.End }.All(p => p.X >= west - 1e-9 && p.X <= west + 24.0 + 1e-9 && p.Y is >= 12.0 and <= 36.0);
}
private static WallSurface[] Facing(IEnumerable<WallSurface> walls, OrientationBin bin) => walls.Where(w => Orientation.Bin(w.PlanAzimuth) == bin).ToArray();
private static void AssertRectangle(double minX, double minY, double maxX, double maxY, Point2[] actual)
{
Point2[] expected = Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
Assert.Equal(expected.Length, actual.Length);
for (int i = 0; i < expected.Length; i++)
{
Assert.Equal(expected[i].X, actual[i].X, 9);
Assert.Equal(expected[i].Y, actual[i].Y, 9);
}
}
private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();
private IGeneratedPlan Generate(CourtClusterParameters 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.CourtCluster.CourtClusterGeneratorTests"
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\CourtCluster\CourtClusterGeneratorTests.cs(8,22): error CS0234: The type or namespace name 'CourtCluster' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\CourtCluster\CourtClusterGeneratorTests.cs(278,37): error CS0246: The type or namespace name 'CourtClusterParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\CourtCluster\CourtClusterGeneratorTests.cs(17,28): error CS0246: The type or namespace name 'CourtClusterPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\CourtCluster\CourtClusterGeneratorTests.cs(26,22): error CS0246: The type or namespace name 'CourtClusterGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 4: Implement — create
src/Lod.Generators/CourtCluster/CourtClusterGenerator.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.CourtCluster;
/// <summary>Parameters of the linked housing clusters around several courts (SYN-TYP-011, ADR-006).</summary>
/// <param name="CourtCount">Number of courts in a row along plan x; at least 2.</param>
/// <param name="CourtWidth">Width of every court along plan x, between the wings on its west and east sides, m.</param>
/// <param name="CourtDepth">Depth of every court between the south and north wings, which is also the west, middle, and east 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 CourtClusterParameters(
int CourtCount,
double CourtWidth,
double CourtDepth,
double Depth,
double UnitWidth,
double StairWidth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: 2 courts of 24 m × 24 m, 12 m deep wings, 7 m dwellings and 3 m stairs, 3 m storeys, orientation 0°.</summary>
public static CourtClusterParameters Default { get; } = new(2, 24.0, 24.0, 12.0, 7.0, 3.0, 3.0, 0.0);
}
/// <summary>The program presets of the court cluster, 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 CourtClusterPresets(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>
/// Linked housing clusters around several courts (SYN-TYP-011, ADR-006): a row of courts along plan x, closed by wings of stair bays, so the dwelling
/// wings are linked into one cluster around the courts (R001); every middle wing lies between two neighbouring courts, so the courts lie between
/// dwelling groups (R002); and every wing has its own stairs, so access is distributed (R003). The footprint is the outer rectangle with one hole per
/// court; each court is outside the building and its façades are outdoor walls facing into it. Each wing holds <c>n = max(1, round(L / (2U + S)))</c>
/// bays of <see cref="StairBayLayout"/> with the dwelling width adjusted to fill its length <c>L</c>, as for the enclosed court (SYN-TYP-005). One
/// zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// With <c>D</c> the depth, <c>w</c> the court width, <c>c</c> the court depth, and <c>k</c> courts, the plan is <c>(k + 1)·D + k·w</c> wide: south
/// wing <c>[0, W] × [0, D]</c> and north wing <c>[0, W] × [D + c, 2D + c]</c> across the full width <c>W</c>; between them, along plan y over
/// <c>[D, D + c]</c>, the west wing at <c>x ∈ [0, D]</c>, middle wing <c>i</c> (from 1) at <c>x ∈ [i·(D + w), i·(D + w) + D]</c>, and the east
/// wing at <c>x ∈ [k·(D + w), k·(D + w) + D]</c>; court <c>j</c> (from 1) is <c>[(j − 1)·(D + w) + D, j·(D + w)] × [D, D + c]</c>, stored
/// clockwise, west to east. Zone IDs: <c>S-ST1…</c>, <c>S-U1…</c> and <c>N-…</c> (west to east), then <c>W-…</c>, <c>M1-…</c>, …, <c>E-…</c>
/// (south to north), each wing stairs first, then dwellings; display names <c>South Unit 3</c>, <c>Middle 1 Stair 1</c>. The generator does not
/// impose the precondition of <i>Perimeter Core</i> (D-087): its default 12 m wings are wider than twice the 4.57 m default perimeter depth.
/// </remarks>
public sealed class CourtClusterGenerator : PlanGenerator<CourtClusterParameters, CourtClusterPresets>
{
/// <summary>The fewest courts: one court is the enclosed court (SYN-TYP-005).</summary>
public const int MinimumCourts = 2;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public CourtClusterGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(CourtClusterGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(CourtClusterParameters parameters)
{
var errors = new List<Diagnostic>();
bool counted = parameters.CourtCount >= MinimumCourts;
if (!counted)
{
errors.Add(Diagnostic.Error(
DiagnosticCodes.InvalidParameter,
$"{nameof(CourtClusterParameters.CourtCount)} must be at least {MinimumCourts} (one court is SYN-TYP-005, Enclosed Court), got {parameters.CourtCount}."));
}
var values = new (string Name, double Value)[]
{
(nameof(CourtClusterParameters.CourtWidth), parameters.CourtWidth),
(nameof(CourtClusterParameters.CourtDepth), parameters.CourtDepth),
(nameof(CourtClusterParameters.Depth), parameters.Depth),
(nameof(CourtClusterParameters.UnitWidth), parameters.UnitWidth),
(nameof(CourtClusterParameters.StairWidth), parameters.StairWidth),
};
var lengths = values
.Where(v => !IsPositive(v.Value))
.Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
.ToList();
errors.AddRange(lengths);
if (lengths.Count == 0)
{
// The south and north wings' length depends on the count, so their stair bays are checked only for a valid count.
if (counted)
{
double width = Width(parameters);
errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(width)} m south and north wing", width, parameters.UnitWidth, parameters.StairWidth));
}
errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(parameters.CourtDepth)} m west, middle, and east wing", parameters.CourtDepth, parameters.UnitWidth, parameters.StairWidth));
}
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(CourtClusterParameters p)
{
double d = p.Depth;
double pitch = d + p.CourtWidth;
double width = Width(p);
double north = d + p.CourtDepth;
(int longBays, double longUnit) = 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("S-", "South ", 0.0, 0.0, width, d, alongY: false, longBays, longUnit, p.StairWidth));
zones.AddRange(StairBayLayout.Wing("N-", "North ", 0.0, north, width, d, alongY: false, longBays, longUnit, p.StairWidth));
zones.AddRange(StairBayLayout.Wing("W-", "West ", 0.0, d, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
for (int i = 1; i < p.CourtCount; i++)
{
zones.AddRange(StairBayLayout.Wing($"M{i}-", $"Middle {i} ", i * pitch, d, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
}
zones.AddRange(StairBayLayout.Wing("E-", "East ", p.CourtCount * pitch, d, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
Point2[] outer = { new(0.0, 0.0), new(width, 0.0), new(width, north + d), new(0.0, north + d) };
IEnumerable<Point2[]> courts = Enumerable.Range(0, p.CourtCount).Select(j =>
{
double west = (j * pitch) + d;
double east = (j + 1) * pitch;
return new[] { new Point2(west, d), new Point2(west, north), new Point2(east, north), new Point2(east, d) };
});
return new PlanLayout(Polygon2.Create(outer, courts.ToArray()).Value, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(CourtClusterParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.CourtCount), p.CourtCount.ToString(CultureInfo.InvariantCulture)),
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)),
};
/// <summary>The plan's width along plan x: a wing on both sides of every court, <c>(k + 1)·D + k·w</c>.</summary>
private static double Width(CourtClusterParameters p) => ((p.CourtCount + 1) * p.Depth) + (p.CourtCount * p.CourtWidth);
}
- [x] Step 5: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.CourtCluster.CourtClusterGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 30, Skipped: 0, Total: 31, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.CourtCluster.CourtClusterGeneratorTests.CanonicalPlanSnapshot
Snapshot 'court-cluster-canonical' is missing or differs. Review Snapshots/court-cluster-canonical.received.txt and rename it to court-cluster-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.received.txt (git-ignored).
- [x] Step 6: Review and accept the received file
Review the received file against the content below; it must be identical: 463 lines, 39 zones, 254 surfaces (72 outdoor walls, each with one window; 104 interzone walls; 78 floors and ceilings), glazing 106.2 m² south, 106.2 m² north, 81 m² east, 81 m² west (374.4 m² in all); the middle wing's walls face only into the two courts. Accept it:
Move-Item -Force tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.received.txt tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt
tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt:
footprint area=2880.000000 floorHeight=3.000000 orientation=0.000000
zone S-ST1 Stair "South 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 S-ST2 Stair "South 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 S-ST3 Stair "South 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 S-ST4 Stair "South 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 S-ST5 Stair "South Stair 5" 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 S-U1 DwellingUnit "South Unit 1" area=82.800000 volume=248.400000 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 S-U2 DwellingUnit "South Unit 2" area=82.800000 volume=248.400000 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 S-U3 DwellingUnit "South Unit 3" area=82.800000 volume=248.400000 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 S-U4 DwellingUnit "South Unit 4" area=82.800000 volume=248.400000 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 S-U5 DwellingUnit "South Unit 5" area=82.800000 volume=248.400000 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 S-U6 DwellingUnit "South Unit 6" area=82.800000 volume=248.400000 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 S-U7 DwellingUnit "South Unit 7" area=82.800000 volume=248.400000 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 S-U8 DwellingUnit "South Unit 8" area=82.800000 volume=248.400000 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 S-U9 DwellingUnit "South Unit 9" area=82.800000 volume=248.400000 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 S-U10 DwellingUnit "South Unit 10" area=82.800000 volume=248.400000 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-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-ST4 Stair "North 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 N-ST5 Stair "North Stair 5" 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=82.800000 volume=248.400000 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=82.800000 volume=248.400000 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=82.800000 volume=248.400000 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=82.800000 volume=248.400000 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=82.800000 volume=248.400000 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=82.800000 volume=248.400000 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-U7 DwellingUnit "North Unit 7" area=82.800000 volume=248.400000 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-U8 DwellingUnit "North Unit 8" area=82.800000 volume=248.400000 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-U9 DwellingUnit "North Unit 9" area=82.800000 volume=248.400000 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-U10 DwellingUnit "North Unit 10" area=82.800000 volume=248.400000 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=126.000000 volume=378.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=126.000000 volume=378.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 M1-ST1 Stair "Middle 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 M1-U1 DwellingUnit "Middle 1 Unit 1" area=126.000000 volume=378.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 M1-U2 DwellingUnit "Middle 1 Unit 2" area=126.000000 volume=378.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=126.000000 volume=378.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=126.000000 volume=378.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 S-ST1/W1 Outdoors area=9.000000 wall (6.900000,0.000000)-(9.900000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST1/W2 Interzone adjacent=S-U2 area=36.000000 wall (9.900000,0.000000)-(9.900000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST1/W3 Interzone adjacent=W-U1 area=9.000000 wall (9.900000,12.000000)-(6.900000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-ST1/W4 Interzone adjacent=S-U1 area=36.000000 wall (6.900000,12.000000)-(6.900000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST2/W1 Outdoors area=9.000000 wall (23.700000,0.000000)-(26.700000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST2/W2 Interzone adjacent=S-U4 area=36.000000 wall (26.700000,0.000000)-(26.700000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST2/W3 Outdoors area=9.000000 wall (26.700000,12.000000)-(23.700000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST2/W4 Interzone adjacent=S-U3 area=36.000000 wall (23.700000,12.000000)-(23.700000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST2/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST3/W1 Outdoors area=9.000000 wall (40.500000,0.000000)-(43.500000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST3/W2 Interzone adjacent=S-U6 area=36.000000 wall (43.500000,0.000000)-(43.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST3/W3 Interzone adjacent=M1-U1 area=9.000000 wall (43.500000,12.000000)-(40.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-ST3/W4 Interzone adjacent=S-U5 area=36.000000 wall (40.500000,12.000000)-(40.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST3/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST4/W1 Outdoors area=9.000000 wall (57.300000,0.000000)-(60.300000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST4/W2 Interzone adjacent=S-U8 area=36.000000 wall (60.300000,0.000000)-(60.300000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST4/W3 Outdoors area=9.000000 wall (60.300000,12.000000)-(57.300000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST4/W4 Interzone adjacent=S-U7 area=36.000000 wall (57.300000,12.000000)-(57.300000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST4/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST4/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST5/W1 Outdoors area=9.000000 wall (74.100000,0.000000)-(77.100000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST5/W2 Interzone adjacent=S-U10 area=36.000000 wall (77.100000,0.000000)-(77.100000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST5/W3 Interzone adjacent=E-U1 area=9.000000 wall (77.100000,12.000000)-(74.100000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-ST5/W4 Interzone adjacent=S-U9 area=36.000000 wall (74.100000,12.000000)-(74.100000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST5/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST5/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-U1/W1 Outdoors area=20.700000 wall (0.000000,0.000000)-(6.900000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U1/W2 Interzone adjacent=S-ST1 area=36.000000 wall (6.900000,0.000000)-(6.900000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U1/W3 Interzone adjacent=W-U1 area=20.700000 wall (6.900000,12.000000)-(0.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-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 S-U1/F Unresolved area=82.800000 Floor z=0.000000
surface S-U1/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U2/W1 Outdoors area=20.700000 wall (9.900000,0.000000)-(16.800000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U2/W2 Interzone adjacent=S-U3 area=36.000000 wall (16.800000,0.000000)-(16.800000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U2/W3 Outdoors area=14.400000 wall (16.800000,12.000000)-(12.000000,12.000000) z=0.000000 facing=North glazing=4.320000 window(offset=1.085466 sill=0.678416 2.629068x1.643168)
surface S-U2/W4 Interzone adjacent=W-U1 area=6.300000 wall (12.000000,12.000000)-(9.900000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U2/W5 Interzone adjacent=S-ST1 area=36.000000 wall (9.900000,12.000000)-(9.900000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U2/F Unresolved area=82.800000 Floor z=0.000000
surface S-U2/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U3/W1 Outdoors area=20.700000 wall (16.800000,0.000000)-(23.700000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U3/W2 Interzone adjacent=S-ST2 area=36.000000 wall (23.700000,0.000000)-(23.700000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U3/W3 Outdoors area=20.700000 wall (23.700000,12.000000)-(16.800000,12.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U3/W4 Interzone adjacent=S-U2 area=36.000000 wall (16.800000,12.000000)-(16.800000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U3/F Unresolved area=82.800000 Floor z=0.000000
surface S-U3/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U4/W1 Outdoors area=20.700000 wall (26.700000,0.000000)-(33.600000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U4/W2 Interzone adjacent=S-U5 area=36.000000 wall (33.600000,0.000000)-(33.600000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U4/W3 Outdoors area=20.700000 wall (33.600000,12.000000)-(26.700000,12.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U4/W4 Interzone adjacent=S-ST2 area=36.000000 wall (26.700000,12.000000)-(26.700000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U4/F Unresolved area=82.800000 Floor z=0.000000
surface S-U4/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U5/W1 Outdoors area=20.700000 wall (33.600000,0.000000)-(40.500000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U5/W2 Interzone adjacent=S-ST3 area=36.000000 wall (40.500000,0.000000)-(40.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U5/W3 Interzone adjacent=M1-U1 area=13.500000 wall (40.500000,12.000000)-(36.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U5/W4 Outdoors area=7.200000 wall (36.000000,12.000000)-(33.600000,12.000000) z=0.000000 facing=North glazing=2.160000 window(offset=0.542733 sill=0.678416 1.314534x1.643168)
surface S-U5/W5 Interzone adjacent=S-U4 area=36.000000 wall (33.600000,12.000000)-(33.600000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U5/F Unresolved area=82.800000 Floor z=0.000000
surface S-U5/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U6/W1 Outdoors area=20.700000 wall (43.500000,0.000000)-(50.400000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U6/W2 Interzone adjacent=S-U7 area=36.000000 wall (50.400000,0.000000)-(50.400000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U6/W3 Outdoors area=7.200000 wall (50.400000,12.000000)-(48.000000,12.000000) z=0.000000 facing=North glazing=2.160000 window(offset=0.542733 sill=0.678416 1.314534x1.643168)
surface S-U6/W4 Interzone adjacent=M1-U1 area=13.500000 wall (48.000000,12.000000)-(43.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U6/W5 Interzone adjacent=S-ST3 area=36.000000 wall (43.500000,12.000000)-(43.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U6/F Unresolved area=82.800000 Floor z=0.000000
surface S-U6/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U7/W1 Outdoors area=20.700000 wall (50.400000,0.000000)-(57.300000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U7/W2 Interzone adjacent=S-ST4 area=36.000000 wall (57.300000,0.000000)-(57.300000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U7/W3 Outdoors area=20.700000 wall (57.300000,12.000000)-(50.400000,12.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U7/W4 Interzone adjacent=S-U6 area=36.000000 wall (50.400000,12.000000)-(50.400000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U7/F Unresolved area=82.800000 Floor z=0.000000
surface S-U7/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U8/W1 Outdoors area=20.700000 wall (60.300000,0.000000)-(67.200000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U8/W2 Interzone adjacent=S-U9 area=36.000000 wall (67.200000,0.000000)-(67.200000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U8/W3 Outdoors area=20.700000 wall (67.200000,12.000000)-(60.300000,12.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U8/W4 Interzone adjacent=S-ST4 area=36.000000 wall (60.300000,12.000000)-(60.300000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U8/F Unresolved area=82.800000 Floor z=0.000000
surface S-U8/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U9/W1 Outdoors area=20.700000 wall (67.200000,0.000000)-(74.100000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U9/W2 Interzone adjacent=S-ST5 area=36.000000 wall (74.100000,0.000000)-(74.100000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U9/W3 Interzone adjacent=E-U1 area=6.300000 wall (74.100000,12.000000)-(72.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U9/W4 Outdoors area=14.400000 wall (72.000000,12.000000)-(67.200000,12.000000) z=0.000000 facing=North glazing=4.320000 window(offset=1.085466 sill=0.678416 2.629068x1.643168)
surface S-U9/W5 Interzone adjacent=S-U8 area=36.000000 wall (67.200000,12.000000)-(67.200000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U9/F Unresolved area=82.800000 Floor z=0.000000
surface S-U9/C Unresolved area=82.800000 Ceiling z=3.000000
surface S-U10/W1 Outdoors area=20.700000 wall (77.100000,0.000000)-(84.000000,0.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface S-U10/W2 Outdoors area=36.000000 wall (84.000000,0.000000)-(84.000000,12.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface S-U10/W3 Interzone adjacent=E-U1 area=20.700000 wall (84.000000,12.000000)-(77.100000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U10/W4 Interzone adjacent=S-ST5 area=36.000000 wall (77.100000,12.000000)-(77.100000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U10/F Unresolved area=82.800000 Floor z=0.000000
surface S-U10/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-ST1/W1 Interzone adjacent=W-U2 area=9.000000 wall (6.900000,36.000000)-(9.900000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST1/W2 Interzone adjacent=N-U2 area=36.000000 wall (9.900000,36.000000)-(9.900000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST1/W3 Outdoors area=9.000000 wall (9.900000,48.000000)-(6.900000,48.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.900000,48.000000)-(6.900000,36.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 (23.700000,36.000000)-(26.700000,36.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 (26.700000,36.000000)-(26.700000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST2/W3 Outdoors area=9.000000 wall (26.700000,48.000000)-(23.700000,48.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 (23.700000,48.000000)-(23.700000,36.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=M1-U2 area=9.000000 wall (40.500000,36.000000)-(43.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST3/W2 Interzone adjacent=N-U6 area=36.000000 wall (43.500000,36.000000)-(43.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST3/W3 Outdoors area=9.000000 wall (43.500000,48.000000)-(40.500000,48.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 (40.500000,48.000000)-(40.500000,36.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-ST4/W1 Outdoors area=9.000000 wall (57.300000,36.000000)-(60.300000,36.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST4/W2 Interzone adjacent=N-U8 area=36.000000 wall (60.300000,36.000000)-(60.300000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST4/W3 Outdoors area=9.000000 wall (60.300000,48.000000)-(57.300000,48.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST4/W4 Interzone adjacent=N-U7 area=36.000000 wall (57.300000,48.000000)-(57.300000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST4/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST4/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-ST5/W1 Interzone adjacent=E-U2 area=9.000000 wall (74.100000,36.000000)-(77.100000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST5/W2 Interzone adjacent=N-U10 area=36.000000 wall (77.100000,36.000000)-(77.100000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST5/W3 Outdoors area=9.000000 wall (77.100000,48.000000)-(74.100000,48.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST5/W4 Interzone adjacent=N-U9 area=36.000000 wall (74.100000,48.000000)-(74.100000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST5/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST5/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-U1/W1 Interzone adjacent=W-U2 area=20.700000 wall (0.000000,36.000000)-(6.900000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U1/W2 Interzone adjacent=N-ST1 area=36.000000 wall (6.900000,36.000000)-(6.900000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U1/W3 Outdoors area=20.700000 wall (6.900000,48.000000)-(0.000000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U1/W4 Outdoors area=36.000000 wall (0.000000,48.000000)-(0.000000,36.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=82.800000 Floor z=0.000000
surface N-U1/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U2/W1 Interzone adjacent=W-U2 area=6.300000 wall (9.900000,36.000000)-(12.000000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U2/W2 Outdoors area=14.400000 wall (12.000000,36.000000)-(16.800000,36.000000) z=0.000000 facing=South glazing=4.320000 window(offset=1.085466 sill=0.678416 2.629068x1.643168)
surface N-U2/W3 Interzone adjacent=N-U3 area=36.000000 wall (16.800000,36.000000)-(16.800000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U2/W4 Outdoors area=20.700000 wall (16.800000,48.000000)-(9.900000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U2/W5 Interzone adjacent=N-ST1 area=36.000000 wall (9.900000,48.000000)-(9.900000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U2/F Unresolved area=82.800000 Floor z=0.000000
surface N-U2/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U3/W1 Outdoors area=20.700000 wall (16.800000,36.000000)-(23.700000,36.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U3/W2 Interzone adjacent=N-ST2 area=36.000000 wall (23.700000,36.000000)-(23.700000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U3/W3 Outdoors area=20.700000 wall (23.700000,48.000000)-(16.800000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U3/W4 Interzone adjacent=N-U2 area=36.000000 wall (16.800000,48.000000)-(16.800000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U3/F Unresolved area=82.800000 Floor z=0.000000
surface N-U3/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U4/W1 Outdoors area=20.700000 wall (26.700000,36.000000)-(33.600000,36.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U4/W2 Interzone adjacent=N-U5 area=36.000000 wall (33.600000,36.000000)-(33.600000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U4/W3 Outdoors area=20.700000 wall (33.600000,48.000000)-(26.700000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U4/W4 Interzone adjacent=N-ST2 area=36.000000 wall (26.700000,48.000000)-(26.700000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U4/F Unresolved area=82.800000 Floor z=0.000000
surface N-U4/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U5/W1 Outdoors area=7.200000 wall (33.600000,36.000000)-(36.000000,36.000000) z=0.000000 facing=South glazing=2.160000 window(offset=0.542733 sill=0.678416 1.314534x1.643168)
surface N-U5/W2 Interzone adjacent=M1-U2 area=13.500000 wall (36.000000,36.000000)-(40.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U5/W3 Interzone adjacent=N-ST3 area=36.000000 wall (40.500000,36.000000)-(40.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U5/W4 Outdoors area=20.700000 wall (40.500000,48.000000)-(33.600000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U5/W5 Interzone adjacent=N-U4 area=36.000000 wall (33.600000,48.000000)-(33.600000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U5/F Unresolved area=82.800000 Floor z=0.000000
surface N-U5/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U6/W1 Interzone adjacent=M1-U2 area=13.500000 wall (43.500000,36.000000)-(48.000000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U6/W2 Outdoors area=7.200000 wall (48.000000,36.000000)-(50.400000,36.000000) z=0.000000 facing=South glazing=2.160000 window(offset=0.542733 sill=0.678416 1.314534x1.643168)
surface N-U6/W3 Interzone adjacent=N-U7 area=36.000000 wall (50.400000,36.000000)-(50.400000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U6/W4 Outdoors area=20.700000 wall (50.400000,48.000000)-(43.500000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U6/W5 Interzone adjacent=N-ST3 area=36.000000 wall (43.500000,48.000000)-(43.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U6/F Unresolved area=82.800000 Floor z=0.000000
surface N-U6/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U7/W1 Outdoors area=20.700000 wall (50.400000,36.000000)-(57.300000,36.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U7/W2 Interzone adjacent=N-ST4 area=36.000000 wall (57.300000,36.000000)-(57.300000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U7/W3 Outdoors area=20.700000 wall (57.300000,48.000000)-(50.400000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U7/W4 Interzone adjacent=N-U6 area=36.000000 wall (50.400000,48.000000)-(50.400000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U7/F Unresolved area=82.800000 Floor z=0.000000
surface N-U7/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U8/W1 Outdoors area=20.700000 wall (60.300000,36.000000)-(67.200000,36.000000) z=0.000000 facing=South glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U8/W2 Interzone adjacent=N-U9 area=36.000000 wall (67.200000,36.000000)-(67.200000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U8/W3 Outdoors area=20.700000 wall (67.200000,48.000000)-(60.300000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U8/W4 Interzone adjacent=N-ST4 area=36.000000 wall (60.300000,48.000000)-(60.300000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U8/F Unresolved area=82.800000 Floor z=0.000000
surface N-U8/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U9/W1 Outdoors area=14.400000 wall (67.200000,36.000000)-(72.000000,36.000000) z=0.000000 facing=South glazing=4.320000 window(offset=1.085466 sill=0.678416 2.629068x1.643168)
surface N-U9/W2 Interzone adjacent=E-U2 area=6.300000 wall (72.000000,36.000000)-(74.100000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U9/W3 Interzone adjacent=N-ST5 area=36.000000 wall (74.100000,36.000000)-(74.100000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U9/W4 Outdoors area=20.700000 wall (74.100000,48.000000)-(67.200000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U9/W5 Interzone adjacent=N-U8 area=36.000000 wall (67.200000,48.000000)-(67.200000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U9/F Unresolved area=82.800000 Floor z=0.000000
surface N-U9/C Unresolved area=82.800000 Ceiling z=3.000000
surface N-U10/W1 Interzone adjacent=E-U2 area=20.700000 wall (77.100000,36.000000)-(84.000000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U10/W2 Outdoors area=36.000000 wall (84.000000,36.000000)-(84.000000,48.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface N-U10/W3 Outdoors area=20.700000 wall (84.000000,48.000000)-(77.100000,48.000000) z=0.000000 facing=North glazing=6.210000 window(offset=1.560357 sill=0.678416 3.779286x1.643168)
surface N-U10/W4 Interzone adjacent=N-ST5 area=36.000000 wall (77.100000,48.000000)-(77.100000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U10/F Unresolved area=82.800000 Floor z=0.000000
surface N-U10/C Unresolved area=82.800000 Ceiling z=3.000000
surface W-ST1/W1 Interzone adjacent=W-U1 area=36.000000 wall (0.000000,22.500000)-(12.000000,22.500000) z=0.000000 facing=South glazing=0.000000
surface W-ST1/W2 Outdoors area=9.000000 wall (12.000000,22.500000)-(12.000000,25.500000) 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,25.500000)-(0.000000,25.500000) z=0.000000 facing=North glazing=0.000000
surface W-ST1/W4 Outdoors area=9.000000 wall (0.000000,25.500000)-(0.000000,22.500000) 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 Interzone adjacent=S-U1 area=20.700000 wall (0.000000,12.000000)-(6.900000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W2 Interzone adjacent=S-ST1 area=9.000000 wall (6.900000,12.000000)-(9.900000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W3 Interzone adjacent=S-U2 area=6.300000 wall (9.900000,12.000000)-(12.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W4 Outdoors area=31.500000 wall (12.000000,12.000000)-(12.000000,22.500000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U1/W5 Interzone adjacent=W-ST1 area=36.000000 wall (12.000000,22.500000)-(0.000000,22.500000) z=0.000000 facing=North glazing=0.000000
surface W-U1/W6 Outdoors area=31.500000 wall (0.000000,22.500000)-(0.000000,12.000000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U1/F Unresolved area=126.000000 Floor z=0.000000
surface W-U1/C Unresolved area=126.000000 Ceiling z=3.000000
surface W-U2/W1 Interzone adjacent=W-ST1 area=36.000000 wall (0.000000,25.500000)-(12.000000,25.500000) z=0.000000 facing=South glazing=0.000000
surface W-U2/W2 Outdoors area=31.500000 wall (12.000000,25.500000)-(12.000000,36.000000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U2/W3 Interzone adjacent=N-U2 area=6.300000 wall (12.000000,36.000000)-(9.900000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W4 Interzone adjacent=N-ST1 area=9.000000 wall (9.900000,36.000000)-(6.900000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W5 Interzone adjacent=N-U1 area=20.700000 wall (6.900000,36.000000)-(0.000000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W6 Outdoors area=31.500000 wall (0.000000,36.000000)-(0.000000,25.500000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U2/F Unresolved area=126.000000 Floor z=0.000000
surface W-U2/C Unresolved area=126.000000 Ceiling z=3.000000
surface M1-ST1/W1 Interzone adjacent=M1-U1 area=36.000000 wall (36.000000,22.500000)-(48.000000,22.500000) z=0.000000 facing=South glazing=0.000000
surface M1-ST1/W2 Outdoors area=9.000000 wall (48.000000,22.500000)-(48.000000,25.500000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface M1-ST1/W3 Interzone adjacent=M1-U2 area=36.000000 wall (48.000000,25.500000)-(36.000000,25.500000) z=0.000000 facing=North glazing=0.000000
surface M1-ST1/W4 Outdoors area=9.000000 wall (36.000000,25.500000)-(36.000000,22.500000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface M1-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface M1-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface M1-U1/W1 Interzone adjacent=S-U5 area=13.500000 wall (36.000000,12.000000)-(40.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface M1-U1/W2 Interzone adjacent=S-ST3 area=9.000000 wall (40.500000,12.000000)-(43.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface M1-U1/W3 Interzone adjacent=S-U6 area=13.500000 wall (43.500000,12.000000)-(48.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface M1-U1/W4 Outdoors area=31.500000 wall (48.000000,12.000000)-(48.000000,22.500000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface M1-U1/W5 Interzone adjacent=M1-ST1 area=36.000000 wall (48.000000,22.500000)-(36.000000,22.500000) z=0.000000 facing=North glazing=0.000000
surface M1-U1/W6 Outdoors area=31.500000 wall (36.000000,22.500000)-(36.000000,12.000000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface M1-U1/F Unresolved area=126.000000 Floor z=0.000000
surface M1-U1/C Unresolved area=126.000000 Ceiling z=3.000000
surface M1-U2/W1 Interzone adjacent=M1-ST1 area=36.000000 wall (36.000000,25.500000)-(48.000000,25.500000) z=0.000000 facing=South glazing=0.000000
surface M1-U2/W2 Outdoors area=31.500000 wall (48.000000,25.500000)-(48.000000,36.000000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface M1-U2/W3 Interzone adjacent=N-U6 area=13.500000 wall (48.000000,36.000000)-(43.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface M1-U2/W4 Interzone adjacent=N-ST3 area=9.000000 wall (43.500000,36.000000)-(40.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface M1-U2/W5 Interzone adjacent=N-U5 area=13.500000 wall (40.500000,36.000000)-(36.000000,36.000000) z=0.000000 facing=North glazing=0.000000
surface M1-U2/W6 Outdoors area=31.500000 wall (36.000000,36.000000)-(36.000000,25.500000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface M1-U2/F Unresolved area=126.000000 Floor z=0.000000
surface M1-U2/C Unresolved area=126.000000 Ceiling z=3.000000
surface E-ST1/W1 Interzone adjacent=E-U1 area=36.000000 wall (72.000000,22.500000)-(84.000000,22.500000) z=0.000000 facing=South glazing=0.000000
surface E-ST1/W2 Outdoors area=9.000000 wall (84.000000,22.500000)-(84.000000,25.500000) 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 (84.000000,25.500000)-(72.000000,25.500000) z=0.000000 facing=North glazing=0.000000
surface E-ST1/W4 Outdoors area=9.000000 wall (72.000000,25.500000)-(72.000000,22.500000) 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 Interzone adjacent=S-U9 area=6.300000 wall (72.000000,12.000000)-(74.100000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W2 Interzone adjacent=S-ST5 area=9.000000 wall (74.100000,12.000000)-(77.100000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W3 Interzone adjacent=S-U10 area=20.700000 wall (77.100000,12.000000)-(84.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W4 Outdoors area=31.500000 wall (84.000000,12.000000)-(84.000000,22.500000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U1/W5 Interzone adjacent=E-ST1 area=36.000000 wall (84.000000,22.500000)-(72.000000,22.500000) z=0.000000 facing=North glazing=0.000000
surface E-U1/W6 Outdoors area=31.500000 wall (72.000000,22.500000)-(72.000000,12.000000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U1/F Unresolved area=126.000000 Floor z=0.000000
surface E-U1/C Unresolved area=126.000000 Ceiling z=3.000000
surface E-U2/W1 Interzone adjacent=E-ST1 area=36.000000 wall (72.000000,25.500000)-(84.000000,25.500000) z=0.000000 facing=South glazing=0.000000
surface E-U2/W2 Outdoors area=31.500000 wall (84.000000,25.500000)-(84.000000,36.000000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U2/W3 Interzone adjacent=N-U10 area=20.700000 wall (84.000000,36.000000)-(77.100000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W4 Interzone adjacent=N-ST5 area=9.000000 wall (77.100000,36.000000)-(74.100000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W5 Interzone adjacent=N-U9 area=6.300000 wall (74.100000,36.000000)-(72.000000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W6 Outdoors area=31.500000 wall (72.000000,36.000000)-(72.000000,25.500000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U2/F Unresolved area=126.000000 Floor z=0.000000
surface E-U2/C Unresolved area=126.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.CourtCluster.CourtClusterGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 31, Skipped: 0, Total: 31, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 8: Write the integration tests — create
tests/Lod.Integration.Tests/CourtClusterTypologyTests.cs(full file)
102 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32), EveryCombinationValidatesWithTheFewestAndTheMostCourts (32, 2 and 4 courts), EveryCombinationValidatesWithTheMostCourtsOnARotatedAndTranslatedPlan (16, 4 courts at 131.4°), SemanticMergeJoinsTheDwellingsBetweenTheStairsAroundTheCourts (6 dwelling groups of 374.4 m² and 457.2 m², 13 stairs, no holes), PerimeterCoreGivesEveryCourtItsOwnCourtZonePerOrientation (2, 3, and 4 courts: the zone IDs, court zones of 130.5649 m², as many core holes as courts, the core area, the court glazing conserved), WingsNarrowerThanTwiceThePerimeterDepthAreTooNarrow (Depth 9: PlanTooNarrow), and SingleZoneBuildingRebuildsEveryCourtsWallsOnEveryStorey (two holes per part, 40 court walls each with a window, 2880 m² of ground). No production code changes.
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.CourtCluster;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.6 court cluster (SYN-TYP-011, ADR-006): wings of stair bays around a row of courts, the first footprint with several holes, through every
/// simplifier and aggregator, canonical and placed, with the fewest and the most tested courts.
/// </summary>
public sealed class CourtClusterTypologyTests
{
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 } };
/// <summary>The fewest courts (ADR-006) and the most tested; the family has no upper bound.</summary>
private static readonly int[] Counts = { CourtClusterGenerator.MinimumCourts, 4 };
public static IEnumerable<object[]> Matrix() =>
from simplifier in Simplifiers
from aggregator in Aggregators
select new object[] { simplifier, aggregator };
public static IEnumerable<object[]> PlacedMatrix() =>
from combination in Matrix()
from placement in Enumerable.Range(0, Placements.Length)
select combination.Append(placement).ToArray();
public static IEnumerable<object[]> CountMatrix() =>
from combination in Matrix()
from count in Counts
select combination.Append(count).ToArray();
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidates(string simplifier, string aggregator)
{
AssertValidates(Plan(), simplifier, aggregator);
}
[Theory]
[MemberData(nameof(PlacedMatrix))]
public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(string simplifier, string aggregator, int placement)
{
double[] p = Placements[placement];
AssertValidates(PlanTransform.Apply(Plan(), p[0], p[1], p[2]).Value, simplifier, aggregator);
}
[Theory]
[MemberData(nameof(CountMatrix))]
public void EveryCombinationValidatesWithTheFewestAndTheMostCourts(string simplifier, string aggregator, int courts)
{
AssertValidates(Plan(CourtClusterParameters.Default with { CourtCount = courts }), simplifier, aggregator);
}
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidatesWithTheMostCourtsOnARotatedAndTranslatedPlan(string simplifier, string aggregator)
{
double[] p = Placements[1];
AssertValidates(PlanTransform.Apply(Plan(CourtClusterParameters.Default with { CourtCount = Counts[1] }), p[0], p[1], p[2]).Value, simplifier, aggregator);
}
[Fact]
public void SemanticMergeJoinsTheDwellingsBetweenTheStairsAroundTheCourts()
{
// The middle stairs of the long wings and the stairs of the west, middle, and east wings separate the dwellings into groups: an L around each
// outer corner and a T where a middle wing meets the south or north wing; stairs never adjoin each other and stay apart.
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan()).Value;
Zone[] dwellings = floor.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit).ToArray();
Assert.Equal(6, dwellings.Length);
Assert.Equal(13, floor.Zones.Count(z => z.SpaceType == SpaceType.Stair));
Assert.Equal(new[] { 374.4, 457.2, 374.4, 374.4, 457.2, 374.4 }, dwellings.Select(z => Math.Round(z.FloorArea, 6)));
Assert.Equal(
new[] { "M1-U1", "S-U4", "S-U5", "S-U6", "S-U7" },
dwellings[1].SourceZones.Select(s => s.Value).OrderBy(id => id, StringComparer.Ordinal));
Assert.All(floor.Zones, z => Assert.Empty(z.Parts[0].Footprint.Holes));
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void PerimeterCoreGivesEveryCourtItsOwnCourtZonePerOrientation(int courts)
{
// The trapezoids of every hole's edges are united per bin across the courts (ADR-008); the middle wings keep the courts' bands apart, so each
// bin gives one polygon per court, numbered P-Court-<Bin>-1… in union output order.
IGeneratedPlan plan = Plan(CourtClusterParameters.Default with { CourtCount = courts });
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(plan).Value;
IEnumerable<string> court = new[] { "North", "East", "South", "West" }.SelectMany(b => Enumerable.Range(1, courts).Select(k => $"P-Court-{b}-{k}"));
Assert.Equal(new[] { "P-North", "P-East", "P-South", "P-West" }.Concat(court).Append("CORE"), floor.Zones.Select(z => z.Id.Value));
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-Court", StringComparison.Ordinal)), z => Assert.Equal((24.0 + 4.57) * 4.57, z.FloorArea, 6));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Equal(courts, core.Holes.Count);
double width = ((courts + 1) * 12.0) + (courts * 24.0);
Assert.Equal(((width - (2 * 4.57)) * (48.0 - (2 * 4.57))) - (courts * Math.Pow(24.0 + (2 * 4.57), 2)), core.Area, 6);
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
double courtGlazing = plan.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnAnyCourt(w, courts)).Sum(w => w.GlazedArea);
Assert.Equal(courtGlazing, floor.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value.StartsWith("P-Court", StringComparison.Ordinal)).Sum(w => w.GlazedArea), 9);
}
[Fact]
public void WingsNarrowerThanTwiceThePerimeterDepthAreTooNarrow()
{
// D-087 applies to every wing between two façades, the middle wings between two courts included: 9 m wings with the 4.57 m default depth
// leave no core.
IGeneratedPlan plan = Plan(CourtClusterParameters.Default with { Depth = 9.0 });
Result<IFloor> floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(plan);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, Assert.Single(floor.Diagnostics).Code);
}
[Fact]
public void SingleZoneBuildingRebuildsEveryCourtsWallsOnEveryStorey()
{
IFloor floor = new NoSimplification().Simplify(Plan()).Value;
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.All(zone.Parts, p => Assert.Equal(2, p.Footprint.Holes.Count));
WallSurface[] court = building.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnAnyCourt(w, 2)).ToArray();
Assert.Equal(5 * 2 * 4, court.Length);
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(2880.0, building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Ground).Sum(h => h.Area), 6);
}
/// <summary>Whether a wall lies on the edge of one of the courts of a plan with the default dimensions.</summary>
private static bool OnAnyCourt(WallSurface wall, int courts) => Enumerable.Range(0, courts).Any(j =>
{
double west = 12.0 + (j * 36.0);
return new[] { wall.Start, wall.End }.All(p => p.X >= west - 1e-9 && p.X <= west + 24.0 + 1e-9 && p.Y is >= 12.0 and <= 36.0);
});
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);
}
private static CourtClusterPresets Presets() => new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(CourtClusterParameters? parameters = null) =>
new CourtClusterGenerator(Pipeline.Tolerances).Generate(parameters ?? CourtClusterParameters.Default, Presets()).Value;
}
- [x] Step 9: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.CourtClusterTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 102, Skipped: 0, Total: 102, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 427 generators, and 1385 integration tests passed on net8.0; git status lists M src/Lod.Generators/StairBayBar/StairBayLayout.cs and the four new files of this task, and no *.received.txt.
- [x] Step 10: Commit
git add src/Lod.Generators/StairBayBar/StairBayLayout.cs src/Lod.Generators/CourtCluster/CourtClusterGenerator.cs tests/Lod.Generators.Tests/CourtCluster/CourtClusterGeneratorTests.cs tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt tests/Lod.Integration.Tests/CourtClusterTypologyTests.cs
git commit -m "feature(generators): add the court cluster of stair-bay wings around several courts"
- [x] Step 11: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/operating-suite-court-cluster
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/operating-suite-court-cluster
git branch -d feature/operating-suite-court-cluster
git diff --name-status v0.8.4 main -- "tests/*/Snapshots/*"
Expected: VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0; the last command lists only
A tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt
A tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt
the twenty-two existing snapshots are byte-identical. Do not push (D-098).
Slice B — feature/operating-suite-court-cluster-components¶
Task 3: Operating Suite and Court Cluster components, with icons¶
Files:
- Modify: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file, last changed in S8.5), scripts/make_icons.py (S4.1 file, last changed in S8.5)
- Create (generated): src/Lod.Grasshopper/Icons/OperatingSuiteComponent.png, CourtClusterComponent.png
Interfaces:
- Consumes: OperatingSuiteGenerator, CourtClusterGenerator, and their records (Tasks 1 and 2); the S8.1 base TypologyGeneratorComponent (panel 2 Generate, one Plan output, AddPreset, TryNumbers, Preset, Output, FloorHeightDescription, OrientationDescription); ComponentSupport.PreviewLocationParameter() (S4.1); IconLibrary.For.
- Produces: two components after Foyer Halls in the file, each with its count as an integer input first, its number inputs defaulting to the ADR-006 defaults, Floor Height, Orientation, one required preset input per space type in the preset record's order (an unconnected one is Grasshopper's missing-input warning, D-065), and Preview Location last (D-062); the preset nicknames follow the nicknames of the preset components:
| Component (nickname) | Inputs → output | Class | GUID |
|---|---|---|---|
| Operating Suite (OSuite) | Bank Count (BC, 2), Bank Length (BL, 36), Theatre Depth (TD, 7), Clean Corridor Width (CW, 3), Dirty Corridor Width (DW, 2.4), Support Length (SL, 12), Floor Height (FH, 4.2), Orientation (O, 0), Operating Theatre (OT), Clean Corridor (Clean), Dirty Corridor (Dirty), Clinical Support (CS), Preview Location (PL) → Plan | OperatingSuiteComponent |
33323afd-abf1-4ecc-bf54-5d67c411c962 |
| Court Cluster (CCluster) | Court Count (CC, 2), Court Width (CW, 24), Court Depth (CD, 24), Depth (D, 12), Unit Width (UW, 7), Stair Width (SW, 3), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Stair (St), Preview Location (PL) → Plan | CourtClusterComponent |
01fc2ca9-3ec0-4142-b827-2519a4523d36 |
The motifs operating_suite (a support block in the west, dark dirty corridors at the top and bottom, a white clean corridor between two banks) and court_cluster (a block with two transparent court holes, a middle wing between them, dark stairs), in the Generate green, mapped after FoyerHallsComponent. 52 components, 6 parameters, and 59 icons. The fourteen earlier typology components and their base class are unchanged.
- [x] Step 1: Create the branch
git switch -c feature/operating-suite-court-cluster-components main
- [x] Step 2: Replace
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs(full file)
Two usings and the two 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.BranchingMall;
using Lod.Generators.Cafeteria;
using Lod.Generators.CareHub;
using Lod.Generators.CourtCluster;
using Lod.Generators.EnclosedCourt;
using Lod.Generators.FoyerHalls;
using Lod.Generators.GalleryBar;
using Lod.Generators.OfficePlate;
using Lod.Generators.OpenCourt;
using Lod.Generators.OperatingSuite;
using Lod.Generators.PointPlate;
using Lod.Generators.RadialLobes;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.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)));
}
}
public sealed class EnclosedCourtComponent : TypologyGeneratorComponent
{
public EnclosedCourtComponent()
: base(
"Enclosed Court",
"ECourt",
"Enclosed courtyard residential block (HDHM-TYP-005, ADR-006): south, north, west, and east wings of stair bays around one court closed on all four sides. The footprint has the court as a hole; the court façades are outdoor walls with windows. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("b9765cd0-a62b-483d-9470-86d5fda287e8");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
EnclosedCourtParameters d = EnclosedCourtParameters.Default;
pManager.AddNumberParameter("Court Width", "CW", "Width of the court between the west and east wings, m.", GH_ParamAccess.item, d.CourtWidth);
pManager.AddNumberParameter("Court Depth", "CD", "Depth of the court between the south and north wings, which is also the west and east wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 7, out double[] v) || Preset(DA, 7) is not { } dwellingUnit || Preset(DA, 8) is not { } stair)
{
return;
}
var parameters = new EnclosedCourtParameters(v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
Output(DA, new EnclosedCourtGenerator(ToleranceSettings.Default).Generate(parameters, new EnclosedCourtPresets(dwellingUnit, stair)));
}
}
public sealed class OfficePlateComponent : TypologyGeneratorComponent
{
public OfficePlateComponent()
: base(
"Office Plate",
"OPlate",
"Central service core within an office plate (SYN-TYP-017, ADR-006): a rectangular plate whose office work area is one zone, a ring around a central Core zone without outdoor walls; the core leaves at least 3 m of work area on every side. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("783cefaa-b836-4dbc-b16d-8e74820ed361");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
OfficePlateParameters d = OfficePlateParameters.Default;
pManager.AddNumberParameter("Plate Length", "L", "Plate length along plan x, m.", GH_ParamAccess.item, d.PlateLength);
pManager.AddNumberParameter("Plate Depth", "D", "Plate depth along plan y, m.", GH_ParamAccess.item, d.PlateDepth);
pManager.AddNumberParameter("Core Length", "CL", "Length of the central core along plan x, m.", GH_ParamAccess.item, d.CoreLength);
pManager.AddNumberParameter("Core Depth", "CD", "Depth of the central core along plan y, m.", GH_ParamAccess.item, d.CoreDepth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Office", "Of", SpaceType.Office, "the office work area");
AddPreset(pManager, "Core", "Co", SpaceType.Core, "the service core");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 6, out double[] v) || Preset(DA, 6) is not { } office || Preset(DA, 7) is not { } core)
{
return;
}
var parameters = new OfficePlateParameters(v[0], v[1], v[2], v[3], v[4], v[5]);
Output(DA, new OfficePlateGenerator(ToleranceSettings.Default).Generate(parameters, new OfficePlatePresets(office, core)));
}
}
public sealed class CafeteriaComponent : TypologyGeneratorComponent
{
public CafeteriaComponent()
: base(
"Cafeteria",
"Cafe",
"Cafeteria service edge beside a dining field (SYN-TYP-016, ADR-006): the kitchen and servery, one zone, along the whole north edge of a larger dining field; the servery counter and the customers' route along it are not zones of their own. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("95956724-ddbc-48ce-9874-0ec6c244c8fe");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
CafeteriaParameters d = CafeteriaParameters.Default;
pManager.AddNumberParameter("Length", "L", "Length of the service edge and the dining field along plan x, m.", GH_ParamAccess.item, d.Length);
pManager.AddNumberParameter("Dining Depth", "DD", "Depth of the dining field along plan y, m.", GH_ParamAccess.item, d.DiningDepth);
pManager.AddNumberParameter("Kitchen Depth", "KD", "Depth of the kitchen and servery along plan y, m; smaller than Dining Depth.", GH_ParamAccess.item, d.KitchenDepth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Kitchen", "Ki", SpaceType.Kitchen, "the kitchen and servery");
AddPreset(pManager, "Dining", "Di", SpaceType.Dining, "the dining field");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 5, out double[] v) || Preset(DA, 5) is not { } kitchen || Preset(DA, 6) is not { } dining)
{
return;
}
var parameters = new CafeteriaParameters(v[0], v[1], v[2], v[3], v[4]);
Output(DA, new CafeteriaGenerator(ToleranceSettings.Default).Generate(parameters, new CafeteriaPresets(kitchen, dining)));
}
}
public sealed class RadialLobesComponent : TypologyGeneratorComponent
{
public RadialLobesComponent()
: base(
"Radial Lobes",
"RLobes",
"Radial lobes around a point tower core (SYN-TYP-010, ADR-006): three or four lobes on the sides of a square core, each holding two dwellings side by side that both adjoin the core; no other circulation. With three lobes the core's south side is a façade. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
RadialLobesParameters d = RadialLobesParameters.Default;
pManager.AddIntegerParameter("Lobe Count", "LC", "Number of lobes, east, north, west, then south of the core; 3 or 4.", GH_ParamAccess.item, d.LobeCount);
pManager.AddNumberParameter("Lobe Length", "LL", "Length of each lobe from the core to its end, which every dwelling spans, m.", GH_ParamAccess.item, d.LobeLength);
pManager.AddNumberParameter("Lobe Width", "LW", "Width of each lobe along the core's side, two dwellings side by side, m; at most Core Width.", GH_ParamAccess.item, d.LobeWidth);
pManager.AddNumberParameter("Core Width", "CW", "Side of the square core, m.", GH_ParamAccess.item, d.CoreWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the core");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int lobes = 0;
if (!DA.GetData(0, ref lobes) || !TryNumbers(DA, 1, 5, out double[] v) || Preset(DA, 6) is not { } dwellingUnit || Preset(DA, 7) is not { } stair)
{
return;
}
var parameters = new RadialLobesParameters(lobes, v[0], v[1], v[2], v[3], v[4]);
Output(DA, new RadialLobesGenerator(ToleranceSettings.Default).Generate(parameters, new RadialLobesPresets(dwellingUnit, stair)));
}
}
public sealed class BranchingMallComponent : TypologyGeneratorComponent
{
public BranchingMallComponent()
: base(
"Branching Mall",
"BMall",
"Branching public mall between retail wings (SYN-TYP-014, ADR-006): one mall zone through a central court and three or four branches, each with a shop wing on either side of the mall and an anchor store at its end; every shop wing and anchor adjoins the mall. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("d886b33f-7b51-41f9-b160-a550e6aadd2b");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
BranchingMallParameters d = BranchingMallParameters.Default;
pManager.AddIntegerParameter("Branch Count", "BC", "Number of branches, east, north, west, then south of the central court; 3 or 4.", GH_ParamAccess.item, d.BranchCount);
pManager.AddNumberParameter("Branch Length", "BL", "Length of the shop wings along each branch, m.", GH_ParamAccess.item, d.BranchLength);
pManager.AddNumberParameter("Mall Width", "MW", "Width of the public mall in each branch, m.", GH_ParamAccess.item, d.MallWidth);
pManager.AddNumberParameter("Shop Depth", "SD", "Depth of the shop wing on each side of the mall, m.", GH_ParamAccess.item, d.ShopDepth);
pManager.AddNumberParameter("Anchor Depth", "AD", "Depth of the anchor store at the end of each branch, m.", GH_ParamAccess.item, d.AnchorDepth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Mall", "Ma", SpaceType.Mall, "the public mall");
AddPreset(pManager, "Retail", "Re", SpaceType.Retail, "the shop wings and anchors");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int branches = 0;
if (!DA.GetData(0, ref branches) || !TryNumbers(DA, 1, 6, out double[] v) || Preset(DA, 7) is not { } mall || Preset(DA, 8) is not { } retail)
{
return;
}
var parameters = new BranchingMallParameters(branches, v[0], v[1], v[2], v[3], v[4], v[5]);
Output(DA, new BranchingMallGenerator(ToleranceSettings.Default).Generate(parameters, new BranchingMallPresets(mall, retail)));
}
}
public sealed class CareHubComponent : TypologyGeneratorComponent
{
public CareHubComponent()
: base(
"Care Hub",
"CHub",
"Communal hub serving elder-care bedroom groups (SYN-TYP-018, ADR-006): two to four wings around a communal hub, each a corridor from the hub to the wing's end with a group of bedrooms on each side; every wing corridor adjoins the hub. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("7d3d1002-c695-47df-afd9-48f50c5b3e90");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
CareHubParameters d = CareHubParameters.Default;
pManager.AddIntegerParameter("Wing Count", "WC", "Number of bedroom wings, east, north, west, then south of the hub; 2 to 4.", GH_ParamAccess.item, d.WingCount);
pManager.AddNumberParameter("Wing Length", "WL", "Length of each wing from the hub to its end, m.", GH_ParamAccess.item, d.WingLength);
pManager.AddNumberParameter("Bedroom Depth", "BD", "Depth of the bedrooms on each side of a wing corridor, m.", GH_ParamAccess.item, d.BedroomDepth);
pManager.AddNumberParameter("Corridor Width", "CoW", "Width of each wing corridor, m.", GH_ParamAccess.item, d.CorridorWidth);
pManager.AddNumberParameter("Hub Width", "HW", "Side of the square communal hub, m; at least the wing width, twice Bedroom Depth plus Corridor Width.", GH_ParamAccess.item, d.HubWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Care Bedroom", "CB", SpaceType.CareBedroom, "the bedroom groups");
AddPreset(pManager, "Care Communal", "CC", SpaceType.CareCommunal, "the communal hub");
AddPreset(pManager, "Corridor", "Co", SpaceType.Corridor, "the wing corridors");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int wings = 0;
if (!DA.GetData(0, ref wings)
|| !TryNumbers(DA, 1, 6, out double[] v)
|| Preset(DA, 7) is not { } careBedroom
|| Preset(DA, 8) is not { } careCommunal
|| Preset(DA, 9) is not { } corridor)
{
return;
}
var parameters = new CareHubParameters(wings, v[0], v[1], v[2], v[3], v[4], v[5]);
Output(DA, new CareHubGenerator(ToleranceSettings.Default).Generate(parameters, new CareHubPresets(careBedroom, careCommunal, corridor)));
}
}
public sealed class FoyerHallsComponent : TypologyGeneratorComponent
{
public FoyerHallsComponent()
: base(
"Foyer Halls",
"FHalls",
"Public foyer branching to distinct activity spaces (SYN-TYP-019, ADR-006): two or three activity halls and a support wing around a common foyer (hall 1 east, support north, hall 2 west, hall 3 south); every hall and the support wing adjoins the foyer. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("6638efe7-c0e8-45b6-89b1-1e9e3f725c15");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
FoyerHallsParameters d = FoyerHallsParameters.Default;
pManager.AddIntegerParameter("Hall Count", "HC", "Number of activity halls; 2 or 3 (the support wing takes the foyer's north side).", GH_ParamAccess.item, d.HallCount);
pManager.AddNumberParameter("Hall Length", "HL", "Length of each hall from the foyer to its end, m.", GH_ParamAccess.item, d.HallLength);
pManager.AddNumberParameter("Hall Width", "HW", "Width of each hall and of the support wing along the foyer's side, m; at most Foyer Width.", GH_ParamAccess.item, d.HallWidth);
pManager.AddNumberParameter("Support Length", "SL", "Length of the support wing from the foyer to its end, m.", GH_ParamAccess.item, d.SupportLength);
pManager.AddNumberParameter("Foyer Width", "FW", "Side of the square foyer, m.", GH_ParamAccess.item, d.FoyerWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Lobby", "Lo", SpaceType.Lobby, "the foyer");
AddPreset(pManager, "Activity Hall", "AH", SpaceType.ActivityHall, "the activity halls");
AddPreset(pManager, "Service", "Sv", SpaceType.Service, "the support wing");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int halls = 0;
if (!DA.GetData(0, ref halls)
|| !TryNumbers(DA, 1, 6, out double[] v)
|| Preset(DA, 7) is not { } lobby
|| Preset(DA, 8) is not { } activityHall
|| Preset(DA, 9) is not { } service)
{
return;
}
var parameters = new FoyerHallsParameters(halls, v[0], v[1], v[2], v[3], v[4], v[5]);
Output(DA, new FoyerHallsGenerator(ToleranceSettings.Default).Generate(parameters, new FoyerHallsPresets(lobby, activityHall, service)));
}
}
public sealed class OperatingSuiteComponent : TypologyGeneratorComponent
{
public OperatingSuiteComponent()
: base(
"Operating Suite",
"OSuite",
"Operating suite with separated clean and dirty routes (SYN-TYP-015, ADR-006): support rooms across the west end, then bands from south to north of a dirty corridor and theatre banks, each followed by a corridor, alternating clean and dirty; every bank lies between one clean and one dirty corridor, and every bank and corridor adjoins the support rooms. One zone per department (D-097).")
{
}
public override Guid ComponentGuid => new("33323afd-abf1-4ecc-bf54-5d67c411c962");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
OperatingSuiteParameters d = OperatingSuiteParameters.Default;
pManager.AddIntegerParameter("Bank Count", "BC", "Number of theatre banks; at least 2.", GH_ParamAccess.item, d.BankCount);
pManager.AddNumberParameter("Bank Length", "BL", "Length of every bank and corridor from the support rooms to the east façade, m.", GH_ParamAccess.item, d.BankLength);
pManager.AddNumberParameter("Theatre Depth", "TD", "Depth of every theatre bank across the bands, m.", GH_ParamAccess.item, d.TheatreDepth);
pManager.AddNumberParameter("Clean Corridor Width", "CW", "Width of every clean corridor, m.", GH_ParamAccess.item, d.CleanCorridorWidth);
pManager.AddNumberParameter("Dirty Corridor Width", "DW", "Width of every dirty corridor, m.", GH_ParamAccess.item, d.DirtyCorridorWidth);
pManager.AddNumberParameter("Support Length", "SL", "Length of the support rooms across the suite's west end, m.", GH_ParamAccess.item, d.SupportLength);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Operating Theatre", "OT", SpaceType.OperatingTheatre, "the theatre banks");
AddPreset(pManager, "Clean Corridor", "Clean", SpaceType.CleanCorridor, "the clean corridors");
AddPreset(pManager, "Dirty Corridor", "Dirty", SpaceType.DirtyCorridor, "the dirty corridors");
AddPreset(pManager, "Clinical Support", "CS", SpaceType.ClinicalSupport, "the support rooms");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int banks = 0;
if (!DA.GetData(0, ref banks)
|| !TryNumbers(DA, 1, 7, out double[] v)
|| Preset(DA, 8) is not { } theatre
|| Preset(DA, 9) is not { } clean
|| Preset(DA, 10) is not { } dirty
|| Preset(DA, 11) is not { } support)
{
return;
}
var parameters = new OperatingSuiteParameters(banks, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
Output(DA, new OperatingSuiteGenerator(ToleranceSettings.Default).Generate(parameters, new OperatingSuitePresets(theatre, clean, dirty, support)));
}
}
public sealed class CourtClusterComponent : TypologyGeneratorComponent
{
public CourtClusterComponent()
: base(
"Court Cluster",
"CCluster",
"Linked housing clusters around several courts (SYN-TYP-011, ADR-006): a row of courts closed by wings of stair bays, south and north wings across the full width and a west, middle, and east wing between them, each middle wing bordering two courts. The footprint has one hole per court; the court façades are outdoor walls with windows. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("01fc2ca9-3ec0-4142-b827-2519a4523d36");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
CourtClusterParameters d = CourtClusterParameters.Default;
pManager.AddIntegerParameter("Court Count", "CC", "Number of courts in a row along plan x; at least 2.", GH_ParamAccess.item, d.CourtCount);
pManager.AddNumberParameter("Court Width", "CW", "Width of every court between the wings on its west and east sides, m.", GH_ParamAccess.item, d.CourtWidth);
pManager.AddNumberParameter("Court Depth", "CD", "Depth of every court between the south and north wings, which is also the west, middle, and east wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int courts = 0;
if (!DA.GetData(0, ref courts) || !TryNumbers(DA, 1, 7, out double[] v) || Preset(DA, 8) is not { } dwellingUnit || Preset(DA, 9) is not { } stair)
{
return;
}
var parameters = new CourtClusterParameters(courts, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
Output(DA, new CourtClusterGenerator(ToleranceSettings.Default).Generate(parameters, new CourtClusterPresets(dwellingUnit, stair)));
}
}
- [x] Step 3: Build, check Python, and see the icon script fail
dotnet build BEMGen.sln -c Release
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"
python scripts/make_icons.py
git status --short
Expected: Build succeeded., 0 Warning(s), 0 Error(s); True 12.3.0; the script stops with exit code 1 and the message Classes without an icon: ['CourtClusterComponent', 'OperatingSuiteComponent']; icons without a class: [], and writes nothing (git status --short lists only M src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs). With another Pillow version the script still runs, but the PNG bytes may differ from Step 5's hashes; then Step 6 decides.
- [x] Step 4: Replace
scripts/make_icons.py(full file)
The two motifs are added after foyer_halls, and their entries after Foyer Halls in the Generate group; every other motif and entry is unchanged.
"""Draws the BEMGen Grasshopper icons (D-063).
Every icon is drawn at 96 x 96 pixels on a transparent background and downsampled to 24 x 24 with LANCZOS, then written to
src/Lod.Grasshopper/Icons/<Name>.png, where <Name> is the class name of the component, parameter, or assembly info that shows it.
Each icon is a dark glyph plus the accent colour of its toolbar panel; a parameter uses the panel of the component that makes its object.
Shapes only, no fonts, so the output does not depend on installed fonts.
Run from the repository root: python scripts/make_icons.py
Requires Python 3.10+ and Pillow. Generated with Pillow 12.3.0; the same Pillow version gives the same pixels.
The script stops with an error when an icon has no class in src/Lod.Grasshopper or a class has no icon.
"""
from __future__ import annotations
import re
import sys
from collections.abc import Callable
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "src" / "Lod.Grasshopper"
OUTPUT = SOURCE / "Icons"
CANVAS = 96
SIZE = 24
LINE = 8 # 2 px after downsampling
DARK = (38, 38, 38, 255)
WHITE = (255, 255, 255, 255)
# One accent per toolbar panel (ComponentCategories).
INFO = (96, 125, 139, 255)
PROGRAM = (230, 140, 30, 255)
GENERATE = (46, 158, 79, 255)
SIMPLIFY = (47, 123, 216, 255)
AGGREGATE = (138, 79, 200, 255)
CONVERT = (208, 69, 58, 255)
INSPECT = (24, 150, 150, 255)
Colour = tuple[int, int, int, int]
Draw = ImageDraw.ImageDraw
Motif = Callable[[Draw, Colour], None]
Painter = Callable[[Image.Image, Colour], None]
def box(d: Draw, xy: tuple[float, float, float, float], fill=None, outline=DARK, width: int = LINE) -> None:
d.rectangle(xy, fill=fill, outline=outline, width=width)
def line(d: Draw, points: list[tuple[float, float]], width: int = LINE, fill=DARK) -> None:
d.line(points, fill=fill, width=width, joint="curve")
def arrow_head(d: Draw, tip: tuple[float, float], direction: str, size: float, fill=DARK) -> None:
x, y = tip
if direction == "right":
d.polygon([(x, y), (x - size, y - size), (x - size, y + size)], fill=fill)
elif direction == "down":
d.polygon([(x, y), (x - size, y - size), (x + size, y - size)], fill=fill)
# --- motifs: each draws inside the 96 x 96 canvas -------------------------------------------------------------
def info(d: Draw, accent) -> None:
d.ellipse((6, 6, 90, 90), fill=accent, outline=DARK, width=LINE)
d.rectangle((42, 40, 54, 74), fill=WHITE)
d.rectangle((42, 20, 54, 32), fill=WHITE)
def assembly(d: Draw, accent) -> None:
# A stepped block: the building at three levels of detail.
box(d, (8, 52, 88, 88), fill=accent)
box(d, (8, 30, 60, 56), fill=accent)
box(d, (8, 8, 36, 34), fill=accent)
def schedule(d: Draw, accent) -> None:
# A 24-hour profile as bars on a time axis.
heights = [16, 48, 72, 36, 60, 76, 24]
for i, h in enumerate(heights):
x = 12 + i * 12
d.rectangle((x, 84 - h, x + 7, 83), fill=accent)
d.rectangle((4, 84, 92, 91), fill=DARK)
d.rectangle((4, 4, 11, 91), fill=DARK)
def load(d: Draw, accent) -> None:
bolt = [(58, 4), (18, 54), (44, 54), (34, 92), (78, 38), (52, 38), (64, 4)]
d.polygon(bolt, fill=accent, outline=DARK, width=6)
def preset_tag(d: Draw, accent) -> None:
tag = [(8, 30), (30, 8), (88, 8), (88, 88), (30, 88), (8, 66)]
d.polygon(tag, fill=accent, outline=DARK, width=LINE)
d.ellipse((24, 40, 40, 56), fill=WHITE, outline=DARK, width=4)
line(d, [(52, 34), (76, 34)], width=6)
line(d, [(52, 48), (76, 48)], width=6)
line(d, [(52, 62), (76, 62)], width=6)
def house(d: Draw, accent) -> None:
d.polygon([(48, 6), (90, 44), (6, 44)], fill=accent, outline=DARK, width=LINE)
box(d, (16, 42, 80, 90), fill=accent)
d.rectangle((40, 60, 56, 90), fill=DARK)
def corridor(d: Draw, accent) -> None:
box(d, (4, 30, 92, 66), fill=accent)
line(d, [(16, 48), (60, 48)], width=8)
arrow_head(d, (78, 48), "right", 12)
def stair(d: Draw, accent) -> None:
steps = [(6, 90), (6, 70), (28, 70), (28, 50), (50, 50), (50, 30), (72, 30), (72, 10), (90, 10), (90, 90)]
d.polygon(steps, fill=accent, outline=DARK, width=LINE)
def office(d: Draw, accent) -> None:
# An office block in elevation with a grid of windows.
box(d, (16, 6, 80, 90), fill=accent)
for y in (18, 38, 58):
for x in (26, 52):
d.rectangle((x, y, x + 16, y + 12), fill=DARK)
d.rectangle((40, 76, 56, 90), fill=DARK)
def core(d: Draw, accent) -> None:
# A service core: a box with two lift doors and an up and down arrow.
box(d, (8, 8, 88, 88), fill=accent)
d.rectangle((20, 34, 42, 80), fill=DARK)
d.rectangle((54, 34, 76, 80), fill=DARK)
d.polygon([(48, 12), (38, 26), (58, 26)], fill=DARK)
def retail(d: Draw, accent) -> None:
# A shopping bag with its handle.
d.arc((30, 6, 66, 46), start=180, end=360, fill=DARK, width=LINE)
d.polygon([(14, 30), (82, 30), (90, 90), (6, 90)], fill=accent, outline=DARK, width=LINE)
def mall(d: Draw, accent) -> None:
# A branching mall in plan: shop blocks (accent) along a T of public routes (white).
box(d, (4, 4, 92, 92), fill=accent)
d.rectangle((40, 8, 56, 88), fill=WHITE)
d.rectangle((8, 40, 40, 56), fill=WHITE)
for a, b in [((40, 4), (40, 40)), ((56, 4), (56, 92)), ((40, 56), (40, 92)), ((4, 40), (40, 40)), ((4, 56), (40, 56))]:
line(d, [a, b], width=4)
def kitchen(d: Draw, accent) -> None:
# A cooking pot with a lid and two handles.
box(d, (16, 40, 80, 86), fill=accent)
line(d, [(4, 50), (16, 50)])
line(d, [(80, 50), (92, 50)])
d.rectangle((12, 28, 84, 38), fill=DARK)
d.rectangle((42, 16, 54, 28), fill=DARK)
def dining(d: Draw, accent) -> None:
# A plate between a fork and a knife.
d.ellipse((26, 22, 74, 70), fill=accent, outline=DARK, width=LINE)
d.ellipse((38, 34, 62, 58), outline=DARK, width=4)
line(d, [(12, 14), (12, 86)], width=6)
line(d, [(84, 14), (84, 86)], width=6)
d.rectangle((80, 14, 88, 46), fill=DARK)
def operating_theatre(d: Draw, accent) -> None:
# A medical cross.
d.polygon(
[(34, 6), (62, 6), (62, 34), (90, 34), (90, 62), (62, 62), (62, 90), (34, 90), (34, 62), (6, 62), (6, 34), (34, 34)],
fill=accent,
outline=DARK,
width=LINE,
)
def clean_corridor(d: Draw, accent) -> None:
# A corridor with a white (clean) route and an arrow.
box(d, (4, 30, 92, 66), fill=accent)
d.rectangle((10, 42, 70, 54), fill=WHITE)
arrow_head(d, (86, 48), "right", 12)
def dirty_corridor(d: Draw, accent) -> None:
# A corridor with a hatched (dirty) route and an arrow.
box(d, (4, 30, 92, 66), fill=accent)
for x in range(12, 66, 14):
line(d, [(x, 60), (x + 10, 36)], width=5)
arrow_head(d, (86, 48), "right", 12)
def clinical_support(d: Draw, accent) -> None:
# Shelves of supplies beside a small medical cross.
box(d, (6, 10, 58, 90), fill=accent)
for y in (34, 58):
line(d, [(6, y), (58, y)], width=6)
d.rectangle((70, 28, 82, 64), fill=DARK)
d.rectangle((58, 40, 94, 52), fill=DARK)
def care_bedroom(d: Draw, accent) -> None:
# A bed in elevation: headboard, mattress, and pillow.
d.rectangle((6, 24, 16, 86), fill=DARK)
box(d, (16, 54, 90, 72), fill=accent)
d.rectangle((20, 42, 42, 54), fill=DARK)
line(d, [(86, 72), (86, 86)])
def care_communal(d: Draw, accent) -> None:
# A round table with four people around it.
d.ellipse((28, 28, 68, 68), fill=accent, outline=DARK, width=LINE)
for x, y in [(48, 12), (84, 48), (48, 84), (12, 48)]:
d.ellipse((x - 9, y - 9, x + 9, y + 9), fill=DARK)
def lobby(d: Draw, accent) -> None:
# A glazed entrance front with an open door and an arrow leading in.
box(d, (6, 10, 90, 88), fill=accent)
line(d, [(34, 10), (34, 88)], width=4)
line(d, [(62, 10), (62, 88)], width=4)
d.rectangle((38, 44, 58, 88), fill=WHITE, outline=DARK, width=4)
arrow_head(d, (48, 84), "down", 8)
def activity_hall(d: Draw, accent) -> None:
# A sports court in plan: the hall, its centre line, and the centre circle.
box(d, (4, 16, 92, 80), fill=accent)
line(d, [(48, 16), (48, 80)], width=5)
d.ellipse((34, 34, 62, 62), outline=DARK, width=5)
def service_rooms(d: Draw, accent) -> None:
# A row of three lockers with their handles.
for left in (6, 36, 66):
box(d, (left, 8, left + 24, 88), fill=accent, width=6)
d.rectangle((left + 15, 40, left + 19, 56), fill=DARK)
def linear_generator(d: Draw, accent) -> None:
# Footprint with a west stair, a corridor band, and dwelling-unit divisions on both sides.
box(d, (4, 12, 91, 83))
d.rectangle((12, 20, 23, 75), fill=DARK)
d.rectangle((28, 40, 87, 55), fill=accent)
for x in (48, 68):
line(d, [(x, 12), (x, 40)])
line(d, [(x, 55), (x, 83)])
def stair_bay_bar(d: Draw, accent) -> None:
# A long bar of two stair bays: dwelling (accent) | stair (dark) | dwelling, repeated, with a party wall between the bays.
box(d, (2, 30, 93, 66), fill=accent)
for x in (20, 66):
d.rectangle((x, 30, x + 10, 66), fill=DARK)
line(d, [(48, 30), (48, 66)], width=4)
def stair_pair(d: Draw, accent) -> None:
# One compact plate: two dwellings (accent) around one full-depth stair (dark).
box(d, (14, 22, 82, 74), fill=accent)
d.rectangle((42, 26, 54, 70), fill=DARK)
def gallery_bar(d: Draw, accent) -> None:
# A bar of dwellings (accent) between two end stairs (dark); the gallery along it is dashed, as it is not modelled.
box(d, (4, 34, 91, 76), fill=accent)
d.rectangle((8, 38, 18, 72), fill=DARK)
d.rectangle((77, 38, 87, 72), fill=DARK)
for x in (36, 54):
line(d, [(x, 34), (x, 76)], width=6)
for x in range(6, 90, 16):
line(d, [(x, 20), (x + 9, 20)], width=6)
def terrace_row(d: Draw, accent) -> None:
# Attached houses in elevation: three gables sharing their side walls.
houses = [(6, 34), (34, 62), (62, 90)]
for left, right in houses:
middle = (left + right) / 2
d.polygon([(left, 46), (middle, 22), (right, 46), (right, 88), (left, 88)], fill=accent, outline=DARK, width=6)
for left, right in houses:
d.rectangle((left + 10, 68, right - 10, 88), fill=DARK)
def point_plate(d: Draw, accent) -> None:
# A square plate: four dwellings (accent) in a pinwheel around one central core (dark) without façades.
box(d, (6, 6, 90, 90), fill=accent)
d.rectangle((36, 36, 60, 60), fill=DARK)
for a, b in [((6, 36), (60, 36)), ((60, 6), (60, 60)), ((36, 60), (90, 60)), ((36, 36), (36, 90))]:
line(d, [a, b], width=6)
def winged_band(d: Draw, accent) -> None:
# A comb in plan: a band (bottom) with three wings projecting north (up), every wing joined to the band along its root; stairs dark.
d.polygon(
[(4, 90), (92, 90), (92, 8), (70, 8), (70, 62), (59, 62), (59, 8), (37, 8), (37, 62), (26, 62), (26, 8), (4, 8)],
fill=accent,
outline=DARK,
width=6,
)
for left, right in ((4, 26), (37, 59), (70, 92)):
d.rectangle((left + 3, 30, right - 3, 38), fill=DARK)
for x in (20, 48, 76):
d.rectangle((x, 66, x + 6, 87), fill=DARK)
def open_court(d: Draw, accent) -> None:
# A U in plan: a north wing (top) across the full width and two side wings, around a court open to the south (bottom); stairs dark.
d.polygon([(4, 92), (28, 92), (28, 34), (68, 34), (68, 92), (92, 92), (92, 6), (4, 6)], fill=accent, outline=DARK, width=6)
for x in (24, 45, 66):
d.rectangle((x, 9, x + 6, 31), fill=DARK)
for left in (4, 68):
d.rectangle((left + 3, 58, left + 21, 66), fill=DARK)
def enclosed_court(d: Draw, accent) -> None:
# A closed ring in plan: south and north wings across the full width and west and east wings between them, around a court (the hole,
# transparent) closed on all four sides; stairs dark.
box(d, (4, 4, 92, 92), fill=accent, width=6)
box(d, (30, 30, 66, 66), fill=(0, 0, 0, 0), width=6)
for y in (7, 69):
for x in (24, 66):
d.rectangle((x, y, x + 6, y + 20), fill=DARK)
for left in (7, 69):
d.rectangle((left, 45, left + 20, 51), fill=DARK)
def office_plate(d: Draw, accent) -> None:
# A rectangular plate: one office work area (accent) all around a central service core (dark).
box(d, (4, 16, 92, 80), fill=accent)
d.rectangle((32, 40, 64, 56), fill=DARK)
def cafeteria(d: Draw, accent) -> None:
# A rectangle: the kitchen and servery along the north edge (dark) and the dining field (accent) with tables.
box(d, (4, 10, 92, 86), fill=accent)
d.rectangle((8, 14, 88, 34), fill=DARK)
for y in (48, 68):
for x in (20, 44, 68):
d.ellipse((x, y, x + 10, y + 10), fill=DARK)
def transform_plan(d: Draw, accent) -> None:
# A footprint outline and the same footprint rotated and moved, joined by a turning arrow.
box(d, (6, 52, 50, 90), width=6)
d.polygon([(66, 12), (92, 28), (76, 56), (50, 40)], fill=accent, outline=DARK, width=6)
d.arc((18, 20, 70, 72), start=180, end=270, fill=DARK, width=8)
arrow_head(d, (58, 24), "right", 10)
def z0(d: Draw, accent) -> None:
# Many cells: every unit its own zone.
box(d, (4, 4, 91, 91), fill=accent)
for k in (32, 60):
line(d, [(k, 4), (k, 91)])
line(d, [(4, k), (91, k)])
def z1(d: Draw, accent) -> None:
# Cells of one space type merged: two unit rows and a corridor.
box(d, (4, 10, 92, 40), fill=accent)
box(d, (4, 56, 92, 86), fill=accent)
box(d, (4, 36, 92, 60))
def z2(d: Draw, accent) -> None:
# Perimeter ring split on the corner bisectors, plus a core.
box(d, (4, 4, 92, 92))
d.rectangle((30, 30, 66, 66), fill=accent, outline=DARK, width=LINE)
for a, b in [((4, 4), (30, 30)), ((92, 4), (66, 30)), ((4, 92), (30, 66)), ((92, 92), (66, 66))]:
line(d, [a, b], width=6)
def z3(d: Draw, accent) -> None:
box(d, (6, 16, 90, 80), fill=accent)
def slab(d: Draw, accent, y: float, height: float = 16, left: float = 6, right: float = 90) -> None:
box(d, (left, y, right, y + height), fill=accent, width=6)
def stack(d: Draw, accent) -> None:
for y in (8, 36, 64):
slab(d, accent, y, 24)
def multiplier(d: Draw, accent) -> None:
slab(d, accent, 6, 24, 6, 90)
line(d, [(18, 44), (50, 84)], width=10)
line(d, [(50, 44), (18, 84)], width=10)
for y in (44, 58, 72):
d.rectangle((62, y, 90, y + 8), fill=DARK)
def single_zone_per_floor_type(d: Draw, accent) -> None:
# One zone per floor entry: a one-storey zone below a three-storey zone.
box(d, (24, 66, 71, 91), fill=accent)
box(d, (24, 4, 71, 58), fill=accent)
def single_zone_building(d: Draw, accent) -> None:
box(d, (24, 4, 71, 91), fill=accent)
def convert(d: Draw, accent) -> None:
d.polygon([(40, 44), (90, 44), (90, 92), (40, 92)], fill=accent, outline=DARK, width=LINE)
line(d, [(6, 14), (44, 14), (64, 34), (64, 56)], width=10)
arrow_head(d, (64, 74), "down", 16)
def inspect(d: Draw, accent) -> None:
d.ellipse((6, 6, 64, 64), fill=accent, outline=DARK, width=LINE)
d.ellipse((18, 18, 38, 38), fill=WHITE)
line(d, [(56, 56), (88, 88)], width=16)
def mini_grid(d: Draw, origin: tuple[float, float], size: float, cells: int, accent) -> None:
x0, y0 = origin
box(d, (x0, y0, x0 + size, y0 + size), fill=accent, width=6)
step = size / cells
for k in range(1, cells):
line(d, [(x0 + k * step, y0), (x0 + k * step, y0 + size)], width=4)
line(d, [(x0, y0 + k * step), (x0 + size, y0 + k * step)], width=4)
def map_source_target(d: Draw, accent) -> None:
mini_grid(d, (4, 4), 40, 2, accent)
box(d, (52, 52, 92, 92), fill=accent, width=6)
line(d, [(32, 56), (32, 72), (40, 72)], width=8)
arrow_head(d, (52, 72), "right", 12)
line(d, [(56, 26), (72, 26), (72, 36)], width=8)
arrow_head(d, (72, 50), "down", 12)
def validate(d: Draw, accent) -> None:
d.ellipse((4, 4, 92, 92), fill=accent, outline=DARK, width=LINE)
line(d, [(24, 50), (42, 68), (74, 30)], width=14, fill=WHITE)
# Parameter motifs: small versions of their object's motif, drawn inside the hexagon (x and y in 20..76).
def small_schedule(d: Draw, accent) -> None:
for x, h in ((28, 16), (40, 36), (52, 24), (64, 40)):
d.rectangle((x, 72 - h, x + 7, 71), fill=accent)
d.rectangle((24, 72, 75, 75), fill=DARK)
def small_load(d: Draw, accent) -> None:
d.polygon([(54, 20), (30, 52), (46, 52), (40, 76), (68, 44), (52, 44), (60, 20)], fill=accent, outline=DARK, width=4)
def small_preset(d: Draw, accent) -> None:
d.polygon([(20, 36), (32, 24), (76, 24), (76, 72), (32, 72), (20, 60)], fill=accent, outline=DARK, width=4)
d.ellipse((30, 42, 42, 54), fill=WHITE, outline=DARK, width=4)
def small_plan(d: Draw, accent) -> None:
box(d, (20, 24, 75, 71), width=4)
d.rectangle((24, 44, 71, 51), fill=accent)
d.rectangle((24, 28, 31, 67), fill=DARK)
for x in (48,):
line(d, [(x, 24), (x, 44)], width=4)
line(d, [(x, 52), (x, 71)], width=4)
def small_floor(d: Draw, accent) -> None:
box(d, (16, 36, 79, 59), fill=accent, width=4)
def small_building(d: Draw, accent) -> None:
box(d, (32, 16, 63, 79), fill=accent, width=4)
for y in (36, 56):
line(d, [(32, y), (63, y)], width=4)
def radial_lobes(d: Draw, accent) -> None:
# A cross in plan: four lobes (accent) on the sides of a square core (dark), each lobe split along its axis into two dwellings.
d.polygon(
[(32, 4), (64, 4), (64, 32), (92, 32), (92, 64), (64, 64), (64, 92), (32, 92), (32, 64), (4, 64), (4, 32), (32, 32)],
fill=accent,
outline=DARK,
width=6,
)
d.rectangle((32, 32, 64, 64), fill=DARK)
for a, b in [((64, 48), (92, 48)), ((48, 4), (48, 32)), ((4, 48), (32, 48)), ((48, 64), (48, 92))]:
line(d, [a, b], width=4)
def branching_mall(d: Draw, accent) -> None:
# A T in plan: shop wings (accent) along a mall (dark) that branches from a central court into three branches, anchors at the branch ends.
d.polygon([(4, 50), (34, 50), (34, 6), (62, 6), (62, 50), (92, 50), (92, 82), (4, 82)], fill=accent, outline=DARK, width=6)
d.rectangle((18, 62, 78, 70), fill=DARK)
d.rectangle((44, 20, 52, 70), fill=DARK)
for a, b in [((18, 50), (18, 82)), ((78, 50), (78, 82)), ((34, 20), (62, 20))]:
line(d, [a, b], width=6)
def care_hub(d: Draw, accent) -> None:
# A communal hub (accent, a table in its middle) with three narrower bedroom wings (accent), each with a corridor (dark) along its axis.
for xy in [(66, 49, 92, 75), (4, 49, 30, 75), (35, 6, 61, 42)]:
box(d, xy, fill=accent, width=6)
box(d, (30, 42, 66, 82), fill=accent, width=6)
for a, b in [((66, 62), (92, 62)), ((4, 62), (30, 62)), ((48, 6), (48, 42))]:
line(d, [a, b], width=4)
d.ellipse((40, 54, 56, 70), fill=DARK)
def foyer_halls(d: Draw, accent) -> None:
# A foyer (white) with two large halls (accent) east and west, the support wing (dark) north, and its entrance from the south.
box(d, (4, 40, 34, 74), fill=accent, width=6)
box(d, (62, 40, 92, 74), fill=accent, width=6)
box(d, (34, 18, 62, 40), fill=DARK, width=6)
box(d, (34, 40, 62, 74), fill=WHITE, width=6)
d.polygon([(48, 78), (40, 90), (56, 90)], fill=DARK)
def operating_suite(d: Draw, accent) -> None:
# Support rooms (accent) across the west end and bands to their east, south to north: a dirty corridor (dark), a theatre bank (accent), a
# clean corridor (white), a theatre bank, and a dirty corridor.
box(d, (4, 6, 92, 90), fill=accent, width=6)
line(d, [(30, 6), (30, 90)], width=6)
d.rectangle((30, 6, 92, 20), fill=DARK)
d.rectangle((30, 76, 92, 90), fill=DARK)
d.rectangle((30, 40, 92, 56), fill=DARK)
d.rectangle((33, 44, 89, 52), fill=WHITE)
def court_cluster(d: Draw, accent) -> None:
# Wings around a row of two courts (the holes, transparent): south and north wings across the full width, and a west, middle, and east wing
# between them; stairs dark.
box(d, (4, 16, 92, 80), fill=accent, width=6)
for left in (18, 56):
box(d, (left, 30, left + 22, 66), fill=(0, 0, 0, 0), width=6)
for y in (19, 69):
for x in (24, 46, 68):
d.rectangle((x, y, x + 5, y + 8), fill=DARK)
for left in (7, 43, 81):
d.rectangle((left, 45, left + 8, 51), fill=DARK)
# --- icons ----------------------------------------------------------------------------------------------------
def component(motif: Motif) -> Painter:
"""A component: its motif at full size."""
def paint(image: Image.Image, accent: Colour) -> None:
motif(ImageDraw.Draw(image), accent)
return paint
def parameter(motif: Motif) -> Painter:
"""A parameter: a small version of its object's motif inside a hexagon (the Grasshopper parameter shape)."""
def paint(image: Image.Image, accent: Colour) -> None:
d = ImageDraw.Draw(image)
d.polygon([(24, 4), (72, 4), (94, 48), (72, 92), (24, 92), (2, 48)], fill=WHITE, outline=DARK, width=4)
motif(d, accent)
return paint
ICONS: dict[str, tuple[Colour, Painter]] = {
"BemGenAssemblyInfo": (INFO, component(assembly)),
"BemGenInfoComponent": (INFO, component(info)),
"ScheduleComponent": (PROGRAM, component(schedule)),
"LoadComponent": (PROGRAM, component(load)),
"ProgramPresetComponent": (PROGRAM, component(preset_tag)),
"DwellingUnitPresetComponent": (PROGRAM, component(house)),
"CorridorPresetComponent": (PROGRAM, component(corridor)),
"StairPresetComponent": (PROGRAM, component(stair)),
"OfficePresetComponent": (PROGRAM, component(office)),
"CorePresetComponent": (PROGRAM, component(core)),
"RetailPresetComponent": (PROGRAM, component(retail)),
"MallPresetComponent": (PROGRAM, component(mall)),
"KitchenPresetComponent": (PROGRAM, component(kitchen)),
"DiningPresetComponent": (PROGRAM, component(dining)),
"OperatingTheatrePresetComponent": (PROGRAM, component(operating_theatre)),
"CleanCorridorPresetComponent": (PROGRAM, component(clean_corridor)),
"DirtyCorridorPresetComponent": (PROGRAM, component(dirty_corridor)),
"ClinicalSupportPresetComponent": (PROGRAM, component(clinical_support)),
"CareBedroomPresetComponent": (PROGRAM, component(care_bedroom)),
"CareCommunalPresetComponent": (PROGRAM, component(care_communal)),
"LobbyPresetComponent": (PROGRAM, component(lobby)),
"ActivityHallPresetComponent": (PROGRAM, component(activity_hall)),
"ServicePresetComponent": (PROGRAM, component(service_rooms)),
"LinearPlanGeneratorComponent": (GENERATE, component(linear_generator)),
"StairBayBarComponent": (GENERATE, component(stair_bay_bar)),
"StairPairComponent": (GENERATE, component(stair_pair)),
"GalleryBarComponent": (GENERATE, component(gallery_bar)),
"TerraceRowComponent": (GENERATE, component(terrace_row)),
"PointPlateComponent": (GENERATE, component(point_plate)),
"WingedBandComponent": (GENERATE, component(winged_band)),
"OpenCourtComponent": (GENERATE, component(open_court)),
"EnclosedCourtComponent": (GENERATE, component(enclosed_court)),
"OfficePlateComponent": (GENERATE, component(office_plate)),
"CafeteriaComponent": (GENERATE, component(cafeteria)),
"RadialLobesComponent": (GENERATE, component(radial_lobes)),
"BranchingMallComponent": (GENERATE, component(branching_mall)),
"CareHubComponent": (GENERATE, component(care_hub)),
"FoyerHallsComponent": (GENERATE, component(foyer_halls)),
"OperatingSuiteComponent": (GENERATE, component(operating_suite)),
"CourtClusterComponent": (GENERATE, component(court_cluster)),
"TransformPlanComponent": (GENERATE, component(transform_plan)),
"NoSimplificationComponent": (SIMPLIFY, component(z0)),
"SemanticMergeComponent": (SIMPLIFY, component(z1)),
"PerimeterCoreComponent": (SIMPLIFY, component(z2)),
"SingleZonePerFloorComponent": (SIMPLIFY, component(z3)),
"StackFloorsComponent": (AGGREGATE, component(stack)),
"StackedFloorZoneMultiplierComponent": (AGGREGATE, component(multiplier)),
"SingleZonePerFloorTypeComponent": (AGGREGATE, component(single_zone_per_floor_type)),
"SingleZoneBuildingComponent": (AGGREGATE, component(single_zone_building)),
"Convert2BemComponent": (CONVERT, component(convert)),
"InspectComponent": (INSPECT, component(inspect)),
"MapSourceToTargetComponent": (INSPECT, component(map_source_target)),
"ValidateComponent": (INSPECT, component(validate)),
"ScheduleParameter": (PROGRAM, parameter(small_schedule)),
"LoadParameter": (PROGRAM, parameter(small_load)),
"ProgramPresetParameter": (PROGRAM, parameter(small_preset)),
"PlanParameter": (GENERATE, parameter(small_plan)),
"FloorParameter": (SIMPLIFY, parameter(small_floor)),
"BuildingParameter": (AGGREGATE, parameter(small_building)),
}
def render(name: str) -> Image.Image:
accent, paint = ICONS[name]
image = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
paint(image, accent)
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
def classes_with_icons() -> set[str]:
"""Concrete classes that show an icon: components, parameters, and the assembly info."""
pattern = re.compile(r"public sealed class (\w+) : (\w+)")
names = set()
for path in SOURCE.rglob("*.cs"):
if "obj" in path.parts or "bin" in path.parts:
continue
for name, _ in pattern.findall(path.read_text(encoding="utf-8")):
if name.endswith(("Component", "Parameter")) or name == "BemGenAssemblyInfo":
names.add(name)
return names
def main() -> int:
expected = classes_with_icons()
missing = sorted(expected - ICONS.keys())
unknown = sorted(ICONS.keys() - expected)
if missing or unknown:
print(f"Classes without an icon: {missing}; icons without a class: {unknown}", file=sys.stderr)
return 1
OUTPUT.mkdir(parents=True, exist_ok=True)
stale = sorted(p.name for p in OUTPUT.glob("*.png") if p.stem not in ICONS)
if stale:
print(f"Icons without a class in {OUTPUT}: {stale}", file=sys.stderr)
return 1
for name in sorted(ICONS):
render(name).save(OUTPUT / f"{name}.png", optimize=False)
print(f"Wrote {len(ICONS)} icons to {OUTPUT.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())
- [x] Step 5: Draw the icons and check the hashes
python scripts/make_icons.py
git status --short
sha256sum src/Lod.Grasshopper/Icons/*.png
Expected: Wrote 59 icons to src\Lod.Grasshopper\Icons; git status --short shows M scripts/make_icons.py, the file of Step 2, and the two new PNGs (?? src/Lod.Grasshopper/Icons/…Component.png), and no existing PNG modified; the hashes (Pillow 12.3.0):
a4e0709b6d8cd05a79c33af9ee864918850e74c8aef2c3ac617859fc08ac2fdc *src/Lod.Grasshopper/Icons/ActivityHallPresetComponent.png
606a11f2bbd6ee6cb7edede403166ae0f1b39231fcf5882dff360717df990cae *src/Lod.Grasshopper/Icons/BemGenAssemblyInfo.png
e33ae284b7f017ee11851986e79e4b8449e1b5027ffedf41cad68ed2abbfb15d *src/Lod.Grasshopper/Icons/BemGenInfoComponent.png
e399a77a78d4425369acec44f0109c0bf7fee9c261e26cf49227dac0a79e3884 *src/Lod.Grasshopper/Icons/BranchingMallComponent.png
431104585e8745167567797eefef526bbf664ba6777101e3677281cb6976bfde *src/Lod.Grasshopper/Icons/BuildingParameter.png
d0a134b08a035f0ca5608468fd9f12804fd21db1a79a8bd3d19ee1b1ac88bd0a *src/Lod.Grasshopper/Icons/CafeteriaComponent.png
9805fc04d927c05f955ef5dfd0e6913e247bfec90c495a8adf051c36637cf866 *src/Lod.Grasshopper/Icons/CareBedroomPresetComponent.png
88900c3c46841a23766ace6b7a1d22a5a3bbb4e6403c2adc71af1fd3c404ff92 *src/Lod.Grasshopper/Icons/CareCommunalPresetComponent.png
5345437fcdf6cb1d454b912816b3e2836edacfe77ef7be233e1453c52eb7ab9a *src/Lod.Grasshopper/Icons/CareHubComponent.png
516ecd2a3fbb009d1b541e392965c78a85bdec71b5a6a99b71caf71628cf31f3 *src/Lod.Grasshopper/Icons/CleanCorridorPresetComponent.png
69d26c3e73a28fc34768fdb14ada2e202940d28ec0ef159786d9be9e2ff8ebd0 *src/Lod.Grasshopper/Icons/ClinicalSupportPresetComponent.png
edc0ff9b82e6161bf8bdf3b5acebc8f4a43644d42d845827a101593d91d1a110 *src/Lod.Grasshopper/Icons/Convert2BemComponent.png
b35b3a0f58c4893203982ac209cb6004dd8a3d6264ad58dc0875a43aa6c65342 *src/Lod.Grasshopper/Icons/CorePresetComponent.png
c8a815ff220dafff9f84af05411eab8c43191b4b6f4be2f0b4750385e1d95489 *src/Lod.Grasshopper/Icons/CorridorPresetComponent.png
9deda397b6112ed3296ae4581a081f45f40c95a86fe19a04914144f5e7ad32eb *src/Lod.Grasshopper/Icons/CourtClusterComponent.png
5e37af921cd4055aeedb4d409d5e966b6515acb880aa28e9080aabf4c593b201 *src/Lod.Grasshopper/Icons/DiningPresetComponent.png
92c7e19aa3faf6fdbf3a4f8d23f6919d18490f19ccf954f41c78efaee741ded6 *src/Lod.Grasshopper/Icons/DirtyCorridorPresetComponent.png
755de6f9d4122d567040d0df98ec3232c76f4f1c0af9da934276fe33585dc528 *src/Lod.Grasshopper/Icons/DwellingUnitPresetComponent.png
04caf35a33a9b6443b7fa808766237aecb235db70ffe0f869002d9d3287223d8 *src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png
324ffc55b8f3aed81902fdaea4ac6293654a194e9f58aa97c9aa088547e584be *src/Lod.Grasshopper/Icons/FloorParameter.png
b4559cae0f76b1d52ebc691bc0a491cba2e2046897d6a1fc9778cdec6886205a *src/Lod.Grasshopper/Icons/FoyerHallsComponent.png
edc98c6c83c0040d935633c0e8fa014ebeeeeae5ef8f596207fd385ad09a0dbe *src/Lod.Grasshopper/Icons/GalleryBarComponent.png
a7191b8f5e30874eaf7d50f0d4555b3565b77157f83ae584393ab9c3b1c3a792 *src/Lod.Grasshopper/Icons/InspectComponent.png
15d1a772ec51f8411eaa7a61cc847b0344147dbf267badc4b61bdc774359a072 *src/Lod.Grasshopper/Icons/KitchenPresetComponent.png
75b4d98c4ad7cffbec53284781d3ad544ccd8ba762c7fd5c33bfd6d6845e42d4 *src/Lod.Grasshopper/Icons/LinearPlanGeneratorComponent.png
acf5163f017f01a1ebb394861f61750e54f9ae8c730a0a6a614f2120f59feadc *src/Lod.Grasshopper/Icons/LoadComponent.png
5045ceb77bc754553aa717110fab061978ef7b51d0c05e0f7f93788a8158f1d5 *src/Lod.Grasshopper/Icons/LoadParameter.png
197cbe6bd28af5e70cc5987020cf0f91b8bd9641afd81f78596e8be7d820a832 *src/Lod.Grasshopper/Icons/LobbyPresetComponent.png
0f1400bcff9d9791690438d777602fa9e00f8405443d8b5108eefdc1d4c5e667 *src/Lod.Grasshopper/Icons/MallPresetComponent.png
438a45a6b5f3071e4918cb6c0f7e6112e4d0ab9ca0ad1aacdc189b4e5de9ecb8 *src/Lod.Grasshopper/Icons/MapSourceToTargetComponent.png
4a702c6b6ae3c4773678a3594228ce4d1e06c2623bc9eeb5bf1fdaaee8e50c30 *src/Lod.Grasshopper/Icons/NoSimplificationComponent.png
7d8823490704b3321526b2c26d4f434f3df9810bf3d9b00d71cf569fc9aebb58 *src/Lod.Grasshopper/Icons/OfficePlateComponent.png
43e15233e02b20e102659703622e13469c5f024f6c587edc9bf2403667390b8f *src/Lod.Grasshopper/Icons/OfficePresetComponent.png
e9504a1cea0d69fe7da893cd10e220358f439b119c3ea86efe6e4d3a9cc29e3e *src/Lod.Grasshopper/Icons/OpenCourtComponent.png
d98e8eb8f24fe7e55eb230fd67dc7090216221cd427b647cd5b905ab9b880f17 *src/Lod.Grasshopper/Icons/OperatingSuiteComponent.png
c376a8fbfc51c257f0d57f1174fd46c110356261f3bbd8d895df89e76a977b49 *src/Lod.Grasshopper/Icons/OperatingTheatrePresetComponent.png
ac5d421e2cded0be0f7797510b864cdfcdd019dd46caed4147251923602184b8 *src/Lod.Grasshopper/Icons/PerimeterCoreComponent.png
a4bd3109301f4fccec8fa698ceca5dcfbb22e4dc839d850dd5aa4241b5cca95b *src/Lod.Grasshopper/Icons/PlanParameter.png
cb207d9cb3ccc6df62da1fbfc077921526b91ffb8e329ee225c97163e7014c3b *src/Lod.Grasshopper/Icons/PointPlateComponent.png
13741aac4525cf0c0d991bf3b673e55cd22612324fdc3ef3a8aaaaf0e66dec45 *src/Lod.Grasshopper/Icons/ProgramPresetComponent.png
bd0a92bf623dd4b3f6b9c4279e425adc44a7287751d97e8206a26e3c26e7e400 *src/Lod.Grasshopper/Icons/ProgramPresetParameter.png
ea3f7960f14b224edeb7eec0c7c52129bec4308f5853347be5313cc0ac5be130 *src/Lod.Grasshopper/Icons/RadialLobesComponent.png
0c84c2d19c379dfe78aea82f8ed400848665472bcc5b6159b7f1036d25af3f02 *src/Lod.Grasshopper/Icons/RetailPresetComponent.png
90646f46d781eca01dcdf67554987ee04fdd80c450e5f94647858b5f7e7b613d *src/Lod.Grasshopper/Icons/ScheduleComponent.png
693d5513ffe5f813ed3a77dc7b68cfce31e008b407e6410ac34c9ed374c6bfaa *src/Lod.Grasshopper/Icons/ScheduleParameter.png
8217bd3bbe904a6026ef7434606168b5d466341f356280a623f92278908a4068 *src/Lod.Grasshopper/Icons/SemanticMergeComponent.png
b60466bed4dd2bdcb51bc0488af40bdeb5db9f2c24d70f12d32bf1d1b147b436 *src/Lod.Grasshopper/Icons/ServicePresetComponent.png
682ee28436ffab1bc47382ff9915e27f2c774d0ef22448422d2316d739b5727f *src/Lod.Grasshopper/Icons/SingleZoneBuildingComponent.png
380ce788eef6adc20c2f45723deae73b4b747d84ffe5e2664001e46d21157c49 *src/Lod.Grasshopper/Icons/SingleZonePerFloorComponent.png
b8bb1b2890ce6ac6273f49e863b3a25ae0c05c87dc37e33b5483913e6ac52162 *src/Lod.Grasshopper/Icons/SingleZonePerFloorTypeComponent.png
5859a90fd17bb2072b1971cd31caa0df678f29bf2ad9f279c910dc59c66ca0f9 *src/Lod.Grasshopper/Icons/StackFloorsComponent.png
aa115c4ba9c51350ff291f45c6e16db41be505bc5153b6ef8159165f3286a37f *src/Lod.Grasshopper/Icons/StackedFloorZoneMultiplierComponent.png
9ffba933d6cd515b59259979ce8ce666f1a40ed906af5d5b25875f25458115f9 *src/Lod.Grasshopper/Icons/StairBayBarComponent.png
d25e67dce5eceed6cb2379c55755d38c495b4ee227069ac54bed185ed751910c *src/Lod.Grasshopper/Icons/StairPairComponent.png
939d76b08d77d2d099bd823473f1d86eb8b16983840230cf995f8ade9d0debcc *src/Lod.Grasshopper/Icons/StairPresetComponent.png
ab59722b25ff1a3407cf7344e1c4595142b7a3bed05b7f952d58f92b51ffae34 *src/Lod.Grasshopper/Icons/TerraceRowComponent.png
3ce5e835f0921d1dee8feeaf23a45aecd36c3aedb55be5a8386fdb3e540e5246 *src/Lod.Grasshopper/Icons/TransformPlanComponent.png
20a372402d7ca8ae673db6a0a1299d7a3b6dcae9141d29de2b535cc543d950ce *src/Lod.Grasshopper/Icons/ValidateComponent.png
61af5011fa771797ae48e99ba73cbb7d1cb165a2ec11784187a94dd0fbfbd704 *src/Lod.Grasshopper/Icons/WingedBandComponent.png
Run python scripts/make_icons.py a second time: the hashes do not change.
- [x] Step 6: Look at the icons
Draw a contact sheet of all icons at 8× (nearest neighbour) into the build folder, which git ignores:
python - <<'EOF'
from pathlib import Path
from PIL import Image
icons = sorted(Path("src/Lod.Grasshopper/Icons").glob("*.png"))
cell = 24 * 8 + 16
sheet = Image.new("RGBA", (6 * cell, -(-len(icons) // 6) * cell), (235, 235, 235, 255))
for i, path in enumerate(icons):
big = Image.open(path).convert("RGBA").resize((24 * 8, 24 * 8), Image.Resampling.NEAREST)
sheet.alpha_composite(big, ((i % 6) * cell + 8, (i // 6) * cell + 8))
Path("src/Lod.Grasshopper/bin").mkdir(parents=True, exist_ok=True)
sheet.save("src/Lod.Grasshopper/bin/icon-sheet.png")
print(len(icons), "icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png")
EOF
Expected: 59 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: Court Cluster is the third icon of the third row, after Corridor Preset (a green block with two transparent court holes side by side, the middle wing between them, and dark stairs in the wings); Operating Suite the fifth icon of the sixth row, after Open Court (a green support block on the left and, to its right, dark dirty corridors at the top and bottom and a white clean corridor between two green banks). Every icon after each of them moves one place on, the 57 icons of S8.5 keep their motifs, and the 59 icons fill nine rows of six and five icons of a tenth.
If the hashes of Step 5 differed (another Pillow version), this check decides: when every icon matches the description above, continue and record the Pillow version in the commit message body; otherwise stop and report the difference. The script is never edited to match hashes.
- [x] Step 7: Build and check the embedded icons
dotnet build BEMGen.sln -c Release
git grep -c "IconLibrary.For(this)" -- src/Lod.Grasshopper
powershell -NoProfile -Command "([Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path 'src/Lod.Grasshopper/bin/Release/net7.0/BEMGen.gha').Path)).GetManifestResourceNames().Count"
Expected: Build succeeded., 0 Warning(s), 0 Error(s); counts src/Lod.Grasshopper/BemGenAssemblyInfo.cs:1, src/Lod.Grasshopper/Components/BemGenInfoComponent.cs:1, src/Lod.Grasshopper/Components/Convert2BemComponent.cs:1, src/Lod.Grasshopper/Components/PipelineComponents.cs:4, src/Lod.Grasshopper/Components/PresetComponents.cs:1, src/Lod.Grasshopper/Components/ProgramComponents.cs:3, src/Lod.Grasshopper/Components/SimplifierComponents.cs:3, src/Lod.Grasshopper/Components/TransformPlanComponent.cs:1, src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs:1, src/Lod.Grasshopper/Parameters/BemParameters.cs:1 (the new components inherit the icon of TypologyGeneratorComponent, as the other typology components do); 59 resource names, one for each PNG of Step 5.
- [x] Step 8: Commit
git add src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): add the operating suite and court cluster components"
Expected before git add: the two modified files and the two new PNGs; nothing under bin/.
- [x] Step 9: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/operating-suite-court-cluster-components
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/operating-suite-court-cluster-components
git branch -d feature/operating-suite-court-cluster-components
Expected: VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0. Do not push (D-098).
Slice C — docs/architecture-operating-suite-court-cluster¶
Task 4: Architecture documents, ADR notes, and the S8.6 smoke-test checklist¶
Files:
- Modify: docs/architecture/pipeline.md, docs/decisions/ADR-006-plan-generation-mechanism.md, docs/decisions/ADR-008-perimeter-core-corners.md, docs/decisions/ADR-002-geometry.md, docs/research/precedents/syn-typologies.md, docs/research/program-presets.md (all last changed in S8.5), docs/development/grasshopper-smoke-test.md (a stray line removed, a section appended)
Interfaces:
- Consumes: Tasks 1–3 (the generators, their rules and defaults, the behaviour of Perimeter Core with several holes, the components, their GUIDs and inputs).
- Produces: pipeline.md after S8.6: both families in step 2, the Perimeter Core sentence on several courts in step 3, "seventeen plan generators", the remaining family 012 for S8.7, and the heading ## Grasshopper components through S8.6 (anchor #grasshopper-components-through-s86) with the two new rows, which the overview of pipeline.md and program-presets.md link to; ADR-006: its status line, an Implemented (S8.6) note per family with the default zones, areas, and windows, one consequence, and the section Pipeline consequences of S8.6; ADR-008: its status line, decision item 6 on several holes, and a consequence (court zones per court and bin, numbered in the union's output order); ADR-002: several holes need no change; syn-typologies.md: the generator cells of 011 and 015 name their components; the smoke-test guide without the stray fence line of S8.5 and with the S8.6 checklist that Task 5 runs.
- [x] Step 1: Create the branch
git switch -c docs/architecture-operating-suite-court-cluster 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.
- Replace
Status: Accepted (by the controller under D-084; provisional pending the owner's reading); extended to the families of the four-source set in S8.4 (by the controller under D-098); hub-and-arms families implemented in S8.5
with
Status: Accepted (by the controller under D-084; provisional pending the owner's reading); extended to the families of the four-source set in S8.4 (by the controller under D-098); hub-and-arms families implemented in S8.5; operating suite and court cluster implemented in S8.6
- Replace
- Not modelled: individual theatres and their anaesthetic and preparation rooms (D-097), pressure cascades (only the presets' ventilation), lifts.
with
- Not modelled: individual theatres and their anaesthetic and preparation rooms (D-097), pressure cascades (only the presets' ventilation), lifts.
- Implemented (S8.6): as specified. Zones `SUP` (*Support*, 261.6 m²), then south to north `DC1` (*Dirty Corridor 1*, 86.4 m²), `TB1` (*Theatre Bank 1*, 252 m²), `CC1` (*Clean Corridor 1*, 108 m²), `TB2`, `DC2`; by default the plan is 48 m × 21.8 m (1046.4 m²). A corridor follows every bank, so *n* banks have *n* + 1 corridors, ⌈*n*/2⌉ clean and ⌊*n*/2⌋ + 1 dirty, and the northmost band is a dirty corridor for an even count and a clean one for an odd count (`CC2` with three banks). Every bank and corridor adjoins `SUP` over its full depth; every bank adjoins its two corridors over the bank length and no other bank; every corridor adjoins only banks and `SUP`. The outdoor walls are `SUP`'s west, south, and north sides, every band's east end, `DC1`'s south side, and the northmost corridor's north side, so a bank's only outdoor wall is its 7 m east end. The theatre preset's WWR is 0, so those walls get no window and no `WindowOmitted` info (ADR-011 rule 1: no glazed area, no window, no diagnostic); the default plan has 8 windows (71.988 m² of glazing): three on `SUP`, two on each dirty corridor, and one at the clean corridor's end. A theatre preset with glazing gives each bank one window on its east end. *Semantic Merge* keeps every zone, since no two zones of one type touch; *Perimeter Core* gives `P-North`, `P-East`, `P-South`, `P-West`, and a `CORE` of 38.86 m × 12.66 m (491.9676 m²) over `SUP`, both banks, and `CC1`; `P-East` carries the glazing of every band's east end. The family has no upper bound; 2 to 5 banks are tested.
- Replace
- Not modelled: courts that are not in one row or not rectangular, the outdoor routes through the courts, a route hierarchy.
with
- Not modelled: courts that are not in one row or not rectangular, the outdoor routes through the courts, a route hierarchy.
- Implemented (S8.6): as specified, on `StairBayLayout` like `005`, which is unchanged: the two generators share the bay geometry and its fitting and differ only in where their wings and holes lie, so no further helper is extracted. With `k` courts the plan is `(k + 1)·D + k·w` wide; middle wing `i` (from 1) lies at `x ∈ [i·(D + w), i·(D + w) + D]` and the east wing at `x ∈ [k·(D + w), k·(D + w) + D]`, both over `y ∈ [D, D + c]`; court `j` (from 1) is the hole `[(j − 1)·(D + w) + D, j·(D + w)] × [D, D + c]`, stored clockwise, holes west to east. Zones `S-ST1…S-ST5`, `S-U1…S-U10` (*South Stair 1*, *South Unit 1*, …; five bays of 6.9 m dwellings in the 84 m south wing), the same for `N-…`, then `W-ST1`, `W-U1`, `W-U2`, `M1-ST1`, `M1-U1`, `M1-U2` (*Middle 1 Stair 1*, *Middle 1 Unit 1*, …), and `E-ST1`, `E-U1`, `E-U2` (one bay of 10.5 m dwellings in each 24 m wing, south to north); 2880 m² (84 m × 48 m less two 24 m × 24 m courts), 39 zones, 72 windows, 32 of them on the court façades, 16 per court (158.4 m² of the 374.4 m² of glazing). The zones of a middle wing have only court façades, west into one court and east into the next. Validation fits the stair bays to the south and north wings only for a valid `CourtCount`, since their length depends on it (`StairWidth 30 m leaves no dwelling width in each 84 m south and north wing: …`), and to the side wings (`… in each 24 m west, middle, and east wing: …`). *Semantic Merge* gives six dwelling zones, an L around each outer corner (374.4 m²) and a T where the middle wing meets the south or north wing (457.2 m²), and the thirteen stairs; no merged zone has a hole. *Perimeter Core* gives `P-North`, `P-East`, `P-South`, `P-West`, one court zone per court and bin, `P-Court-North-1`, `P-Court-North-2`, … `P-Court-West-2` (130.5649 m² each, [ADR-008](ADR-008-perimeter-core-corners.md)), and a `CORE` with one hole per court (712.5404 m²); 9 m wings fail it with `PlanTooNarrow` (D-087). The family has no upper bound; up to 6 courts are tested in the generator's tests and 4 through every simplifier and aggregator.
#### Pipeline consequences of S8.6
Every plan simplifier and floor aggregator was run on both families, canonical, at the two rotated placements of the test suite, with the fewest and the most tested counts (2 and 5 banks, 2 and 4 courts), and with the most at 131.4°, and, while building the reference, in a sweep of 50 rotations from 0° to 353.5° with translations for the operating suite with 2, 3, and 5 banks, the court cluster with 2, 3, and 4 courts, one plan of each with other dimensions, and, as a regression, the enclosed court and the four S8.5 families with their fewest and most arms: 14 400 combinations without a failure. No step of `Lod.Core` changed, and no tolerance or check was relaxed:
- The court cluster is the first footprint with several holes. The surface builder and its boundary coverage, *No Simplification*, *Semantic Merge*, *Single Zone per Floor*, the four floor aggregators, and validation treat every hole as they treat the one hole of `005` ([ADR-002](ADR-002-geometry.md)).
- *Perimeter Core* offsets every hole by the bisector rule and unites the court trapezoids of one bin across all holes, as it did for one hole. The middle wings keep the bands of neighbouring courts apart, so each bin gives one polygon per court, numbered `P-Court-<Bin>-1`, `-2`, … in the union's output order, which is west to east on the canonical plan but is not a court number on a rotated plan. Its check that the offset holes lie apart from each other is what rejects middle wings not wider than twice the depth (`PlanTooNarrow`, D-087, [ADR-008](ADR-008-perimeter-core-corners.md)).
- The theatre banks are the first generated zones whose outdoor walls get no window because their preset's WWR is 0; the base's window rule already places none and reports nothing for them (ADR-011), and every later step conserves the glazing that remains.
- Replace
- The hub-and-arms families share one layout (`HubArmsLayout`, S8.5); arms narrower than the hub leave short hub façades at its corners, which get no window below the ADR-011 limits (the care hub's 0.8 m corners by default).
with
- The hub-and-arms families share one layout (`HubArmsLayout`, S8.5); arms narrower than the hub leave short hub façades at its corners, which get no window below the ADR-011 limits (the care hub's 0.8 m corners by default).
- The court cluster (`SYN-TYP-011`, S8.6) has several holes; *Perimeter Core* gives each court its own court zone per bin, whose number follows the union's output order, not a court. The operating suite (`SYN-TYP-015`, S8.6) has no glazing on its theatre banks with the example theatre preset (WWR 0); the banks' only outdoor walls are their east ends.
- [x] Step 3: Edit
docs/decisions/ADR-008-perimeter-core-corners.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls); extended to footprints with holes in S8.3; perimeter zones keep the footprint vertices since S8.5
with
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls); extended to footprints with holes in S8.3; perimeter zones keep the footprint vertices since S8.5; several holes checked in S8.6
- Replace
6. Trapezoids of the same bin are united, and every vertex of the union in the grid cell of a footprint vertex is replaced by that footprint vertex ([ADR-002](ADR-002-geometry.md)), so the perimeter zones keep the footprint's own vertices instead of vertices rounded to the clipping grid (S8.5); the mitred offset vertices keep their rounding. A bin that unites into one polygon becomes zone `P-<Bin>` ("Perimeter <Bin>"); a bin that yields several polygons becomes `P-<Bin>-1`, `P-<Bin>-2`, … in union output order, so every zone is one polygon. The trapezoids of the holes' edges, which face into the courts, are united per bin separately and become the court perimeter zones `P-Court-<Bin>` ("Perimeter Court <Bin>"), numbered in the same way; the bin is the direction the court façade faces, so the south wing's court façade belongs to `P-Court-North`. Zones are listed North, East, South, West, then the court zones North, East, South, West, then the core `CORE` ("Core"), which has a hole for each court.
with
6. Trapezoids of the same bin are united, and every vertex of the union in the grid cell of a footprint vertex is replaced by that footprint vertex ([ADR-002](ADR-002-geometry.md)), so the perimeter zones keep the footprint's own vertices instead of vertices rounded to the clipping grid (S8.5); the mitred offset vertices keep their rounding. A bin that unites into one polygon becomes zone `P-<Bin>` ("Perimeter <Bin>"); a bin that yields several polygons becomes `P-<Bin>-1`, `P-<Bin>-2`, … in union output order, so every zone is one polygon. The trapezoids of the holes' edges, which face into the courts, are united per bin separately and become the court perimeter zones `P-Court-<Bin>` ("Perimeter Court <Bin>"), numbered in the same way; with several holes the trapezoids of one bin are united across all holes, so each court gives its own polygon and `P-Court-<Bin>-1`, `-2`, … are numbered in union output order (S8.6); the bin is the direction the court façade faces, so the south wing's court façade belongs to `P-Court-North`. Zones are listed North, East, South, West, then the court zones North, East, South, West, then the core `CORE` ("Core"), which has a hole for each court.
- Replace
- Footprints with holes (S8.3, the enclosed court `HDHM-TYP-005` of [ADR-006](ADR-006-plan-generation-mechanism.md)): every ring gets bisector trapezoids, so a court façade is zoned like an outer façade, and the core is a ring around the court. The court façades get their own zones because they are different façades from the outer façades of the same bin, facing a court instead of the street; uniting them with the outer zone of their bin would always give two separate polygons (the core lies between them), numbered `P-North-1`, `P-North-2` in an order that says nothing about which is which. For the default enclosed court (48 m × 48 m with a 24 m × 24 m court, 12 m wings) with `d` = 4.57 m: `P-North`, `P-East`, `P-South`, `P-West` 198.4751 m² each, `P-Court-North` … `P-Court-West` 130.5649 m² each, and a `CORE` ring of 38.86² − 33.14² = 411.84 m². Every wing between the outer and a court façade must be wider than `2d` (D-087): 9 m wings fail with `PlanTooNarrow`. The bands of the outer and court façades never touch, so no trapezoid of a hole is ever united with one of the outer ring.
with
- Footprints with holes (S8.3, the enclosed court `HDHM-TYP-005` of [ADR-006](ADR-006-plan-generation-mechanism.md)): every ring gets bisector trapezoids, so a court façade is zoned like an outer façade, and the core is a ring around the court. The court façades get their own zones because they are different façades from the outer façades of the same bin, facing a court instead of the street; uniting them with the outer zone of their bin would always give two separate polygons (the core lies between them), numbered `P-North-1`, `P-North-2` in an order that says nothing about which is which. For the default enclosed court (48 m × 48 m with a 24 m × 24 m court, 12 m wings) with `d` = 4.57 m: `P-North`, `P-East`, `P-South`, `P-West` 198.4751 m² each, `P-Court-North` … `P-Court-West` 130.5649 m² each, and a `CORE` ring of 38.86² − 33.14² = 411.84 m². Every wing between the outer and a court façade must be wider than `2d` (D-087): 9 m wings fail with `PlanTooNarrow`. The bands of the outer and court façades never touch, so no trapezoid of a hole is ever united with one of the outer ring.
- Footprints with several holes (S8.6, the court cluster `SYN-TYP-011` of [ADR-006](ADR-006-plan-generation-mechanism.md), a row of courts between wings of stair bays) need no change. Every hole is offset by the same rule, and the court trapezoids of each bin are united across all holes; the bands of two courts never touch when the middle wing between them is wider than `2d`, so each bin gives one polygon per court, `P-Court-North-1`, `P-Court-North-2`, …, numbered in the union's output order. On the canonical plan that order is west to east; on a rotated plan the number is not tied to a court, as for the numbered outer zones. For the default court cluster (84 m × 48 m with two 24 m × 24 m courts, 12 m wings) with `d` = 4.57 m: `P-North` and `P-South` 362.9951 m², `P-East` and `P-West` 198.4751 m², eight court zones of 130.5649 m² (as for the enclosed court), and a `CORE` with two holes of 74.86 × 38.86 − 2 × 33.14² = 712.5404 m². A middle wing not wider than `2d` makes the offset holes of its two courts overlap, which the check that clipping the offset holes from the offset outer ring gives one polygon of the core's area rejects (`PlanTooNarrow`): 9 m wings fail. Naming the court zones by their court instead is left for a later ADR, if a study needs to tell the courts apart.
- [x] Step 4: Edit
docs/decisions/ADR-002-geometry.md
Make this replacement; the "Replace" text occurs exactly once in the file.
- Replace
**Footprints with holes (S8.3).** A court is a hole of the footprint, outside the building: its edges are outdoor walls with windows by the generator's rule, it has no zone, and no floor or ceiling covers it, so it adds neither ground nor roof area. Every plan simplifier and floor aggregator supports such footprints ([ADR-008](ADR-008-perimeter-core-corners.md) for *Perimeter Core*, [ADR-013](ADR-013-vertical-aggregation-methods.md) for the floor aggregators). In Grasshopper a polygon with holes is one planar face with an inner loop per hole, and a zone part one closed extrusion of that face. A zone may also have a hole inside a footprint without one: the office work area of `SYN-TYP-017` (S8.4, [ADR-006](ADR-006-plan-generation-mechanism.md)) is a ring around the core; the hole's edges are interzone walls with the zone inside it, and no step needed a change for it.
with
**Footprints with holes (S8.3).** A court is a hole of the footprint, outside the building: its edges are outdoor walls with windows by the generator's rule, it has no zone, and no floor or ceiling covers it, so it adds neither ground nor roof area. Every plan simplifier and floor aggregator supports such footprints ([ADR-008](ADR-008-perimeter-core-corners.md) for *Perimeter Core*, [ADR-013](ADR-013-vertical-aggregation-methods.md) for the floor aggregators). In Grasshopper a polygon with holes is one planar face with an inner loop per hole, and a zone part one closed extrusion of that face. A zone may also have a hole inside a footprint without one: the office work area of `SYN-TYP-017` (S8.4, [ADR-006](ADR-006-plan-generation-mechanism.md)) is a ring around the core; the hole's edges are interzone walls with the zone inside it, and no step needed a change for it. A footprint may have several holes: the court cluster of `SYN-TYP-011` (S8.6) has one per court, and no step needed a change for them either.
- [x] Step 5: Edit
docs/architecture/pipeline.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan; the revision stage S4.1 (D-061 to D-064) gave the plan generator one preset input per space type and added default preset components, a *Preview Location* input, and component icons; the revision stage S4.2 (D-068 to D-075, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md)) added *Transform Plan*, made the floor aggregators designate floors and ceilings on the assembled stack, turned them into four methods, and made heat transfer between zones a conversion option; stage S5 (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)) gives every rebuilt outdoor wall one centred window with the glazed area it covers; stage S8.1 (D-082 to D-084, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md)) adds a plan generator for each bar family of the *High-Density Housing* precedent study and the minimum wall and window geometry of every generator; stage S8.2 (D-084) adds the non-rectangular families without holes, the point plate, the winged band, and the open court, and makes the united zones and the edge tests of the surface builder exact on rotated plans; stage S8.3 (D-084) adds the enclosed court, the first footprint with a hole, which every simplifier and aggregator supports; stage S8.4 (D-093 to D-098, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)) adds the non-residential space types with their presets and default preset components, and the office plate and the cafeteria of the four-source precedent set; stage S8.5 (D-098, D-100) adds the four families that branch from a hub on the shared hub-and-arms layout, the radial lobes, the branching mall, the care hub, and the foyer with halls, and makes the perimeter zones of *Perimeter Core* keep the footprint's own vertices. This document describes each step, the rules it applies, where results are validated, and the Grasshopper components that expose it. The domain types are described in [domain-model.md](domain-model.md), the validation checks in [validation.md](validation.md), and the `Convert2BEM` output layout in [convert2bem.md](convert2bem.md).
with
At Checkpoint 1 (stages S0–S4, D-030) the pipeline is complete for the linear plan; the revision stage S4.1 (D-061 to D-064) gave the plan generator one preset input per space type and added default preset components, a *Preview Location* input, and component icons; the revision stage S4.2 (D-068 to D-075, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md)) added *Transform Plan*, made the floor aggregators designate floors and ceilings on the assembled stack, turned them into four methods, and made heat transfer between zones a conversion option; stage S5 (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)) gives every rebuilt outdoor wall one centred window with the glazed area it covers; stage S8.1 (D-082 to D-084, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md)) adds a plan generator for each bar family of the *High-Density Housing* precedent study and the minimum wall and window geometry of every generator; stage S8.2 (D-084) adds the non-rectangular families without holes, the point plate, the winged band, and the open court, and makes the united zones and the edge tests of the surface builder exact on rotated plans; stage S8.3 (D-084) adds the enclosed court, the first footprint with a hole, which every simplifier and aggregator supports; stage S8.4 (D-093 to D-098, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)) adds the non-residential space types with their presets and default preset components, and the office plate and the cafeteria of the four-source precedent set; stage S8.5 (D-098, D-100) adds the four families that branch from a hub on the shared hub-and-arms layout, the radial lobes, the branching mall, the care hub, and the foyer with halls, and makes the perimeter zones of *Perimeter Core* keep the footprint's own vertices; stage S8.6 (D-098) adds the operating suite with separated clean and dirty routes and the court cluster, the first footprint with several holes. 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).
- Replace
FoyerHalls
with
FoyerHalls | OperatingSuite | CourtCluster
- 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-s85)).
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-s86)).
- Replace
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, `RadialLobesGenerator`, `BranchingMallGenerator`, `CareHubGenerator`, `FoyerHallsGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Point Plate*, *Winged Band*, *Open Court*, *Enclosed Court*, *Office Plate*, *Cafeteria*, *Radial Lobes*, *Branching Mall*, *Care Hub*, *Foyer Halls*, *Transform Plan* |
with
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, `RadialLobesGenerator`, `BranchingMallGenerator`, `CareHubGenerator`, `FoyerHallsGenerator`, `OperatingSuiteGenerator`, `CourtClusterGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Point Plate*, *Winged Band*, *Open Court*, *Enclosed Court*, *Office Plate*, *Cafeteria*, *Radial Lobes*, *Branching Mall*, *Care Hub*, *Foyer Halls*, *Operating Suite*, *Court Cluster*, *Transform Plan* |
- Replace
- Non-residential families place one zone per department (D-097, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)); their default dimensions are controller design defaults (D-098). The remaining families of the four-source set are specified in ADR-006 and built in S8.6–S8.7.
with
- Operating suite (`SYN-TYP-015`, S8.6): `SupportLength` 12 m of support rooms `SUP` (`ClinicalSupport`) across the full depth at the west end; east of them, bands of `BankLength` 36 m stacked from south to north: a dirty corridor, then per bank (`BankCount` ≥ 2, default 2) the bank of `TheatreDepth` 7 m and the next corridor, the corridors alternating dirty and clean: `DC1`, `TB1`, `CC1`, `TB2`, `DC2`, … (`DirtyCorridor` 2.4 m, `OperatingTheatre`, `CleanCorridor` 3 m). Every bank lies between one clean and one dirty corridor, no clean corridor adjoins a dirty one, and every band adjoins `SUP`. A bank's only outdoor wall is its east end, which the theatre preset's WWR 0 leaves without a window (no `WindowOmitted`). Floor height 4.2 m; 1046.4 m², 8 windows.
- Court cluster (`SYN-TYP-011`, S8.6): `CourtCount` (≥ 2, default 2) courts of `CourtWidth` 24 m × `CourtDepth` 24 m in a row along plan x, closed by wings of stair bays `Depth` 12 m deep: south and north wings across the full width `(k + 1) · Depth + k · CourtWidth`, and a west wing, `k − 1` middle wings, and an east wing between them, each middle wing bordering two courts. Bays are fitted to each wing as in the winged band (five bays of 6.9 m dwellings in each 84 m long wing by default). The footprint is the outer rectangle with one hole per court, west to east. Zones `S-…`, `N-…` (west to east), then `W-…`, `M1-…`, …, `E-…` (south to north); 2880 m², 72 windows, 16 on the façades of each court.
- Non-residential families place one zone per department (D-097, [ADR-014](../decisions/ADR-014-program-types-and-department-zoning.md)); their default dimensions are controller design defaults (D-098). The remaining family of the four-source set, `SYN-TYP-012`, is specified in ADR-006 and built in S8.7.
- Replace
- *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…`. A footprint with holes is offset on every ring: the court façades get their own zones `P-Court-North`, `P-Court-East`, `P-Court-South`, `P-Court-West`, named by the direction they face and listed after the outer zones, and the core is a ring around the court (ADR-008, S8.3). The perimeter zones keep the footprint's own vertices, not vertices rounded to the clipping grid, so a façade edge shorter than about 1 m, such as a hub corner of the care hub, is covered by the single-zone aggregators on a rotated plan (ADR-008, S8.5). Every wing or other part of the footprint, including the wings between the outer and the court façades, 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.
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…`. A footprint with holes is offset on every ring: the court façades get their own zones `P-Court-North`, `P-Court-East`, `P-Court-South`, `P-Court-West`, named by the direction they face and listed after the outer zones, and the core is a ring around the court (ADR-008, S8.3). With several courts the court trapezoids of each bin are united across the courts, giving one zone per court and bin, `P-Court-North-1`, `P-Court-North-2`, …, numbered in the union's output order, and a core with one hole per court (ADR-008, S8.6). The perimeter zones keep the footprint's own vertices, not vertices rounded to the clipping grid, so a façade edge shorter than about 1 m, such as a hub corner of the care hub, is covered by the single-zone aggregators on a rotated plan (ADR-008, S8.5). Every wing or other part of the footprint, including the wings between the outer and the court façades and between two courts, 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.
- Replace
## Grasshopper components through S8.5
with
## Grasshopper components through S8.6
- Replace
- **Preview Location (D-062).** The fifteen 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 seventeen plan generators, *Transform Plan*, the four plan simplifiers, and the four floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.
- Replace
- **Typology generators (S8.1, ADR-006).** *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* follow *Linear Plan Generator*: their number inputs (counts are integers) default to the family's `Default` record, then *Floor Height* (3 m) and *Orientation* (0°), then one required preset input per space type they place (*Dwelling Unit*, and *Stair* except for *Terrace Row*; an unconnected one is Grasshopper's missing-input warning, D-065), then *Preview Location*. Their descriptions name the family and what is not modelled. `WindowOmitted` shows as a remark. *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged. *Point Plate*, *Winged Band*, and *Open Court* (S8.2) follow the same pattern with the preset inputs *Dwelling Unit* and *Stair*; on *Point Plate* the *Stair* preset is the core's. *Enclosed Court* (S8.3) follows the same pattern; its plan previews the court as a hole, and every zone, floor, and ceiling Brep of a footprint with holes is one face or closed extrusion with an inner loop per hole (`RhinoGeometry`). *Office Plate* and *Cafeteria* (S8.4) follow the same pattern with the preset inputs *Office* and *Core*, and *Kitchen* and *Dining*; their descriptions name the `SYN-TYP-*` family. The office work area is previewed as a ring around the core. *Radial Lobes*, *Branching Mall*, *Care Hub*, and *Foyer Halls* (S8.5) follow the same pattern, their count input (*Lobe Count*, *Branch Count*, *Wing Count*, *Hall Count*) an integer first, with the preset inputs *Dwelling Unit* and *Stair* (the core's); *Mall* and *Retail*; *Care Bedroom*, *Care Communal*, and *Corridor*; and *Lobby*, *Activity Hall*, and *Service*.
with
- **Typology generators (S8.1, ADR-006).** *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row* follow *Linear Plan Generator*: their number inputs (counts are integers) default to the family's `Default` record, then *Floor Height* (3 m) and *Orientation* (0°), then one required preset input per space type they place (*Dwelling Unit*, and *Stair* except for *Terrace Row*; an unconnected one is Grasshopper's missing-input warning, D-065), then *Preview Location*. Their descriptions name the family and what is not modelled. `WindowOmitted` shows as a remark. *Linear Plan Generator*'s description names `HDHM-TYP-009`; its inputs and GUID are unchanged. *Point Plate*, *Winged Band*, and *Open Court* (S8.2) follow the same pattern with the preset inputs *Dwelling Unit* and *Stair*; on *Point Plate* the *Stair* preset is the core's. *Enclosed Court* (S8.3) follows the same pattern; its plan previews the court as a hole, and every zone, floor, and ceiling Brep of a footprint with holes is one face or closed extrusion with an inner loop per hole (`RhinoGeometry`). *Office Plate* and *Cafeteria* (S8.4) follow the same pattern with the preset inputs *Office* and *Core*, and *Kitchen* and *Dining*; their descriptions name the `SYN-TYP-*` family. The office work area is previewed as a ring around the core. *Radial Lobes*, *Branching Mall*, *Care Hub*, and *Foyer Halls* (S8.5) follow the same pattern, their count input (*Lobe Count*, *Branch Count*, *Wing Count*, *Hall Count*) an integer first, with the preset inputs *Dwelling Unit* and *Stair* (the core's); *Mall* and *Retail*; *Care Bedroom*, *Care Communal*, and *Corridor*; and *Lobby*, *Activity Hall*, and *Service*. *Operating Suite* and *Court Cluster* (S8.6) follow the same pattern, their count input (*Bank Count*, *Court Count*) an integer first, with the preset inputs *Operating Theatre*, *Clean Corridor*, *Dirty Corridor*, and *Clinical Support*, and *Dwelling Unit* and *Stair*; the court cluster's plan previews every court as a hole.
- Replace
| 2 Generate | *Foyer Halls* (FHalls) | Hall Count (2), Hall Length (30), Hall Width (20), Support Length (10), Foyer Width (20), Floor Height (6), Orientation (0), Lobby, Activity Hall, Service, Preview Location → Plan | `FoyerHallsComponent` | `6638efe7-c0e8-45b6-89b1-1e9e3f725c15` | S8.5 |
with
| 2 Generate | *Foyer Halls* (FHalls) | Hall Count (2), Hall Length (30), Hall Width (20), Support Length (10), Foyer Width (20), Floor Height (6), Orientation (0), Lobby, Activity Hall, Service, Preview Location → Plan | `FoyerHallsComponent` | `6638efe7-c0e8-45b6-89b1-1e9e3f725c15` | S8.5 |
| 2 Generate | *Operating Suite* (OSuite) | Bank Count (2), Bank Length (36), Theatre Depth (7), Clean Corridor Width (3), Dirty Corridor Width (2.4), Support Length (12), Floor Height (4.2), Orientation (0), Operating Theatre, Clean Corridor, Dirty Corridor, Clinical Support, Preview Location → Plan | `OperatingSuiteComponent` | `33323afd-abf1-4ecc-bf54-5d67c411c962` | S8.6 |
| 2 Generate | *Court Cluster* (CCluster) | Court Count (2), Court Width (24), Court Depth (24), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `CourtClusterComponent` | `01fc2ca9-3ec0-4142-b827-2519a4523d36` | S8.6 |
- Replace
| Plan generators for the four-source families `SYN-TYP-010`, `014`, `018`, `019` (S8.5), `015`, `011` (S8.6), and `012` with plans per storey (S8.7), specified in ADR-006 | S8.5–S8.7 | an ADR on plans per storey (S8.7, D-095) |
with
| The plan generator for the four-source family `SYN-TYP-012` with plans per storey, specified in ADR-006 | S8.7 | an ADR on plans per storey (S8.7, D-095) |
- [x] Step 6: Edit
docs/research/precedents/syn-typologies.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
| `SYN-TYP-011` | Linked housing clusters around multiple courts | new | FPMH recurrent (3) | recurrent | `CourtClusterGenerator` (specified) | S8.6 |
with
| `SYN-TYP-011` | Linked housing clusters around multiple courts | new | FPMH recurrent (3) | recurrent | `CourtClusterGenerator` (*Court Cluster*) | S8.6 |
- Replace
| `SYN-TYP-015` | Operating suite with separated clean and dirty routes | new | AD provisional (1) | provisional | `OperatingSuiteGenerator` (specified) | S8.6 |
with
| `SYN-TYP-015` | Operating suite with separated clean and dirty routes | new | AD provisional (1) | provisional | `OperatingSuiteGenerator` (*Operating Suite*) | S8.6 |
- [x] Step 7: Edit
docs/research/program-presets.md
Make this replacement; the "Replace" text occurs exactly once in the file.
- Replace
S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." S8.4 adds one default preset component per non-residential space type of §2.2, *Office Preset*, *Core Preset*, *Retail Preset*, *Mall Preset*, *Kitchen Preset*, *Dining Preset*, *Operating Theatre Preset*, *Clean Corridor Preset*, *Dirty Corridor Preset*, *Clinical Support Preset*, *Care Bedroom Preset*, *Care Communal Preset*, *Lobby Preset*, *Activity Hall Preset*, and *Service Preset*, with the same inputs; *Gas Equipment* is in W/m², and *Ventilation* and *Infiltration* are in 1/h. Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s85).
with
S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." S8.4 adds one default preset component per non-residential space type of §2.2, *Office Preset*, *Core Preset*, *Retail Preset*, *Mall Preset*, *Kitchen Preset*, *Dining Preset*, *Operating Theatre Preset*, *Clean Corridor Preset*, *Dirty Corridor Preset*, *Clinical Support Preset*, *Care Bedroom Preset*, *Care Communal Preset*, *Lobby Preset*, *Activity Hall Preset*, and *Service Preset*, with the same inputs; *Gas Equipment* is in W/m², and *Ventilation* and *Infiltration* are in 1/h. Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s86).
- [x] Step 8: Remove the stray fence line at the end of
docs/development/grasshopper-smoke-test.md
The S8.5 close-out (16ead1f) appended its result section together with the closing fence line of the code block it was given, so the file ends with a line of three backticks (line 570) that opens a code block nothing closes, and GitHub renders the rest of the file as code. Remove that one line; the script first checks that it is the last line, after the last item of the S8.5 result:
python - <<'EOF'
from pathlib import Path
fence = "`" * 3
path = Path("docs/development/grasshopper-smoke-test.md")
lines = path.read_text(encoding="utf-8").split("\n")
assert lines[-2:] == [fence, ""], lines[-3:]
assert lines[-3].startswith("- Not performed, open for a person (D-058):"), lines[-3]
del lines[-2]
path.write_text("\n".join(lines), encoding="utf-8")
print(f"removed the stray fence line {len(lines)} of {path.as_posix()}")
EOF
Expected: removed the stray fence line 570 of docs/development/grasshopper-smoke-test.md.
- [x] Step 9: Append the S8.6 checklist at the end of
docs/development/grasshopper-smoke-test.md
Append the content of this code block (not the fence lines around it) after the last line of the file (after the S8.5 result), separated by one blank line, so that it nests under ## Stage checklists like the S2–S8.5 checklists:
### S8.6 checklist — operating suite and court cluster (`v0.8.5`)
S8.6 adds two plan generator components in *2 Generate* ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)): the operating suite with separated clean and dirty routes (`SYN-TYP-015`) and the court cluster around several courts (`SYN-TYP-011`), the first footprint with several holes ([ADR-008](../decisions/ADR-008-perimeter-core-corners.md)). No existing component, input, output, or GUID changes.
#### Toolbar and icons
- [ ] *2 Generate* shows, after *Foyer Halls*, *Operating Suite* (a support block on the left and bands to its right: dark dirty corridors at the top and bottom, a white clean corridor between two green banks) and *Court Cluster* (a block with two court holes and a middle wing between them), each with its own green icon.
- [ ] *Operating Suite* (OSuite) inputs, in order, with their defaults: Bank Count 2, Bank Length 36, Theatre Depth 7, Clean Corridor Width 3, Dirty Corridor Width 2.4, Support Length 12, Floor Height 4.2, Orientation 0, Operating Theatre, Clean Corridor, Dirty Corridor, Clinical Support, Preview Location; one output, *Plan*.
- [ ] *Court Cluster* (CCluster): Court Count 2, Court Width 24, Court Depth 24, Depth 12, Unit Width 7, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location.
- [ ] The count inputs are integers. With a preset input unconnected, each generator shows Grasshopper's missing-input warning and outputs nothing (D-065); a *Clean Corridor Preset* on the *Dirty Corridor* input gives `Error PresetSpaceTypeMismatch [DirtyCorridor]: Input DirtyCorridor needs a DirtyCorridor preset, got 'Example Clean Corridor' (CleanCorridor).`
#### Default plans
1. Place *Operating Suite* with *Operating Theatre Preset*, *Clean Corridor Preset*, *Dirty Corridor Preset*, and *Clinical Support Preset* connected; connect *Inspect* to its *Plan*.
- [ ] No runtime message; a 48 m × 21.8 m plan of 1046.4 m², 4.2 m high: `SUP` at x = 0–12 m over the full depth, then from south to north `DC1` (y = 0–2.4 m), `TB1`, `CC1`, `TB2`, `DC2` (y = 19.4–21.8 m), each at x = 12–48 m.
- [ ] 8 windows, 71.988 m² of glazing: three on `SUP`, two on each dirty corridor, one at the clean corridor's east end; the banks' east walls have no window (WWR 0) and no `WindowOmitted` remark.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/OperatingSuite/Snapshots/operating-suite-canonical.txt`.
2. Place *Court Cluster* with *Dwelling Unit Preset* and *Stair Preset* connected; connect *Inspect*.
- [ ] No runtime message; an 84 m × 48 m block of 2880 m² with two courts as holes at x = 12–36 m and x = 48–72 m, y = 12–36 m; 39 zones: `S-…` and `N-…` (five stairs and ten dwellings each), `W-…`, `M1-…`, `E-…` (one stair and two dwellings each).
- [ ] 72 windows, 374.4 m² of glazing, 32 of them (158.4 m²) on the court façades, facing into the courts; `M1-U1` has windows to the west and the east.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/CourtCluster/Snapshots/court-cluster-canonical.txt`.
#### Combinations
3. Connect each 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.
- [ ] Operating suite: *Semantic Merge* keeps all six zones; *Perimeter Core* gives `P-North`, `P-East`, `P-South`, `P-West`, and a `CORE` of 491.9676 m².
- [ ] Court cluster: *Semantic Merge* gives six dwelling zones and thirteen stairs, none with a hole; *Perimeter Core* gives `P-North` to `P-West`, `P-Court-North-1`, `P-Court-North-2` to `P-Court-West-2` (130.5649 m² each), and a `CORE` of 712.5404 m² with two holes; *Single Zone Building* previews one solid with two courts through every storey.
- [ ] For all 16 combinations of each plan, *Validate* gives Passed = True (only `ConditionedFloorArea` may read `note`), no aggregator shows a warning, and *Convert2BEM* with Override = False converts without an error; every zone Brep has a positive volume (D-091).
#### Counts and rotated placement
4. Set Bank Count to 3 and 5 and Court Count to 3 and 4, and repeat step 3 for each.
- [ ] Bank Count 3: the northmost band is `CC2`, a clean corridor with a north façade; Court Count 3: two middle wings `M1-…` and `M2-…` and three holes; *Perimeter Core* gives `P-Court-<Bin>-1` to `-3`.
- [ ] *Validate* gives Passed = True for every combination.
5. Put each default plan through *Transform Plan* with a 30° rotation and a move by 5.3, −2.1, 0, then through each simplifier and aggregator (Multipliers 1, 3, 1); repeat with 131.4° and 15.08, −153.9, 0, and with Bank Count 5 and Court Count 4 at 131.4°.
- [ ] *Validate* gives Passed = True for all 16 combinations of each placement, with the same total glazing as the unrotated plan.
#### Parameters
6. Change inputs one at a time and set each back afterwards.
- [ ] *Operating Suite* Bank Count 1: `Error InvalidParameter: BankCount must be at least 2 (the theatre banks repeat beside the support rooms), got 1.`; Bank Length 0: `Error InvalidParameter: BankLength must be positive, got 0.`
- [ ] *Operating Theatre Preset* WWR 0.2: the operating suite has 10 windows, one on the east end of each bank.
- [ ] *Court Cluster* Court Count 1: `Error InvalidParameter: CourtCount must be at least 2 (one court is SYN-TYP-005, Enclosed Court), got 1.`
- [ ] *Court Cluster* Unit Width 0.5 and Stair Width 30: `Error InvalidParameter: StairWidth 30 m leaves no dwelling width in each 84 m south and north wing: its 3 stair bays need 90 m of stairs; reduce StairWidth or UnitWidth.` and `… in each 24 m west, middle, and east wing: its one stair bay needs 30 m of stairs; reduce StairWidth or UnitWidth.`
- [ ] *Court Cluster* Depth 9 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 10: Check the code fences of the edited documents
Check the code fences of the seven documents of this task: every opening fence line has a language, every closing one is bare, and no file ends inside a block.
python - <<'EOF'
from pathlib import Path
fence = "`" * 3
names = [
"docs/architecture/pipeline.md",
"docs/decisions/ADR-002-geometry.md",
"docs/decisions/ADR-006-plan-generation-mechanism.md",
"docs/decisions/ADR-008-perimeter-core-corners.md",
"docs/development/grasshopper-smoke-test.md",
"docs/research/precedents/syn-typologies.md",
"docs/research/program-presets.md",
]
for name in names:
lines = Path(name).read_text(encoding="utf-8").split("\n")
inside, blocks, problems = False, 0, []
for number, line in enumerate(lines, 1):
mark = line.strip()
if not mark.startswith(fence):
continue
if not inside and mark == fence:
problems.append(f"line {number} opens a block without a language")
if inside and mark != fence:
problems.append(f"line {number} opens a block inside a block")
blocks += inside
inside = not inside
if inside:
problems.append("the file ends inside a block")
print(f"{name}: {blocks} code blocks, " + ("; ".join(problems) or "fences balanced"))
EOF
Expected:
docs/architecture/pipeline.md: 1 code blocks, fences balanced
docs/decisions/ADR-002-geometry.md: 0 code blocks, fences balanced
docs/decisions/ADR-006-plan-generation-mechanism.md: 0 code blocks, fences balanced
docs/decisions/ADR-008-perimeter-core-corners.md: 0 code blocks, fences balanced
docs/development/grasshopper-smoke-test.md: 2 code blocks, fences balanced
docs/research/precedents/syn-typologies.md: 0 code blocks, fences balanced
docs/research/program-presets.md: 1 code blocks, fences balanced
If a line reports anything else, stop: find the stray or missing fence line, fix the file, and run the check again.
- [x] Step 11: Check the links and commit
git grep -n "grasshopper-components-through-s85" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s86" -- docs/architecture/pipeline.md docs/research/program-presets.md
git grep -c -e "Implemented (S8.6)" -e "### Pipeline consequences of S8.6" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git add docs/architecture/pipeline.md docs/decisions/ADR-006-plan-generation-mechanism.md docs/decisions/ADR-008-perimeter-core-corners.md docs/decisions/ADR-002-geometry.md docs/research/precedents/syn-typologies.md docs/research/program-presets.md docs/development/grasshopper-smoke-test.md
git commit -m "docs(architecture): document the operating suite, the court cluster, and their components"
Expected: no output from the first command; docs/architecture/pipeline.md:1, docs/research/program-presets.md:1 from the second; and docs/decisions/ADR-006-plan-generation-mechanism.md:3 from the third.
Task 5: Smoke test (the controller headless, D-056; viewport items for a person)¶
Files: none (the result goes into docs/development/grasshopper-smoke-test.md and the body of the close-out commit, Task 6)
- [x] Step 1: Build the branch and check that it is the tested code
dotnet build BEMGen.sln -c Release
git rev-parse HEAD
git diff --stat 373af00d04cb32932947462f081c742711bcb638 HEAD -- src tests scripts
Expected: Build succeeded., 0 Warning(s), 0 Error(s); note the SHA; the last command prints nothing: src/, tests/, and scripts/ are identical to the verified reference 373af00 on which the controller ran the headless session (D-056) whose values Step 2 lists (note 9). 373af00 is the tip of the local reference branch ref/s8.6 in the controller's repository. If the command prints anything, or the commit is not available, stop: the record of Step 2 does not apply, and the session is run again on this branch.
- [x] Step 2: The S8.6 checklist and its record
The controller ran the checks that do not need a viewport as a scripted headless Grasshopper session (D-056: Rhino 8 /nosplash /netcore with RHINO_PACKAGE_DIRS pointing at src/Lod.Grasshopper/bin/Release/net7.0/, components placed by GUID, states, messages, outputs, data trees, bounding boxes, Brep face, solid, and validity counts, Brep.GetVolume, and BrepFace.NormalAt at each face's domain centre written to a file; 27 scenarios) on the verified reference 373af00 in Rhino 8.25.25314.11001 (.NET 8.0.31); the reference assembly reported the informational version 0.8.4+373af00d04cb32932947462f081c742711bcb638 (the bump to 0.8.5 comes in Task 6). "The presets" are the default preset components of the family with nothing connected; "1, 3, 1" means a simplified floor given three times to an aggregator with those multipliers. Observed, and all passed:
- Registration. 52 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, both primary: Operating Suite OSuite
33323afd-abf1-4ecc-bf54-5d67c411c962and Court Cluster CCluster01fc2ca9-3ec0-4142-b827-2519a4523d36. Every other component and parameter as in S8.5. - Inputs. The inputs, nicknames, and defaults of the table of Task 3, in that order: the count inputs
Param_Integerwith persistent values 2 and 2, the lengthsParam_Number, the presets requiredProgramPresetParameterinputs, Preview Location an optional vector{0,0,0}. With the presets unconnected, the warning state withInput parameter OT failed to collect data,Input parameter Clean failed to collect data,Input parameter Dirty failed to collect data, andInput parameter CS failed to collect data(Operating Suite),DUandSt(Court Cluster), and no output. - Operating suite defaults. Operating Suite with Operating Theatre Preset, Clean Corridor Preset, Dirty Corridor Preset, and Clinical Support Preset → Inspect; → No Simplification → Stack Floors (one floor) → Convert2BEM; and → Semantic Merge, Perimeter Core (default depth), and Single Zone per Floor: no runtime message. Plan box (0, 0, 0)–(48, 21.8, 4.2); Inspect
footprint area=1046.400000 floorHeight=4.200000 orientation=0.000000; floors of 6, 5, and 1 zones; Boundaries Adiabatic 18, Ground 6, Outdoors 16; Windows 8 (three on the support rooms, two on each dirty corridor, one on the clean corridor, none on the banks). Zones: the support rooms a valid closed Brep of 6 faces, volume 1098.72, box (0, 0, 0)–(12, 21.8, 4.2); the dirty corridors 362.88, the banks 1058.4, the clean corridor 453.6, all valid closed Breps of 6 faces. Perimeter Core:P-NorthandP-Southof volume 833.595,P-EastandP-Westof 330.713, and the core of 2066.264, box (4.57, 4.57, 0)–(43.43, 17.23, 4.2). Single Zone per Floor: one Brep of 6 faces, volume 4394.88. Single Zone Building (1, 3, 1) → Validate: Passed =True; the preview one solid (0, 0, 0)–(48, 21.8, 21). - Court cluster defaults. The same with Dwelling Unit Preset and Stair Preset: no runtime message; plan box (0, 0, 0)–(84, 48, 3); Inspect
footprint area=2880.000000 floorHeight=3.000000 orientation=0.000000; floors of 19, 13, and 1 zones; Boundaries Adiabatic 104, Ground 39, Outdoors 111; Windows 72 on the 39 zones. Zones: 13 stairs of volume 108, 20 dwellings of the long wings of 248.4, and 6 dwellings of the side and middle wings of 378, all valid closed Breps of 6 faces. Perimeter Core:P-NorthandP-Southof volume 1088.985,P-EastandP-Westof 595.425, eight court zones of 391.695 (four per court), and the core a valid closed Brep of 14 faces with two holes in its top and bottom faces, volume 2137.621, box (4.57, 4.57, 0)–(79.43, 43.43, 3). Single Zone per Floor: one Brep of 14 faces with the two courts as holes, volume 8640. With three courts, Perimeter Core gives 17 zones, twelve court zones of 391.695 and a core of 18 faces with three holes, volume 3039.722. Single Zone Building (1, 3, 1): Validate Passed =True; the preview one solid (0, 0, 0)–(84, 48, 15). - Every combination, also rotated, and with the fewest and most counts. Each plan on its canonical placement, through Transform Plan with a 30° rotation about the world z axis and a move by (5.3, −2.1, 0), and with a 131.4° rotation and a move by (15.08, −153.9, 0), and with its fewest and its largest tested count (2 and 5 banks, 2 and 4 courts) at the 131.4° placement, then 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 and Convert2BEM: no runtime message, and Validate Passed =
Truefor all 160 buildings. The 92 zone Breps inspected in items 3 and 4 are valid closed solids; no Brep has a negative volume in any scenario of items 3 to 5. - Parameters. Bank Count 1:
Error InvalidParameter: BankCount must be at least 2 (the theatre banks repeat beside the support rooms), got 1.; Court Count 1:Error InvalidParameter: CourtCount must be at least 2 (one court is SYN-TYP-005, Enclosed Court), got 1.; Bank Length 0:Error InvalidParameter: BankLength must be positive, got 0.; Court Cluster Unit Width 0.5 and Stair Width 30:Error InvalidParameter: StairWidth 30 m leaves no dwelling width in each 84 m south and north wing: its 3 stair bays need 90 m of stairs; reduce StairWidth or UnitWidth.andError InvalidParameter: StairWidth 30 m leaves no dwelling width in each 24 m west, middle, and east wing: its one stair bay needs 30 m of stairs; reduce StairWidth or UnitWidth.; Court Cluster Depth 9 into Perimeter Core:Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.
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 bands, the courts as holes, the zone colours), the Inspect reports compared with the canonical snapshots (the generator tests check them), the zone IDs of Semantic Merge and Perimeter Core (the integration tests check them), the glazing values of Validate, and Convert2BEM with Override = False on each of the combinations. No example definition is part of S8.6; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).
If Step 1 printed a difference, or a person or a repeated session finds a failure, the stage stops: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Task 6 records this result in the guide and in the close-out commit, following the guide's "Recording the result".
- [x] Step 3: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/architecture-operating-suite-court-cluster
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-operating-suite-court-cluster
git branch -d docs/architecture-operating-suite-court-cluster
Expected: VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0. Do not push (D-098).
Stage close-out¶
Task 6: Version 0.8.5, S8.6 records, tag v0.8.5¶
Files:
- Modify: Directory.Build.props, AGENTS.md, README.md, docs/plans/2026-09-30-implementation-roadmap.md, docs/development/grasshopper-smoke-test.md, docs/decisions/decision-log.md, docs/plans/2026-10-02-s8.6-operating-suite-court-cluster.md (this plan: checkboxes and status, D-051)
Interfaces:
- Consumes: the merged Slices A–C; the smoke-test record of Task 5; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump.
- Produces: the tag v0.8.5; the S8.6 records: the project state, the status, the roadmap's Progress line and detailed-plans row, the smoke-test result in the guide, the decision log without the two stray fence lines of S8.5, and the decision-log entries D-104 (the layout choices of S8.6: the corridor count of the operating suite and the stricter window check of the generator tests, no helper shared between 005 and 011 beyond StairBayLayout, Perimeter Core court zones numbered in the union's output order) and D-105 (S8.6 complete, with the markup repair).
The decision-log entries of Step 9 and the roadmap Progress line of Step 6 have one field filled in when this task runs: <date>, the date the stage is closed (ISO format, YYYY-MM-DD). The entries are decided by the controller (unsupervised run, D-098) and Provisional. The close-out uses its own branch, as the S1–S8.5 close-outs do. Text that this task appends or inserts is the content of its code blocks only: the fence lines around a block are never copied (the S8.5 close-out copied the closing fence lines into the smoke-test guide and the decision log; Task 4 and Step 8 remove them, and Step 10 checks every file).
- [x] Step 1: Create the branch and run the gate
git switch -c chore/repo-s8.6-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
Expected: VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0.
- [x] Step 2: Bump the version in
Directory.Build.props
Replace
<Version>0.8.4</Version>
with
<Version>0.8.5</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.5</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.5"
- [x] Step 5: Update
AGENTS.mdandREADME.md
In AGENTS.md, replace everything between the heading ## Project state and the heading ## Commands (both headings stay, with one blank line after the first and before the second) with the content of this block:
Checkpoint 1 (D-030) is reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), and so are the revision stages S4.1 (tag `v0.4.1`, D-064) and S4.2 (tag `v0.4.2`, D-073), stage S5 (tag `v0.5.0`, D-079), and sub-stages S8.1 (tag `v0.8.0`), S8.2 (tag `v0.8.1`), S8.3 (tag `v0.8.2`, D-084), S8.4 (tag `v0.8.3`, D-101), S8.5 (tag `v0.8.4`, D-103), and S8.6 (tag `v0.8.5`, D-105). The pipeline runs end to end for seventeen plan generators: program presets (the residential `ExampleResidentialPresets` and, for the non-residential departments of [ADR-014](docs/decisions/ADR-014-program-types-and-department-zoning.md), `ExampleNonResidentialPresets`, one typed preset and default preset component per space type) → a plan generator (one `PlanGenerator` subclass per typology family with typed parameter and preset records, [ADR-006](docs/decisions/ADR-006-plan-generation-mechanism.md); `SYN-TYP-001` to `009` are the *High-Density Housing* families `HDHM-TYP-001` to `009`, D-099: `LinearPlanGenerator`, the double-sided internal corridor bar `SYN-TYP-009`; the bar families `StairBayBarGenerator` 001, `StairPairGenerator` 008, `GalleryBarGenerator` 002 with its gallery not modelled, and `TerraceRowGenerator` 006 with one dwelling zone per plot and storey, D-085; the non-rectangular families `PointPlateGenerator` 007, a square plate whose core has no outdoor wall, `WingedBandGenerator` 003, a comb, and `OpenCourtGenerator` 004, a U; `EnclosedCourtGenerator` 005, four wings around a court closed on all four sides, the first footprint with a hole; and the non-residential `OfficePlateGenerator` `SYN-TYP-017`, whose office work area is one ring-shaped zone around a central `Core` zone, `CafeteriaGenerator` `SYN-TYP-016`, a `Kitchen` zone along the whole north edge of a larger `Dining` zone; the hub-and-arms families on the shared `HubArmsLayout`, a square hub with up to four arms (D-100, D-102): `RadialLobesGenerator` 010, `BranchingMallGenerator` 014, `CareHubGenerator` 018, and `FoyerHallsGenerator` 019; and the operating suite `OperatingSuiteGenerator` 015, theatre banks between alternating clean and dirty corridors beside the support rooms, and the court cluster `CourtClusterGenerator` 011, stair-bay wings around a row of courts, the first footprint with several holes (D-104); the non-residential ones zoned one zone per department, D-097; one typed preset per space type, D-061; optionally rotated and moved by `PlanTransform`, D-070) → plan simplifiers (`NoSimplification`, `SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`) → floor aggregators (`Stack`, `StackedFloorZoneMultiplier`, `SingleZonePerFloorType`, `SingleZoneBuilding`, D-071; floors and ceilings designated on the assembled stack, D-068) → validation → `Convert2BEM` (heat transfer between zones an export option, off by default, D-069), as described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md). Every generator places one centred window per outdoor wall (D-026), except on a wall shorter than 1 m or where the window would be narrower or lower than 0.3 m (`GeometryLimits`, info `WindowOmitted`, [ADR-011](docs/decisions/ADR-011-minimum-wall-and-window-geometry.md)). Wherever zones are merged and walls are rebuilt (`SemanticMerge`, `PerimeterCore`, `SingleZonePerFloor`, `SingleZonePerFloorType`, `SingleZoneBuilding`), every outdoor wall carries one centred window with the glazed area of the source windows it covers (`CentredWindows`, D-079, [ADR-012](docs/decisions/ADR-012-centred-windows.md)); the other steps keep their input's windows. Merged zones are unions of their sources as tiles that keep the sources' own vertices, so they may have holes and stay exact on rotated plans, and vertices on edges are recognised at their original positions (D-088, [ADR-002](docs/decisions/ADR-002-geometry.md)). Every step supports footprints with holes: a court is outside the building, its edges are outdoor walls facing into it, every footprint edge, court edges included, must be covered by outdoor zone edges (ADR-002), and `PerimeterCore` gives the court façades their own zones `P-Court-<Bin>` around a core that is a ring, several of them in the court cluster, where *Perimeter Core* gives each court its own `P-Court-<Bin>-k` zones ([ADR-008](docs/decisions/ADR-008-perimeter-core-corners.md)); `PerimeterCore` needs every part of a footprint, including the wings between the outer and the court façades, to be wider than twice its depth and otherwise fails with `PlanTooNarrow` (D-087). Its perimeter zones keep the footprint's own vertices, so short façades stay covered on rotated plans (ADR-008, S8.5). Vertical aggregation follows [ADR-013](docs/decisions/ADR-013-vertical-aggregation-methods.md), which supersedes [ADR-009](docs/decisions/ADR-009-vertical-aggregation.md) where it differs. In *Convert2BEM* and the previews, zone solids are outward-oriented and every surface and window faces out of its zone (D-091). ADR-006, ADR-011, ADR-012, ADR-013, ADR-014, and the S4.2, S5, S8.1, S8.2, S8.3, S8.4, S8.5, and S8.6 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084, D-098). In Grasshopper every component has an icon drawn by `scripts/make_icons.py` (D-063), and the generator, *Transform Plan*, simplifier, and aggregator components have a *Preview Location* input that moves only their preview (D-062). Until the owner says otherwise, changes need not keep backward compatibility, but component GUIDs never change (D-064).
The precedent set is the four-source family set `SYN-TYP-001` to `SYN-TYP-019` of every program type, housing and non-residential ([docs/research/precedents/syn-typologies.md](docs/research/precedents/syn-typologies.md), D-093, D-094); `SYN-TYP-013` is set aside (D-096), non-residential floors are zoned one zone per department (D-097, [ADR-014](docs/decisions/ADR-014-program-types-and-department-zoning.md)), and BEMGen documents use the `SYN-TYP-*` IDs (D-099). The families `017` and `016` were built in S8.4, `010`, `014`, `018`, `019` in S8.5, the hub-and-arms families with at most four arms because the shared layout is axis-aligned (D-100), and `015` and `011` in S8.6; `012` (S8.7) is specified in ADR-006 and built before S6 (D-098).
Not implemented yet: the stepped bands of S8.7 (`SYN-TYP-012`), `Lod.Export` and Convert2IDF (S6), and Grasshopper UX and release (S9). The precedent study of S7 was carried out in a separate repository (D-082); its nine families are paraphrased in [docs/research/precedents/hdhm-typologies.md](docs/research/precedents/hdhm-typologies.md). A zone only about 1 m wide on a rotated plan can fail validation after a merging simplifier, a known limit of clipping on the grid (D-090). The example program presets hold illustrative values, not DOE prototype values, and the `Convert2BEM` output layout stays provisional until a ClimateStudio reference definition exists (D-023).
In README.md, replace everything between the heading ## Status and the heading ## What it does (both headings stay, with one blank line after the first and before the second) with the content of this block:
Checkpoint 1 reached: stages S0–S4 of the [implementation roadmap](docs/plans/2026-09-30-implementation-roadmap.md) are complete (tag `v0.4.0`), followed by the revision stages S4.1 (tag `v0.4.1`) and S4.2 (tag `v0.4.2`), stage S5 (tag `v0.5.0`), and sub-stages S8.1 (tag `v0.8.0`), S8.2 (tag `v0.8.1`), S8.3 (tag `v0.8.2`), S8.4 (tag `v0.8.3`), S8.5 (tag `v0.8.4`), and S8.6 (tag `v0.8.5`). In Rhino 8 the plugin runs the whole pipeline for seventeen plan generators: program presets with conditioning (a default preset component per space type, housing and non-residential); the linear plan generator and the eight other typologies of the *High-Density Housing* precedent study (`SYN-TYP-001`–`009`, ADR-006), the bars *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row*, the non-rectangular *Point Plate*, *Winged Band*, and *Open Court*, *Enclosed Court*, whose footprint has the court as a hole, the non-residential *Office Plate* (`SYN-TYP-017`, a work-area ring around a central core) and *Cafeteria* (`SYN-TYP-016`, a kitchen and servery edge beside a dining field), and the branching plans on one hub-and-arms layout, *Radial Lobes* (`SYN-TYP-010`), *Branching Mall* (`SYN-TYP-014`), *Care Hub* (`SYN-TYP-018`), *Foyer Halls* (`SYN-TYP-019`), the operating suite *Operating Suite* (`SYN-TYP-015`, theatre banks between separate clean and dirty corridors), and *Court Cluster* (`SYN-TYP-011`, wings around a row of courts, one hole per court), the non-residential ones zoned one zone per department (D-097, ADR-014), each with one preset input per space type and explicit windows (none on a wall shorter than 1 m or for a window below 0.3 m, ADR-011); *Transform Plan* to rotate and move a plan; four plan simplifiers (Z0–Z3); four floor aggregators (Stack, StackedFloorZoneMultiplier, SingleZonePerFloorType, SingleZoneBuilding) that designate floors and ceilings on the assembled stack and warn about unsupported storeys; invariant validation; and `Convert2BEM`, which writes heat transfer between zones only when asked (off by default). Wherever zones are merged and walls are rebuilt, every outdoor wall carries one centred window with the glazed area of the windows it covers, so glazing is conserved per wall, façade, orientation, and building (D-079); merged zones stay exact on rotated plans (D-088); every simplifier and aggregator supports footprints with one or more holes, with the court façades as outdoor walls; and zone solids, surfaces, and windows are oriented out of their zones (D-091). Every component has an icon, and every component with a viewport preview has a *Preview Location* input that moves only its preview. The pipeline is described in [docs/architecture/pipeline.md](docs/architecture/pipeline.md); the example definition `examples/grasshopper/checkpoint-1-pipeline.gh`, which wires every simplifier into every aggregator, is still to be built by a person. Next: S8.7 (the stepped bands of `SYN-TYP-012`, with plans per storey, D-095, D-098), then Convert2IDF (S6) and Grasshopper UX and release (S9). The `Convert2BEM` output stays provisional until a ClimateStudio reference definition exists (D-023), and the example program presets hold illustrative values, not DOE prototype values.
In README.md, make this replacement in the "Planned layout" block; the "Replace" text occurs exactly once in the file.
- Replace
Lod.Generators/ plan generators, one per typology family (the linear plan, the S8.1 bars, the S8.2 non-rectangular families, the S8.3 enclosed court, the S8.4 office plate and cafeteria, and the S8.5 hub-and-arms families)
with
Lod.Generators/ plan generators, one per typology family (the linear plan, the S8.1 bars, the S8.2 non-rectangular families, the S8.3 enclosed court, the S8.4 office plate and cafeteria, the S8.5 hub-and-arms families, and the S8.6 operating suite and court cluster)
- [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.6 Progress line below the S8.5 Progress line (the last > **Progress:** line), with a > separator line between them; the second adds the S8.6 row of the detailed-plans table.
- Replace
> **Progress:** 2026-10-02 · S8.5 Branching plans on a shared hub-and-arms layout complete (tag `v0.8.4`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.6. `HubArmsLayout` lays out a square hub with up to four arms (D-100, D-102) for four new generators and components: *Radial Lobes* (010), *Branching Mall* (014, one mall zone through every branch), *Care Hub* (018), and *Foyer Halls* (019); *Perimeter Core* keeps the footprint's own vertices, which repairs short façades on rotated plans (ADR-008); existing snapshots byte-identical, four new; 193 core, 360 generator, and 1184 integration tests pass on `net8.0`.
with
> **Progress:** 2026-10-02 · S8.5 Branching plans on a shared hub-and-arms layout complete (tag `v0.8.4`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.6. `HubArmsLayout` lays out a square hub with up to four arms (D-100, D-102) for four new generators and components: *Radial Lobes* (010), *Branching Mall* (014, one mall zone through every branch), *Care Hub* (018), and *Foyer Halls* (019); *Perimeter Core* keeps the footprint's own vertices, which repairs short façades on rotated plans (ADR-008); existing snapshots byte-identical, four new; 193 core, 360 generator, and 1184 integration tests pass on `net8.0`.
>
> **Progress:** <date> · S8.6 Operating suite and court clusters complete (tag `v0.8.5`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.7. Two new generators and components: *Operating Suite* (015, theatre banks between alternating clean and dirty corridors beside the support rooms) and *Court Cluster* (011, stair-bay wings around a row of courts, the first footprint with several holes, which every step already handles; D-104); existing snapshots byte-identical, two new; 193 core, 427 generator, and 1385 integration tests pass on `net8.0`.
- Replace
| S8.5 | [2026-10-02-s8.5-hub-and-arms-typologies.md](2026-10-02-s8.5-hub-and-arms-typologies.md) | 193 core, 360 generators, 1184 integration |
with
| S8.5 | [2026-10-02-s8.5-hub-and-arms-typologies.md](2026-10-02-s8.5-hub-and-arms-typologies.md) | 193 core, 360 generators, 1184 integration |
| S8.6 | [2026-10-02-s8.6-operating-suite-court-cluster.md](2026-10-02-s8.6-operating-suite-court-cluster.md) | 193 core, 427 generators, 1385 integration |
The S8.6 row of the sub-stage table (written in S8.4) stays as it is.
- [x] Step 7: Record the smoke test in
docs/development/grasshopper-smoke-test.md
Append the content of this code block (not the fence lines around it) at the end of docs/development/grasshopper-smoke-test.md (after the S8.6 checklist of Task 4), separated by one blank line; it records Task 5 as the guide's "Recording the result" asks, and the commit body of Step 11 repeats it:
#### Result (S8.6)
Run by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02, on the verified reference of this sub-stage (`373af00d04cb32932947462f081c742711bcb638`, whose `src/`, `tests/`, and `scripts/` are identical to the executed branch): Rhino 8.25.25314.11001, .NET 8.0.31, `BEMGen.gha` built in Release with 0 warnings, *BEMGen Info* version `0.8.4+373af00d04cb32932947462f081c742711bcb638` (the version becomes 0.8.5 at the close-out); 27 scenarios.
- Passed: *Operating Suite* (OSuite) and *Court Cluster* (CCluster) in *2 Generate*, both primary, with the GUIDs of the S8.6 plan and 24 × 24 icons; the integer count input first, the presets in record order (Operating Theatre, Clean Corridor, Dirty Corridor, Clinical Support; Dwelling Unit, Stair), *Preview Location* last; unconnected presets give Grasshopper's missing-input warnings.
- Passed: *Operating Suite* defaults: box (0, 0, 0)–(48, 21.8, 4.2), footprint 1046.4 m²; *Semantic Merge* 6 zones, *Perimeter Core* 5, *Single Zone per Floor* 1; *Stack Floors* (one floor) boundaries Adiabatic 18, Ground 6, Outdoors 16; 8 windows, none on the theatre banks. *Court Cluster*: box (0, 0, 0)–(84, 48, 3), footprint 2880 m²; 19, 13, and 1 zones; Adiabatic 104, Ground 39, Outdoors 111; 72 windows; with three courts *Perimeter Core* gives 17 zones.
- Passed: the 92 zone Breps inspected are valid solids, with no negative volume anywhere; the *Perimeter Core* core of the court cluster is one solid with two holes (three with three courts); *Single Zone Building* (1, 3, 1) previews one solid per family, (0, 0, 0)–(48, 21.8, 21) and (84, 48, 15), and *Validate* Passed = `True`.
- Passed: all 160 combinations (the two families on the canonical plan, rotated by 30° and moved by (5.3, −2.1, 0), rotated by 131.4° and moved by (15.08, −153.9, 0), and with the fewest and the largest tested count (5 banks, 4 courts) at 131.4°, each through the four simplifiers and the four aggregators with 1, 3, 1): *Validate* Passed = `True`, no error.
- Passed: the error cases (`Error InvalidParameter: BankCount must be at least 2 (the theatre banks repeat beside the support rooms), got 1.`, `Error InvalidParameter: CourtCount must be at least 2 (one court is SYN-TYP-005, Enclosed Court), got 1.`, `Error InvalidParameter: BankLength must be positive, got 0.`; Unit Width 0.5 and Stair Width 30: `Error InvalidParameter: StairWidth 30 m leaves no dwelling width in each 84 m south and north wing: its 3 stair bays need 90 m of stairs; reduce StairWidth or UnitWidth.` and the same for the west, middle, and east wings); Depth 9 into *Perimeter Core*: `Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: …`.
- Not performed, open for a person (D-058): the viewport look of the previews, the icons in the toolbar, the *Inspect* reports compared with the snapshots (the generator tests check them), and example definitions.
- [x] Step 8: Remove the two stray fence lines of S8.5 from the decision log
The S8.5 close-out (16ead1f) appended the entries D-102 and D-103 each together with the closing fence line of its code block, so the log has a line of three backticks after D-102 (line 705) and after D-103 (line 715, the last line), and D-103 renders as code. Remove both lines (only markup: no entry's text changes, D-105 records it); the script first checks that they are the only bare fence lines and where they are:
python - <<'EOF'
from pathlib import Path
fence = "`" * 3
path = Path("docs/decisions/decision-log.md")
lines = path.read_text(encoding="utf-8").split("\n")
stray = [number for number, line in enumerate(lines, 1) if line == fence]
assert stray == [705, 715], stray
assert lines[703].startswith("- `Lod.Generators.csproj` makes its internals visible") and lines[705] == "", lines[703:706]
assert lines[706].startswith("### D-103 ") and lines[713].startswith("- S8.6 (families 015 and 011) can start.") and lines[715:] == [""], lines[706:]
for number in reversed(stray):
del lines[number - 1]
path.write_text("\n".join(lines), encoding="utf-8")
print("removed the stray fence lines", " and ".join(map(str, stray)), "of", path.as_posix())
EOF
Expected: removed the stray fence lines 705 and 715 of docs/decisions/decision-log.md.
- [x] Step 9: Append the decision-log entries (template; fill
<date>now)
Append at the end of docs/decisions/decision-log.md, after a blank line, the content of the two code blocks below (not their fence lines), separated by one blank line. The log ends with D-103 when this plan is approved, so the entries are D-104 and D-105; if the log ends with another number, use the next free numbers, change the references to them (D-105 cites D-104; Task 1 of this plan cites D-104 for the corridor count, Task 2 for the court cluster's choices; AGENTS.md of Step 5 cites D-104 and D-105) to match, and say so in the commit body.
### D-104 — The layout choices of the operating suite and the court cluster
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Why: [ADR-006](ADR-006-plan-generation-mechanism.md) fixes the records, defaults, zone IDs, and band and wing order of `SYN-TYP-015` and `SYN-TYP-011`, but not the corridor count that follows from the band order, how *Perimeter Core* zones several courts, or whether the court cluster shares more code with the enclosed court; the S8.6 reference settled these.
- Operating suite: a corridor follows every bank and the corridors alternate from a dirty one in the south, so *n* banks have ⌈n/2⌉ clean and ⌊n/2⌋ + 1 dirty corridors, numbered separately (`DC1, TB1, CC1, TB2, DC2, TB3, CC2, …`); with an odd count the northmost band is a clean corridor. The theatre banks' only outdoor walls are their east ends, which the example theatre preset's WWR 0 leaves without a window and without `WindowOmitted` (ADR-011 rule 1). The generator tests' window check (`PlanAssertions.HasOneCentredWindowPerOutdoorWall`) now requires no window on an outdoor wall whose zone's preset has WWR 0, a stricter rule that every earlier generator test still passes.
- Court cluster: shares `StairBayLayout` with the enclosed court and nothing else, so `EnclosedCourtGenerator` and its snapshot are unchanged; middle wings are named *Middle i*; the stair bays of the south and north wings are checked only for a valid `CourtCount`.
- *Perimeter Core* with several holes is unchanged: the court trapezoids of one bin are united across all holes, so each court gives its own zone per bin, numbered `P-Court-<Bin>-1…` in the union's output order, which is west to east on the canonical plan but is not a court number on a rotated plan ([ADR-008](ADR-008-perimeter-core-corners.md)). Naming court zones per court is left to a later ADR.
### D-105 — S8.6 complete
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Sub-stage S8.6 (D-098) is complete and tagged `v0.8.5`; after its gate and a fresh clone of the tag pass `scripts/verify.ps1`, `main` and `v0.8.5` are pushed (D-098). The two families are implemented as [ADR-006](ADR-006-plan-generation-mechanism.md) specifies them, with no record, default, or rule changed (D-104). New generators and components in *2 Generate*, with icons: *Operating Suite* (`OperatingSuiteGenerator`, `SYN-TYP-015`, OSuite, `33323afd-abf1-4ecc-bf54-5d67c411c962`): a `ClinicalSupport` zone across the west end and bands of `OperatingTheatre` banks between alternating `DirtyCorridor` and `CleanCorridor` zones, every bank between one clean and one dirty corridor, no clean corridor beside a dirty one, every band adjoining the support rooms; *Court Cluster* (`CourtClusterGenerator`, `SYN-TYP-011`, CCluster, `01fc2ca9-3ec0-4142-b827-2519a4523d36`): wings of stair bays around a row of at least two courts, each middle wing bordering two courts, the first footprint with several holes.
- No step of `Lod.Core` changed: every simplifier, aggregator, and validation handles several holes, and *Perimeter Core* gives each court its own court zones per orientation ([ADR-008](ADR-008-perimeter-core-corners.md), [ADR-002](ADR-002-geometry.md)); wings not wider than twice the perimeter depth, between two courts included, fail it with `PlanTooNarrow` (D-087). No tolerance or check was relaxed.
- The twenty-two existing snapshots are byte-identical; two canonical snapshots are new. 193 core, 427 generator, and 1385 integration tests pass on `net8.0`, including both families with every simplifier and aggregator on the canonical 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, with the fewest and the most tested counts (2 and 5 banks, 2 and 4 courts), and with the most at 131.4°; a sweep of 50 rotations for both families and, as a regression, the enclosed court and the S8.5 families found no failure in 14 400 combinations.
- Smoke test (D-056): the controller's scripted headless Grasshopper session on the verified reference (`373af00`, whose `src/`, `tests/`, and `scripts/` equal the executed branch) in Rhino 8.25.25314.11001 (.NET 8.0.31) passed every performed item: the 2 generator components registered with their GUIDs, integer count inputs first, presets in record order, *Preview Location* last, and 24 × 24 icons; the default operating suite (1046.4 m²) and court cluster (2880 m², *Perimeter Core* 13 zones, 17 with three courts), with all 92 zone Breps inspected valid solids and no negative volume anywhere; all 160 combinations (two families, five placements and counts, four simplifiers, four aggregators, 1/3/1) with *Validate* passing; and the error cases. It is recorded in `docs/development/grasshopper-smoke-test.md` and the close-out commit; the viewport look, the icons in the toolbar, and example definitions stay open for a person (D-058).
- Markup: the S8.5 close-out left a stray code-fence line after D-102 and after D-103 in this log and at the end of `docs/development/grasshopper-smoke-test.md`, so the text after it rendered as code. The S8.6 documentation commit removed the line in the smoke-test guide and the S8.6 close-out the two in this log, changing no entry's text; the close-out checks that every Markdown file it touches has balanced code fences and that no appended entry ends with a fence line.
- S8.7 (`SYN-TYP-012`, plans per storey, D-095) can start.
Before committing, check that no <date> is left:
git grep -n -F -e "<date>" -- docs/decisions/decision-log.md docs/plans/2026-09-30-implementation-roadmap.md
Expected: no output.
- [x] Step 10: Check the code fences of every document of the stage
Check the code fences of every Markdown file this stage touches, this plan included, and the entries and sections appended to the decision log and the smoke-test guide: every opening fence line has a language, every closing one is bare, and no file ends inside a block.
python - <<'EOF'
import re
from pathlib import Path
fence = "`" * 3
names = [
"AGENTS.md",
"README.md",
"docs/architecture/pipeline.md",
"docs/decisions/ADR-002-geometry.md",
"docs/decisions/ADR-006-plan-generation-mechanism.md",
"docs/decisions/ADR-008-perimeter-core-corners.md",
"docs/decisions/decision-log.md",
"docs/development/grasshopper-smoke-test.md",
"docs/plans/2026-09-30-implementation-roadmap.md",
"docs/plans/2026-10-02-s8.6-operating-suite-court-cluster.md",
"docs/research/precedents/syn-typologies.md",
"docs/research/program-presets.md",
]
for name in names:
lines = Path(name).read_text(encoding="utf-8").split("\n")
inside, blocks, problems = False, 0, []
for number, line in enumerate(lines, 1):
mark = line.strip()
if not mark.startswith(fence):
continue
if not inside and mark == fence:
problems.append(f"line {number} opens a block without a language")
if inside and mark != fence:
problems.append(f"line {number} opens a block inside a block")
blocks += inside
inside = not inside
if inside:
problems.append("the file ends inside a block")
print(f"{name}: {blocks} code blocks, " + ("; ".join(problems) or "fences balanced"))
log = Path("docs/decisions/decision-log.md").read_text(encoding="utf-8")
guide = Path("docs/development/grasshopper-smoke-test.md").read_text(encoding="utf-8")
entries = [part for part in log.split("\n### ")[1:] if part.startswith("D-10")]
sections = [part for part in re.split(r"\n(?=#{3,4} )", guide) if re.match(r"#{3,4} .*S8\.[56]", part)]
for kind, parts in (("decision-log entries D-100 to D-105", entries), ("smoke-test sections of S8.5 and S8.6", sections)):
ending = [part.split("\n")[0] for part in parts if part.rstrip().split("\n")[-1].strip().startswith(fence)]
print(f"{len(parts)} {kind}, ending with a fence line: " + (", ".join(ending) or "none"))
EOF
Expected:
AGENTS.md: 3 code blocks, fences balanced
README.md: 3 code blocks, fences balanced
docs/architecture/pipeline.md: 1 code blocks, fences balanced
docs/decisions/ADR-002-geometry.md: 0 code blocks, fences balanced
docs/decisions/ADR-006-plan-generation-mechanism.md: 0 code blocks, fences balanced
docs/decisions/ADR-008-perimeter-core-corners.md: 0 code blocks, fences balanced
docs/decisions/decision-log.md: 1 code blocks, fences balanced
docs/development/grasshopper-smoke-test.md: 2 code blocks, fences balanced
docs/plans/2026-09-30-implementation-roadmap.md: 4 code blocks, fences balanced
docs/plans/2026-10-02-s8.6-operating-suite-court-cluster.md: 121 code blocks, fences balanced
docs/research/precedents/syn-typologies.md: 0 code blocks, fences balanced
docs/research/program-presets.md: 1 code blocks, fences balanced
6 decision-log entries D-100 to D-105, ending with a fence line: none
4 smoke-test sections of S8.5 and S8.6, ending with a fence line: none
If a line reports anything else, stop: find the stray or missing fence line (a fence line copied from this plan with a block's content is the usual cause), fix the file, and run the check again.
- [x] Step 11: Tick this plan and commit the records
Tick every completed checkbox of this plan and set its status line to > **Status:** Done (controller, D-098; pending the owner's reading) · … (D-051). Leave unticked the steps that run after this commit (Steps 12 and 13 below) and every step of Task 7, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 4, Step 9 are file content of grasshopper-smoke-test.md and stay unticked, so the block keeps matching the file. The second -m records the smoke test as docs/development/grasshopper-smoke-test.md prescribes ("Recording the result").
git add AGENTS.md README.md docs/plans/2026-09-30-implementation-roadmap.md docs/development/grasshopper-smoke-test.md docs/decisions/decision-log.md docs/plans/2026-10-02-s8.6-operating-suite-court-cluster.md
git commit -m "docs(repo): record s8.6 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.6 checklist) by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02: Rhino 8.25.25314.11001 (.NET 8.0.31), .NET Core runtime, build 373af00d04cb32932947462f081c742711bcb638 (the verified reference; src, tests, and scripts identical to this branch), Version output '0.8.4+373af00d04cb32932947462f081c742711bcb638', all performed items passed. Not performed, pending a person (D-058): the viewport previews, the icons in the toolbar and on the canvas, and the other items named in Task 5 of the S8.6 plan." -m "Removed the two stray code-fence lines that the S8.5 close-out left in the decision log after D-102 and D-103; no entry's text changed." -m "Ticked per D-051: every completed checkbox of Tasks 1 to 5 and of Task 6 up to this commit (Steps 1 to 11). Left unticked: Task 6 Step 12 (merge) and Step 13 (tag v0.8.5), which run after this commit, and every step of Task 7 (fresh-clone gate and push), which the controller performs after the tag (D-098). The checklist items inside Task 4 are file content of grasshopper-smoke-test.md and stay unticked."
- [ ] Step 12: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s8.6-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.6-close-out
git branch -d chore/repo-s8.6-close-out
Expected: VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0.
- [ ] Step 13: Tag
git tag -a v0.8.5 -m "S8.6 operating suite and court clusters complete"
git describe --tags
Expected: v0.8.5. Nothing is pushed before Task 7.
Task 7: Fresh-clone gate and push (controller, D-098)¶
Files: none
D-098 pushes each sub-stage after its gate: the controller performs this task itself (a push is never delegated), and never with force. Its steps stay unticked in the plan, because they run after the close-out commit.
- [ ] Step 1: Check the state to push
git status --short
git fetch origin
git rev-parse main v0.8.5^{commit}
git merge-base --is-ancestor origin/main main && echo fast-forward
git tag --list "v0.8.*"
git ls-remote --tags origin "v0.8.*"
Expected: no output from git status --short; the two SHAs are equal (the tag points at the tip of main); fast-forward (local main contains origin/main, which was at 16ead1f when this plan was written); the tags v0.8.0 to v0.8.5; ls-remote shows which of them the remote has. If origin/main has commits that local main does not, or the remote already has a v0.8.5 that points at another commit, stop and report: nothing is pushed, and no rebase or force push is attempted. If the remote lacks one of v0.8.0 to v0.8.4, report it to the person; this task pushes only main and v0.8.5.
- [ ] Step 2: Verify a fresh clone of the tag
git clone --branch v0.8.5 . ../BEMGen-v0.8.5-clone
powershell -NoProfile -ExecutionPolicy Bypass -File ../BEMGen-v0.8.5-clone/scripts/verify.ps1
git -C ../BEMGen-v0.8.5-clone describe --tags
Expected: the clone reports a detached HEAD at v0.8.5; verify builds the clone with 0 Warning(s) and 0 Error(s) and ends with VERIFY PASSED, with 193 core, 427 generators, and 1385 integration tests passed on net8.0; describe prints v0.8.5. Then delete the clone (rm -rf ../BEMGen-v0.8.5-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.5
git ls-remote --tags origin "v0.8.5*"
git status -sb
Expected: each push succeeds without --force (main as a fast-forward, the tag as [new tag]); ls-remote lists v0.8.5 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¶
OperatingSuiteGenerator(015) andCourtClusterGenerator(011) implement ADR-006 with their defaults, zone IDs and order, and validation (at least 2 banks, at least 2 courts, no upper bound); each has tiling, adjacency, window, count, invalid-parameter, preset, determinism, provenance, and snapshot tests that failed to build on the code before them; the court cluster's footprint has one hole per court and every middle wing borders two courts.- The generator tests' window check expects no window on a wall whose zone's preset has WWR 0, and every earlier generator test passes it.
- Both families validate with every plan simplifier and floor aggregator, on their canonical plans, at two rotated and moved placements, with their fewest and most tested counts, and with the most at 131.4° (99 and 102 cases); Perimeter Core gives every court its own court zones per orientation, and 9 m wings fail it with
PlanTooNarrow(D-087); noLod.Corefile changed. - Two generator components in 2 Generate, with the GUIDs of this plan, their defaults, integer count inputs, required preset inputs, Preview Location last, and icons (59 icons, the existing ones byte-identical); no existing component, input, or GUID changed.
- Snapshots: the twenty-two existing snapshots byte-identical; two new canonical snapshots.
- ADR-006, ADR-008, ADR-002,
pipeline.md,syn-typologies.md, andprogram-presets.mddescribe S8.6; the S8.6 smoke-test checklist and its headless result are in the guide. - Every Markdown file the stage touches has balanced code fences, with no stray fence line left by S8.5.
scripts/verify.ps1printsVERIFY PASSED; tests: 193 core, 427 generators, 1385 integration onnet8.0; 0 warnings.- Version 0.8.5, tag
v0.8.5; AGENTS.md, README, the roadmap Progress line and detailed-plans row, and the decision-log entries D-104 and D-105 are written; this plan is ticked and marked Done. - After a fresh clone of
v0.8.5passesscripts/verify.ps1,mainandv0.8.5are pushed without force (D-098).
Notes for the reviewer¶
- Source. Every code block, snapshot, and documentation replacement of Tasks 1 to 4 was extracted programmatically from a verified reference implementation built on
mainat16ead1f, 4 commits froma3dda20to373af00, each passingscripts/verify.ps1with 0 warnings; the tasks keep the reference's commit order, one task per commit (no reordering was needed: a relative-link check of every reference commit found no link to a file that a later commit creates). The only content not taken from the reference is this plan's own commit, the two fence repairs and the fence checks (Task 4, Steps 8 and 10; Task 6, Steps 8 and 10), and the close-out of Task 6 (adapted from the reference's proposed texts). Every task was then replayed on a worktree ofmainat16ead1f(its branch standing in formain; nothing fetched, pulled, tagged, or pushed), taking each file content, each replacement, and each script from this plan's text: the stricter window check against every earlier generator test, the red build errors of Tasks 1 and 2 (with line and column numbers and in the compiler's order), the single failing snapshot test of Tasks 1 and 2 and the received snapshots (byte-identical to the expected content), the failure ofmake_icons.pybefore Task 3 and the 59 hashes after it (every PNG byte-identical to the reference's), the counts after every task, a 0-warning build after every task, every documentation replacement (each "Replace" text occurs exactly once when it is applied), the fence repairs and checks, the link checks, the close-out edits, andVERIFY PASSEDon every merge are observed results. The replay's end state equals the reference:git diff --stat ref/s8.6 replay/s8.6limited to the 21 paths the reference changes prints nothing for the state before the close-out, and after it lists onlydocs/development/grasshopper-smoke-test.md, whose difference is the result section of Task 6, Step 7;Directory.Build.props(0.8.5), AGENTS.md, README.md, the decision log, the roadmap, and this plan are files the reference does not change. Every observation of a first replay was observed again, unchanged, in a second replay of the final plan from a fresh worktree. - Slices and branches. The roadmap names no slices for S8.6; this plan uses three slices and a close-out, named after D-004:
feature/operating-suite-court-cluster(Tasks 1–2),feature/operating-suite-court-cluster-components(Task 3),docs/architecture-operating-suite-court-cluster(Tasks 4–5), andchore/repo-s8.6-close-out. Merges rebase onto localmain. As in S8.4 and S8.5, the stage is pushed at its end (D-098): Task 7 is the controller's, after the tag and a fresh clone of it passscripts/verify.ps1.origin/mainwas at16ead1fwhen this plan was written, so the push is a fast-forward. - Links. A relative-link check (file targets and heading anchors, GitHub's slug rule) over every Markdown file of the replay found: no broken link at the plan commit or after any commit of the replay (checked after Tasks 2, 4, 5, and 6). Task 4 renames the heading
## Grasshopper components through S8.5ofpipeline.mdto… through S8.6and updates its two links (inpipeline.mdandprogram-presets.md) in the same commit. - The baseline is 193 core, 360 generators, and 1184 integration tests (D-103).
- Tests that were not test-first, and why. The integration matrices of Tasks 1 and 2 (201 cases) passed at once in the reference and in the replay: the surface builder, the four simplifiers, the four aggregators, and validation already handle bands of rectangles and footprints with several holes; each task says to stop if a case fails. Perimeter Core with several holes needed no change, so it has no red phase; the integration tests of Task 2 check its result (court zones per court and bin, as many core holes as courts, the court glazing conserved) and its precondition on 9 m wings. Task 3 is Grasshopper code, checked by the build, the embedded icons, and Task 5. The build-error red phases list only declaration-level errors: the compiler stops before binding method bodies. The stricter window check of Task 1 changes a test helper, not a test; Step 3 runs it against every earlier generator test before the new tests exist.
- Window rule with WWR 0 (D-104).
PlaceWindowsByRatiocomputes a glazed area of 0 for a zone whose preset has WWR 0, skips the length check (it requires a positive glazed area), andWindow.Centeredreturns no window, so the theatre banks' east ends get no window and noWindowOmitted(ADR-011, rule 1). Before Task 1 the helperHasOneCentredWindowPerOutdoorWallwould have required one window on every outdoor wall; it now requires none on such a wall. A glazed theatre preset (WWR 0.2) gives 10 windows, tested. - Choices of the reference (D-104). The corridor count follows from ADR-006's band order: a corridor after every bank, alternating from a dirty one, so n banks have ⌈n/2⌉ clean and ⌊n/2⌋ + 1 dirty corridors and the northmost band is a clean corridor for an odd count (
CC2with three banks).EnclosedCourtGeneratoris untouched: both generators useStairBayLayout.Wing,Fit, andFitErrors, and what is left per generator, the placement of wings and holes (about fifteen lines), differs by the middle wings, the hole list, and the wing names in the fit messages; a shared helper for that remainder would be a parameterised abstraction that risks005's output. Perimeter Core groups court trapezoids by orientation bin across all holes; a middle wing wider than2dkeeps the courts' bands apart, so each bin gives one polygon per court, numberedP-Court-<Bin>-1,-2, … in the clipping library's union output order: west to east on the canonical plan (the reference observed the court zones' smallest x at 7.43, 43.43, and 79.43 m for three courts), not a court identifier on a rotated plan. Naming them per court would changeLod.Core, which the brief did not ask for; ADR-008 leaves it to a later ADR. - Sweeps. An uncommitted sweep in the reference (50 rotations from 0° to 353.5° with translations, each through the 4 simplifiers × 4 aggregators with 1, 3, 1, 800 runs per configuration) found no failure for the operating suite (2, 3, and 5 banks, and 3 banks with odd dimensions), the court cluster (2, 3, and 4 courts, and 3 courts with odd dimensions), and, as a regression sweep, the enclosed court and the S8.5 families (radial lobes 3 and 4, branching mall 3 and 4, care hub 2, 3, and 4, foyer halls 2 and 3): 18 configurations, 14 400 combinations. The sweep was not replayed.
- Smoke-test values. The values of Task 5 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit
373af00, which reported the informational version0.8.4+373af00…; they were not re-observed on the replay. Task 5, Step 1 checks thatsrc/,tests/, andscripts/of the branch equal the reference's, so the record applies; the replay found no difference. The viewport, the toolbar icons, the Inspect texts beyond the footprint line, the zone IDs, the Validate glazing values, and Convert2BEM with Override = False were not observed. - Icons. The 57 existing PNGs are byte-identical after Task 3 (Pillow 12.3.0); the two new ones equal the reference's bytes. The contact-sheet description is from the replay's sheet.
- Stray fence lines of S8.5. The S8.5 plan gave the smoke-test result and the two decision-log entries as code blocks to append; the S8.5 close-out on
main(16ead1f) appended each block's content together with its closing fence line, which left a line of three backticks at the end of the smoke-test guide (line 570) and after D-102 and D-103 in the decision log (lines 705 and 715), so the text after the first of them renders as code. The S8.5 plan and its replay had no check that would catch it: the replay appended the blocks' contents correctly, but compared only the files of the reference, which holds neither the decision log nor the close-out's result section, and checked no code fences. This plan therefore says, wherever text is appended, that the block's content is appended without its fence lines; removes the three stray lines with scripts that first assert exactly where they are (Task 4, Step 8; Task 6, Step 8); and checks every Markdown file of the stage, this plan included, after the documentation commit and before the close-out commit (Task 4, Step 10; Task 6, Step 10): every opening fence line has a language, every closing one is bare, no file ends inside a block, and no decision-log entry and no appended smoke-test section ends with a fence line. In the replay, the scripts of Task 4, Step 8 and Task 6, Step 8 removed exactly lines 570, 705, and 715; the checks of Task 4, Step 10 and Task 6, Step 10 printed the expected output; an independent check written apart from the plan's script found no problem in the twelve files after the close-out merge, and the same independent check run onmainat16ead1freports the three stray lines (decision log line 705 a bare opening fence, D-102 and D-103 ending with a fence line; smoke-test guide line 570 a bare opening fence, the file ending inside a block). - Clean working tree. The
git statusexpectations assume a working tree with no untracked files outside.gitignorebefore Task 1. - D-numbers. The reference's report proposed D-104 for the choices of notes 6 and 7, which Tasks 1 and 2 cite, and D-105 for the completion, which cites D-104 and records the fence repair of note 11.
- Roadmap order. The reference report placed the S8.6 Progress line above the S8.5 one; the roadmap lists its Progress lines oldest first, so Task 6 adds it below the S8.5 line, as the S8.5 close-out did for its own.
check_plans.pyfalse positives. The Global Constraints line that states the no-attribution rule is flagged as a possible attribution; it states the rule. Themake_icons.pydocstring line that names the Pillow version of the icons is flagged, as in S4.1 to S8.5.