S8.5 — Branching plans on a hub and its arms Implementation Plan¶
Status: Done (controller, D-098; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.5 (section S8, Typologies) · Decisions: D-009, D-026, D-041, D-061, D-062, D-063, D-064, D-065, D-079, D-085, D-087, D-088, D-090, D-091, D-097, D-098, D-099, D-100; ADR-006 (the specification of the four families, Task 9), ADR-002 and ADR-008 (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.5 of D-098: the four families that branch from a hub, as ADR-006 specifies them, on one shared, axis-aligned layout HubArmsLayout (a square hub with up to four arms of one width centred on its sides in the order east, north, west, south, D-100): SYN-TYP-010 (Radial Lobes, 3–4 lobes of two dwellings each around a stair core), SYN-TYP-014 (Branching Mall, one mall zone through a central court and 3–4 branches of shop wings and anchors), SYN-TYP-018 (Care Hub, 2–4 wings of a corridor between two bedroom groups around a communal hub), and SYN-TYP-019 (Foyer Halls, 2–3 activity halls and a support wing around a public foyer), with their Grasshopper components and icons. The care hub's default 14.4 m wings on a 16 m hub leave 0.8 m hub corners, on which Perimeter Core followed by Single Zone per Floor Type or Single Zone Building failed with FacadeNotCovered on rotated plans; Perimeter Core now keeps the footprint's own vertices in its perimeter zones (test-first, Task 4, ADR-008, ADR-002), which changes no existing result. Every family validates with every simplifier and aggregator on its canonical plan, rotated and moved by two placements, and with its fewest and most arms. Every existing plan, floor, building, and snapshot stays unchanged. S8.5 ends with version 0.8.4 and the tag v0.8.4; after the stage gate and a fresh clone of the tag pass scripts/verify.ps1, the controller pushes main and v0.8.4 (D-098).
Architecture: Lod.Generators/HubArms/HubArmsLayout.cs holds the internal HubArmsLayout, HubArm, and HubSide: the hub, the arms with their own frames (along from the arm's root, across from its clockwise side), the footprint as one counter-clockwise polygon with its bounding box at the origin, HubWithStrips for a polygon through the hub and one strip of every arm (the mall), and the shared validation messages CountErrors and WidthErrors. Lod.Generators.csproj makes the internals visible to Lod.Generators.Tests, so the layout is tested directly. 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 that cuts its zones from the hub and its arms; no geometry is duplicated. In Lod.Core, PerimeterCore replaces every vertex of a bin's union that lies in the grid cell of a footprint vertex by that vertex (VertexGrid.Restore, D-088). Four components derive from the S8.1 TypologyGeneratorComponent, and scripts/make_icons.py draws four 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.4 is complete on
main(49efa75, tagv0.8.3, D-101), with D-098 to D-101 recorded; this plan is committed tomainbefore Task 1 ("docs(plans): add the s8.5 hub-and-arms typologies plan"); it is the only file of that commit. ADR-006 (with the S8.4 specification of the four families, their defaults, zone IDs, 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 hub first, then arm by arm in the arm order east, north, west, south (radial lobes
ST,L1-U1,L1-U2, …; branching mallMALL,B1-S1,B1-S2,B1-AN, …; care hubHUB,W1-CO,W1-B1,W1-B2, …), except the foyer with halls, which listsFO, the hallsH1…Hn, then the support wingSV(its ADR-006 section; Task 9 records the exception in the shared paragraph). A family with n arms uses the first n sides in the arm order, so two care-hub wings leave the hub east and north (an L). - The layout is axis-aligned (D-100). At most four arms, one per side of the hub; the families accept 3–4 lobes, 3–4 branches, 2–4 wings, and 2–3 halls (the support wing takes one more side). A count outside its range and an arm wider than the hub are the error
InvalidParameter, every error reported at once; a fifth arm reachingHubArmsLayoutis a programming error (ArgumentOutOfRangeException), unreachable through validation. All arms of a plan have one width. - Numerical tolerances come only from
ToleranceSettings(ADR-004); S8.5 adds none and changes none, andToleranceSettings.csis not touched. The arm-versus-hub comparison is exact (an arm as wide as the hub is valid). The minimum wall and window geometry stays inGeometryLimits(ADR-011): the care hub's 0.8 m hub corners get no window (infoWindowOmitted), unchanged. - Existing outputs do not change. The eighteen 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, and the S8.4 snapshotsoffice-plate-canonicalandcafeteria-canonical) stay byte-identical, and every existing test keeps passing; no test is removed or changed, and no existing icon PNG changes. The Perimeter Core change of Task 4 restores only footprint vertices: restoring the offset vertices too (a union as tiles) would flip the facing bin of the canonical linear plan's 45° corner walls and change two existing snapshots, so it is not done (ADR-008). - Never weaken a check (AGENTS.md rules 11, 12). Tasks 1, 2, 3, 5, and 6 start from tests that fail to build, and Task 4 from 58 failing cases of a new test class, each the way the task says; the integration matrices of Tasks 2, 3, 5, and 6 pass at once (the care hub's after Task 4), and each task says to stop if a case fails. 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 7 tests it on 9 m lobes and 9 m care wings. Zones about 1 m wide on rotated plans remain the known limit of D-090; S8.5 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
DwellingUnitandStair(010),MallandRetail(014),CareBedroom,CareCommunal, andCorridor(018), andLobby,ActivityHall, andService(019), 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: the mall is one zone through the court and every branch, each shop wing and each anchor one zone; each wing corridor and each bedroom group of the care hub one zone, the hub another; each hall, the support rooms together, and the foyer one zone each. Housing keeps one zone per dwelling and storey (D-009, D-085): every lobe holds two dwellings.
- 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). Four components are added with new GUIDs: Radial Lobes
a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80, Branching Malld886b33f-7b51-41f9-b160-a550e6aadd2b, Care Hub7d3d1002-c695-47df-afd9-48f50c5b3e90, and Foyer Halls6638efe7-c0e8-45b6-89b1-1e9e3f725c15. 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): the Perimeter Core fix changes where its zone vertices lie (on the footprint's own vertices, not grid-rounded copies of them), not how loads, schedules, setpoints, or windows are aggregated, so the research documentation needs no edit; ADR-008 and ADR-002 record the change.
- 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 11), its tag, and a fresh clone ofv0.8.4that passesscripts/verify.ps1, the controller pushesmainandv0.8.4(Task 12); 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.5 adds none.- S8.5 changes these earlier files:
Lod.Generators.csproj(S2, unchanged since),Strategies.cs(S3, last changed by5038512afterv0.8.2),TypologyGeneratorComponents.cs(S8.1, last changed in S8.4),scripts/make_icons.py(S4.1, last changed in S8.4), andDirectory.Build.props(S0, last changed in S8.4); and the documents AGENTS.md, README.md, ADR-006,pipeline.md,program-presets.md,syn-typologies.md, the roadmap,docs/development/grasshopper-smoke-test.md, and the decision log (last changed in S8.4), ADR-002 (last changed in S8.4), and ADR-008 (last changed by5038512afterv0.8.2).
Files¶
| Path | Change | Responsibility |
|---|---|---|
src/Lod.Generators/HubArms/HubArmsLayout.cs |
Create | HubArmsLayout, HubArm, HubSide (internal): hub, arms and their frames, footprint, HubWithStrips, CountErrors, WidthErrors |
src/Lod.Generators/Lod.Generators.csproj |
Modify (S2 file) | InternalsVisibleTo for Lod.Generators.Tests |
tests/Lod.Generators.Tests/HubArms/HubArmsLayoutTests.cs |
Create | Arm order, the cross, the T, the L, footprint orientation and origin, exact tiling, arm frames, shared edges, the mall strip polygon, counts, widths |
src/Lod.Generators/RadialLobes/RadialLobesGenerator.cs |
Create | RadialLobesParameters, RadialLobesPresets, RadialLobesGenerator (010) |
tests/Lod.Generators.Tests/RadialLobes/RadialLobesGeneratorTests.cs, …/Snapshots/radial-lobes-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs |
Create (Task 2), Modify (Task 7) | Every simplifier × aggregator, canonical, at two placements, with 3 and 4 lobes; Semantic Merge, Perimeter Core; 9 m lobes PlanTooNarrow (Task 7) |
src/Lod.Generators/BranchingMall/BranchingMallGenerator.cs |
Create | BranchingMallParameters, BranchingMallPresets, BranchingMallGenerator (014) |
tests/Lod.Generators.Tests/BranchingMall/BranchingMallGeneratorTests.cs, …/Snapshots/branching-mall-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/BranchingMallTypologyTests.cs |
Create | Every simplifier × aggregator, canonical, at two placements, with 3 and 4 branches; Semantic Merge |
src/Lod.Core/Simplification/Strategies.cs |
Modify (S3 file, last changed by 5038512) |
Perimeter Core keeps the footprint's own vertices |
tests/Lod.Integration.Tests/PerimeterCoreShortEdgeTests.cs |
Create | A block with 0.8 m façade edges at 50 placements: the footprint's vertices kept, the single-zone methods valid |
docs/decisions/ADR-008-perimeter-core-corners.md, docs/decisions/ADR-002-geometry.md |
Modify | The footprint vertices kept by Perimeter Core |
src/Lod.Generators/CareHub/CareHubGenerator.cs |
Create | CareHubParameters, CareHubPresets, CareHubGenerator (018) |
tests/Lod.Generators.Tests/CareHub/CareHubGeneratorTests.cs, …/Snapshots/care-hub-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/CareHubTypologyTests.cs |
Create (Task 5), Modify (Task 7) | Every simplifier × aggregator, canonical, at two placements, with 2 and 4 wings; Semantic Merge, Perimeter Core; 9 m wings PlanTooNarrow (Task 7) |
src/Lod.Generators/FoyerHalls/FoyerHallsGenerator.cs |
Create | FoyerHallsParameters, FoyerHallsPresets, FoyerHallsGenerator (019) |
tests/Lod.Generators.Tests/FoyerHalls/FoyerHallsGeneratorTests.cs, …/Snapshots/foyer-halls-canonical.txt |
Create | Generator tests and canonical plan |
tests/Lod.Integration.Tests/FoyerHallsTypologyTests.cs |
Create | Every simplifier × aggregator, canonical, at two placements, with 2 and 3 halls; Semantic Merge |
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs |
Modify (S8.1 file, last changed in S8.4) | Radial Lobes, Branching Mall, Care Hub, Foyer Halls |
scripts/make_icons.py |
Modify (S4.1 file, last changed in S8.4) | Four new motifs |
src/Lod.Grasshopper/Icons/RadialLobesComponent.png, BranchingMallComponent.png, CareHubComponent.png, FoyerHallsComponent.png |
Create (generated) | 57 icons |
docs/decisions/ADR-006-plan-generation-mechanism.md, docs/architecture/pipeline.md, docs/research/precedents/syn-typologies.md, docs/research/program-presets.md |
Modify | The four families as implemented, the layout, the components, the renamed components heading |
docs/development/grasshopper-smoke-test.md |
Modify | Append the S8.5 checklist (Task 9) and its result (Task 11) |
Directory.Build.props |
Modify | Version 0.8.4 |
AGENTS.md, README.md |
Modify | "Project state" and "Status" after S8.5; README layout line |
docs/plans/2026-09-30-implementation-roadmap.md |
Modify | Progress line and detailed-plans row |
docs/decisions/decision-log.md |
Modify | D-102 (the layout choices of S8.5), D-103 (S8.5 complete) |
Test counts on net8.0 at the start of S8.5: 193 core, 224 generators, 704 integration (D-101). After Task 1: 193 / 251 / 704. After Task 2: 193 / 279 / 786. After Task 3: 193 / 304 / 868. After Task 4: 193 / 304 / 1018. After Task 5: 193 / 333 / 1100. After Task 6: 193 / 360 / 1182. After Task 7 and at the end: 193 core, 360 generators, 1184 integration (generators: +27 HubArmsLayoutTests, +28 RadialLobesGeneratorTests, +25 BranchingMallGeneratorTests, +29 CareHubGeneratorTests, +27 FoyerHallsGeneratorTests; integration: +82 each for RadialLobesTypologyTests, BranchingMallTypologyTests, CareHubTypologyTests, and FoyerHallsTypologyTests, +150 PerimeterCoreShortEdgeTests, +2 in Task 7). No test is removed or changed except the two integration classes Task 7 extends by one test each. Grasshopper code has no unit tests; Task 8 is checked by a 0-warning build, the embedded icons, and the smoke test of Task 10.
Task assignment (D-048, execution handoff §6)¶
| Task | Agent | Why |
|---|---|---|
| 1 Hub-and-arms layout | bemgen-complex |
Shared plan geometry, exact tiling and frames, test-first |
| 2 Radial lobes (010) | bemgen-complex |
New plan generator geometry; new snapshot; combination matrix |
| 3 Branching mall (014) | bemgen-complex |
New plan generator geometry, one branching zone; new snapshot; combination matrix |
| 4 Perimeter Core keeps the footprint's vertices | bemgen-complex |
Plan simplifier geometry and the clipping grid, test-first, in Lod.Core |
| 5 Care hub (018) | bemgen-complex |
New plan generator geometry with short hub corners; new snapshot; combination matrix |
| 6 Foyer with halls (019) | bemgen-complex |
New plan generator geometry; new snapshot; combination matrix |
| 7 Perimeter Core precondition on narrow arms | bemgen-complex |
Plan simplifier precondition tests |
| 8 Generator components and icons | bemgen-complex |
Thin components with generated icons and a visual check |
| 9 Architecture documents and smoke-test checklist | bemgen-bounded |
Documents, exact replacements |
| 10 Smoke test | The controller headless (D-056); person for the viewport items | Needs Rhino 8 |
| 11 Close-out | bemgen-bounded |
Bookkeeping |
| 12 Fresh-clone gate and push | Controller (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/hub-arms-lobes-mall¶
The shared layout comes first (Task 1), then the two families whose every combination validates on it without a pipeline change: the radial lobes (Task 2) and the branching mall (Task 3). Each family is one task: 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: The shared hub-and-arms layout¶
Files:
- Create: src/Lod.Generators/HubArms/HubArmsLayout.cs
- Modify: src/Lod.Generators/Lod.Generators.csproj (S2 file)
- Test: create tests/Lod.Generators.Tests/HubArms/HubArmsLayoutTests.cs
Interfaces:
- Consumes (S2–S8.4): Polygon2.Rectangle, Polygon2.Create, Polygon2.Outer, Point2, Diagnostic.Error, DiagnosticCodes.InvalidParameter, Provenance.Format; ADR-006 Shared building block: the hub and its arms (S8.5); D-100; in the tests, PolygonOps(ToleranceSettings.Default) (Union, IntersectionArea).
- Produces (all internal, namespace Lod.Generators.HubArms): enum HubSide { East, North, West, South }; sealed class HubArmsLayout(double hubWidth, double armWidth, IReadOnlyList<double> armLengths) with const int MaximumArms = 4, HubWidth, ArmWidth, Arms : IReadOnlyList<HubArm> (east, north, west, south; n arms take the first n sides), Hub : Polygon2 (the square, with its south-west corner at (west arm length, south arm length)), Footprint : Polygon2 (the union of hub and arms, an L, a T, or a cross, counter-clockwise from its lowest, then leftmost, vertex, with repeated and collinear vertices dropped), HubWithStrips(double acrossFrom, double acrossTo, double length) : Polygon2 (the hub and one strip of every arm as one polygon), static CountErrors(string name, int count, int minimum, int maximum, string minimumReason, string maximumReason) ({name} must be at least {minimum} ({minimumReason}), got {count}. or {name} must be at most {maximum} ({maximumReason}, D-100), got {count}.), and static WidthErrors(string arm, double armWidth, string hub, double hubWidth) ({arm} {w} m is wider than {hub} {h} m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.); more than four arms throw ArgumentOutOfRangeException. sealed class HubArm with Number (from 1), Side, Length, Width, Footprint, At(double along, double across) : Point2, and Piece(double alongFrom, double alongTo, double acrossFrom, double acrossTo) : Polygon2: along runs from the arm's root on the hub's side to its tip, across from its clockwise side (the right-hand side looking out from the hub); a reversed side is computed as low + (Width − across), so pieces with the same along or across values share their edges exactly. Lod.Generators.csproj gains <InternalsVisibleTo Include="Lod.Generators.Tests" />, the first use of the item in the repository (D-102 records it).
- [x] Step 1: Create the branch
git switch -c feature/hub-arms-lobes-mall main
- [x] Step 2: Write the failing tests — create
tests/Lod.Generators.Tests/HubArms/HubArmsLayoutTests.cs(full file)
27 tests: the arm order east, north, west, south; four arms as wide as the hub make a cross of 12 vertices; three arms make a T whose free hub side faces south; two narrower arms leave the hub corners outdoor (an L); the footprint counter-clockwise with its bounding box at the origin and its area, for 2, 3, and 4 arms and three hub and arm widths (7 cases); the hub and its arms tile the footprint exactly (4 cases); the arm frames; pieces of an arm share their edges exactly (2 cases); the hub with strips as one polygon through every arm; more than four arms rejected; too few and too many arms as errors with the family's reason and the layout limit; counts within the limits (2 cases); an arm wider than the hub (3 cases).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Generators.HubArms;
using Xunit;
namespace Lod.Generators.Tests.HubArms;
public sealed class HubArmsLayoutTests
{
private readonly PolygonOps _ops = new(ToleranceSettings.Default);
[Fact]
public void ArmsLeaveTheHubEastNorthWestSouthInThatOrder()
{
var layout = new HubArmsLayout(12.0, 12.0, new[] { 10.0, 10.0, 10.0, 10.0 });
Assert.Equal(new[] { HubSide.East, HubSide.North, HubSide.West, HubSide.South }, layout.Arms.Select(a => a.Side));
Assert.Equal(new[] { 1, 2, 3, 4 }, layout.Arms.Select(a => a.Number));
Assert.Equal(Rectangle(10, 10, 22, 22), layout.Hub.Outer);
Assert.Equal(Rectangle(22, 10, 32, 22), layout.Arms[0].Footprint.Outer);
Assert.Equal(Rectangle(10, 22, 22, 32), layout.Arms[1].Footprint.Outer);
Assert.Equal(Rectangle(0, 10, 10, 22), layout.Arms[2].Footprint.Outer);
Assert.Equal(Rectangle(10, 0, 22, 10), layout.Arms[3].Footprint.Outer);
}
[Fact]
public void FourArmsAsWideAsTheHubMakeACross()
{
var layout = new HubArmsLayout(12.0, 12.0, new[] { 10.0, 10.0, 10.0, 10.0 });
Assert.Equal(
new[]
{
P(10, 0), P(22, 0), P(22, 10), P(32, 10), P(32, 22), P(22, 22),
P(22, 32), P(10, 32), P(10, 22), P(0, 22), P(0, 10), P(10, 10),
},
layout.Footprint.Outer);
Assert.Empty(layout.Footprint.Holes);
Assert.Equal(144.0 + (4 * 120.0), layout.Footprint.Area, 12);
}
[Fact]
public void ThreeArmsMakeATeeWhoseFreeHubSideFacesSouth()
{
var layout = new HubArmsLayout(32.0, 32.0, new[] { 70.0, 70.0, 70.0 });
Assert.Equal(new[] { HubSide.East, HubSide.North, HubSide.West }, layout.Arms.Select(a => a.Side));
Assert.Equal(
new[] { P(0, 0), P(172, 0), P(172, 32), P(102, 32), P(102, 102), P(70, 102), P(70, 32), P(0, 32) },
layout.Footprint.Outer);
Assert.Equal(Rectangle(70, 0, 102, 32), layout.Hub.Outer);
Assert.Equal((32.0 * 32.0) + (3 * 70.0 * 32.0), layout.Footprint.Area, 9);
}
[Fact]
public void TwoNarrowerArmsLeaveTheHubCornersOutdoor()
{
var layout = new HubArmsLayout(16.0, 14.4, new[] { 30.0, 30.0 });
Assert.Equal(new[] { HubSide.East, HubSide.North }, layout.Arms.Select(a => a.Side));
Assert.Equal(Rectangle(0, 0, 16, 16), layout.Hub.Outer);
Assert.Equal(
new[]
{
P(0, 0), P(16, 0), P(16, 0.8), P(46, 0.8), P(46, 15.2), P(16, 15.2),
P(16, 16), P(15.2, 16), P(15.2, 46), P(0.8, 46), P(0.8, 16), P(0, 16),
},
layout.Footprint.Outer.Select(Round));
Assert.Equal(256.0 + (2 * 30.0 * 14.4), layout.Footprint.Area, 9);
}
[Theory]
[InlineData(2, 12.0, 12.0, 6)]
[InlineData(3, 12.0, 12.0, 8)]
[InlineData(4, 12.0, 12.0, 12)]
[InlineData(2, 16.0, 14.4, 12)]
[InlineData(3, 16.0, 14.4, 16)]
[InlineData(4, 16.0, 14.4, 20)]
[InlineData(4, 20.0, 7.3, 20)]
public void TheFootprintIsCounterClockwiseWithItsBoundingBoxAtTheOrigin(int arms, double hub, double width, int corners)
{
double[] lengths = Enumerable.Range(0, arms).Select(i => 10.0 + (3.5 * i)).ToArray();
var layout = new HubArmsLayout(hub, width, lengths);
Assert.True(Polygon2.SignedArea(layout.Footprint.Outer) > 0);
Assert.Empty(layout.Footprint.Holes);
Assert.Equal(0.0, layout.Footprint.Outer.Min(p => p.X));
Assert.Equal(0.0, layout.Footprint.Outer.Min(p => p.Y));
Assert.Equal((hub * hub) + lengths.Sum(l => l * width), layout.Footprint.Area, 9);
Assert.Equal(lengths, layout.Arms.Select(a => a.Length));
// Arms as wide as the hub continue its sides, so a hub corner between a side with an arm and one without is not a vertex.
Assert.Equal(corners, layout.Footprint.Outer.Count);
}
[Theory]
[InlineData(2, 12.0, 12.0)]
[InlineData(3, 16.0, 14.4)]
[InlineData(4, 16.0, 14.4)]
[InlineData(4, 20.0, 7.3)]
public void TheHubAndItsArmsTileTheFootprintExactly(int arms, double hub, double width)
{
var layout = new HubArmsLayout(hub, width, Enumerable.Range(0, arms).Select(i => 10.0 + (3.5 * i)).ToArray());
Polygon2[] tiles = new[] { layout.Hub }.Concat(layout.Arms.Select(a => a.Footprint)).ToArray();
Assert.Equal(layout.Footprint.Area, tiles.Sum(t => t.Area), 9);
for (int i = 0; i < tiles.Length; i++)
{
for (int j = i + 1; j < tiles.Length; j++)
{
Assert.Equal(0.0, _ops.IntersectionArea(tiles[i], tiles[j]), 12);
}
}
HashSet<Point2> corners = tiles.SelectMany(t => t.Outer).ToHashSet();
Assert.All(layout.Footprint.Outer, p => Assert.Contains(p, corners));
Assert.True(ToleranceSettings.Default.AreaEquals(layout.Footprint.Area, Assert.Single(_ops.Union(tiles)).Area));
}
[Fact]
public void ArmFramesRunFromTheRootAndAcrossFromTheClockwiseSide()
{
var layout = new HubArmsLayout(16.0, 12.0, new[] { 10.0, 10.0, 10.0, 10.0 });
// Hub [10, 26]²; every arm is 12 m wide, centred on its side, so it spans [12, 24] across.
Assert.Equal(new[] { P(26, 12), P(36, 12), P(26, 24) }, Frame(layout.Arms[0]));
Assert.Equal(new[] { P(24, 26), P(24, 36), P(12, 26) }, Frame(layout.Arms[1]));
Assert.Equal(new[] { P(10, 24), P(0, 24), P(10, 12) }, Frame(layout.Arms[2]));
Assert.Equal(new[] { P(12, 10), P(12, 0), P(24, 10) }, Frame(layout.Arms[3]));
Assert.Equal(Rectangle(26, 12, 36, 18), layout.Arms[0].Piece(0.0, 10.0, 0.0, 6.0).Outer);
Assert.Equal(Rectangle(18, 26, 24, 36), layout.Arms[1].Piece(0.0, 10.0, 0.0, 6.0).Outer);
Assert.Equal(Rectangle(0, 18, 10, 24), layout.Arms[2].Piece(0.0, 10.0, 0.0, 6.0).Outer);
Assert.Equal(Rectangle(12, 0, 18, 10), layout.Arms[3].Piece(0.0, 10.0, 0.0, 6.0).Outer);
Assert.Equal(Rectangle(31, 12, 36, 24), layout.Arms[0].Piece(5.0, 10.0, 0.0, 12.0).Outer);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void PiecesOfAnArmShareTheirEdgesExactly(int arms)
{
var layout = new HubArmsLayout(32.0, 32.0, Enumerable.Repeat(70.0, arms).ToArray());
foreach (HubArm arm in layout.Arms)
{
Polygon2[] pieces =
{
arm.Piece(0.0, 40.0, 0.0, 12.0), arm.Piece(0.0, 40.0, 12.0, 20.0), arm.Piece(0.0, 40.0, 20.0, 32.0), arm.Piece(40.0, 70.0, 0.0, 32.0),
};
Assert.Equal(arm.Footprint.Area, pieces.Sum(p => p.Area), 9);
Assert.All(pieces[1].Outer, p => Assert.Contains(p, pieces[0].Outer.Concat(pieces[2].Outer)));
Assert.All(arm.Footprint.Outer, p => Assert.Contains(p, pieces.SelectMany(q => q.Outer)));
}
}
[Fact]
public void TheHubWithStripsIsOnePolygonThroughEveryArm()
{
var layout = new HubArmsLayout(32.0, 32.0, new[] { 70.0, 70.0, 70.0 });
Polygon2 mall = layout.HubWithStrips(12.0, 20.0, 40.0);
Assert.Equal(
new[]
{
P(70, 0), P(102, 0), P(102, 12), P(142, 12), P(142, 20), P(102, 20), P(102, 32), P(90, 32),
P(90, 72), P(82, 72), P(82, 32), P(70, 32), P(70, 20), P(30, 20), P(30, 12), P(70, 12),
},
mall.Outer);
Assert.Empty(mall.Holes);
Assert.Equal((32.0 * 32.0) + (3 * 40.0 * 8.0), mall.Area, 9);
Assert.All(layout.Arms, a => Assert.Equal(0.0, _ops.IntersectionArea(mall, a.Piece(0.0, 40.0, 0.0, 12.0)), 12));
Assert.All(layout.Arms, a => Assert.Equal(320.0, _ops.IntersectionArea(mall, a.Piece(0.0, 40.0, 12.0, 20.0)), 9));
}
[Fact]
public void MoreThanFourArmsAreRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new HubArmsLayout(12.0, 12.0, new[] { 1.0, 1.0, 1.0, 1.0, 1.0 }));
}
[Fact]
public void TooFewArmsAreAnErrorWithTheFamilysReason()
{
Diagnostic error = Assert.Single(HubArmsLayout.CountErrors("LobeCount", 2, 3, 4, "two lobes would be a bar through the core", "arms leave the core only on its four sides"));
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal("LobeCount must be at least 3 (two lobes would be a bar through the core), got 2.", error.Message);
}
[Fact]
public void TooManyArmsAreAnErrorThatCitesTheLayoutLimit()
{
Diagnostic error = Assert.Single(HubArmsLayout.CountErrors("LobeCount", 5, 3, 4, "two lobes would be a bar through the core", "arms leave the core only on its four sides"));
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal("LobeCount must be at most 4 (arms leave the core only on its four sides, D-100), got 5.", error.Message);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void CountsWithinTheLimitsAreNoError(int count)
{
Assert.Empty(HubArmsLayout.CountErrors("LobeCount", count, 3, 4, "two lobes would be a bar through the core", "arms leave the core only on its four sides"));
}
[Theory]
[InlineData(12.0, 12.0, 0)]
[InlineData(11.5, 12.0, 0)]
[InlineData(12.5, 12.0, 1)]
public void AnArmWiderThanTheHubIsAnError(double width, double hub, int expected)
{
Diagnostic[] errors = HubArmsLayout.WidthErrors("LobeWidth", width, "CoreWidth", hub).ToArray();
Assert.Equal(expected, errors.Length);
if (expected == 1)
{
Assert.Equal(DiagnosticCodes.InvalidParameter, errors[0].Code);
Assert.Equal(
"LobeWidth 12.5 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.",
errors[0].Message);
}
}
private static Point2 P(double x, double y) => new(x, y);
private static Point2 Round(Point2 p) => new(Math.Round(p.X, 9), Math.Round(p.Y, 9));
private static Point2[] Rectangle(double minX, double minY, double maxX, double maxY) => Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
private static Point2[] Frame(HubArm arm) => new[] { arm.At(0.0, 0.0), arm.At(arm.Length, 0.0), arm.At(0.0, 12.0) };
}
- [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.HubArms.HubArmsLayoutTests"
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\HubArms\HubArmsLayoutTests.cs(6,22): error CS0234: The type or namespace name 'HubArms' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\HubArms\HubArmsLayoutTests.cs(235,35): error CS0246: The type or namespace name 'HubArm' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 4: Implement — replace
src/Lod.Generators/Lod.Generators.csproj(full file)
One item group with InternalsVisibleTo is added; nothing else changes.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>Lod.Generators</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- The shared layouts (HubArmsLayout) are internal; their own tests need them. -->
<InternalsVisibleTo Include="Lod.Generators.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lod.Core\Lod.Core.csproj" />
</ItemGroup>
</Project>
- [x] Step 5: Implement — create
src/Lod.Generators/HubArms/HubArmsLayout.cs(full file)
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Generators.HubArms;
/// <summary>A side of the hub that an arm leaves from, in the arm order of ADR-006 (counter-clockwise from plan east).</summary>
internal enum HubSide
{
/// <summary>Plan east (+x).</summary>
East,
/// <summary>Plan north (+y).</summary>
North,
/// <summary>Plan west (−x).</summary>
West,
/// <summary>Plan south (−y).</summary>
South,
}
/// <summary>
/// The hub and its arms of ADR-006: a square hub of side <see cref="HubWidth"/> and up to four rectangular arms of width <see cref="ArmWidth"/>, one
/// on each of the hub's sides in the order east, north, west, south, each centred on its side. Shared by the radial lobes (SYN-TYP-010), the branching
/// mall (014), the care hub (018), and the foyer with halls (019), so the hub-and-arms geometry exists once; the families subdivide the hub and the arms
/// into their zones in each arm's own frame (<see cref="HubArm.Piece"/>).
/// </summary>
/// <remarks>
/// The footprint is the union of hub and arms (an L for two arms, a T for three, a cross for four), placed with the south-west corner of its bounding
/// box at the origin. A hub side without an arm, and the hub's corners when the arms are narrower than the hub, are outdoor walls. An arm must not be
/// wider than the hub (<see cref="WidthErrors"/>); the families validate it.
/// </remarks>
internal sealed class HubArmsLayout
{
/// <summary>The most arms the axis-aligned layout holds: one per side of the hub (D-100).</summary>
public const int MaximumArms = 4;
private readonly Point2[] _corners;
/// <summary>Lays out the hub and its arms.</summary>
/// <param name="hubWidth">Side of the square hub, m; positive.</param>
/// <param name="armWidth">Width of every arm along the hub's side, m; positive and at most <paramref name="hubWidth"/>.</param>
/// <param name="armLengths">Length of each arm away from the hub, m, positive, in the order east, north, west, south; at most four.</param>
/// <exception cref="ArgumentOutOfRangeException">More than four arms.</exception>
public HubArmsLayout(double hubWidth, double armWidth, IReadOnlyList<double> armLengths)
{
if (armLengths.Count > MaximumArms)
{
throw new ArgumentOutOfRangeException(nameof(armLengths), armLengths.Count, $"A hub has at most {MaximumArms} arms, one per side (D-100).");
}
HubWidth = hubWidth;
ArmWidth = armWidth;
double x0 = armLengths.Count > (int)HubSide.West ? armLengths[(int)HubSide.West] : 0.0;
double y0 = armLengths.Count > (int)HubSide.South ? armLengths[(int)HubSide.South] : 0.0;
Hub = Polygon2.Rectangle(x0, y0, x0 + hubWidth, y0 + hubWidth);
_corners = new[] { new Point2(x0, y0), new Point2(x0 + hubWidth, y0), new Point2(x0 + hubWidth, y0 + hubWidth), new Point2(x0, y0 + hubWidth) };
Arms = armLengths.Select((length, i) => new HubArm(i + 1, (HubSide)i, length, armWidth, x0, y0, hubWidth)).ToArray();
Footprint = Outline(0.0, armWidth, Arms.Select(a => a.Length).ToArray());
}
/// <summary>Side of the square hub, m.</summary>
public double HubWidth { get; }
/// <summary>Width of every arm, m.</summary>
public double ArmWidth { get; }
/// <summary>The arms in the order east, north, west, south.</summary>
public IReadOnlyList<HubArm> Arms { get; }
/// <summary>The square hub.</summary>
public Polygon2 Hub { get; }
/// <summary>The union of the hub and every arm, counter-clockwise from its lowest, then leftmost, vertex.</summary>
public Polygon2 Footprint { get; }
/// <summary>
/// The errors for an arm count outside <paramref name="minimum"/>–<paramref name="maximum"/>, or none; the messages give the family's reason for
/// its minimum and the layout's reason for its maximum.
/// </summary>
/// <param name="name">The count's parameter name.</param>
/// <param name="count">The count.</param>
/// <param name="minimum">The family's minimum.</param>
/// <param name="maximum">The largest count the layout holds for the family; at most <see cref="MaximumArms"/>.</param>
/// <param name="minimumReason">Why fewer would lose the family's relationship.</param>
/// <param name="maximumReason">Why the layout holds no more.</param>
/// <returns>Zero or one <see cref="DiagnosticCodes.InvalidParameter"/> error.</returns>
public static IEnumerable<Diagnostic> CountErrors(string name, int count, int minimum, int maximum, string minimumReason, string maximumReason)
{
if (count < minimum)
{
yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be at least {minimum} ({minimumReason}), got {count}.");
}
else if (count > maximum)
{
yield return Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{name} must be at most {maximum} ({maximumReason}, D-100), got {count}.");
}
}
/// <summary>The error for an arm wider than the hub, or none.</summary>
/// <param name="arm">The arm width as named in the message, such as <c>LobeWidth</c>.</param>
/// <param name="armWidth">Arm width, m.</param>
/// <param name="hub">The hub width's parameter name.</param>
/// <param name="hubWidth">Hub width, m.</param>
/// <returns>Zero or one <see cref="DiagnosticCodes.InvalidParameter"/> error.</returns>
public static IEnumerable<Diagnostic> WidthErrors(string arm, double armWidth, string hub, double hubWidth)
{
if (armWidth <= hubWidth)
{
yield break;
}
yield return Diagnostic.Error(
DiagnosticCodes.InvalidParameter,
$"{arm} {Provenance.Format(armWidth)} m is wider than {hub} {Provenance.Format(hubWidth)} m; an arm must not be wider than the hub it leaves, "
+ "or it would overlap its neighbour beyond the hub's corner.");
}
/// <summary>
/// The hub united with one strip of every arm, <c>[0, length]</c> along and <c>[acrossFrom, acrossTo]</c> across the arm (<see cref="HubArm.Piece"/>),
/// as one polygon, such as a mall through the hub and every branch.
/// </summary>
/// <param name="acrossFrom">Start of the strip across each arm, m from its clockwise side.</param>
/// <param name="acrossTo">End of the strip across each arm, m; at most <see cref="ArmWidth"/>.</param>
/// <param name="length">Length of each strip from the hub, m; at most its arm's length.</param>
/// <returns>The polygon, counter-clockwise from its lowest, then leftmost, vertex.</returns>
public Polygon2 HubWithStrips(double acrossFrom, double acrossTo, double length) =>
Outline(acrossFrom, acrossTo, Arms.Select(_ => length).ToArray());
/// <summary>
/// Walks the hub counter-clockwise from its south-west corner, side by side in the order south, east, north, west, and around the strip
/// <c>[acrossFrom, acrossTo]</c> of each arm's first <c>lengths[i]</c> metres; repeated and collinear points are dropped, so an arm as wide as the
/// hub continues the hub's sides without a vertex at the hub's corner.
/// </summary>
private Polygon2 Outline(double acrossFrom, double acrossTo, IReadOnlyList<double> lengths)
{
var points = new List<Point2>();
foreach (HubSide side in new[] { HubSide.South, HubSide.East, HubSide.North, HubSide.West })
{
points.Add(_corners[((int)side + 1) % 4]);
if ((int)side < Arms.Count)
{
HubArm arm = Arms[(int)side];
double length = lengths[(int)side];
points.Add(arm.At(0.0, acrossFrom));
points.Add(arm.At(length, acrossFrom));
points.Add(arm.At(length, acrossTo));
points.Add(arm.At(0.0, acrossTo));
}
}
List<Point2> ring = Simplify(points);
int start = ring.IndexOf(ring.OrderBy(p => p.Y).ThenBy(p => p.X).First());
return Polygon2.Create(ring.Skip(start).Concat(ring.Take(start))).Value;
}
private static List<Point2> Simplify(List<Point2> points)
{
var ring = new List<Point2>();
foreach (Point2 p in points)
{
if (ring.Count == 0 || ring[ring.Count - 1] != p)
{
ring.Add(p);
}
}
if (ring.Count > 1 && ring[0] == ring[ring.Count - 1])
{
ring.RemoveAt(ring.Count - 1);
}
bool removed = true;
while (removed && ring.Count > 3)
{
removed = false;
for (int i = 0; i < ring.Count; i++)
{
Point2 previous = ring[(i + ring.Count - 1) % ring.Count];
Point2 next = ring[(i + 1) % ring.Count];
if ((previous.X == ring[i].X && ring[i].X == next.X) || (previous.Y == ring[i].Y && ring[i].Y == next.Y))
{
ring.RemoveAt(i);
removed = true;
break;
}
}
}
return ring;
}
}
/// <summary>
/// One arm of a <see cref="HubArmsLayout"/> with its own frame: <c>along</c> runs from the arm's root on the hub's side to its tip, and <c>across</c>
/// from the arm's clockwise side (the right-hand side looking out from the hub) to its counter-clockwise side. Pieces with the same along or across
/// values share their edges exactly.
/// </summary>
internal sealed class HubArm
{
private readonly double _x0;
private readonly double _y0;
private readonly double _hubWidth;
private readonly double _low;
/// <summary>Creates an arm; see <see cref="HubArmsLayout"/>.</summary>
/// <param name="number">The arm's number, from 1 in the arm order.</param>
/// <param name="side">The hub side it leaves from.</param>
/// <param name="length">Length away from the hub, m.</param>
/// <param name="width">Width along the hub's side, m.</param>
/// <param name="x0">Hub's west edge, plan x, m.</param>
/// <param name="y0">Hub's south edge, plan y, m.</param>
/// <param name="hubWidth">Hub side, m.</param>
public HubArm(int number, HubSide side, double length, double width, double x0, double y0, double hubWidth)
{
Number = number;
Side = side;
Length = length;
Width = width;
_x0 = x0;
_y0 = y0;
_hubWidth = hubWidth;
_low = (hubWidth - width) / 2.0;
}
/// <summary>The arm's number, from 1 in the order east, north, west, south.</summary>
public int Number { get; }
/// <summary>The hub side the arm leaves from.</summary>
public HubSide Side { get; }
/// <summary>Length from the hub to the arm's tip, m.</summary>
public double Length { get; }
/// <summary>Width of the arm along the hub's side, m.</summary>
public double Width { get; }
/// <summary>The whole arm.</summary>
public Polygon2 Footprint => Piece(0.0, Length, 0.0, Width);
/// <summary>A point of the arm's frame in plan coordinates.</summary>
/// <param name="along">Distance from the arm's root, m.</param>
/// <param name="across">Distance from the arm's clockwise side, m.</param>
/// <returns>The plan point.</returns>
public Point2 At(double along, double across) => Side switch
{
HubSide.East => new Point2(_x0 + _hubWidth + along, _y0 + _low + across),
HubSide.North => new Point2(_x0 + _low + (Width - across), _y0 + _hubWidth + along),
HubSide.West => new Point2(_x0 - along, _y0 + _low + (Width - across)),
_ => new Point2(_x0 + _low + across, _y0 - along),
};
/// <summary>The rectangle <c>[alongFrom, alongTo] × [acrossFrom, acrossTo]</c> of the arm's frame.</summary>
/// <param name="alongFrom">Start along the arm, m from its root.</param>
/// <param name="alongTo">End along the arm, m; greater than <paramref name="alongFrom"/>.</param>
/// <param name="acrossFrom">Start across the arm, m from its clockwise side.</param>
/// <param name="acrossTo">End across the arm, m; greater than <paramref name="acrossFrom"/>.</param>
/// <returns>The rectangle in plan coordinates.</returns>
public Polygon2 Piece(double alongFrom, double alongTo, double acrossFrom, double acrossTo)
{
Point2 a = At(alongFrom, acrossFrom);
Point2 b = At(alongTo, acrossTo);
return Polygon2.Rectangle(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Max(a.X, b.X), Math.Max(a.Y, b.Y));
}
}
- [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.HubArms.HubArmsLayoutTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 27, Skipped: 0, Total: 27, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 251 generators, and 704 integration tests passed on net8.0; git status lists M src/Lod.Generators/Lod.Generators.csproj and the two new files, and no *.received.txt.
- [x] Step 7: Commit
git add src/Lod.Generators/HubArms/HubArmsLayout.cs src/Lod.Generators/Lod.Generators.csproj tests/Lod.Generators.Tests/HubArms/HubArmsLayoutTests.cs
git commit -m "feature(generators): add the shared hub-and-arms layout"
Task 2: Radial lobes around a point tower core (SYN-TYP-010)¶
Files:
- Create: src/Lod.Generators/RadialLobes/RadialLobesGenerator.cs
- Test: create tests/Lod.Generators.Tests/RadialLobes/RadialLobesGeneratorTests.cs, tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt
Interfaces:
- Consumes (S2–S8.4, Task 1): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings), Validate, Layout, Describe, protected static bool IsPositive(double)), PlanParameters, PlanPresets.Slots, PresetSlot, PlanLayout, PlannedZone(ZoneId, string, SpaceType, Polygon2), Provenance.Format, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch; HubArmsLayout, HubArm.Piece, CountErrors, WidthErrors (Task 1); SpaceType.DwellingUnit and Stair, ExampleResidentialPresets.DwellingUnit and Stair; 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: public sealed record RadialLobesParameters(int LobeCount, double LobeLength, double LobeWidth, double CoreWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (4, 10, 12, 12, 3, 0); public sealed record RadialLobesPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets (slots DwellingUnit, Stair); public sealed class RadialLobesGenerator(ToleranceSettings), Name = RadialLobesGenerator, public const int MinimumLobes = 3, MaximumLobes = 4: the core ST (Core, Stair) is the hub, and each lobe i is split along its axis into L{i}-U1 (the half clockwise of the axis) and L{i}-U2 (Lobe i Unit k, DwellingUnit); errors InvalidParameter for a lobe count outside 3–4 (LobeCount must be at least 3 (two lobes would be a bar through the core), got 2., LobeCount must be at most 4 (lobes leave the core only on its four sides, D-100), got 5.), for each length that is not positive and finite ({name} must be positive, got {value}.), and, only when those pass, for a lobe wider than the core; provenance keys LobeCount, LobeLength, LobeWidth, CoreWidth. By default a cross of 624 m²: the core of 144 m² without an outdoor wall and eight dwellings of 60 m², 16 windows with 115.2 m² of glazing (28.8 m² per orientation); with three lobes the core's south side is an outdoor wall with a stair window.
- [x] Step 1: Write the failing tests — create
tests/Lod.Generators.Tests/RadialLobes/RadialLobesGeneratorTests.cs(full file)
28 tests: the ADR-006 defaults; the canonical plan (zone IDs, names, space types, areas, the core's square); the cross footprint; tiling for four sizes and counts; every dwelling adjoining the core over half its lobe's root (3 and 4 lobes); two façades per dwelling; no outdoor wall of the core with four lobes and a south façade with a stair window with three; the window rule; core corners too short for a window beside narrower lobes; three lobe counts outside 3–4; four invalid lengths; a lobe wider than the core; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.RadialLobes;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.RadialLobes;
public sealed class RadialLobesGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly RadialLobesPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);
private static readonly string[] CanonicalZones =
{
"ST", "L1-U1", "L1-U2", "L2-U1", "L2-U2", "L3-U1", "L3-U2", "L4-U1", "L4-U2",
};
private readonly RadialLobesGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new RadialLobesParameters(LobeCount: 4, LobeLength: 10.0, LobeWidth: 12.0, CoreWidth: 12.0, FloorHeight: 3.0, OrientationDegrees: 0.0),
RadialLobesParameters.Default);
}
[Fact]
public void CanonicalPlanIsACoreWithFourLobesOfTwoDwellings()
{
IGeneratedPlan plan = Generate(RadialLobesParameters.Default);
Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(
new[] { "Core", "Lobe 1 Unit 1", "Lobe 1 Unit 2", "Lobe 2 Unit 1", "Lobe 2 Unit 2", "Lobe 3 Unit 1", "Lobe 3 Unit 2", "Lobe 4 Unit 1", "Lobe 4 Unit 2" },
plan.Zones.Select(z => z.Name));
Assert.Equal(SpaceType.Stair, plan.Zones[0].SpaceType);
Assert.All(plan.Zones.Skip(1), z => Assert.Equal(SpaceType.DwellingUnit, z.SpaceType));
Assert.Equal(624.0, plan.Footprint.Area, 9);
Assert.Empty(plan.Footprint.Holes);
Assert.Equal(144.0, plan.Zones[0].FloorArea, 9);
Assert.All(plan.Zones.Skip(1), z => Assert.Equal(60.0, z.FloorArea, 9));
Assert.Equal(Rectangle(10, 10, 22, 22), Outer(plan, "ST"));
Assert.Equal(Rectangle(22, 10, 32, 16), Outer(plan, "L1-U1"));
Assert.Equal(Rectangle(22, 16, 32, 22), Outer(plan, "L1-U2"));
Assert.Equal(Rectangle(16, 22, 22, 32), Outer(plan, "L2-U1"));
Assert.Equal(Rectangle(10, 22, 16, 32), Outer(plan, "L2-U2"));
Assert.Equal(Rectangle(0, 16, 10, 22), Outer(plan, "L3-U1"));
Assert.Equal(Rectangle(10, 0, 16, 10), Outer(plan, "L4-U1"));
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 TheFootprintIsACross()
{
IGeneratedPlan plan = Generate(RadialLobesParameters.Default);
Assert.Equal(
new[]
{
new Point2(10, 0), new Point2(22, 0), new Point2(22, 10), new Point2(32, 10), new Point2(32, 22), new Point2(22, 22),
new Point2(22, 32), new Point2(10, 32), new Point2(10, 22), new Point2(0, 22), new Point2(0, 10), new Point2(10, 10),
},
plan.Footprint.Outer);
}
[Theory]
[InlineData(3, 10.0, 12.0, 12.0)]
[InlineData(4, 10.0, 12.0, 12.0)]
[InlineData(3, 8.5, 9.0, 14.0)]
[InlineData(4, 15.0, 7.3, 11.0)]
public void ZonesTileTheFootprint(int lobes, double length, double width, double core)
{
IGeneratedPlan plan = Generate(new RadialLobesParameters(lobes, length, width, core, 3.0, 0.0));
PlanAssertions.TilesTheFootprint(plan);
Assert.Equal((core * core) + (lobes * length * width), plan.Footprint.Area, 9);
Assert.Equal(1 + (2 * lobes), plan.Zones.Count);
Assert.All(plan.Zones.Skip(1), z => Assert.Equal(length * width / 2.0, z.FloorArea, 9));
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void EveryDwellingAdjoinsTheCoreOverHalfItsLobesRoot(int lobes)
{
IGeneratedPlan plan = Generate(RadialLobesParameters.Default with { LobeCount = lobes });
string[] dwellings = plan.Zones.Skip(1).Select(z => z.Id.Value).ToArray();
Assert.Equal(dwellings.OrderBy(id => id, System.StringComparer.Ordinal), PlanAssertions.Neighbours(plan, "ST"));
foreach (string dwelling in dwellings)
{
string partner = dwelling.EndsWith("U1") ? dwelling.Replace("U1", "U2") : dwelling.Replace("U2", "U1");
Assert.Equal(new[] { "ST", partner }.OrderBy(id => id, System.StringComparer.Ordinal), PlanAssertions.Neighbours(plan, dwelling));
double root = plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == dwelling && w.AdjacentZone?.Value == "ST").Sum(w => w.Length);
Assert.Equal(6.0, root, 9);
}
}
[Fact]
public void EveryDwellingHasTwoFacades()
{
IGeneratedPlan plan = Generate(RadialLobesParameters.Default);
Assert.Equal(new[] { OrientationBin.East, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "L1-U1"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North }, PlanAssertions.OutdoorFacings(plan, "L2-U1"));
Assert.Equal(new[] { OrientationBin.North, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "L3-U1"));
Assert.Equal(new[] { OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "L4-U1"));
Assert.All(plan.Zones.Skip(1), z => Assert.Equal(2, plan.Surfaces.OfType<WallSurface>().Count(w => w.Zone == z.Id && w.Boundary == BoundaryCondition.Outdoors)));
}
[Fact]
public void WithFourLobesTheCoreHasNoOutdoorWall()
{
IGeneratedPlan plan = Generate(RadialLobesParameters.Default);
Assert.Empty(PlanAssertions.OutdoorFacings(plan, "ST"));
}
[Fact]
public void WithThreeLobesTheCoresFreeSideIsASouthFacadeWithAStairWindow()
{
Result<IGeneratedPlan> result = _generator.Generate(RadialLobesParameters.Default with { LobeCount = 3 }, Examples);
Assert.Equal(new[] { "ST", "L1-U1", "L1-U2", "L2-U1", "L2-U2", "L3-U1", "L3-U2" }, result.Value.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { OrientationBin.South }, PlanAssertions.OutdoorFacings(result.Value, "ST"));
WallSurface south = Assert.Single(result.Value.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "ST" && w.Boundary == BoundaryCondition.Outdoors);
Assert.Equal(12.0, south.Length, 9);
Assert.Equal(0.1 * 12.0 * 3.0, Assert.Single(south.Windows).Area, 9);
Assert.Equal(504.0, result.Value.Footprint.Area, 9);
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(RadialLobesParameters.Default, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
Assert.Equal(16, result.Value.Surfaces.OfType<WallSurface>().Count(w => w.Windows.Count == 1));
Assert.Equal(0.3 * 8 * (10.0 + 6.0) * 3.0, result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 9);
}
[Fact]
public void NarrowerLobesLeaveCoreCornersThatAreTooShortForAWindow()
{
// 11 m lobes on a 12 m core leave 0.5 m of core façade beside each lobe's root.
Result<IGeneratedPlan> result = _generator.Generate(RadialLobesParameters.Default with { LobeWidth = 11.0 }, Examples);
Assert.True(result.IsSuccess);
WallSurface[] corners = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "ST" && w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(8, corners.Length);
Assert.All(corners, w => Assert.Equal(0.5, w.Length, 9));
Assert.All(corners, w => Assert.Empty(w.Windows));
Assert.Equal(corners.Select(w => w.Id.Value), result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).Select(d => d.Subject));
PlanAssertions.TilesTheFootprint(result.Value);
}
[Theory]
[InlineData(2, "LobeCount must be at least 3 (two lobes would be a bar through the core), got 2.")]
[InlineData(0, "LobeCount must be at least 3 (two lobes would be a bar through the core), got 0.")]
[InlineData(5, "LobeCount must be at most 4 (lobes leave the core only on its four sides, D-100), got 5.")]
public void LobeCountsOutsideThreeToFourAreErrors(int lobes, string message)
{
Result<IGeneratedPlan> result = _generator.Generate(RadialLobesParameters.Default with { LobeCount = lobes }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(message, error.Message);
}
[Theory]
[InlineData(0.0, 12.0, 12.0, "LobeLength")]
[InlineData(10.0, -12.0, 12.0, "LobeWidth")]
[InlineData(10.0, 12.0, double.NaN, "CoreWidth")]
[InlineData(double.PositiveInfinity, 12.0, 12.0, "LobeLength")]
public void InvalidParametersAreErrors(double length, double width, double core, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new RadialLobesParameters(4, length, width, core, 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 ALobeWiderThanTheCoreIsAnError()
{
Result<IGeneratedPlan> result = _generator.Generate(RadialLobesParameters.Default with { LobeWidth = 13.0 }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(
"LobeWidth 13 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.",
error.Message);
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new RadialLobesParameters(2, 0.0, -12.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(6, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Stair", SpaceType.Stair) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.DwellingUnit, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(
RadialLobesParameters.Default,
new RadialLobesPresets(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(RadialLobesParameters.Default)), TextReport.Describe(Generate(RadialLobesParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(RadialLobesParameters.Default).Provenance;
Assert.Equal(nameof(RadialLobesGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"CoreWidth=12", "FloorHeight=3", "LobeCount=4", "LobeLength=10", "LobeWidth=12", "OrientationDegrees=0",
"Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair",
},
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(RadialLobesParameters.Default)), "radial-lobes-canonical");
}
private static Point2[] Rectangle(double minX, double minY, double maxX, double maxY) => Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();
private IGeneratedPlan Generate(RadialLobesParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.RadialLobes.RadialLobesGeneratorTests"
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\RadialLobes\RadialLobesGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'RadialLobes' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\RadialLobes\RadialLobesGeneratorTests.cs(260,37): error CS0246: The type or namespace name 'RadialLobesParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\RadialLobes\RadialLobesGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'RadialLobesPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\RadialLobes\RadialLobesGeneratorTests.cs(22,22): error CS0246: The type or namespace name 'RadialLobesGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — create
src/Lod.Generators/RadialLobes/RadialLobesGenerator.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.HubArms;
namespace Lod.Generators.RadialLobes;
/// <summary>Parameters of the radial lobes around a point tower core (SYN-TYP-010, ADR-006).</summary>
/// <param name="LobeCount">Number of lobes around the core, 3–4.</param>
/// <param name="LobeLength">Length of each lobe from the core to its end, which every dwelling spans, m.</param>
/// <param name="LobeWidth">Width of each lobe along the core's side, m; two dwellings side by side; at most <paramref name="CoreWidth"/>.</param>
/// <param name="CoreWidth">Side of the square core, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record RadialLobesParameters(
int LobeCount,
double LobeLength,
double LobeWidth,
double CoreWidth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: 4 lobes, 10 m long and 12 m wide, around a 12 m core, 3 m storeys, orientation 0°.</summary>
public static RadialLobesParameters Default { get; } = new(4, 10.0, 12.0, 12.0, 3.0, 0.0);
}
/// <summary>The program presets of the radial lobes, one per space type they place (D-061).</summary>
/// <param name="DwellingUnit">Preset of the dwellings; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Stair">Preset of the core; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record RadialLobesPresets(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>
/// Radial lobes around a point tower core (SYN-TYP-010, ADR-006): dwelling lobes branch around one compact core (R001) on the shared
/// <see cref="HubArmsLayout"/>, the core as the hub and the lobes as its arms. Each lobe holds two dwellings side by side, each spanning the lobe's
/// length and half its width, so every dwelling adjoins the core over half the lobe's root (R002, point access); movement spreads from the core into
/// the lobes, and there is no other circulation zone (R003). One zone per dwelling unit and storey (D-009, D-085); the core is a
/// <see cref="SpaceType.Stair"/> zone.
/// </summary>
/// <remarks>
/// Zone IDs: <c>ST</c> (the core), then per lobe <c>L{i}-U1</c>, <c>L{i}-U2</c>, lobe <c>i</c> from 1 in the order east, north, west, south and
/// <c>U1</c> the half clockwise of the lobe's axis. With three lobes the core's south side is an outdoor wall with the stair preset's windows; lobes
/// narrower than the core leave the core's corners as outdoor walls.
/// </remarks>
public sealed class RadialLobesGenerator : PlanGenerator<RadialLobesParameters, RadialLobesPresets>
{
/// <summary>The fewest lobes: two would be a bar through the core.</summary>
public const int MinimumLobes = 3;
/// <summary>The most lobes: one per side of the core (D-100).</summary>
public const int MaximumLobes = HubArmsLayout.MaximumArms;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public RadialLobesGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(RadialLobesGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(RadialLobesParameters parameters)
{
var errors = HubArmsLayout.CountErrors(
nameof(RadialLobesParameters.LobeCount),
parameters.LobeCount,
MinimumLobes,
MaximumLobes,
"two lobes would be a bar through the core",
"lobes leave the core only on its four sides").ToList();
var values = new (string Name, double Value)[]
{
(nameof(RadialLobesParameters.LobeLength), parameters.LobeLength),
(nameof(RadialLobesParameters.LobeWidth), parameters.LobeWidth),
(nameof(RadialLobesParameters.CoreWidth), parameters.CoreWidth),
};
var lengths = values
.Where(v => !IsPositive(v.Value))
.Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
.ToList();
if (lengths.Count == 0)
{
lengths.AddRange(HubArmsLayout.WidthErrors(nameof(RadialLobesParameters.LobeWidth), parameters.LobeWidth, nameof(RadialLobesParameters.CoreWidth), parameters.CoreWidth));
}
errors.AddRange(lengths);
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(RadialLobesParameters p)
{
var layout = new HubArmsLayout(p.CoreWidth, p.LobeWidth, Enumerable.Repeat(p.LobeLength, p.LobeCount).ToArray());
double half = p.LobeWidth / 2.0;
var zones = new List<PlannedZone> { new(new ZoneId("ST"), "Core", SpaceType.Stair, layout.Hub) };
foreach (HubArm lobe in layout.Arms)
{
zones.Add(Unit(lobe, 1, lobe.Piece(0.0, lobe.Length, 0.0, half)));
zones.Add(Unit(lobe, 2, lobe.Piece(0.0, lobe.Length, half, lobe.Width)));
}
return new PlanLayout(layout.Footprint, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(RadialLobesParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.LobeCount), p.LobeCount.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>(nameof(p.LobeLength), Provenance.Format(p.LobeLength)),
new KeyValuePair<string, string>(nameof(p.LobeWidth), Provenance.Format(p.LobeWidth)),
new KeyValuePair<string, string>(nameof(p.CoreWidth), Provenance.Format(p.CoreWidth)),
};
private static PlannedZone Unit(HubArm lobe, int number, Polygon2 footprint) =>
new(new ZoneId($"L{lobe.Number}-U{number}"), $"Lobe {lobe.Number} Unit {number}", SpaceType.DwellingUnit, footprint);
}
- [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.RadialLobes.RadialLobesGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 27, Skipped: 0, Total: 28, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.RadialLobes.RadialLobesGeneratorTests.CanonicalPlanSnapshot
Snapshot 'radial-lobes-canonical' is missing or differs. Review Snapshots/radial-lobes-canonical.received.txt and rename it to radial-lobes-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.received.txt (git-ignored by *.received.*).
- [x] Step 5: Review and accept the received file
Review the received file against the content below; it must be identical: 111 lines, 9 zones, 58 surfaces (16 outdoor walls, each with one window; 24 interzone walls; 18 floors and ceilings), glazing 28.8 m² south, 28.8 m² north, 28.8 m² east, 28.8 m² west (115.2 m² in all); the core ST has only interzone walls, and each lobe's two dwellings share one wall along its axis. Accept it:
Move-Item -Force tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.received.txt tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt
tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt:
footprint area=624.000000 floorHeight=3.000000 orientation=0.000000
zone ST Stair "Core" area=144.000000 volume=432.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 L1-U1 DwellingUnit "Lobe 1 Unit 1" area=60.000000 volume=180.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 L1-U2 DwellingUnit "Lobe 1 Unit 2" area=60.000000 volume=180.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 L2-U1 DwellingUnit "Lobe 2 Unit 1" area=60.000000 volume=180.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 L2-U2 DwellingUnit "Lobe 2 Unit 2" area=60.000000 volume=180.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 L3-U1 DwellingUnit "Lobe 3 Unit 1" area=60.000000 volume=180.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 L3-U2 DwellingUnit "Lobe 3 Unit 2" area=60.000000 volume=180.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 L4-U1 DwellingUnit "Lobe 4 Unit 1" area=60.000000 volume=180.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 L4-U2 DwellingUnit "Lobe 4 Unit 2" area=60.000000 volume=180.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
surface ST/W1 Interzone adjacent=L4-U1 area=18.000000 wall (10.000000,10.000000)-(16.000000,10.000000) z=0.000000 facing=South glazing=0.000000
surface ST/W2 Interzone adjacent=L4-U2 area=18.000000 wall (16.000000,10.000000)-(22.000000,10.000000) z=0.000000 facing=South glazing=0.000000
surface ST/W3 Interzone adjacent=L1-U1 area=18.000000 wall (22.000000,10.000000)-(22.000000,16.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W4 Interzone adjacent=L1-U2 area=18.000000 wall (22.000000,16.000000)-(22.000000,22.000000) z=0.000000 facing=East glazing=0.000000
surface ST/W5 Interzone adjacent=L2-U1 area=18.000000 wall (22.000000,22.000000)-(16.000000,22.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W6 Interzone adjacent=L2-U2 area=18.000000 wall (16.000000,22.000000)-(10.000000,22.000000) z=0.000000 facing=North glazing=0.000000
surface ST/W7 Interzone adjacent=L3-U1 area=18.000000 wall (10.000000,22.000000)-(10.000000,16.000000) z=0.000000 facing=West glazing=0.000000
surface ST/W8 Interzone adjacent=L3-U2 area=18.000000 wall (10.000000,16.000000)-(10.000000,10.000000) z=0.000000 facing=West glazing=0.000000
surface ST/F Unresolved area=144.000000 Floor z=0.000000
surface ST/C Unresolved area=144.000000 Ceiling z=3.000000
surface L1-U1/W1 Outdoors area=30.000000 wall (22.000000,10.000000)-(32.000000,10.000000) z=0.000000 facing=South glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L1-U1/W2 Outdoors area=18.000000 wall (32.000000,10.000000)-(32.000000,16.000000) z=0.000000 facing=East glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L1-U1/W3 Interzone adjacent=L1-U2 area=30.000000 wall (32.000000,16.000000)-(22.000000,16.000000) z=0.000000 facing=North glazing=0.000000
surface L1-U1/W4 Interzone adjacent=ST area=18.000000 wall (22.000000,16.000000)-(22.000000,10.000000) z=0.000000 facing=West glazing=0.000000
surface L1-U1/F Unresolved area=60.000000 Floor z=0.000000
surface L1-U1/C Unresolved area=60.000000 Ceiling z=3.000000
surface L1-U2/W1 Interzone adjacent=L1-U1 area=30.000000 wall (22.000000,16.000000)-(32.000000,16.000000) z=0.000000 facing=South glazing=0.000000
surface L1-U2/W2 Outdoors area=18.000000 wall (32.000000,16.000000)-(32.000000,22.000000) z=0.000000 facing=East glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L1-U2/W3 Outdoors area=30.000000 wall (32.000000,22.000000)-(22.000000,22.000000) z=0.000000 facing=North glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L1-U2/W4 Interzone adjacent=ST area=18.000000 wall (22.000000,22.000000)-(22.000000,16.000000) z=0.000000 facing=West glazing=0.000000
surface L1-U2/F Unresolved area=60.000000 Floor z=0.000000
surface L1-U2/C Unresolved area=60.000000 Ceiling z=3.000000
surface L2-U1/W1 Interzone adjacent=ST area=18.000000 wall (16.000000,22.000000)-(22.000000,22.000000) z=0.000000 facing=South glazing=0.000000
surface L2-U1/W2 Outdoors area=30.000000 wall (22.000000,22.000000)-(22.000000,32.000000) z=0.000000 facing=East glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L2-U1/W3 Outdoors area=18.000000 wall (22.000000,32.000000)-(16.000000,32.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L2-U1/W4 Interzone adjacent=L2-U2 area=30.000000 wall (16.000000,32.000000)-(16.000000,22.000000) z=0.000000 facing=West glazing=0.000000
surface L2-U1/F Unresolved area=60.000000 Floor z=0.000000
surface L2-U1/C Unresolved area=60.000000 Ceiling z=3.000000
surface L2-U2/W1 Interzone adjacent=ST area=18.000000 wall (10.000000,22.000000)-(16.000000,22.000000) z=0.000000 facing=South glazing=0.000000
surface L2-U2/W2 Interzone adjacent=L2-U1 area=30.000000 wall (16.000000,22.000000)-(16.000000,32.000000) z=0.000000 facing=East glazing=0.000000
surface L2-U2/W3 Outdoors area=18.000000 wall (16.000000,32.000000)-(10.000000,32.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L2-U2/W4 Outdoors area=30.000000 wall (10.000000,32.000000)-(10.000000,22.000000) z=0.000000 facing=West glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L2-U2/F Unresolved area=60.000000 Floor z=0.000000
surface L2-U2/C Unresolved area=60.000000 Ceiling z=3.000000
surface L3-U1/W1 Interzone adjacent=L3-U2 area=30.000000 wall (0.000000,16.000000)-(10.000000,16.000000) z=0.000000 facing=South glazing=0.000000
surface L3-U1/W2 Interzone adjacent=ST area=18.000000 wall (10.000000,16.000000)-(10.000000,22.000000) z=0.000000 facing=East glazing=0.000000
surface L3-U1/W3 Outdoors area=30.000000 wall (10.000000,22.000000)-(0.000000,22.000000) z=0.000000 facing=North glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L3-U1/W4 Outdoors area=18.000000 wall (0.000000,22.000000)-(0.000000,16.000000) z=0.000000 facing=West glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L3-U1/F Unresolved area=60.000000 Floor z=0.000000
surface L3-U1/C Unresolved area=60.000000 Ceiling z=3.000000
surface L3-U2/W1 Outdoors area=30.000000 wall (0.000000,10.000000)-(10.000000,10.000000) z=0.000000 facing=South glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L3-U2/W2 Interzone adjacent=ST area=18.000000 wall (10.000000,10.000000)-(10.000000,16.000000) z=0.000000 facing=East glazing=0.000000
surface L3-U2/W3 Interzone adjacent=L3-U1 area=30.000000 wall (10.000000,16.000000)-(0.000000,16.000000) z=0.000000 facing=North glazing=0.000000
surface L3-U2/W4 Outdoors area=18.000000 wall (0.000000,16.000000)-(0.000000,10.000000) z=0.000000 facing=West glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L3-U2/F Unresolved area=60.000000 Floor z=0.000000
surface L3-U2/C Unresolved area=60.000000 Ceiling z=3.000000
surface L4-U1/W1 Outdoors area=18.000000 wall (10.000000,0.000000)-(16.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L4-U1/W2 Interzone adjacent=L4-U2 area=30.000000 wall (16.000000,0.000000)-(16.000000,10.000000) z=0.000000 facing=East glazing=0.000000
surface L4-U1/W3 Interzone adjacent=ST area=18.000000 wall (16.000000,10.000000)-(10.000000,10.000000) z=0.000000 facing=North glazing=0.000000
surface L4-U1/W4 Outdoors area=30.000000 wall (10.000000,10.000000)-(10.000000,0.000000) z=0.000000 facing=West glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L4-U1/F Unresolved area=60.000000 Floor z=0.000000
surface L4-U1/C Unresolved area=60.000000 Ceiling z=3.000000
surface L4-U2/W1 Outdoors area=18.000000 wall (16.000000,0.000000)-(22.000000,0.000000) z=0.000000 facing=South glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface L4-U2/W2 Outdoors area=30.000000 wall (22.000000,0.000000)-(22.000000,10.000000) z=0.000000 facing=East glazing=9.000000 window(offset=2.261387 sill=0.678416 5.477226x1.643168)
surface L4-U2/W3 Interzone adjacent=ST area=18.000000 wall (22.000000,10.000000)-(16.000000,10.000000) z=0.000000 facing=North glazing=0.000000
surface L4-U2/W4 Interzone adjacent=L4-U1 area=30.000000 wall (16.000000,10.000000)-(16.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface L4-U2/F Unresolved area=60.000000 Floor z=0.000000
surface L4-U2/C Unresolved area=60.000000 Ceiling z=3.000000
- [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.RadialLobes.RadialLobesGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 28, Skipped: 0, Total: 28, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 7: Write the integration tests — create
tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs(full file)
82 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), EveryCombinationValidatesWithTheFewestAndTheMostLobes (32, 3 and 4 lobes), SemanticMergeUnitesTheDwellingsOfEachLobeAndKeepsTheCore (the core and one dwelling zone of 120 m² per lobe), and PerimeterCoreLeavesACrossShapedCore (P-North-1 to P-West-3 and a cross-shaped CORE of 2 · 22.86 · 2.86 − 2.86² m² from all nine zones). No production code changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.RadialLobes;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 radial lobes (SYN-TYP-010, ADR-006): a cross or a T of dwelling lobes around a point-access core on the shared hub-and-arms layout,
/// through every simplifier and aggregator, canonical and placed, with the fewest and the most lobes.
/// </summary>
public sealed class RadialLobesTypologyTests
{
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 and the most lobes (ADR-006, D-100).</summary>
private static readonly int[] Counts = { RadialLobesGenerator.MinimumLobes, RadialLobesGenerator.MaximumLobes };
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 EveryCombinationValidatesWithTheFewestAndTheMostLobes(string simplifier, string aggregator, int lobes)
{
AssertValidates(Plan(RadialLobesParameters.Default with { LobeCount = lobes }), simplifier, aggregator);
}
[Fact]
public void SemanticMergeUnitesTheDwellingsOfEachLobeAndKeepsTheCore()
{
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan()).Value;
Assert.Equal(new[] { SpaceType.Stair, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit }, floor.Zones.Select(z => z.SpaceType));
Assert.Equal(new[] { "ST" }, floor.Zones[0].SourceZones.Select(z => z.Value));
for (int lobe = 1; lobe <= 4; lobe++)
{
Zone dwellings = floor.Zones[lobe];
Assert.Equal(new[] { $"L{lobe}-U1", $"L{lobe}-U2" }, dwellings.SourceZones.Select(z => z.Value));
Assert.Equal(120.0, dwellings.FloorArea, 6);
Assert.Empty(dwellings.Parts[0].Footprint.Holes);
}
}
[Fact]
public void PerimeterCoreLeavesACrossShapedCore()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan()).Value;
Assert.Equal(
"P-North-1,P-North-2,P-North-3,P-East-1,P-East-2,P-East-3,P-South-1,P-South-2,P-South-3,P-West-1,P-West-2,P-West-3,CORE",
string.Join(",", floor.Zones.Select(z => z.Id.Value)));
Zone core = floor.Zones.Single(z => z.Id.Value == "CORE");
double arm = 12.0 - (2 * 4.57);
Assert.Equal((2 * (32.0 - (2 * 4.57)) * arm) - (arm * arm), core.FloorArea, 6);
Assert.Equal(9, core.SourceZones.Count);
}
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 RadialLobesPresets 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(RadialLobesParameters? parameters = null) =>
new RadialLobesGenerator(Pipeline.Tolerances).Generate(parameters ?? RadialLobesParameters.Default, Presets()).Value;
}
- [x] Step 8: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RadialLobesTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 82, Skipped: 0, Total: 82, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 279 generators, and 786 integration tests passed on net8.0; git status lists the four new files of this task and no *.received.txt.
- [x] Step 9: Commit
git add src/Lod.Generators/RadialLobes/RadialLobesGenerator.cs tests/Lod.Generators.Tests/RadialLobes/RadialLobesGeneratorTests.cs tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs
git commit -m "feature(generators): add the radial lobes around a point tower core"
Task 3: Branching mall between retail wings (SYN-TYP-014)¶
Files:
- Create: src/Lod.Generators/BranchingMall/BranchingMallGenerator.cs
- Test: create tests/Lod.Generators.Tests/BranchingMall/BranchingMallGeneratorTests.cs, tests/Lod.Integration.Tests/BranchingMallTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt
Interfaces:
- Consumes: as Task 2, with HubArmsLayout.HubWithStrips, SpaceType.Mall and Retail, and ExampleNonResidentialPresets.Mall and Retail (S8.4).
- Produces: public sealed record BranchingMallParameters(int BranchCount, double BranchLength, double MallWidth, double ShopDepth, double AnchorDepth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (3, 40, 8, 12, 30, 5, 0); public sealed record BranchingMallPresets(ProgramPreset Mall, ProgramPreset Retail) : PlanPresets (slots Mall, Retail); public sealed class BranchingMallGenerator(ToleranceSettings), Name = BranchingMallGenerator, public const int MinimumBranches = 3, MaximumBranches = 4: the hub (the central court) and every arm are 2 · ShopDepth + MallWidth wide, each branch BranchLength + AnchorDepth long; MALL (Mall, Mall) is one polygon, the court with the mall strip of every branch (HubWithStrips), and each branch i has the shop wings B{i}-S1 (clockwise of the mall) and B{i}-S2 along its first BranchLength metres and the anchor B{i}-AN across its end (Branch i Shops k, Branch i Anchor, Retail); errors InvalidParameter for a branch count outside 3–4 (BranchCount must be at least 3 (two branches are a bend, not a branching), got 2., BranchCount must be at most 4 (branches leave the central court only on its four sides, D-100), got 5.) and for each length that is not positive and finite; provenance keys BranchCount, BranchLength, MallWidth, ShopDepth, AnchorDepth. By default a T of 7744 m²: the mall 1984 m², six shop wings of 480 m², and three anchors of 960 m²; 16 windows with 806 m² of glazing, the mall's only window on the court's south side; with four branches the mall has no outdoor wall.
- [x] Step 1: Write the failing tests — create
tests/Lod.Generators.Tests/BranchingMall/BranchingMallGeneratorTests.cs(full file)
25 tests: the ADR-006 defaults; the canonical plan (zone IDs, names, space types, areas); the mall as one branching polygon through the court and every branch; tiling for four sizes and counts; every shop wing and anchor adjoining the mall (3 and 4 branches); the mall's south entrance with three branches and no outdoor wall with four; the window rule; two branch counts outside 3–4; five invalid lengths; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.BranchingMall;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.BranchingMall;
public sealed class BranchingMallGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly BranchingMallPresets Examples = new(ExampleNonResidentialPresets.Mall, ExampleNonResidentialPresets.Retail);
private static readonly string[] CanonicalZones =
{
"MALL", "B1-S1", "B1-S2", "B1-AN", "B2-S1", "B2-S2", "B2-AN", "B3-S1", "B3-S2", "B3-AN",
};
private readonly BranchingMallGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new BranchingMallParameters(BranchCount: 3, BranchLength: 40.0, MallWidth: 8.0, ShopDepth: 12.0, AnchorDepth: 30.0, FloorHeight: 5.0, OrientationDegrees: 0.0),
BranchingMallParameters.Default);
}
[Fact]
public void CanonicalPlanIsAMallThroughThreeBranchesOfShopsAndAnchors()
{
IGeneratedPlan plan = Generate(BranchingMallParameters.Default);
Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(
new[]
{
"Mall", "Branch 1 Shops 1", "Branch 1 Shops 2", "Branch 1 Anchor", "Branch 2 Shops 1", "Branch 2 Shops 2", "Branch 2 Anchor",
"Branch 3 Shops 1", "Branch 3 Shops 2", "Branch 3 Anchor",
},
plan.Zones.Select(z => z.Name));
Assert.Equal(SpaceType.Mall, plan.Zones[0].SpaceType);
Assert.All(plan.Zones.Skip(1), z => Assert.Equal(SpaceType.Retail, z.SpaceType));
Assert.Equal(7744.0, plan.Footprint.Area, 9);
Assert.Equal((32.0 * 32.0) + (3 * 40.0 * 8.0), plan.Zones[0].FloorArea, 9);
Assert.All(plan.Zones.Where(z => z.Name.Contains("Shops")), z => Assert.Equal(480.0, z.FloorArea, 9));
Assert.All(plan.Zones.Where(z => z.Name.EndsWith("Anchor")), z => Assert.Equal(960.0, z.FloorArea, 9));
Assert.Equal(Rectangle(102, 0, 142, 12), Outer(plan, "B1-S1"));
Assert.Equal(Rectangle(102, 20, 142, 32), Outer(plan, "B1-S2"));
Assert.Equal(Rectangle(142, 0, 172, 32), Outer(plan, "B1-AN"));
Assert.Equal(Rectangle(90, 32, 102, 72), Outer(plan, "B2-S1"));
Assert.Equal(Rectangle(70, 72, 102, 102), Outer(plan, "B2-AN"));
Assert.Equal(Rectangle(30, 20, 70, 32), Outer(plan, "B3-S1"));
Assert.Equal(Rectangle(0, 0, 30, 32), Outer(plan, "B3-AN"));
Assert.All(plan.Zones, z => Assert.Equal(5.0, z.Parts[0].Height));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Fact]
public void TheMallIsOneBranchingPolygonThroughTheHubAndEveryBranch()
{
IGeneratedPlan plan = Generate(BranchingMallParameters.Default);
Zone mall = plan.Zones[0];
Assert.Single(mall.Parts);
Assert.Empty(mall.Parts[0].Footprint.Holes);
Assert.Equal(
new[]
{
new Point2(70, 0), new Point2(102, 0), new Point2(102, 12), new Point2(142, 12), new Point2(142, 20), new Point2(102, 20),
new Point2(102, 32), new Point2(90, 32), new Point2(90, 72), new Point2(82, 72), new Point2(82, 32), new Point2(70, 32),
new Point2(70, 20), new Point2(30, 20), new Point2(30, 12), new Point2(70, 12),
},
mall.Parts[0].Footprint.Outer);
Assert.Equal(
new[] { new Point2(0, 0), new Point2(172, 0), new Point2(172, 32), new Point2(102, 32), new Point2(102, 102), new Point2(70, 102), new Point2(70, 32), new Point2(0, 32) },
plan.Footprint.Outer);
}
[Theory]
[InlineData(3, 40.0, 8.0, 12.0, 30.0)]
[InlineData(4, 40.0, 8.0, 12.0, 30.0)]
[InlineData(3, 25.5, 6.0, 9.0, 18.0)]
[InlineData(4, 12.0, 10.5, 7.25, 40.0)]
public void ZonesTileTheFootprint(int branches, double length, double mall, double shops, double anchor)
{
IGeneratedPlan plan = Generate(new BranchingMallParameters(branches, length, mall, shops, anchor, 5.0, 0.0));
double width = (2 * shops) + mall;
PlanAssertions.TilesTheFootprint(plan);
Assert.Equal((width * width) + (branches * (length + anchor) * width), plan.Footprint.Area, 9);
Assert.Equal(1 + (3 * branches), plan.Zones.Count);
Assert.Equal((width * width) + (branches * length * mall), plan.Zones[0].FloorArea, 9);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void EveryShopWingAndAnchorAdjoinsTheMall(int branches)
{
IGeneratedPlan plan = Generate(BranchingMallParameters.Default with { BranchCount = branches });
string[] retail = plan.Zones.Skip(1).Select(z => z.Id.Value).ToArray();
Assert.Equal(retail.OrderBy(id => id, System.StringComparer.Ordinal), PlanAssertions.Neighbours(plan, "MALL"));
foreach (string zone in retail)
{
double shared = plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == zone && w.AdjacentZone?.Value == "MALL").Sum(w => w.Length);
Assert.Equal(zone.EndsWith("AN") ? 8.0 : 40.0 + 12.0, shared, 9);
string branch = zone.Split('-')[0];
string[] expected = zone.EndsWith("AN") ? new[] { $"{branch}-S1", $"{branch}-S2", "MALL" } : new[] { $"{branch}-AN", "MALL" };
Assert.Equal(expected, PlanAssertions.Neighbours(plan, zone));
}
}
[Fact]
public void WithThreeBranchesTheMallsFreeSideIsItsSouthEntrance()
{
IGeneratedPlan plan = Generate(BranchingMallParameters.Default);
WallSurface entrance = Assert.Single(plan.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "MALL" && w.Boundary == BoundaryCondition.Outdoors);
Assert.Equal(OrientationBin.South, Orientation.Bin(entrance.PlanAzimuth));
Assert.Equal(32.0, entrance.Length, 9);
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "B1-AN"));
Assert.Equal(new[] { OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "B1-S1"));
Assert.Equal(new[] { OrientationBin.North }, PlanAssertions.OutdoorFacings(plan, "B1-S2"));
}
[Fact]
public void WithFourBranchesTheMallHasNoOutdoorWall()
{
Result<IGeneratedPlan> result = _generator.Generate(BranchingMallParameters.Default with { BranchCount = 4 }, Examples);
Assert.Empty(result.Diagnostics);
Assert.Empty(PlanAssertions.OutdoorFacings(result.Value, "MALL"));
Assert.Equal(new[] { "B4-S1", "B4-S2", "B4-AN" }, result.Value.Zones.Skip(10).Select(z => z.Id.Value));
Assert.Equal(Rectangle(70, 30, 82, 70), Outer(result.Value, "B4-S1"));
Assert.Equal(Rectangle(70, 0, 102, 30), Outer(result.Value, "B4-AN"));
Assert.Equal((172.0 * 172.0) - (4 * 70.0 * 70.0), result.Value.Footprint.Area, 9);
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(BranchingMallParameters.Default, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
Assert.Equal(16, result.Value.Surfaces.OfType<WallSurface>().Count(w => w.Windows.Count == 1));
double retail = 3 * ((2 * 40.0) + 32.0 + (2 * 30.0)) * 5.0;
Assert.Equal((0.2 * 32.0 * 5.0) + (0.3 * retail), result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 9);
}
[Theory]
[InlineData(2, "BranchCount must be at least 3 (two branches are a bend, not a branching), got 2.")]
[InlineData(5, "BranchCount must be at most 4 (branches leave the central court only on its four sides, D-100), got 5.")]
public void BranchCountsOutsideThreeToFourAreErrors(int branches, string message)
{
Result<IGeneratedPlan> result = _generator.Generate(BranchingMallParameters.Default with { BranchCount = branches }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(message, error.Message);
}
[Theory]
[InlineData(0.0, 8.0, 12.0, 30.0, "BranchLength")]
[InlineData(40.0, -8.0, 12.0, 30.0, "MallWidth")]
[InlineData(40.0, 8.0, double.NaN, 30.0, "ShopDepth")]
[InlineData(40.0, 8.0, 12.0, 0.0, "AnchorDepth")]
[InlineData(40.0, double.PositiveInfinity, 12.0, 30.0, "MallWidth")]
public void InvalidParametersAreErrors(double length, double mall, double shops, double anchor, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new BranchingMallParameters(3, length, mall, shops, anchor, 5.0, 0.0), Examples);
Assert.False(result.IsSuccess);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.StartsWith(name, error.Message);
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new BranchingMallParameters(1, 0.0, -8.0, 0.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(7, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(new[] { ("Mall", SpaceType.Mall), ("Retail", SpaceType.Retail) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.Mall, Examples.Retail }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(
BranchingMallParameters.Default,
new BranchingMallPresets(ExampleNonResidentialPresets.Retail, ExampleNonResidentialPresets.Mall));
Assert.Equal(new[] { "Mall", "Retail" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
}
[Fact]
public void GenerationIsDeterministic()
{
Assert.Equal(TextReport.Describe(Generate(BranchingMallParameters.Default)), TextReport.Describe(Generate(BranchingMallParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(BranchingMallParameters.Default).Provenance;
Assert.Equal(nameof(BranchingMallGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"AnchorDepth=30", "BranchCount=3", "BranchLength=40", "FloorHeight=5", "MallWidth=8", "OrientationDegrees=0",
"Presets=Mall:Example Mall;Retail:Example Retail", "ShopDepth=12",
},
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(BranchingMallParameters.Default)), "branching-mall-canonical");
}
private static Point2[] Rectangle(double minX, double minY, double maxX, double maxY) => Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();
private IGeneratedPlan Generate(BranchingMallParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.BranchingMall.BranchingMallGeneratorTests"
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\BranchingMall\BranchingMallGeneratorTests.cs(6,22): error CS0234: The type or namespace name 'BranchingMall' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\BranchingMall\BranchingMallGeneratorTests.cs(240,37): error CS0246: The type or namespace name 'BranchingMallParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\BranchingMall\BranchingMallGeneratorTests.cs(15,28): error CS0246: The type or namespace name 'BranchingMallPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\BranchingMall\BranchingMallGeneratorTests.cs(22,22): error CS0246: The type or namespace name 'BranchingMallGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — create
src/Lod.Generators/BranchingMall/BranchingMallGenerator.cs(full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.HubArms;
namespace Lod.Generators.BranchingMall;
/// <summary>Parameters of the branching public mall between retail wings (SYN-TYP-014, ADR-006).</summary>
/// <param name="BranchCount">Number of mall branches from the central court, 3–4.</param>
/// <param name="BranchLength">Length of the shop wings along each branch, m.</param>
/// <param name="MallWidth">Width of the public mall in each branch, m.</param>
/// <param name="ShopDepth">Depth of the shop wing on each side of the mall, m.</param>
/// <param name="AnchorDepth">Depth of the anchor store at the end of each branch, 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 BranchingMallParameters(
int BranchCount,
double BranchLength,
double MallWidth,
double ShopDepth,
double AnchorDepth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: 3 branches of 40 m shop wings, 12 m deep beside an 8 m mall, 30 m anchors, 5 m storeys, orientation 0°.</summary>
public static BranchingMallParameters Default { get; } = new(3, 40.0, 8.0, 12.0, 30.0, 5.0, 0.0);
}
/// <summary>The program presets of the branching mall, one per space type it places (D-061).</summary>
/// <param name="Mall">Preset of the public mall; space type <see cref="SpaceType.Mall"/>.</param>
/// <param name="Retail">Preset of the shop wings and the anchors; space type <see cref="SpaceType.Retail"/>.</param>
public sealed record BranchingMallPresets(ProgramPreset Mall, ProgramPreset Retail) : PlanPresets
{
/// <inheritdoc />
public override IReadOnlyList<PresetSlot> Slots => new[]
{
new PresetSlot(nameof(Mall), SpaceType.Mall, Mall),
new PresetSlot(nameof(Retail), SpaceType.Retail, Retail),
};
}
/// <summary>
/// Branching public mall between retail wings (SYN-TYP-014, ADR-006), on the shared <see cref="HubArmsLayout"/>: the hub is the mall's central court,
/// as wide as the branches (<c>2·ShopDepth + MallWidth</c>), and each branch holds, from the court outwards, a shop wing on either side of a mall strip
/// and then an anchor store across the branch's width. The mall, court and every strip, is one connected, branching zone reaching every branch (R002),
/// and every shop wing and anchor adjoins it (R001). Zones are departments (D-097, ADR-014): the shops of one side of a branch are one zone.
/// </summary>
/// <remarks>
/// Zone IDs: <c>MALL</c>, then per branch <c>B{i}-S1</c>, <c>B{i}-S2</c> (the shop wings clockwise and counter-clockwise of the mall strip) and
/// <c>B{i}-AN</c> (the anchor), branch <c>i</c> from 1 in the order east, north, west, south. With three branches the court's south side is the mall's
/// entrance façade; with four the mall has no outdoor wall.
/// </remarks>
public sealed class BranchingMallGenerator : PlanGenerator<BranchingMallParameters, BranchingMallPresets>
{
/// <summary>The fewest branches: two would be a bend, not a branching.</summary>
public const int MinimumBranches = 3;
/// <summary>The most branches: one per side of the court (D-100).</summary>
public const int MaximumBranches = HubArmsLayout.MaximumArms;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public BranchingMallGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(BranchingMallGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(BranchingMallParameters parameters)
{
var errors = HubArmsLayout.CountErrors(
nameof(BranchingMallParameters.BranchCount),
parameters.BranchCount,
MinimumBranches,
MaximumBranches,
"two branches are a bend, not a branching",
"branches leave the central court only on its four sides").ToList();
var values = new (string Name, double Value)[]
{
(nameof(BranchingMallParameters.BranchLength), parameters.BranchLength),
(nameof(BranchingMallParameters.MallWidth), parameters.MallWidth),
(nameof(BranchingMallParameters.ShopDepth), parameters.ShopDepth),
(nameof(BranchingMallParameters.AnchorDepth), parameters.AnchorDepth),
};
errors.AddRange(values.Where(v => !IsPositive(v.Value)).Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}.")));
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(BranchingMallParameters p)
{
double width = (2.0 * p.ShopDepth) + p.MallWidth;
double mallEnd = p.ShopDepth + p.MallWidth;
var layout = new HubArmsLayout(width, width, Enumerable.Repeat(p.BranchLength + p.AnchorDepth, p.BranchCount).ToArray());
var zones = new List<PlannedZone> { new(new ZoneId("MALL"), "Mall", SpaceType.Mall, layout.HubWithStrips(p.ShopDepth, mallEnd, p.BranchLength)) };
foreach (HubArm branch in layout.Arms)
{
string id = $"B{branch.Number}-";
string name = $"Branch {branch.Number} ";
zones.Add(new PlannedZone(new ZoneId(id + "S1"), name + "Shops 1", SpaceType.Retail, branch.Piece(0.0, p.BranchLength, 0.0, p.ShopDepth)));
zones.Add(new PlannedZone(new ZoneId(id + "S2"), name + "Shops 2", SpaceType.Retail, branch.Piece(0.0, p.BranchLength, mallEnd, width)));
zones.Add(new PlannedZone(new ZoneId(id + "AN"), name + "Anchor", SpaceType.Retail, branch.Piece(p.BranchLength, branch.Length, 0.0, width)));
}
return new PlanLayout(layout.Footprint, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(BranchingMallParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.BranchCount), p.BranchCount.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>(nameof(p.BranchLength), Provenance.Format(p.BranchLength)),
new KeyValuePair<string, string>(nameof(p.MallWidth), Provenance.Format(p.MallWidth)),
new KeyValuePair<string, string>(nameof(p.ShopDepth), Provenance.Format(p.ShopDepth)),
new KeyValuePair<string, string>(nameof(p.AnchorDepth), Provenance.Format(p.AnchorDepth)),
};
}
- [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.BranchingMall.BranchingMallGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 24, Skipped: 0, Total: 25, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.BranchingMall.BranchingMallGeneratorTests.CanonicalPlanSnapshot
Snapshot 'branching-mall-canonical' is missing or differs. Review Snapshots/branching-mall-canonical.received.txt and rename it to branching-mall-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.received.txt (git-ignored).
- [x] Step 5: Review and accept the received file
Review the received file against the content below; it must be identical: 138 lines, 10 zones, 78 surfaces (16 outdoor walls, each with one window; 42 interzone walls; 20 floors and ceilings), glazing 242 m² south, 258 m² north, 153 m² east, 153 m² west (806 m² in all). Accept it:
Move-Item -Force tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.received.txt tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt
tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt:
footprint area=7744.000000 floorHeight=5.000000 orientation=0.000000
zone MALL Mall "Mall" area=1984.000000 volume=9920.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2414.400000
load Lighting PerFloorArea value=10.000000 annual=4547.500000
load Infiltration AirChangesPerHour value=0.800000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B1-S1 Retail "Branch 1 Shops 1" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B1-S2 Retail "Branch 1 Shops 2" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B1-AN Retail "Branch 1 Anchor" area=960.000000 volume=4800.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B2-S1 Retail "Branch 2 Shops 1" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B2-S2 Retail "Branch 2 Shops 2" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B2-AN Retail "Branch 2 Anchor" area=960.000000 volume=4800.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B3-S1 Retail "Branch 3 Shops 1" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B3-S2 Retail "Branch 3 Shops 2" area=480.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone B3-AN Retail "Branch 3 Anchor" area=960.000000 volume=4800.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.200000 annual=2414.400000
load Lighting PerFloorArea value=15.000000 annual=4547.500000
load ElectricEquipment PerFloorArea value=5.000000 annual=5100.000000
load Infiltration AirChangesPerHour value=0.500000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
surface MALL/W1 Outdoors area=160.000000 wall (70.000000,0.000000)-(102.000000,0.000000) z=0.000000 facing=South glazing=32.000000 window(offset=8.844582 sill=1.381966 14.310835x2.236068)
surface MALL/W2 Interzone adjacent=B1-S1 area=60.000000 wall (102.000000,0.000000)-(102.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface MALL/W3 Interzone adjacent=B1-S1 area=200.000000 wall (102.000000,12.000000)-(142.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface MALL/W4 Interzone adjacent=B1-AN area=40.000000 wall (142.000000,12.000000)-(142.000000,20.000000) z=0.000000 facing=East glazing=0.000000
surface MALL/W5 Interzone adjacent=B1-S2 area=200.000000 wall (142.000000,20.000000)-(102.000000,20.000000) z=0.000000 facing=North glazing=0.000000
surface MALL/W6 Interzone adjacent=B1-S2 area=60.000000 wall (102.000000,20.000000)-(102.000000,32.000000) z=0.000000 facing=East glazing=0.000000
surface MALL/W7 Interzone adjacent=B2-S1 area=60.000000 wall (102.000000,32.000000)-(90.000000,32.000000) z=0.000000 facing=North glazing=0.000000
surface MALL/W8 Interzone adjacent=B2-S1 area=200.000000 wall (90.000000,32.000000)-(90.000000,72.000000) z=0.000000 facing=East glazing=0.000000
surface MALL/W9 Interzone adjacent=B2-AN area=40.000000 wall (90.000000,72.000000)-(82.000000,72.000000) z=0.000000 facing=North glazing=0.000000
surface MALL/W10 Interzone adjacent=B2-S2 area=200.000000 wall (82.000000,72.000000)-(82.000000,32.000000) z=0.000000 facing=West glazing=0.000000
surface MALL/W11 Interzone adjacent=B2-S2 area=60.000000 wall (82.000000,32.000000)-(70.000000,32.000000) z=0.000000 facing=North glazing=0.000000
surface MALL/W12 Interzone adjacent=B3-S1 area=60.000000 wall (70.000000,32.000000)-(70.000000,20.000000) z=0.000000 facing=West glazing=0.000000
surface MALL/W13 Interzone adjacent=B3-S1 area=200.000000 wall (70.000000,20.000000)-(30.000000,20.000000) z=0.000000 facing=North glazing=0.000000
surface MALL/W14 Interzone adjacent=B3-AN area=40.000000 wall (30.000000,20.000000)-(30.000000,12.000000) z=0.000000 facing=West glazing=0.000000
surface MALL/W15 Interzone adjacent=B3-S2 area=200.000000 wall (30.000000,12.000000)-(70.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface MALL/W16 Interzone adjacent=B3-S2 area=60.000000 wall (70.000000,12.000000)-(70.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface MALL/F Unresolved area=1984.000000 Floor z=0.000000
surface MALL/C Unresolved area=1984.000000 Ceiling z=5.000000
surface B1-S1/W1 Outdoors area=200.000000 wall (102.000000,0.000000)-(142.000000,0.000000) z=0.000000 facing=South glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B1-S1/W2 Interzone adjacent=B1-AN area=60.000000 wall (142.000000,0.000000)-(142.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B1-S1/W3 Interzone adjacent=MALL area=200.000000 wall (142.000000,12.000000)-(102.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B1-S1/W4 Interzone adjacent=MALL area=60.000000 wall (102.000000,12.000000)-(102.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B1-S1/F Unresolved area=480.000000 Floor z=0.000000
surface B1-S1/C Unresolved area=480.000000 Ceiling z=5.000000
surface B1-S2/W1 Interzone adjacent=MALL area=200.000000 wall (102.000000,20.000000)-(142.000000,20.000000) z=0.000000 facing=South glazing=0.000000
surface B1-S2/W2 Interzone adjacent=B1-AN area=60.000000 wall (142.000000,20.000000)-(142.000000,32.000000) z=0.000000 facing=East glazing=0.000000
surface B1-S2/W3 Outdoors area=200.000000 wall (142.000000,32.000000)-(102.000000,32.000000) z=0.000000 facing=North glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B1-S2/W4 Interzone adjacent=MALL area=60.000000 wall (102.000000,32.000000)-(102.000000,20.000000) z=0.000000 facing=West glazing=0.000000
surface B1-S2/F Unresolved area=480.000000 Floor z=0.000000
surface B1-S2/C Unresolved area=480.000000 Ceiling z=5.000000
surface B1-AN/W1 Outdoors area=150.000000 wall (142.000000,0.000000)-(172.000000,0.000000) z=0.000000 facing=South glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B1-AN/W2 Outdoors area=160.000000 wall (172.000000,0.000000)-(172.000000,32.000000) z=0.000000 facing=East glazing=48.000000 window(offset=7.236439 sill=1.130694 17.527122x2.738613)
surface B1-AN/W3 Outdoors area=150.000000 wall (172.000000,32.000000)-(142.000000,32.000000) z=0.000000 facing=North glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B1-AN/W4 Interzone adjacent=B1-S2 area=60.000000 wall (142.000000,32.000000)-(142.000000,20.000000) z=0.000000 facing=West glazing=0.000000
surface B1-AN/W5 Interzone adjacent=MALL area=40.000000 wall (142.000000,20.000000)-(142.000000,12.000000) z=0.000000 facing=West glazing=0.000000
surface B1-AN/W6 Interzone adjacent=B1-S1 area=60.000000 wall (142.000000,12.000000)-(142.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B1-AN/F Unresolved area=960.000000 Floor z=0.000000
surface B1-AN/C Unresolved area=960.000000 Ceiling z=5.000000
surface B2-S1/W1 Interzone adjacent=MALL area=60.000000 wall (90.000000,32.000000)-(102.000000,32.000000) z=0.000000 facing=South glazing=0.000000
surface B2-S1/W2 Outdoors area=200.000000 wall (102.000000,32.000000)-(102.000000,72.000000) z=0.000000 facing=East glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B2-S1/W3 Interzone adjacent=B2-AN area=60.000000 wall (102.000000,72.000000)-(90.000000,72.000000) z=0.000000 facing=North glazing=0.000000
surface B2-S1/W4 Interzone adjacent=MALL area=200.000000 wall (90.000000,72.000000)-(90.000000,32.000000) z=0.000000 facing=West glazing=0.000000
surface B2-S1/F Unresolved area=480.000000 Floor z=0.000000
surface B2-S1/C Unresolved area=480.000000 Ceiling z=5.000000
surface B2-S2/W1 Interzone adjacent=MALL area=60.000000 wall (70.000000,32.000000)-(82.000000,32.000000) z=0.000000 facing=South glazing=0.000000
surface B2-S2/W2 Interzone adjacent=MALL area=200.000000 wall (82.000000,32.000000)-(82.000000,72.000000) z=0.000000 facing=East glazing=0.000000
surface B2-S2/W3 Interzone adjacent=B2-AN area=60.000000 wall (82.000000,72.000000)-(70.000000,72.000000) z=0.000000 facing=North glazing=0.000000
surface B2-S2/W4 Outdoors area=200.000000 wall (70.000000,72.000000)-(70.000000,32.000000) z=0.000000 facing=West glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B2-S2/F Unresolved area=480.000000 Floor z=0.000000
surface B2-S2/C Unresolved area=480.000000 Ceiling z=5.000000
surface B2-AN/W1 Interzone adjacent=B2-S2 area=60.000000 wall (70.000000,72.000000)-(82.000000,72.000000) z=0.000000 facing=South glazing=0.000000
surface B2-AN/W2 Interzone adjacent=MALL area=40.000000 wall (82.000000,72.000000)-(90.000000,72.000000) z=0.000000 facing=South glazing=0.000000
surface B2-AN/W3 Interzone adjacent=B2-S1 area=60.000000 wall (90.000000,72.000000)-(102.000000,72.000000) z=0.000000 facing=South glazing=0.000000
surface B2-AN/W4 Outdoors area=150.000000 wall (102.000000,72.000000)-(102.000000,102.000000) z=0.000000 facing=East glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B2-AN/W5 Outdoors area=160.000000 wall (102.000000,102.000000)-(70.000000,102.000000) z=0.000000 facing=North glazing=48.000000 window(offset=7.236439 sill=1.130694 17.527122x2.738613)
surface B2-AN/W6 Outdoors area=150.000000 wall (70.000000,102.000000)-(70.000000,72.000000) z=0.000000 facing=West glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B2-AN/F Unresolved area=960.000000 Floor z=0.000000
surface B2-AN/C Unresolved area=960.000000 Ceiling z=5.000000
surface B3-S1/W1 Interzone adjacent=MALL area=200.000000 wall (30.000000,20.000000)-(70.000000,20.000000) z=0.000000 facing=South glazing=0.000000
surface B3-S1/W2 Interzone adjacent=MALL area=60.000000 wall (70.000000,20.000000)-(70.000000,32.000000) z=0.000000 facing=East glazing=0.000000
surface B3-S1/W3 Outdoors area=200.000000 wall (70.000000,32.000000)-(30.000000,32.000000) z=0.000000 facing=North glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B3-S1/W4 Interzone adjacent=B3-AN area=60.000000 wall (30.000000,32.000000)-(30.000000,20.000000) z=0.000000 facing=West glazing=0.000000
surface B3-S1/F Unresolved area=480.000000 Floor z=0.000000
surface B3-S1/C Unresolved area=480.000000 Ceiling z=5.000000
surface B3-S2/W1 Outdoors area=200.000000 wall (30.000000,0.000000)-(70.000000,0.000000) z=0.000000 facing=South glazing=60.000000 window(offset=9.045549 sill=1.130694 21.908902x2.738613)
surface B3-S2/W2 Interzone adjacent=MALL area=60.000000 wall (70.000000,0.000000)-(70.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B3-S2/W3 Interzone adjacent=MALL area=200.000000 wall (70.000000,12.000000)-(30.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface B3-S2/W4 Interzone adjacent=B3-AN area=60.000000 wall (30.000000,12.000000)-(30.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface B3-S2/F Unresolved area=480.000000 Floor z=0.000000
surface B3-S2/C Unresolved area=480.000000 Ceiling z=5.000000
surface B3-AN/W1 Outdoors area=150.000000 wall (0.000000,0.000000)-(30.000000,0.000000) z=0.000000 facing=South glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B3-AN/W2 Interzone adjacent=B3-S2 area=60.000000 wall (30.000000,0.000000)-(30.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface B3-AN/W3 Interzone adjacent=MALL area=40.000000 wall (30.000000,12.000000)-(30.000000,20.000000) z=0.000000 facing=East glazing=0.000000
surface B3-AN/W4 Interzone adjacent=B3-S1 area=60.000000 wall (30.000000,20.000000)-(30.000000,32.000000) z=0.000000 facing=East glazing=0.000000
surface B3-AN/W5 Outdoors area=150.000000 wall (30.000000,32.000000)-(0.000000,32.000000) z=0.000000 facing=North glazing=45.000000 window(offset=6.784162 sill=1.130694 16.431677x2.738613)
surface B3-AN/W6 Outdoors area=160.000000 wall (0.000000,32.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=48.000000 window(offset=7.236439 sill=1.130694 17.527122x2.738613)
surface B3-AN/F Unresolved area=960.000000 Floor z=0.000000
surface B3-AN/C Unresolved area=960.000000 Ceiling z=5.000000
- [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.BranchingMall.BranchingMallGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 25, Skipped: 0, Total: 25, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 7: Write the integration tests — create
tests/Lod.Integration.Tests/BranchingMallTypologyTests.cs(full file)
82 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32), EveryCombinationValidatesWithTheFewestAndTheMostBranches (32, 3 and 4 branches), and SemanticMergeKeepsTheMallWholeAndUnitesTheRetailOfEachBranch (3 and 4 branches: the mall without a hole and one Retail zone of 1920 m² per branch). No production code changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.BranchingMall;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 branching mall (SYN-TYP-014, ADR-006): one branching mall zone through a central court and three or four branches of shop wings and
/// anchors on the shared hub-and-arms layout, through every simplifier and aggregator, canonical and placed, with the fewest and the most branches.
/// </summary>
public sealed class BranchingMallTypologyTests
{
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 and the most branches (ADR-006, D-100).</summary>
private static readonly int[] Counts = { BranchingMallGenerator.MinimumBranches, BranchingMallGenerator.MaximumBranches };
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 EveryCombinationValidatesWithTheFewestAndTheMostBranches(string simplifier, string aggregator, int branches)
{
AssertValidates(Plan(BranchingMallParameters.Default with { BranchCount = branches }), simplifier, aggregator);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void SemanticMergeKeepsTheMallWholeAndUnitesTheRetailOfEachBranch(int branches)
{
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(BranchingMallParameters.Default with { BranchCount = branches })).Value;
Assert.Equal(new[] { SpaceType.Mall }.Concat(Enumerable.Repeat(SpaceType.Retail, branches)), floor.Zones.Select(z => z.SpaceType));
Assert.Equal(new[] { "MALL" }, floor.Zones[0].SourceZones.Select(z => z.Value));
Assert.Empty(floor.Zones[0].Parts[0].Footprint.Holes);
for (int branch = 1; branch <= branches; branch++)
{
Zone retail = floor.Zones[branch];
Assert.Equal(new[] { $"B{branch}-S1", $"B{branch}-S2", $"B{branch}-AN" }, retail.SourceZones.Select(z => z.Value));
Assert.Equal((2 * 480.0) + 960.0, retail.FloorArea, 6);
}
}
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 BranchingMallPresets Presets() => new(ExampleNonResidentialPresets.Mall, ExampleNonResidentialPresets.Retail);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(BranchingMallParameters? parameters = null) =>
new BranchingMallGenerator(Pipeline.Tolerances).Generate(parameters ?? BranchingMallParameters.Default, Presets()).Value;
}
- [x] Step 8: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.BranchingMallTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 82, Skipped: 0, Total: 82, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 304 generators, and 868 integration tests passed on net8.0; git status lists the four new files of this task and no *.received.txt.
- [x] Step 9: Commit
git add src/Lod.Generators/BranchingMall/BranchingMallGenerator.cs tests/Lod.Generators.Tests/BranchingMall/BranchingMallGeneratorTests.cs tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt tests/Lod.Integration.Tests/BranchingMallTypologyTests.cs
git commit -m "feature(generators): add the branching mall between retail wings"
- [x] Step 10: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/hub-arms-lobes-mall
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/hub-arms-lobes-mall
git branch -d feature/hub-arms-lobes-mall
git diff --name-status v0.8.3 main -- "tests/*/Snapshots/*"
Expected: VERIFY PASSED, with 193 core, 304 generators, and 868 integration tests passed on net8.0; the last command lists only
A tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt
A tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt
the eighteen existing snapshots are byte-identical. Do not push (D-098).
Slice B — fix/perimeter-core-footprint-vertices¶
Task 4: Perimeter Core keeps the footprint's own vertices¶
The care hub of Task 5 has, by default, 14.4 m wings on a 16 m hub, which leave 0.8 m of hub façade beside each wing. On rotated plans, Perimeter Core followed by Single Zone per Floor Type or Single Zone Building failed with FacadeNotCovered ("Rebuilt façades cover 0 m of this 0.8 m wall"): PerimeterCore united each orientation bin's trapezoids with a plain PolygonOps.Union, which rounds every vertex to the clipping grid; the single-zone aggregators unite the perimeter zones as tiles and keep those rounded vertices, while the source walls lie on the exact footprint vertices, and for a 0.8 m wall the endpoint rounding (at most 0.7 grid units) turns its direction by up to about 2·10⁻⁶ rad, beyond ToleranceSettings.Angle. No part of the plan is too narrow, so this is a pipeline defect, not a precondition (D-087). This task fixes it test-first on a plan of its own, before the care hub exists: after the plain union, each vertex in the grid cell of a footprint vertex is replaced by that footprint vertex (VertexGrid.Restore, D-088); the mitred offset vertices keep their rounding, so the canonical linear plan's 45° corner walls, and every existing snapshot, stay as they are.
Files:
- Modify: src/Lod.Core/Simplification/Strategies.cs (S3 file, last changed by 5038512 after v0.8.2), docs/decisions/ADR-008-perimeter-core-corners.md (last changed by 5038512), docs/decisions/ADR-002-geometry.md (last changed in S8.4)
- Test: create tests/Lod.Integration.Tests/PerimeterCoreShortEdgeTests.cs
Interfaces:
- Consumes (S2–S8.4): PerimeterCore(ToleranceSettings, double) and its bisector trapezoids (ADR-008), PolygonOps.Union, VertexGrid(double) with Register and Restore (D-088), Polygon2.Create(outer, holes), ToleranceSettings.Distance; test support PlanGenerator, PlanTransform.Apply, Pipeline.Aggregator, Pipeline.Tolerances, FloorEntry, BuildingValidator, ExampleResidentialPresets.DwellingUnit.
- Produces: PerimeterCore registers every vertex of every footprint ring in a VertexGrid and maps each bin's union through the private KeepFootprintVertices(Polygon2 union, VertexGrid footprintVertices), which restores every outer and hole vertex and falls back to the union if the restored polygon is not valid; no public API changes. PerimeterCoreShortEdgeTests (150 cases): a 20 m square block with an 18.4 m wing centred on its east side (0.8 m of the block's east façade either side of the wing, as at the care hub's corners), at 50 placements (rotation 7.2° · i + 0.7° · (i mod 3), translation (3.7 i − 50, 20 − 2.3 i) m): PerimeterZonesKeepTheFootprintsOwnVertices (50) and TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor (100, Single Zone per Floor Type and Single Zone Building with 1, 3, 1). ADR-008 (point 6, a consequence, the status line) and ADR-002 (the unions-of-zones paragraph, the status line) record the rule.
- [x] Step 1: Create the branch
git switch -c fix/perimeter-core-footprint-vertices main
- [x] Step 2: Write the failing tests — create
tests/Lod.Integration.Tests/PerimeterCoreShortEdgeTests.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// A footprint with façade edges shorter than 1 m for <see cref="PerimeterCore"/>: a 20 m square block with an 18.4 m wide wing centred on its east
/// side, which leaves 0.8 m of the block's east façade beside the wing, as at the hub corners of the care hub (SYN-TYP-018). A perimeter zone whose
/// vertices were rounded to the clipping grid gives such a short wall a direction up to about 2·10⁻⁶ rad off the source wall's on a rotated plan,
/// beyond the angular tolerance, so the single-zone methods found the rebuilt façade not to cover it (ADR-008).
/// </summary>
public sealed class PerimeterCoreShortEdgeTests
{
private const double PerimeterDepth = 4.57;
/// <summary>Rotations every 7.2° from 0° to 352.8°, each with its own translation, m.</summary>
public static IEnumerable<object[]> Placements() =>
Enumerable.Range(0, 50).Select(i => new object[] { (i * 7.2) + (0.7 * (i % 3)), (3.7 * i) - 50.0, 20.0 - (2.3 * i) });
public static IEnumerable<object[]> PlacedAggregators() =>
from placement in Placements()
from aggregator in new[] { "SingleZonePerFloorType", "SingleZoneBuilding" }
select placement.Append(aggregator).ToArray();
[Theory]
[MemberData(nameof(Placements))]
public void PerimeterZonesKeepTheFootprintsOwnVertices(double rotation, double dx, double dy)
{
IGeneratedPlan plan = PlanTransform.Apply(SteppedPlan(), rotation, dx, dy).Value;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(plan).Value;
HashSet<Point2> vertices = floor.Zones.Where(z => z.Id.Value != "CORE").SelectMany(z => z.Parts[0].Footprint.Rings).SelectMany(r => r).ToHashSet();
Assert.All(plan.Footprint.Outer, p => Assert.Contains(p, vertices));
}
[Theory]
[MemberData(nameof(PlacedAggregators))]
public void TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(double rotation, double dx, double dy, string aggregator)
{
IGeneratedPlan plan = PlanTransform.Apply(SteppedPlan(), rotation, dx, dy).Value;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(plan).Value;
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) });
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
}
private static IGeneratedPlan SteppedPlan() =>
new SteppedGenerator().Generate(new SteppedParameters(), new SteppedPresets(ExampleResidentialPresets.DwellingUnit)).Value;
private sealed record SteppedParameters() : PlanParameters(3.0, 0.0);
private sealed record SteppedPresets(ProgramPreset DwellingUnit) : PlanPresets
{
public override IReadOnlyList<PresetSlot> Slots => new[] { new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit) };
}
private sealed class SteppedGenerator : PlanGenerator<SteppedParameters, SteppedPresets>
{
public SteppedGenerator()
: base(ToleranceSettings.Default)
{
}
public override string Name => nameof(SteppedGenerator);
protected override IEnumerable<Diagnostic> Validate(SteppedParameters parameters) => Enumerable.Empty<Diagnostic>();
protected override PlanLayout Layout(SteppedParameters p)
{
Polygon2 footprint = Polygon2.Create(new[]
{
new Point2(0, 0), new Point2(20, 0), new Point2(20, 0.8), new Point2(50, 0.8),
new Point2(50, 19.2), new Point2(20, 19.2), new Point2(20, 20), new Point2(0, 20),
}).Value;
return new PlanLayout(footprint, new[]
{
new PlannedZone(new ZoneId("BLOCK"), "Block", SpaceType.DwellingUnit, Polygon2.Rectangle(0, 0, 20, 20)),
new PlannedZone(new ZoneId("WING"), "Wing", SpaceType.DwellingUnit, Polygon2.Rectangle(20, 0.8, 50, 19.2)),
});
}
protected override IEnumerable<KeyValuePair<string, string>> Describe(SteppedParameters p) => Enumerable.Empty<KeyValuePair<string, string>>();
}
}
- [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PerimeterCoreShortEdgeTests"
Expected: the tests build and 58 of the 150 cases fail on net8.0:
Failed! - Failed: 58, Passed: 92, Skipped: 0, Total: 150, Duration: … - Lod.Integration.Tests.dll (net8.0)
All 50 cases of PerimeterZonesKeepTheFootprintsOwnVertices fail: in each, footprint vertices are missing from the perimeter zones, whose union rounded them to the clipping grid (8 of the 8 in 38 cases, 7 of the 8 in 11 cases, 2 of the 8 in 1 case). 8 of the 100 cases of TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor fail with FacadeNotCovered. The failing cases, sorted by test and rotation, each with the first line of its message (for the second test, the message up to its first ;):
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 0, dx: -50, dy: 20): Assert.All() Failure: 2 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 7.9000000000000004, dx: -46.299999999999997, dy: 17.699999999999999): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 15.800000000000001, dx: -42.600000000000001, dy: 15.4): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 21.600000000000001, dx: -38.899999999999999, dy: 13.100000000000001): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 29.5, dx: -35.200000000000003, dy: 10.800000000000001): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 37.399999999999999, dx: -31.5, dy: 8.5): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 43.200000000000003, dx: -27.799999999999997, dy: 6.2000000000000011): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 51.100000000000001, dx: -24.099999999999998, dy: 3.9000000000000021): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 59, dx: -20.399999999999999, dy: 1.6000000000000014): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 64.799999999999997, dx: -16.699999999999996, dy: -0.69999999999999929): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 72.700000000000003, dx: -13, dy: -3): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 80.600000000000009, dx: -9.2999999999999972, dy: -5.2999999999999972): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 86.400000000000006, dx: -5.5999999999999943, dy: -7.5999999999999979): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 94.300000000000011, dx: -1.8999999999999986, dy: -9.8999999999999986): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 102.2, dx: 1.8000000000000043, dy: -12.199999999999996): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 108, dx: 5.5, dy: -14.5): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 115.90000000000001, dx: 9.2000000000000028, dy: -16.799999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 123.80000000000001, dx: 12.900000000000006, dy: -19.099999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 129.59999999999999, dx: 16.600000000000009, dy: -21.399999999999999): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 137.5, dx: 20.299999999999997, dy: -23.699999999999996): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 145.40000000000001, dx: 24, dy: -26): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 151.20000000000002, dx: 27.700000000000003, dy: -28.299999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 159.09999999999999, dx: 31.400000000000006, dy: -30.599999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 167, dx: 35.100000000000009, dy: -32.899999999999999): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 172.80000000000001, dx: 38.800000000000011, dy: -35.199999999999996): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 180.69999999999999, dx: 42.5, dy: -37.499999999999993): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 188.60000000000002, dx: 46.200000000000003, dy: -39.799999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 194.40000000000001, dx: 49.900000000000006, dy: -42.099999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 202.29999999999998, dx: 53.600000000000009, dy: -44.399999999999991): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 210.20000000000002, dx: 57.300000000000011, dy: -46.699999999999989): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 216, dx: 61, dy: -49): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 223.90000000000001, dx: 64.700000000000003, dy: -51.299999999999997): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 231.80000000000001, dx: 68.400000000000006, dy: -53.599999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 237.59999999999999, dx: 72.100000000000009, dy: -55.899999999999991): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 245.5, dx: 75.800000000000011, dy: -58.199999999999989): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 253.40000000000001, dx: 79.5, dy: -60.5): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 259.19999999999999, dx: 83.200000000000017, dy: -62.799999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 267.10000000000002, dx: 86.900000000000006, dy: -65.099999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 275, dx: 90.599999999999994, dy: -67.399999999999991): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 280.80000000000001, dx: 94.300000000000011, dy: -69.699999999999989): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 288.69999999999999, dx: 98, dy: -72): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 296.59999999999997, dx: 101.70000000000002, dy: -74.299999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 302.40000000000003, dx: 105.40000000000001, dy: -76.599999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 310.30000000000001, dx: 109.09999999999999, dy: -78.899999999999991): Assert.All() Failure: 7 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 318.19999999999999, dx: 112.80000000000001, dy: -81.199999999999989): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 324, dx: 116.5, dy: -83.499999999999986): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 331.89999999999998, dx: 120.20000000000002, dy: -85.799999999999997): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 339.80000000000001, dx: 123.90000000000001, dy: -88.099999999999994): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 345.60000000000002, dx: 127.60000000000002, dy: -90.399999999999991): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 353.5, dx: 131.30000000000001, dy: -92.699999999999989): Assert.All() Failure: 8 out of 8 items in the collection did not pass.
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 72.700000000000003, dx: -13, dy: -3, aggregator: "SingleZoneBuilding"): Error FacadeNotCovered [L0/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 72.700000000000003, dx: -13, dy: -3, aggregator: "SingleZonePerFloorType"): Error FacadeNotCovered [L0/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 80.600000000000009, dx: -9.2999999999999972, dy: -5.2999999999999972, aggregator: "SingleZoneBuilding"): Error FacadeNotCovered [L0/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 80.600000000000009, dx: -9.2999999999999972, dy: -5.2999999999999972, aggregator: "SingleZonePerFloorType"): Error FacadeNotCovered [L0/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 167, dx: 35.100000000000009, dy: -32.899999999999999, aggregator: "SingleZoneBuilding"): Error FacadeNotCovered [L0/P-West-3/W3]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 167, dx: 35.100000000000009, dy: -32.899999999999999, aggregator: "SingleZonePerFloorType"): Error FacadeNotCovered [L0/P-West-3/W3]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 296.59999999999997, dx: 101.70000000000002, dy: -74.299999999999997, aggregator: "SingleZoneBuilding"): Error FacadeNotCovered [L0/P-South-2/W4]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 296.59999999999997, dx: 101.70000000000002, dy: -74.299999999999997, aggregator: "SingleZonePerFloorType"): Error FacadeNotCovered [L0/P-South-2/W4]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; …
The first failure of each test, as dotnet test prints it; in the first, the items after the first (…) and the Set: line of the item, which lists the first five vertices of the perimeter zones and an ellipsis, are left out; the second lists, for every storey of the building, the 0.8 m wall that the rebuilt façades do not cover:
Failed Lod.Integration.Tests.PerimeterCoreShortEdgeTests.PerimeterZonesKeepTheFootprintsOwnVertices(rotation: 0, dx: -50, dy: 20)
Assert.All() Failure: 2 out of 8 items in the collection did not pass.
[4]: Item: Point2 { X = 0, Y = 39.2 }
Error: Assert.Contains() Failure: Item not found in set
Not found: Point2 { X = 0, Y = 39.2 }
…
Failed Lod.Integration.Tests.PerimeterCoreShortEdgeTests.TheSingleZoneMethodsCoverTheShortFacadesOfARotatedPerimeterCoreFloor(rotation: 72.700000000000003, dx: -13, dy: -3, aggregator: "SingleZoneBuilding")
Error FacadeNotCovered [L0/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; Error FacadeNotCovered [L1/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; Error FacadeNotCovered [L2/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; Error FacadeNotCovered [L3/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).; Error FacadeNotCovered [L4/P-North-2/W1]: Rebuilt façades cover 0 m of this 0.8 m wall (D-041).
If other cases fail, or the failures are not these, stop and report.
- [x] Step 4: Implement — replace
src/Lod.Core/Simplification/Strategies.cs(full file)
Only PerimeterCore changes: one sentence of its summary, the VertexGrid of the footprint vertices, the union of each bin mapped through KeepFootprintVertices, and that private method. SemanticMerge, NoSimplification, SingleZonePerFloor, and every check of PerimeterCore, including the self-intersecting offset rings of 5038512, are unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
namespace Lod.Core.Simplification;
/// <summary>
/// Z1: merges zones of the same space type that are connected through shared walls. Each connected group becomes one zone, the union of its
/// footprints as tiles (<see cref="PolygonOps.UnionTiles"/>), so zones meeting at T-junctions unite exactly on rotated plans; the union may have
/// holes, where the group surrounds zones of another type.
/// </summary>
public sealed class SemanticMerge : PlanSimplifier
{
/// <summary>Creates the simplifier.</summary>
/// <param name="tolerances">Tolerances.</param>
public SemanticMerge(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(SemanticMerge);
/// <inheritdoc />
protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
{
Dictionary<ZoneId, Zone> byId = plan.Zones.ToDictionary(z => z.Id);
ILookup<ZoneId, ZoneId> neighbours = plan.Surfaces
.Where(s => s.Boundary == BoundaryCondition.Interzone)
.ToLookup(s => s.Zone, s => s.AdjacentZone!.Value);
var ops = new PolygonOps(Tolerances);
var visited = new HashSet<ZoneId>();
var targets = new List<TargetZone>();
var counters = new Dictionary<SpaceType, int>();
foreach (Zone seed in plan.Zones.Where(z => !visited.Contains(z.Id)))
{
var group = new List<Zone>();
var queue = new Queue<Zone>(new[] { seed });
visited.Add(seed.Id);
while (queue.Count > 0)
{
Zone current = queue.Dequeue();
group.Add(current);
foreach (ZoneId next in neighbours[current.Id].Where(n => !visited.Contains(n) && byId[n].SpaceType == seed.SpaceType))
{
visited.Add(next);
queue.Enqueue(byId[next]);
}
}
IReadOnlyList<Polygon2> union = ops.UnionTiles(group.Select(z => z.Parts[0].Footprint).ToArray());
if (union.Count != 1)
{
return Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(
DiagnosticCodes.DisconnectedGroup,
$"Merged group {string.Join(",", group.Select(z => z.Id.Value))} is not one polygon.",
seed.Id.Value));
}
int number = counters.TryGetValue(seed.SpaceType, out int n) ? n + 1 : 1;
counters[seed.SpaceType] = number;
targets.Add(new TargetZone(new ZoneId($"{seed.SpaceType}-{number}"), $"{seed.SpaceType} {number}", seed.SpaceType, union[0]));
}
return Result.Success<IReadOnlyList<TargetZone>>(targets);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
/// <summary>
/// Z2: perimeter zones of a configurable depth plus a core (ADR-008). Each footprint edge owns the trapezoid between the edge and its inward
/// mitred offset, so corners are split along the bisectors; trapezoids in the same orientation bin are merged, keeping the footprint's own vertices.
/// A footprint with holes (courts) is
/// offset on every ring: the edges of the holes face into their courts and give court perimeter zones <c>P-Court-<Bin></c>, separate from
/// the outer façades' zones, and the core is what lies between the offset rings, a ring around each court.
/// </summary>
public sealed class PerimeterCore : PlanSimplifier
{
private readonly double _depth;
/// <summary>Creates the simplifier.</summary>
/// <param name="tolerances">Tolerances.</param>
/// <param name="perimeterDepth">Perimeter zone depth, m.</param>
public PerimeterCore(ToleranceSettings tolerances, double perimeterDepth)
: base(tolerances)
{
_depth = perimeterDepth;
}
/// <inheritdoc />
public override string Name => nameof(PerimeterCore);
/// <inheritdoc />
protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
{
if (!(_depth > 0) || double.IsInfinity(_depth))
{
return Fail(DiagnosticCodes.InvalidParameter, $"Perimeter depth must be positive, got {_depth}.");
}
// Every ring keeps the floor on its left (outer ring counter-clockwise, holes clockwise), so one mitre rule offsets each ring into the floor.
IReadOnlyList<Point2>[] rings = plan.Footprint.Rings.ToArray();
Point2[][] offsets = rings.Select(Offset).ToArray();
Result<Polygon2> core = Polygon2.Create(offsets[0], offsets.Skip(1));
bool ringsValid = Enumerable.Range(0, rings.Length).All(r =>
KeepsDirections(rings[r], offsets[r]) && (r == 0 ? Polygon2.SignedArea(offsets[r]) > 0 : Polygon2.SignedArea(offsets[r]) < 0) && Polygon2.Create(offsets[r]).IsSuccess && IsSimple(offsets[r]));
if (!core.IsSuccess || !ringsValid)
{
return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter depth {_depth} m leaves no valid core for this footprint: {Precondition()}");
}
var ops = new PolygonOps(Tolerances);
if (rings.Length > 1)
{
// The offset holes must lie inside the offset outer ring and apart from each other; otherwise a wing between the outer and a court façade,
// or between two courts, is not wider than twice the depth and the perimeter bands overlap.
IReadOnlyList<Polygon2> between = ops.Difference(Polygon2.Create(offsets[0]).Value, offsets.Skip(1).Select(h => Polygon2.Create(h).Value));
if (between.Count != 1 || !Tolerances.AreaEquals(core.Value.Area, between.Sum(p => p.Area)))
{
return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter depth {_depth} m leaves no valid core between the outer and the court façades: {Precondition()}");
}
}
var footprintVertices = new VertexGrid(Tolerances.Distance);
foreach (Point2 vertex in rings.SelectMany(r => r))
{
footprintVertices.Register(vertex);
}
var trapezoids = new List<(bool Court, OrientationBin Bin, Polygon2 Shape)>();
for (int r = 0; r < rings.Length; r++)
{
IReadOnlyList<Point2> v = rings[r];
Point2[] offset = offsets[r];
int n = v.Count;
for (int i = 0; i < n; i++)
{
Polygon2 shape = Polygon2.Create(new[] { v[i], v[(i + 1) % n], offset[(i + 1) % n], offset[i] }).Value;
trapezoids.Add((r > 0, Orientation.Bin(Orientation.OutwardAzimuth(v[i], v[(i + 1) % n]) + plan.OrientationDegrees), shape));
}
}
double tiled = trapezoids.Sum(t => t.Shape.Area) + core.Value.Area;
if (!Tolerances.AreaEquals(plan.Footprint.Area, tiled))
{
return Fail(DiagnosticCodes.PlanTooNarrow, $"Perimeter zones and core cover {tiled:0.###} m² of a {plan.Footprint.Area:0.###} m² footprint: {Precondition()}");
}
var targets = new List<TargetZone>();
foreach (bool court in new[] { false, true })
{
string id = court ? "P-Court-" : "P-";
string name = court ? "Perimeter Court " : "Perimeter ";
foreach (IGrouping<OrientationBin, (bool Court, OrientationBin Bin, Polygon2 Shape)> bin in trapezoids.Where(t => t.Court == court).GroupBy(t => t.Bin).OrderBy(g => g.Key))
{
IReadOnlyList<Polygon2> merged = ops.Union(bin.Select(t => t.Shape)).Select(u => KeepFootprintVertices(u, footprintVertices)).ToArray();
for (int k = 0; k < merged.Count; k++)
{
string suffix = merged.Count == 1 ? string.Empty : $"-{k + 1}";
targets.Add(new TargetZone(new ZoneId($"{id}{bin.Key}{suffix}"), $"{name}{bin.Key}{suffix}", SpaceType.Mixed, merged[k]));
}
}
}
targets.Add(new TargetZone(new ZoneId("CORE"), "Core", SpaceType.Mixed, core.Value));
return Result.Success<IReadOnlyList<TargetZone>>(targets);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe() =>
new[] { new KeyValuePair<string, string>("PerimeterDepth", Provenance.Format(_depth)) };
/// <summary>The precondition a <see cref="DiagnosticCodes.PlanTooNarrow"/> plan breaks, with what the user can do (ADR-008).</summary>
private string Precondition() =>
$"every wing and every part of the plan must be wider than twice the depth ({2.0 * _depth} m). Use a smaller depth or another simplifier.";
/// <summary>The mitred inward offset of every vertex of a ring, so offset vertex <c>i</c> belongs to ring vertex <c>i</c> (ADR-008).</summary>
private Point2[] Offset(IReadOnlyList<Point2> ring)
{
int n = ring.Count;
return Enumerable.Range(0, n).Select(i => Mitre(ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n])).ToArray();
}
/// <summary>Whether every offset edge keeps the direction of its ring edge; a reversed edge means the depth is too large for the edge.</summary>
private static bool KeepsDirections(IReadOnlyList<Point2> ring, Point2[] offset)
{
int n = ring.Count;
return Enumerable.Range(0, n).All(i =>
{
Point2 a = ring[i];
Point2 b = ring[(i + 1) % n];
Point2 c = offset[i];
Point2 d = offset[(i + 1) % n];
return ((b.X - a.X) * (d.X - c.X)) + ((b.Y - a.Y) * (d.Y - c.Y)) > 0;
});
}
/// <summary>
/// Whether an offset ring is simple: no two non-adjacent edges come within the distance tolerance of each other, so the ring neither crosses
/// nor touches itself. A part of the footprint not wider than twice the depth that no single edge spans, such as a neck between two blocks,
/// keeps every offset edge's direction and the signed areas still add up, but its two offset façades cross (ADR-008).
/// </summary>
private bool IsSimple(Point2[] ring)
{
int n = ring.Length;
for (int i = 0; i < n; i++)
{
for (int j = i + 2; j < (i == 0 ? n - 1 : n); j++)
{
if (SegmentDistance(ring[i], ring[i + 1], ring[j], ring[(j + 1) % n]) <= Tolerances.Distance)
{
return false;
}
}
}
return true;
}
/// <summary>The shortest distance between segments <c>ab</c> and <c>cd</c>; zero when they cross.</summary>
private static double SegmentDistance(Point2 a, Point2 b, Point2 c, Point2 d)
{
if (Cross(a, b, c) * Cross(a, b, d) < 0 && Cross(c, d, a) * Cross(c, d, b) < 0)
{
return 0.0;
}
return Math.Min(Math.Min(PointSegmentDistance(a, c, d), PointSegmentDistance(b, c, d)), Math.Min(PointSegmentDistance(c, a, b), PointSegmentDistance(d, a, b)));
}
/// <summary>The z component of <c>(b − a) × (p − a)</c>: positive when <paramref name="p"/> lies left of <c>ab</c>.</summary>
private static double Cross(Point2 a, Point2 b, Point2 p) => ((b.X - a.X) * (p.Y - a.Y)) - ((b.Y - a.Y) * (p.X - a.X));
private static double PointSegmentDistance(Point2 p, Point2 a, Point2 b)
{
double dx = b.X - a.X;
double dy = b.Y - a.Y;
double t = Math.Max(0.0, Math.Min(1.0, (((p.X - a.X) * dx) + ((p.Y - a.Y) * dy)) / ((dx * dx) + (dy * dy))));
return p.DistanceTo(new Point2(a.X + (t * dx), a.Y + (t * dy)));
}
private Point2 Mitre(Point2 previous, Point2 vertex, Point2 next)
{
(double X, double Y) a = InwardNormal(previous, vertex);
(double X, double Y) b = InwardNormal(vertex, next);
double scale = _depth / (1.0 + (a.X * b.X) + (a.Y * b.Y));
return new Point2(vertex.X + ((a.X + b.X) * scale), vertex.Y + ((a.Y + b.Y) * scale));
}
private static (double X, double Y) InwardNormal(Point2 from, Point2 to)
{
double length = from.DistanceTo(to);
return (-(to.Y - from.Y) / length, (to.X - from.X) / length);
}
/// <summary>
/// The union of a bin's trapezoids with every vertex in the grid cell of a footprint vertex replaced by that vertex. Clipping rounds every vertex
/// to the grid, up to 0.7 grid units off; on a rotated plan that turns a façade edge shorter than about 1 m by more than the angular tolerance,
/// so a single-zone aggregator would find its outline not to cover the source wall (ADR-008). The offset vertices keep their rounding.
/// </summary>
private static Polygon2 KeepFootprintVertices(Polygon2 union, VertexGrid footprintVertices) =>
Polygon2.Create(union.Outer.Select(footprintVertices.Restore), union.Holes.Select(h => h.Select(footprintVertices.Restore))) is { IsSuccess: true } restored
? restored.Value
: union;
private static Result<IReadOnlyList<TargetZone>> Fail(string code, string message) =>
Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(code, message));
}
/// <summary>Z3: the whole floor as one zone.</summary>
public sealed class SingleZonePerFloor : PlanSimplifier
{
/// <summary>Creates the simplifier.</summary>
/// <param name="tolerances">Tolerances.</param>
public SingleZonePerFloor(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(SingleZonePerFloor);
/// <inheritdoc />
protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
{
SpaceType[] types = plan.Zones.Select(z => z.SpaceType).Distinct().ToArray();
SpaceType type = types.Length == 1 ? types[0] : SpaceType.Mixed;
return Result.Success<IReadOnlyList<TargetZone>>(new[] { new TargetZone(new ZoneId("FLOOR"), "Floor", type, plan.Footprint) });
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
- [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PerimeterCoreShortEdgeTests"
Expected: Passed! - Failed: 0, Passed: 150, Skipped: 0, Total: 150, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 6: 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
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
- Replace
6. Trapezoids of the same bin are united. 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; 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.
- Short façade edges (S8.5, the hub corners of the care hub `SYN-TYP-018` of [ADR-006](ADR-006-plan-generation-mechanism.md)): until S8.5 the trapezoids of a bin were united with a plain union, which rounds every vertex to the clipping grid, up to 0.7 µm off. On a rotated plan that turns a footprint edge shorter than about 1 m, such as the 0.8 m of hub façade beside a 14.4 m wing on a 16 m hub, by more than the angular tolerance (ADR-004), so *Single Zone per Floor Type* and *Single Zone Building* found that the outline united from the perimeter zones did not cover the short source wall (`FacadeNotCovered`) at about one rotation in five. Restoring the footprint's vertices after the union puts the rebuilt outline exactly on the source walls. Only footprint vertices are restored: restoring the offset vertices too (a union as tiles) would move the 45° corner walls of the canonical linear plan by less than the grid and flip their facing bin at the North/East boundary, changing results for no gain. No tolerance changed, and every existing result is unchanged.
- [x] Step 7: Edit
docs/decisions/ADR-002-geometry.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window); unions of zones refined in S8.2; footprints with holes supported by every step and the boundary coverage of edge matching added in S8.3
with
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows through simplification); placement rule refined by [ADR-011](ADR-011-minimum-wall-and-window-geometry.md) (minimum wall and window); unions of zones refined in S8.2; footprints with holes supported by every step and the boundary coverage of edge matching added in S8.3; perimeter zones keep the footprint vertices since S8.5
- Replace
**Unions of zones (S8.2).** Wherever zones are united into one zone, their footprints are united as tiles (`PolygonOps.UnionTiles`, [ADR-013](ADR-013-vertical-aggregation-methods.md)): the groups of *Semantic Merge* (Z1) as well as the storey outlines of the single-zone floor aggregators. A plain union of footprints that meet at T-junctions, such as the pinwheel dwellings of the point plate (`HDHM-TYP-007`, [ADR-006](ADR-006-plan-generation-mechanism.md)), splits the group or leaves slits once the plan is rotated off the axes, because rounding on the clipping grid moves the T-junction off its edge. A united zone may have holes, where it surrounds zones of another type (the point plate's dwellings around the core). The union keeps the tiles' own vertices: clipping rounds every vertex to the grid, up to 0.7 grid units off its true position, so each vertex of the result is replaced by the tile vertex of its grid cell. United zones therefore lie on their sources' façade lines, and uniting zones that were themselves united, as the single-zone aggregators do with a *Semantic Merge* floor, adds no rounding; without this, a rotated *Semantic Merge* floor could fail *Single Zone per Floor Type* and *Single Zone Building* with `FacadeNotCovered` (S8.2).
with
**Unions of zones (S8.2).** Wherever zones are united into one zone, their footprints are united as tiles (`PolygonOps.UnionTiles`, [ADR-013](ADR-013-vertical-aggregation-methods.md)): the groups of *Semantic Merge* (Z1) as well as the storey outlines of the single-zone floor aggregators. A plain union of footprints that meet at T-junctions, such as the pinwheel dwellings of the point plate (`HDHM-TYP-007`, [ADR-006](ADR-006-plan-generation-mechanism.md)), splits the group or leaves slits once the plan is rotated off the axes, because rounding on the clipping grid moves the T-junction off its edge. A united zone may have holes, where it surrounds zones of another type (the point plate's dwellings around the core). The union keeps the tiles' own vertices: clipping rounds every vertex to the grid, up to 0.7 grid units off its true position, so each vertex of the result is replaced by the tile vertex of its grid cell. United zones therefore lie on their sources' façade lines, and uniting zones that were themselves united, as the single-zone aggregators do with a *Semantic Merge* floor, adds no rounding; without this, a rotated *Semantic Merge* floor could fail *Single Zone per Floor Type* and *Single Zone Building* with `FacadeNotCovered` (S8.2). *Perimeter Core* (Z2) unites the trapezoids of one orientation bin with a plain union, because they meet edge to edge on the bisectors, and then replaces every vertex in the grid cell of a footprint vertex by that vertex, so its zones lie on the footprint's own vertices too; without this, a footprint edge shorter than about 1 m, such as a hub corner beside a narrower arm, could fail the same check on a rotated plan (S8.5, [ADR-008](ADR-008-perimeter-core-corners.md)).
- [x] Step 8: Build, test, and check that no snapshot changed
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 304 generators, and 1018 integration tests passed on net8.0, including the canonical snapshots of every simplifier and aggregator; git status lists
M docs/decisions/ADR-002-geometry.md
M docs/decisions/ADR-008-perimeter-core-corners.md
M src/Lod.Core/Simplification/Strategies.cs
?? tests/Lod.Integration.Tests/PerimeterCoreShortEdgeTests.cs
and no *.received.txt: every existing Perimeter Core result is unchanged.
- [x] Step 9: Commit
git add src/Lod.Core/Simplification/Strategies.cs tests/Lod.Integration.Tests/PerimeterCoreShortEdgeTests.cs docs/decisions/ADR-008-perimeter-core-corners.md docs/decisions/ADR-002-geometry.md
git commit -m "fix(simplifiers): keep the footprint's vertices in perimeter core zones"
- [x] Step 10: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch fix/perimeter-core-footprint-vertices
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only fix/perimeter-core-footprint-vertices
git branch -d fix/perimeter-core-footprint-vertices
Expected: VERIFY PASSED, with 193 core, 304 generators, and 1018 integration tests passed on net8.0. Do not push (D-098).
Slice C — feature/care-hub-foyer-halls¶
The two families with hubs that are not always covered by their arms: the care hub (Task 5), whose integration matrix passes because of Task 4, and the foyer with halls (Task 6), then the Perimeter Core precondition on arms that are too narrow (Task 7).
Task 5: Care hub serving elder-care bedroom wings (SYN-TYP-018)¶
Files:
- Create: src/Lod.Generators/CareHub/CareHubGenerator.cs
- Test: create tests/Lod.Generators.Tests/CareHub/CareHubGeneratorTests.cs, tests/Lod.Integration.Tests/CareHubTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt
Interfaces:
- Consumes: as Task 2, with SpaceType.CareBedroom, CareCommunal, and Corridor, ExampleNonResidentialPresets.CareBedroom and CareCommunal (S8.4), ExampleResidentialPresets.Corridor, GeometryLimits and the info WindowOmitted (ADR-011), and the Perimeter Core of Task 4.
- Produces: public sealed record CareHubParameters(int WingCount, double WingLength, double BedroomDepth, double CorridorWidth, double HubWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (3, 30, 6, 2.4, 16, 3, 0); public sealed record CareHubPresets(ProgramPreset CareBedroom, ProgramPreset CareCommunal, ProgramPreset Corridor) : PlanPresets (slots CareBedroom, CareCommunal, Corridor); public sealed class CareHubGenerator(ToleranceSettings), Name = CareHubGenerator, public const int MinimumWings = 2, MaximumWings = 4: the hub HUB (Communal Hub, CareCommunal) and wings 2 · BedroomDepth + CorridorWidth wide; each wing i has the corridor W{i}-CO (Wing i Corridor, Corridor) along its axis from the hub to its end, and the bedroom groups W{i}-B1 (clockwise of the corridor) and W{i}-B2 (Wing i Bedrooms k, CareBedroom), listed CO, B1, B2; two wings leave the hub east and north (an L, D-102); errors InvalidParameter for a wing count outside 2–4 (WingCount must be at least 2 (the hub serves several bedroom groups), got 1., WingCount must be at most 4 (wings leave the hub only on its four sides, D-100), got 5.), for each length that is not positive and finite, and, only when those pass, for wings wider than the hub (The wing width 2·BedroomDepth + CorridorWidth 18.4 m is wider than HubWidth 16 m; …); provenance keys WingCount, WingLength, BedroomDepth, CorridorWidth, HubWidth. By default a T of 1552 m²: the hub 256 m², three corridors of 72 m², six bedroom groups of 180 m²; 16 windows with 217.92 m² of glazing, each corridor with one window at its end; the six 0.8 m hub corners beside the wings get no window (six infos WindowOmitted).
- [x] Step 1: Create the branch
git switch -c feature/care-hub-foyer-halls main
- [x] Step 2: Write the failing tests — create
tests/Lod.Generators.Tests/CareHub/CareHubGeneratorTests.cs(full file)
29 tests: the ADR-006 defaults; the canonical plan (zone IDs, names, space types, areas); tiling for five sizes and counts; every wing corridor adjoining the hub (2, 3, and 4 wings); two wings east and north; the hub corners too short for a window; the window rule on the other walls; every corridor ending in an outdoor wall with a window; two wing counts outside 2–4; five invalid lengths; wings wider than the hub, and as wide as the hub; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
using System;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.CareHub;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.CareHub;
public sealed class CareHubGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly CareHubPresets Examples = new(ExampleNonResidentialPresets.CareBedroom, ExampleNonResidentialPresets.CareCommunal, ExampleResidentialPresets.Corridor);
private static readonly string[] CanonicalZones =
{
"HUB", "W1-CO", "W1-B1", "W1-B2", "W2-CO", "W2-B1", "W2-B2", "W3-CO", "W3-B1", "W3-B2",
};
private readonly CareHubGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new CareHubParameters(WingCount: 3, WingLength: 30.0, BedroomDepth: 6.0, CorridorWidth: 2.4, HubWidth: 16.0, FloorHeight: 3.0, OrientationDegrees: 0.0),
CareHubParameters.Default);
}
[Fact]
public void CanonicalPlanIsAHubWithThreeWingsOfBedroomsOffACorridor()
{
IGeneratedPlan plan = Generate(CareHubParameters.Default);
Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(
new[]
{
"Communal Hub", "Wing 1 Corridor", "Wing 1 Bedrooms 1", "Wing 1 Bedrooms 2", "Wing 2 Corridor", "Wing 2 Bedrooms 1", "Wing 2 Bedrooms 2",
"Wing 3 Corridor", "Wing 3 Bedrooms 1", "Wing 3 Bedrooms 2",
},
plan.Zones.Select(z => z.Name));
Assert.Equal(SpaceType.CareCommunal, plan.Zones[0].SpaceType);
Assert.All(plan.Zones.Where(z => z.Id.Value.EndsWith("CO")), z => Assert.Equal(SpaceType.Corridor, z.SpaceType));
Assert.All(plan.Zones.Where(z => z.Id.Value.Contains("-B")), z => Assert.Equal(SpaceType.CareBedroom, z.SpaceType));
Assert.Equal(256.0 + (3 * 30.0 * 14.4), plan.Footprint.Area, 9);
Assert.Equal(256.0, plan.Zones[0].FloorArea, 9);
Assert.All(plan.Zones.Where(z => z.Id.Value.EndsWith("CO")), z => Assert.Equal(72.0, z.FloorArea, 9));
Assert.All(plan.Zones.Where(z => z.Id.Value.Contains("-B")), z => Assert.Equal(180.0, z.FloorArea, 9));
Assert.Equal(Rectangle(30, 0, 46, 16), Outer(plan, "HUB"));
Assert.Equal(Rectangle(46, 6.8, 76, 9.2), Outer(plan, "W1-CO"));
Assert.Equal(Rectangle(46, 0.8, 76, 6.8), Outer(plan, "W1-B1"));
Assert.Equal(Rectangle(46, 9.2, 76, 15.2), Outer(plan, "W1-B2"));
Assert.Equal(Rectangle(36.8, 16, 39.2, 46), Outer(plan, "W2-CO"));
Assert.Equal(Rectangle(39.2, 16, 45.2, 46), Outer(plan, "W2-B1"));
Assert.Equal(Rectangle(0, 9.2, 30, 15.2), Outer(plan, "W3-B1"));
Assert.All(plan.Zones, z => Assert.Equal(3.0, z.Parts[0].Height));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Theory]
[InlineData(2, 30.0, 6.0, 2.4, 16.0)]
[InlineData(3, 30.0, 6.0, 2.4, 16.0)]
[InlineData(4, 30.0, 6.0, 2.4, 16.0)]
[InlineData(3, 22.5, 5.5, 2.0, 13.0)]
[InlineData(4, 41.0, 4.8, 1.8, 11.4)]
public void ZonesTileTheFootprint(int wings, double length, double bedrooms, double corridor, double hub)
{
IGeneratedPlan plan = Generate(new CareHubParameters(wings, length, bedrooms, corridor, hub, 3.0, 0.0));
PlanAssertions.TilesTheFootprint(plan);
Assert.Equal((hub * hub) + (wings * length * ((2 * bedrooms) + corridor)), plan.Footprint.Area, 9);
Assert.Equal(1 + (3 * wings), plan.Zones.Count);
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void EveryWingCorridorAdjoinsTheHub(int wings)
{
IGeneratedPlan plan = Generate(CareHubParameters.Default with { WingCount = wings });
Assert.Equal(plan.Zones.Skip(1).Select(z => z.Id.Value).OrderBy(id => id, StringComparer.Ordinal), PlanAssertions.Neighbours(plan, "HUB"));
for (int wing = 1; wing <= wings; wing++)
{
Assert.Equal(new[] { "HUB", $"W{wing}-B1", $"W{wing}-B2" }, PlanAssertions.Neighbours(plan, $"W{wing}-CO"));
Assert.Equal(2.4, SharedLength(plan, $"W{wing}-CO", "HUB"), 9);
foreach (string bedrooms in new[] { $"W{wing}-B1", $"W{wing}-B2" })
{
Assert.Equal(new[] { "HUB", $"W{wing}-CO" }, PlanAssertions.Neighbours(plan, bedrooms));
Assert.Equal(30.0, SharedLength(plan, bedrooms, $"W{wing}-CO"), 9);
Assert.Equal(6.0, SharedLength(plan, bedrooms, "HUB"), 9);
}
}
}
[Fact]
public void TwoWingsLeaveTheHubEastAndNorth()
{
IGeneratedPlan plan = Generate(CareHubParameters.Default with { WingCount = 2 });
Assert.Equal(Rectangle(0, 0, 16, 16), Outer(plan, "HUB"));
Assert.Equal(Rectangle(16, 6.8, 46, 9.2), Outer(plan, "W1-CO"));
Assert.Equal(Rectangle(6.8, 16, 9.2, 46), Outer(plan, "W2-CO"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "HUB"));
}
[Fact]
public void TheHubCornersBesideTheWingsAreTooShortForAWindow()
{
Result<IGeneratedPlan> result = _generator.Generate(CareHubParameters.Default, Examples);
Assert.True(result.IsSuccess);
WallSurface[] corners = result.Value.Surfaces.OfType<WallSurface>()
.Where(w => w.Zone.Value == "HUB" && w.Boundary == BoundaryCondition.Outdoors && w.Length < 1.0)
.ToArray();
Assert.Equal(6, corners.Length);
Assert.All(corners, w => Assert.Equal(0.8, w.Length, 9));
Assert.All(corners, w => Assert.Empty(w.Windows));
Assert.Equal(corners.Select(w => w.Id.Value), result.Diagnostics.Where(d => d.Code == DiagnosticCodes.WindowOmitted).Select(d => d.Subject));
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.WindowOmitted, d.Code));
WallSurface south = Assert.Single(result.Value.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "HUB" && w.Boundary == BoundaryCondition.Outdoors && w.Length > 1.0);
Assert.Equal(16.0, south.Length, 9);
Assert.Equal(OrientationBin.South, Orientation.Bin(south.PlanAzimuth));
}
[Fact]
public void EveryOutdoorWallAboveTheLimitsHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(CareHubParameters.Default with { HubWidth = 14.4 }, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
Assert.Equal(1 + (3 * 5), result.Value.Surfaces.OfType<WallSurface>().Count(w => w.Windows.Count == 1));
double bedrooms = 3 * 2 * (30.0 + 6.0) * 3.0;
double corridors = 3 * 2.4 * 3.0;
Assert.Equal((0.4 * 14.4 * 3.0) + (0.3 * bedrooms) + (0.2 * corridors), result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 9);
}
[Fact]
public void EveryWingCorridorEndsInAnOutdoorWallWithAWindow()
{
IGeneratedPlan plan = Generate(CareHubParameters.Default);
for (int wing = 1; wing <= 3; wing++)
{
WallSurface end = Assert.Single(plan.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == $"W{wing}-CO" && w.Boundary == BoundaryCondition.Outdoors);
Assert.Equal(2.4, end.Length, 9);
Assert.Single(end.Windows);
}
}
[Theory]
[InlineData(1, "WingCount must be at least 2 (the hub serves several bedroom groups), got 1.")]
[InlineData(5, "WingCount must be at most 4 (wings leave the hub only on its four sides, D-100), got 5.")]
public void WingCountsOutsideTwoToFourAreErrors(int wings, string message)
{
Result<IGeneratedPlan> result = _generator.Generate(CareHubParameters.Default with { WingCount = wings }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(message, error.Message);
}
[Theory]
[InlineData(0.0, 6.0, 2.4, 16.0, "WingLength")]
[InlineData(30.0, -6.0, 2.4, 16.0, "BedroomDepth")]
[InlineData(30.0, 6.0, double.NaN, 16.0, "CorridorWidth")]
[InlineData(30.0, 6.0, 2.4, 0.0, "HubWidth")]
[InlineData(double.PositiveInfinity, 6.0, 2.4, 16.0, "WingLength")]
public void InvalidParametersAreErrors(double length, double bedrooms, double corridor, double hub, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new CareHubParameters(3, length, bedrooms, corridor, hub, 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 AWingWiderThanTheHubIsAnError()
{
Result<IGeneratedPlan> result = _generator.Generate(CareHubParameters.Default with { BedroomDepth = 8.0 }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(
"The wing width 2·BedroomDepth + CorridorWidth 18.4 m is wider than HubWidth 16 m; an arm must not be wider than the hub it leaves, "
+ "or it would overlap its neighbour beyond the hub's corner.",
error.Message);
}
[Fact]
public void AWingAsWideAsTheHubIsValid()
{
Result<IGeneratedPlan> result = _generator.Generate(CareHubParameters.Default with { HubWidth = 14.4 }, Examples);
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
PlanAssertions.TilesTheFootprint(result.Value);
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new CareHubParameters(1, 0.0, -6.0, 0.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(7, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(
new[] { ("CareBedroom", SpaceType.CareBedroom), ("CareCommunal", SpaceType.CareCommunal), ("Corridor", SpaceType.Corridor) },
Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.CareBedroom, Examples.CareCommunal, Examples.Corridor }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(
CareHubParameters.Default,
new CareHubPresets(ExampleNonResidentialPresets.CareCommunal, ExampleNonResidentialPresets.CareBedroom, ExampleResidentialPresets.Corridor));
Assert.Equal(new[] { "CareBedroom", "CareCommunal" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
}
[Fact]
public void GenerationIsDeterministic()
{
Assert.Equal(TextReport.Describe(Generate(CareHubParameters.Default)), TextReport.Describe(Generate(CareHubParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(CareHubParameters.Default).Provenance;
Assert.Equal(nameof(CareHubGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"BedroomDepth=6", "CorridorWidth=2.4", "FloorHeight=3", "HubWidth=16", "OrientationDegrees=0",
"Presets=CareBedroom:Example Care Bedroom;CareCommunal:Example Care Communal;Corridor:Example Corridor", "WingCount=3", "WingLength=30",
},
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(CareHubParameters.Default)), "care-hub-canonical");
}
private static double SharedLength(IGeneratedPlan plan, string zone, string other) =>
plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == zone && w.AdjacentZone?.Value == other).Sum(w => w.Length);
private static Point2[] Rectangle(double minX, double minY, double maxX, double maxY) => Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
/// <summary>A zone's outer ring rounded to 9 decimals: the arms of the default plan are offset by 0.8 m, which is not exact in binary.</summary>
private static Point2[] Outer(IGeneratedPlan plan, string zone) =>
plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.Select(p => new Point2(Math.Round(p.X, 9), Math.Round(p.Y, 9))).ToArray();
private IGeneratedPlan Generate(CareHubParameters 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.CareHub.CareHubGeneratorTests"
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\CareHub\CareHubGeneratorTests.cs(7,22): error CS0234: The type or namespace name 'CareHub' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\CareHub\CareHubGeneratorTests.cs(270,37): error CS0246: The type or namespace name 'CareHubParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\CareHub\CareHubGeneratorTests.cs(16,28): error CS0246: The type or namespace name 'CareHubPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\CareHub\CareHubGeneratorTests.cs(23,22): error CS0246: The type or namespace name 'CareHubGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 4: Implement — create
src/Lod.Generators/CareHub/CareHubGenerator.cs(full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.HubArms;
namespace Lod.Generators.CareHub;
/// <summary>Parameters of the communal hub serving elder-care bedroom groups (SYN-TYP-018, ADR-006).</summary>
/// <param name="WingCount">Number of bedroom wings around the hub, 2–4.</param>
/// <param name="WingLength">Length of each wing from the hub to its end, m.</param>
/// <param name="BedroomDepth">Depth of the bedrooms on each side of a wing corridor, m.</param>
/// <param name="CorridorWidth">Width of each wing corridor, m.</param>
/// <param name="HubWidth">Side of the square communal hub, m; at least the wing width <c>2·BedroomDepth + CorridorWidth</c>.</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 CareHubParameters(
int WingCount,
double WingLength,
double BedroomDepth,
double CorridorWidth,
double HubWidth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: 3 wings of 30 m, 6 m bedrooms either side of a 2.4 m corridor, a 16 m hub, 3 m storeys, orientation 0°.</summary>
public static CareHubParameters Default { get; } = new(3, 30.0, 6.0, 2.4, 16.0, 3.0, 0.0);
}
/// <summary>The program presets of the care hub, one per space type it places (D-061).</summary>
/// <param name="CareBedroom">Preset of the bedroom groups; space type <see cref="SpaceType.CareBedroom"/>.</param>
/// <param name="CareCommunal">Preset of the communal hub; space type <see cref="SpaceType.CareCommunal"/>.</param>
/// <param name="Corridor">Preset of the wing corridors; space type <see cref="SpaceType.Corridor"/>.</param>
public sealed record CareHubPresets(ProgramPreset CareBedroom, ProgramPreset CareCommunal, ProgramPreset Corridor) : PlanPresets
{
/// <inheritdoc />
public override IReadOnlyList<PresetSlot> Slots => new[]
{
new PresetSlot(nameof(CareBedroom), SpaceType.CareBedroom, CareBedroom),
new PresetSlot(nameof(CareCommunal), SpaceType.CareCommunal, CareCommunal),
new PresetSlot(nameof(Corridor), SpaceType.Corridor, Corridor),
};
}
/// <summary>
/// Communal hub serving elder-care bedroom groups (SYN-TYP-018, ADR-006), on the shared <see cref="HubArmsLayout"/>: bedroom wings are arranged
/// around the communal and service hub (R001), and circulation branches from it (R002): each wing is a corridor along its axis from the hub to the
/// wing's end, with a group of bedrooms on each side, so every wing corridor adjoins the hub. Zones are departments (D-097, ADR-014): the bedrooms of
/// one side of a wing are one zone.
/// </summary>
/// <remarks>
/// Zone IDs: <c>HUB</c>, then per wing <c>W{i}-CO</c> (the corridor), <c>W{i}-B1</c>, <c>W{i}-B2</c> (the bedrooms clockwise and counter-clockwise
/// of it), wing <c>i</c> from 1 in the order east, north, west, south; two wings leave the hub east and north. Wings narrower than the hub leave its
/// corners as short outdoor walls, which get no window below the ADR-011 limits (0.8 m by default).
/// </remarks>
public sealed class CareHubGenerator : PlanGenerator<CareHubParameters, CareHubPresets>
{
/// <summary>The fewest wings: the hub serves several bedroom groups.</summary>
public const int MinimumWings = 2;
/// <summary>The most wings: one per side of the hub (D-100).</summary>
public const int MaximumWings = HubArmsLayout.MaximumArms;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public CareHubGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(CareHubGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(CareHubParameters parameters)
{
var errors = HubArmsLayout.CountErrors(
nameof(CareHubParameters.WingCount),
parameters.WingCount,
MinimumWings,
MaximumWings,
"the hub serves several bedroom groups",
"wings leave the hub only on its four sides").ToList();
var values = new (string Name, double Value)[]
{
(nameof(CareHubParameters.WingLength), parameters.WingLength),
(nameof(CareHubParameters.BedroomDepth), parameters.BedroomDepth),
(nameof(CareHubParameters.CorridorWidth), parameters.CorridorWidth),
(nameof(CareHubParameters.HubWidth), parameters.HubWidth),
};
var lengths = values
.Where(v => !IsPositive(v.Value))
.Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
.ToList();
if (lengths.Count == 0)
{
lengths.AddRange(HubArmsLayout.WidthErrors(
$"The wing width 2·{nameof(CareHubParameters.BedroomDepth)} + {nameof(CareHubParameters.CorridorWidth)}",
WingWidth(parameters),
nameof(CareHubParameters.HubWidth),
parameters.HubWidth));
}
errors.AddRange(lengths);
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(CareHubParameters p)
{
double width = WingWidth(p);
double corridorEnd = p.BedroomDepth + p.CorridorWidth;
var layout = new HubArmsLayout(p.HubWidth, width, Enumerable.Repeat(p.WingLength, p.WingCount).ToArray());
var zones = new List<PlannedZone> { new(new ZoneId("HUB"), "Communal Hub", SpaceType.CareCommunal, layout.Hub) };
foreach (HubArm wing in layout.Arms)
{
string id = $"W{wing.Number}-";
string name = $"Wing {wing.Number} ";
zones.Add(new PlannedZone(new ZoneId(id + "CO"), name + "Corridor", SpaceType.Corridor, wing.Piece(0.0, wing.Length, p.BedroomDepth, corridorEnd)));
zones.Add(new PlannedZone(new ZoneId(id + "B1"), name + "Bedrooms 1", SpaceType.CareBedroom, wing.Piece(0.0, wing.Length, 0.0, p.BedroomDepth)));
zones.Add(new PlannedZone(new ZoneId(id + "B2"), name + "Bedrooms 2", SpaceType.CareBedroom, wing.Piece(0.0, wing.Length, corridorEnd, width)));
}
return new PlanLayout(layout.Footprint, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(CareHubParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.WingCount), p.WingCount.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>(nameof(p.WingLength), Provenance.Format(p.WingLength)),
new KeyValuePair<string, string>(nameof(p.BedroomDepth), Provenance.Format(p.BedroomDepth)),
new KeyValuePair<string, string>(nameof(p.CorridorWidth), Provenance.Format(p.CorridorWidth)),
new KeyValuePair<string, string>(nameof(p.HubWidth), Provenance.Format(p.HubWidth)),
};
private static double WingWidth(CareHubParameters p) => (2.0 * p.BedroomDepth) + p.CorridorWidth;
}
- [x] Step 5: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.CareHub.CareHubGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 28, Skipped: 0, Total: 29, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.CareHub.CareHubGeneratorTests.CanonicalPlanSnapshot
Snapshot 'care-hub-canonical' is missing or differs. Review Snapshots/care-hub-canonical.received.txt and rename it to care-hub-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-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: 127 lines, 10 zones, 72 surfaces (22 outdoor walls, 16 with one window; 30 interzone walls; 20 floors and ceilings), glazing 73.2 m² south, 66.24 m² north, 39.24 m² east, 39.24 m² west (217.92 m² in all); the six outdoor walls of the hub without a window are its 0.8 m corners. Accept it:
Move-Item -Force tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.received.txt tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt
tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt:
footprint area=1552.000000 floorHeight=3.000000 orientation=0.000000
zone HUB CareCommunal "Communal Hub" area=256.000000 volume=768.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.150000 annual=3777.750000
load Lighting PerFloorArea value=10.000000 annual=6132.000000
load ElectricEquipment PerFloorArea value=5.000000 annual=6132.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W1-CO Corridor "Wing 1 Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=5.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone W1-B1 CareBedroom "Wing 1 Bedrooms 1" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W1-B2 CareBedroom "Wing 1 Bedrooms 2" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W2-CO Corridor "Wing 2 Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=5.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone W2-B1 CareBedroom "Wing 2 Bedrooms 1" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W2-B2 CareBedroom "Wing 2 Bedrooms 2" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W3-CO Corridor "Wing 3 Corridor" area=72.000000 volume=216.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=5.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone W3-B1 CareBedroom "Wing 3 Bedrooms 1" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
zone W3-B2 CareBedroom "Wing 3 Bedrooms 2" area=180.000000 volume=540.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.040000 annual=5803.500000
load Lighting PerFloorArea value=6.000000 annual=2226.500000
load ElectricEquipment PerFloorArea value=3.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=22.000000 coolingMean=25.000000
surface HUB/W1 Outdoors area=48.000000 wall (30.000000,0.000000)-(46.000000,0.000000) z=0.000000 facing=South glazing=19.200000 window(offset=2.940356 sill=0.551317 10.119289x1.897367)
surface HUB/W2 Outdoors area=2.400000 wall (46.000000,0.000000)-(46.000000,0.800000) z=0.000000 facing=East glazing=0.000000
surface HUB/W3 Interzone adjacent=W1-B1 area=18.000000 wall (46.000000,0.800000)-(46.000000,6.800000) z=0.000000 facing=East glazing=0.000000
surface HUB/W4 Interzone adjacent=W1-CO area=7.200000 wall (46.000000,6.800000)-(46.000000,9.200000) z=0.000000 facing=East glazing=0.000000
surface HUB/W5 Interzone adjacent=W1-B2 area=18.000000 wall (46.000000,9.200000)-(46.000000,15.200000) z=0.000000 facing=East glazing=0.000000
surface HUB/W6 Outdoors area=2.400000 wall (46.000000,15.200000)-(46.000000,16.000000) z=0.000000 facing=East glazing=0.000000
surface HUB/W7 Outdoors area=2.400000 wall (46.000000,16.000000)-(45.200000,16.000000) z=0.000000 facing=North glazing=0.000000
surface HUB/W8 Interzone adjacent=W2-B1 area=18.000000 wall (45.200000,16.000000)-(39.200000,16.000000) z=0.000000 facing=North glazing=0.000000
surface HUB/W9 Interzone adjacent=W2-CO area=7.200000 wall (39.200000,16.000000)-(36.800000,16.000000) z=0.000000 facing=North glazing=0.000000
surface HUB/W10 Interzone adjacent=W2-B2 area=18.000000 wall (36.800000,16.000000)-(30.800000,16.000000) z=0.000000 facing=North glazing=0.000000
surface HUB/W11 Outdoors area=2.400000 wall (30.800000,16.000000)-(30.000000,16.000000) z=0.000000 facing=North glazing=0.000000
surface HUB/W12 Outdoors area=2.400000 wall (30.000000,16.000000)-(30.000000,15.200000) z=0.000000 facing=West glazing=0.000000
surface HUB/W13 Interzone adjacent=W3-B1 area=18.000000 wall (30.000000,15.200000)-(30.000000,9.200000) z=0.000000 facing=West glazing=0.000000
surface HUB/W14 Interzone adjacent=W3-CO area=7.200000 wall (30.000000,9.200000)-(30.000000,6.800000) z=0.000000 facing=West glazing=0.000000
surface HUB/W15 Interzone adjacent=W3-B2 area=18.000000 wall (30.000000,6.800000)-(30.000000,0.800000) z=0.000000 facing=West glazing=0.000000
surface HUB/W16 Outdoors area=2.400000 wall (30.000000,0.800000)-(30.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface HUB/F Unresolved area=256.000000 Floor z=0.000000
surface HUB/C Unresolved area=256.000000 Ceiling z=3.000000
surface W1-CO/W1 Interzone adjacent=W1-B1 area=90.000000 wall (46.000000,6.800000)-(76.000000,6.800000) z=0.000000 facing=South glazing=0.000000
surface W1-CO/W2 Outdoors area=7.200000 wall (76.000000,6.800000)-(76.000000,9.200000) z=0.000000 facing=East glazing=1.440000 window(offset=0.663344 sill=0.829180 1.073313x1.341641)
surface W1-CO/W3 Interzone adjacent=W1-B2 area=90.000000 wall (76.000000,9.200000)-(46.000000,9.200000) z=0.000000 facing=North glazing=0.000000
surface W1-CO/W4 Interzone adjacent=HUB area=7.200000 wall (46.000000,9.200000)-(46.000000,6.800000) z=0.000000 facing=West glazing=0.000000
surface W1-CO/F Unresolved area=72.000000 Floor z=0.000000
surface W1-CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface W1-B1/W1 Outdoors area=90.000000 wall (46.000000,0.800000)-(76.000000,0.800000) z=0.000000 facing=South glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W1-B1/W2 Outdoors area=18.000000 wall (76.000000,0.800000)-(76.000000,6.800000) z=0.000000 facing=East glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W1-B1/W3 Interzone adjacent=W1-CO area=90.000000 wall (76.000000,6.800000)-(46.000000,6.800000) z=0.000000 facing=North glazing=0.000000
surface W1-B1/W4 Interzone adjacent=HUB area=18.000000 wall (46.000000,6.800000)-(46.000000,0.800000) z=0.000000 facing=West glazing=0.000000
surface W1-B1/F Unresolved area=180.000000 Floor z=0.000000
surface W1-B1/C Unresolved area=180.000000 Ceiling z=3.000000
surface W1-B2/W1 Interzone adjacent=W1-CO area=90.000000 wall (46.000000,9.200000)-(76.000000,9.200000) z=0.000000 facing=South glazing=0.000000
surface W1-B2/W2 Outdoors area=18.000000 wall (76.000000,9.200000)-(76.000000,15.200000) z=0.000000 facing=East glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W1-B2/W3 Outdoors area=90.000000 wall (76.000000,15.200000)-(46.000000,15.200000) z=0.000000 facing=North glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W1-B2/W4 Interzone adjacent=HUB area=18.000000 wall (46.000000,15.200000)-(46.000000,9.200000) z=0.000000 facing=West glazing=0.000000
surface W1-B2/F Unresolved area=180.000000 Floor z=0.000000
surface W1-B2/C Unresolved area=180.000000 Ceiling z=3.000000
surface W2-CO/W1 Interzone adjacent=HUB area=7.200000 wall (36.800000,16.000000)-(39.200000,16.000000) z=0.000000 facing=South glazing=0.000000
surface W2-CO/W2 Interzone adjacent=W2-B1 area=90.000000 wall (39.200000,16.000000)-(39.200000,46.000000) z=0.000000 facing=East glazing=0.000000
surface W2-CO/W3 Outdoors area=7.200000 wall (39.200000,46.000000)-(36.800000,46.000000) z=0.000000 facing=North glazing=1.440000 window(offset=0.663344 sill=0.829180 1.073313x1.341641)
surface W2-CO/W4 Interzone adjacent=W2-B2 area=90.000000 wall (36.800000,46.000000)-(36.800000,16.000000) z=0.000000 facing=West glazing=0.000000
surface W2-CO/F Unresolved area=72.000000 Floor z=0.000000
surface W2-CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface W2-B1/W1 Interzone adjacent=HUB area=18.000000 wall (39.200000,16.000000)-(45.200000,16.000000) z=0.000000 facing=South glazing=0.000000
surface W2-B1/W2 Outdoors area=90.000000 wall (45.200000,16.000000)-(45.200000,46.000000) z=0.000000 facing=East glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W2-B1/W3 Outdoors area=18.000000 wall (45.200000,46.000000)-(39.200000,46.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W2-B1/W4 Interzone adjacent=W2-CO area=90.000000 wall (39.200000,46.000000)-(39.200000,16.000000) z=0.000000 facing=West glazing=0.000000
surface W2-B1/F Unresolved area=180.000000 Floor z=0.000000
surface W2-B1/C Unresolved area=180.000000 Ceiling z=3.000000
surface W2-B2/W1 Interzone adjacent=HUB area=18.000000 wall (30.800000,16.000000)-(36.800000,16.000000) z=0.000000 facing=South glazing=0.000000
surface W2-B2/W2 Interzone adjacent=W2-CO area=90.000000 wall (36.800000,16.000000)-(36.800000,46.000000) z=0.000000 facing=East glazing=0.000000
surface W2-B2/W3 Outdoors area=18.000000 wall (36.800000,46.000000)-(30.800000,46.000000) z=0.000000 facing=North glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W2-B2/W4 Outdoors area=90.000000 wall (30.800000,46.000000)-(30.800000,16.000000) z=0.000000 facing=West glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W2-B2/F Unresolved area=180.000000 Floor z=0.000000
surface W2-B2/C Unresolved area=180.000000 Ceiling z=3.000000
surface W3-CO/W1 Interzone adjacent=W3-B2 area=90.000000 wall (0.000000,6.800000)-(30.000000,6.800000) z=0.000000 facing=South glazing=0.000000
surface W3-CO/W2 Interzone adjacent=HUB area=7.200000 wall (30.000000,6.800000)-(30.000000,9.200000) z=0.000000 facing=East glazing=0.000000
surface W3-CO/W3 Interzone adjacent=W3-B1 area=90.000000 wall (30.000000,9.200000)-(0.000000,9.200000) z=0.000000 facing=North glazing=0.000000
surface W3-CO/W4 Outdoors area=7.200000 wall (0.000000,9.200000)-(0.000000,6.800000) z=0.000000 facing=West glazing=1.440000 window(offset=0.663344 sill=0.829180 1.073313x1.341641)
surface W3-CO/F Unresolved area=72.000000 Floor z=0.000000
surface W3-CO/C Unresolved area=72.000000 Ceiling z=3.000000
surface W3-B1/W1 Interzone adjacent=W3-CO area=90.000000 wall (0.000000,9.200000)-(30.000000,9.200000) z=0.000000 facing=South glazing=0.000000
surface W3-B1/W2 Interzone adjacent=HUB area=18.000000 wall (30.000000,9.200000)-(30.000000,15.200000) z=0.000000 facing=East glazing=0.000000
surface W3-B1/W3 Outdoors area=90.000000 wall (30.000000,15.200000)-(0.000000,15.200000) z=0.000000 facing=North glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W3-B1/W4 Outdoors area=18.000000 wall (0.000000,15.200000)-(0.000000,9.200000) z=0.000000 facing=West glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W3-B1/F Unresolved area=180.000000 Floor z=0.000000
surface W3-B1/C Unresolved area=180.000000 Ceiling z=3.000000
surface W3-B2/W1 Outdoors area=90.000000 wall (0.000000,0.800000)-(30.000000,0.800000) z=0.000000 facing=South glazing=27.000000 window(offset=6.784162 sill=0.678416 16.431677x1.643168)
surface W3-B2/W2 Interzone adjacent=HUB area=18.000000 wall (30.000000,0.800000)-(30.000000,6.800000) z=0.000000 facing=East glazing=0.000000
surface W3-B2/W3 Interzone adjacent=W3-CO area=90.000000 wall (30.000000,6.800000)-(0.000000,6.800000) z=0.000000 facing=North glazing=0.000000
surface W3-B2/W4 Outdoors area=18.000000 wall (0.000000,6.800000)-(0.000000,0.800000) z=0.000000 facing=West glazing=5.400000 window(offset=1.356832 sill=0.678416 3.286335x1.643168)
surface W3-B2/F Unresolved area=180.000000 Floor z=0.000000
surface W3-B2/C Unresolved area=180.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.CareHub.CareHubGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 29, Skipped: 0, Total: 29, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 8: Write the integration tests — create
tests/Lod.Integration.Tests/CareHubTypologyTests.cs(full file)
82 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32), EveryCombinationValidatesWithTheFewestAndTheMostWings (32, 2 and 4 wings), SemanticMergeKeepsEveryDepartmentBecauseNoTwoOfOneTypeTouch (all ten zones, each from itself), and PerimeterCoreGivesEachHubCornerItsOwnSmallPerimeterZone (six zones of 0.8 · 4.57 m² from HUB). No production code changes. Without the change of Task 4, the cases of Perimeter Core with Single Zone per Floor Type and with Single Zone Building at the 131.4° placement fail with FacadeNotCovered (note 6).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.CareHub;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 care hub (SYN-TYP-018, ADR-006): two to four wings of bedrooms off a corridor around a communal hub on the shared hub-and-arms layout,
/// with 0.8 m hub corners beside the 14.4 m wings, through every simplifier and aggregator, canonical and placed, with the fewest and the most wings.
/// </summary>
public sealed class CareHubTypologyTests
{
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 and the most wings (ADR-006, D-100).</summary>
private static readonly int[] Counts = { CareHubGenerator.MinimumWings, CareHubGenerator.MaximumWings };
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 EveryCombinationValidatesWithTheFewestAndTheMostWings(string simplifier, string aggregator, int wings)
{
AssertValidates(Plan(CareHubParameters.Default with { WingCount = wings }), simplifier, aggregator);
}
[Fact]
public void SemanticMergeKeepsEveryDepartmentBecauseNoTwoOfOneTypeTouch()
{
IGeneratedPlan plan = Plan();
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));
Assert.Equal(plan.Zones.Select(z => System.Math.Round(z.FloorArea, 6)), floor.Zones.Select(z => System.Math.Round(z.FloorArea, 6)));
}
[Fact]
public void PerimeterCoreGivesEachHubCornerItsOwnSmallPerimeterZone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan()).Value;
Zone[] corners = floor.Zones.Where(z => z.SourceZones.Count == 1 && z.SourceZones[0].Value == "HUB" && z.Id.Value != "CORE" && z.FloorArea < 5.0).ToArray();
Assert.Equal(6, corners.Length);
Assert.All(corners, z => Assert.Equal(0.8 * 4.57, z.FloorArea, 6));
}
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 CareHubPresets Presets() => new(ExampleNonResidentialPresets.CareBedroom, ExampleNonResidentialPresets.CareCommunal, ExampleResidentialPresets.Corridor);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(CareHubParameters? parameters = null) =>
new CareHubGenerator(Pipeline.Tolerances).Generate(parameters ?? CareHubParameters.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.CareHubTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 82, Skipped: 0, Total: 82, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 333 generators, and 1100 integration tests passed on net8.0; git status lists the four new files of this task and no *.received.txt.
- [x] Step 10: Commit
git add src/Lod.Generators/CareHub/CareHubGenerator.cs tests/Lod.Generators.Tests/CareHub/CareHubGeneratorTests.cs tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt tests/Lod.Integration.Tests/CareHubTypologyTests.cs
git commit -m "feature(generators): add the care hub serving elder-care bedroom wings"
Task 6: Public foyer branching to activity halls (SYN-TYP-019)¶
Files:
- Create: src/Lod.Generators/FoyerHalls/FoyerHallsGenerator.cs
- Test: create tests/Lod.Generators.Tests/FoyerHalls/FoyerHallsGeneratorTests.cs, tests/Lod.Integration.Tests/FoyerHallsTypologyTests.cs
- Snapshot: create tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt
Interfaces:
- Consumes: as Task 2, with SpaceType.Lobby, ActivityHall, and Service, and ExampleNonResidentialPresets.Lobby, ActivityHall, and Service (S8.4).
- Produces: public sealed record FoyerHallsParameters(int HallCount, double HallLength, double HallWidth, double SupportLength, double FoyerWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (2, 30, 20, 10, 20, 6, 0); public sealed record FoyerHallsPresets(ProgramPreset Lobby, ProgramPreset ActivityHall, ProgramPreset Service) : PlanPresets (slots Lobby, ActivityHall, Service); public sealed class FoyerHallsGenerator(ToleranceSettings), Name = FoyerHallsGenerator, public const int MinimumHalls = 2, MaximumHalls = 3: the foyer FO (Foyer, Lobby) is the hub; the arms in the order east, north, west, south are hall 1, the support wing SV (Support, Service, SupportLength long), hall 2, and hall 3 (H{i}, Hall i, ActivityHall, HallLength long), all HallWidth wide; zones are listed FO, H1…Hn, SV (ADR-006's section of the family; D-102); errors InvalidParameter for a hall count outside 2–3 (HallCount must be at least 2 (the foyer serves several distinct activity spaces), got 1., HallCount must be at most 3 (the support wing takes one of the foyer's four sides, D-100), got 4.), for each length that is not positive and finite, and, only when those pass, for halls wider than the foyer; provenance keys HallCount, HallLength, HallWidth, SupportLength, FoyerWidth. By default a T of 1800 m²: the foyer 400 m², two halls of 600 m², the support wing 200 m²; 10 windows with 276 m² of glazing, the foyer's one window on its south entrance façade; with three halls as wide as the foyer the foyer has no outdoor wall.
- [x] Step 1: Write the failing tests — create
tests/Lod.Generators.Tests/FoyerHalls/FoyerHallsGeneratorTests.cs(full file)
27 tests: the ADR-006 defaults; the canonical plan (zone IDs, names, space types, areas); a third hall south of the foyer and listed before the support wing; tiling for four sizes and counts; every hall and the support wing adjoining the foyer (2 and 3 halls); the foyer's south entrance façade with two halls and no outdoor wall with three; halls narrower than the foyer leaving its corners outdoor; the window rule; two hall counts outside 2–3; five invalid lengths; a hall wider than the foyer; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
using System;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.FoyerHalls;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.FoyerHalls;
public sealed class FoyerHallsGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly FoyerHallsPresets Examples = new(ExampleNonResidentialPresets.Lobby, ExampleNonResidentialPresets.ActivityHall, ExampleNonResidentialPresets.Service);
private readonly FoyerHallsGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new FoyerHallsParameters(HallCount: 2, HallLength: 30.0, HallWidth: 20.0, SupportLength: 10.0, FoyerWidth: 20.0, FloorHeight: 6.0, OrientationDegrees: 0.0),
FoyerHallsParameters.Default);
}
[Fact]
public void CanonicalPlanIsAFoyerWithTwoHallsAndASupportWing()
{
IGeneratedPlan plan = Generate(FoyerHallsParameters.Default);
Assert.Equal(new[] { "FO", "H1", "H2", "SV" }, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { "Foyer", "Hall 1", "Hall 2", "Support" }, plan.Zones.Select(z => z.Name));
Assert.Equal(new[] { SpaceType.Lobby, SpaceType.ActivityHall, SpaceType.ActivityHall, SpaceType.Service }, plan.Zones.Select(z => z.SpaceType));
Assert.Equal(1800.0, plan.Footprint.Area, 9);
Assert.Equal(new[] { 400.0, 600.0, 600.0, 200.0 }, plan.Zones.Select(z => z.FloorArea));
Assert.Equal(Rectangle(30, 0, 50, 20), Outer(plan, "FO"));
Assert.Equal(Rectangle(50, 0, 80, 20), Outer(plan, "H1"));
Assert.Equal(Rectangle(0, 0, 30, 20), Outer(plan, "H2"));
Assert.Equal(Rectangle(30, 20, 50, 30), Outer(plan, "SV"));
Assert.Equal(
new[] { new Point2(0, 0), new Point2(80, 0), new Point2(80, 20), new Point2(50, 20), new Point2(50, 30), new Point2(30, 30), new Point2(30, 20), new Point2(0, 20) },
plan.Footprint.Outer);
Assert.All(plan.Zones, z => Assert.Equal(6.0, z.Parts[0].Height));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Fact]
public void AThirdHallLeavesTheFoyerSouthAndIsListedBeforeTheSupportWing()
{
IGeneratedPlan plan = Generate(FoyerHallsParameters.Default with { HallCount = 3 });
Assert.Equal(new[] { "FO", "H1", "H2", "H3", "SV" }, plan.Zones.Select(z => z.Id.Value));
Assert.Equal(Rectangle(30, 30, 50, 50), Outer(plan, "FO"));
Assert.Equal(Rectangle(50, 30, 80, 50), Outer(plan, "H1"));
Assert.Equal(Rectangle(0, 30, 30, 50), Outer(plan, "H2"));
Assert.Equal(Rectangle(30, 0, 50, 30), Outer(plan, "H3"));
Assert.Equal(Rectangle(30, 50, 50, 60), Outer(plan, "SV"));
Assert.Equal(2400.0, plan.Footprint.Area, 9);
}
[Theory]
[InlineData(2, 30.0, 20.0, 10.0, 20.0)]
[InlineData(3, 30.0, 20.0, 10.0, 20.0)]
[InlineData(2, 24.0, 15.0, 8.0, 18.0)]
[InlineData(3, 40.0, 12.5, 6.0, 16.0)]
public void ZonesTileTheFootprint(int halls, double length, double width, double support, double foyer)
{
IGeneratedPlan plan = Generate(new FoyerHallsParameters(halls, length, width, support, foyer, 6.0, 0.0));
PlanAssertions.TilesTheFootprint(plan);
Assert.Equal((foyer * foyer) + (halls * length * width) + (support * width), plan.Footprint.Area, 9);
Assert.Equal(halls + 2, plan.Zones.Count);
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void EveryHallAndTheSupportWingAdjoinTheFoyer(int halls)
{
IGeneratedPlan plan = Generate(FoyerHallsParameters.Default with { HallCount = halls });
string[] arms = plan.Zones.Skip(1).Select(z => z.Id.Value).ToArray();
Assert.Equal(arms.OrderBy(id => id, StringComparer.Ordinal), PlanAssertions.Neighbours(plan, "FO"));
foreach (string arm in arms)
{
Assert.Equal(new[] { "FO" }, PlanAssertions.Neighbours(plan, arm));
Assert.Equal(20.0, plan.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == arm && w.AdjacentZone?.Value == "FO").Sum(w => w.Length), 9);
}
}
[Fact]
public void WithTwoHallsTheFoyersSouthSideIsItsEntranceFacade()
{
IGeneratedPlan plan = Generate(FoyerHallsParameters.Default);
WallSurface entrance = Assert.Single(plan.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "FO" && w.Boundary == BoundaryCondition.Outdoors);
Assert.Equal(OrientationBin.South, Orientation.Bin(entrance.PlanAzimuth));
Assert.Equal(20.0, entrance.Length, 9);
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "SV"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "H1"));
}
[Fact]
public void WithThreeHallsAsWideAsTheFoyerTheFoyerHasNoOutdoorWall()
{
Result<IGeneratedPlan> result = _generator.Generate(FoyerHallsParameters.Default with { HallCount = 3 }, Examples);
Assert.Empty(result.Diagnostics);
Assert.Empty(PlanAssertions.OutdoorFacings(result.Value, "FO"));
}
[Fact]
public void HallsNarrowerThanTheFoyerLeaveItsCornersOutdoor()
{
Result<IGeneratedPlan> result = _generator.Generate(FoyerHallsParameters.Default with { HallCount = 3, FoyerWidth = 24.0 }, Examples);
Assert.Empty(result.Diagnostics);
WallSurface[] corners = result.Value.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "FO" && w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(8, corners.Length);
Assert.All(corners, w => Assert.Equal(2.0, w.Length, 9));
Assert.All(corners, w => Assert.Single(w.Windows));
PlanAssertions.TilesTheFootprint(result.Value);
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(FoyerHallsParameters.Default, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
Assert.Equal(10, result.Value.Surfaces.OfType<WallSurface>().Count(w => w.Windows.Count == 1));
double halls = 2 * ((2 * 30.0) + 20.0) * 6.0;
double support = ((2 * 10.0) + 20.0) * 6.0;
Assert.Equal((0.5 * 20.0 * 6.0) + (0.2 * halls) + (0.1 * support), result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 9);
}
[Theory]
[InlineData(1, "HallCount must be at least 2 (the foyer serves several distinct activity spaces), got 1.")]
[InlineData(4, "HallCount must be at most 3 (the support wing takes one of the foyer's four sides, D-100), got 4.")]
public void HallCountsOutsideTwoToThreeAreErrors(int halls, string message)
{
Result<IGeneratedPlan> result = _generator.Generate(FoyerHallsParameters.Default with { HallCount = halls }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(message, error.Message);
}
[Theory]
[InlineData(0.0, 20.0, 10.0, 20.0, "HallLength")]
[InlineData(30.0, -20.0, 10.0, 20.0, "HallWidth")]
[InlineData(30.0, 20.0, double.NaN, 20.0, "SupportLength")]
[InlineData(30.0, 20.0, 10.0, 0.0, "FoyerWidth")]
[InlineData(double.PositiveInfinity, 20.0, 10.0, 20.0, "HallLength")]
public void InvalidParametersAreErrors(double length, double width, double support, double foyer, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new FoyerHallsParameters(2, length, width, support, foyer, 6.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 AHallWiderThanTheFoyerIsAnError()
{
Result<IGeneratedPlan> result = _generator.Generate(FoyerHallsParameters.Default with { HallWidth = 25.0 }, Examples);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.Equal(
"HallWidth 25 m is wider than FoyerWidth 20 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.",
error.Message);
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new FoyerHallsParameters(4, 0.0, -20.0, 0.0, 0.0, 0.0, double.NaN), Examples);
Assert.Equal(7, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(
new[] { ("Lobby", SpaceType.Lobby), ("ActivityHall", SpaceType.ActivityHall), ("Service", SpaceType.Service) },
Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.Lobby, Examples.ActivityHall, Examples.Service }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(
FoyerHallsParameters.Default,
new FoyerHallsPresets(ExampleNonResidentialPresets.Service, ExampleNonResidentialPresets.ActivityHall, ExampleNonResidentialPresets.Lobby));
Assert.Equal(new[] { "Lobby", "Service" }, result.Diagnostics.Where(d => d.Code == DiagnosticCodes.PresetSpaceTypeMismatch).Select(d => d.Subject));
}
[Fact]
public void GenerationIsDeterministic()
{
Assert.Equal(TextReport.Describe(Generate(FoyerHallsParameters.Default)), TextReport.Describe(Generate(FoyerHallsParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(FoyerHallsParameters.Default).Provenance;
Assert.Equal(nameof(FoyerHallsGenerator), provenance.Operation);
Assert.Equal(
new[]
{
"FloorHeight=6", "FoyerWidth=20", "HallCount=2", "HallLength=30", "HallWidth=20", "OrientationDegrees=0",
"Presets=Lobby:Example Lobby;ActivityHall:Example Activity Hall;Service:Example Service", "SupportLength=10",
},
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(FoyerHallsParameters.Default)), "foyer-halls-canonical");
}
private static Point2[] Rectangle(double minX, double minY, double maxX, double maxY) => Polygon2.Rectangle(minX, minY, maxX, maxY).Outer.ToArray();
private static Point2[] Outer(IGeneratedPlan plan, string zone) => plan.Zones.Single(z => z.Id.Value == zone).Parts[0].Footprint.Outer.ToArray();
private IGeneratedPlan Generate(FoyerHallsParameters parameters) => _generator.Generate(parameters, Examples).Value;
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.FoyerHalls.FoyerHallsGeneratorTests"
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\FoyerHalls\FoyerHallsGeneratorTests.cs(7,22): error CS0234: The type or namespace name 'FoyerHalls' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\FoyerHalls\FoyerHallsGeneratorTests.cs(238,37): error CS0246: The type or namespace name 'FoyerHallsParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\FoyerHalls\FoyerHallsGeneratorTests.cs(16,28): error CS0246: The type or namespace name 'FoyerHallsPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\FoyerHalls\FoyerHallsGeneratorTests.cs(18,22): error CS0246: The type or namespace name 'FoyerHallsGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — create
src/Lod.Generators/FoyerHalls/FoyerHallsGenerator.cs(full file)
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.HubArms;
namespace Lod.Generators.FoyerHalls;
/// <summary>Parameters of the public foyer branching to distinct activity spaces (SYN-TYP-019, ADR-006).</summary>
/// <param name="HallCount">Number of activity halls around the foyer, 2–3.</param>
/// <param name="HallLength">Length of each hall from the foyer to its end, m.</param>
/// <param name="HallWidth">Width of each hall and of the support wing along the foyer's side, m; at most <paramref name="FoyerWidth"/>.</param>
/// <param name="SupportLength">Length of the support wing from the foyer to its end, m.</param>
/// <param name="FoyerWidth">Side of the square foyer, 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 FoyerHallsParameters(
int HallCount,
double HallLength,
double HallWidth,
double SupportLength,
double FoyerWidth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: 2 halls of 30 m × 20 m, a 10 m support wing, a 20 m foyer, 6 m storeys, orientation 0°.</summary>
public static FoyerHallsParameters Default { get; } = new(2, 30.0, 20.0, 10.0, 20.0, 6.0, 0.0);
}
/// <summary>The program presets of the foyer with halls, one per space type it places (D-061).</summary>
/// <param name="Lobby">Preset of the foyer; space type <see cref="SpaceType.Lobby"/>.</param>
/// <param name="ActivityHall">Preset of the halls; space type <see cref="SpaceType.ActivityHall"/>.</param>
/// <param name="Service">Preset of the support wing; space type <see cref="SpaceType.Service"/>.</param>
public sealed record FoyerHallsPresets(ProgramPreset Lobby, ProgramPreset ActivityHall, ProgramPreset Service) : PlanPresets
{
/// <inheritdoc />
public override IReadOnlyList<PresetSlot> Slots => new[]
{
new PresetSlot(nameof(Lobby), SpaceType.Lobby, Lobby),
new PresetSlot(nameof(ActivityHall), SpaceType.ActivityHall, ActivityHall),
new PresetSlot(nameof(Service), SpaceType.Service, Service),
};
}
/// <summary>
/// Public foyer branching to distinct activity spaces (SYN-TYP-019, ADR-006), on the shared <see cref="HubArmsLayout"/>: the foyer is the hub, and
/// the activity halls and the support wing are its arms, so the distinct activity spaces and the support rooms lie around the common foyer (R001)
/// and access branches from it: every hall and the support wing adjoins it (R002). Zones are departments (D-097, ADR-014): each hall, and the support
/// rooms together.
/// </summary>
/// <remarks>
/// Arms in the order east, north, west, south: hall 1 east, the support wing north, hall 2 west, and hall 3 south when there are three; with two
/// halls the foyer's south side is its entrance façade, and with three halls as wide as the foyer the foyer has no outdoor wall. Zone IDs: <c>FO</c>,
/// then <c>H1…Hn</c>, then <c>SV</c>; the halls are listed before the support wing, which ADR-006 records as this family's exception to the arm order.
/// </remarks>
public sealed class FoyerHallsGenerator : PlanGenerator<FoyerHallsParameters, FoyerHallsPresets>
{
/// <summary>The fewest halls: the foyer serves several distinct activity spaces.</summary>
public const int MinimumHalls = 2;
/// <summary>The most halls: the support wing takes one of the foyer's four sides (D-100).</summary>
public const int MaximumHalls = HubArmsLayout.MaximumArms - 1;
/// <summary>The arm of the support wing: the second in the arm order, plan north.</summary>
private const int SupportArm = (int)HubSide.North;
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public FoyerHallsGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(FoyerHallsGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(FoyerHallsParameters parameters)
{
var errors = HubArmsLayout.CountErrors(
nameof(FoyerHallsParameters.HallCount),
parameters.HallCount,
MinimumHalls,
MaximumHalls,
"the foyer serves several distinct activity spaces",
"the support wing takes one of the foyer's four sides").ToList();
var values = new (string Name, double Value)[]
{
(nameof(FoyerHallsParameters.HallLength), parameters.HallLength),
(nameof(FoyerHallsParameters.HallWidth), parameters.HallWidth),
(nameof(FoyerHallsParameters.SupportLength), parameters.SupportLength),
(nameof(FoyerHallsParameters.FoyerWidth), parameters.FoyerWidth),
};
var lengths = values
.Where(v => !IsPositive(v.Value))
.Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
.ToList();
if (lengths.Count == 0)
{
lengths.AddRange(HubArmsLayout.WidthErrors(nameof(FoyerHallsParameters.HallWidth), parameters.HallWidth, nameof(FoyerHallsParameters.FoyerWidth), parameters.FoyerWidth));
}
errors.AddRange(lengths);
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(FoyerHallsParameters p)
{
double[] lengths = Enumerable.Range(0, p.HallCount + 1).Select(i => i == SupportArm ? p.SupportLength : p.HallLength).ToArray();
var layout = new HubArmsLayout(p.FoyerWidth, p.HallWidth, lengths);
var zones = new List<PlannedZone> { new(new ZoneId("FO"), "Foyer", SpaceType.Lobby, layout.Hub) };
HubArm[] halls = layout.Arms.Where(a => (int)a.Side != SupportArm).ToArray();
for (int i = 0; i < halls.Length; i++)
{
zones.Add(new PlannedZone(new ZoneId($"H{i + 1}"), $"Hall {i + 1}", SpaceType.ActivityHall, halls[i].Footprint));
}
zones.Add(new PlannedZone(new ZoneId("SV"), "Support", SpaceType.Service, layout.Arms[SupportArm].Footprint));
return new PlanLayout(layout.Footprint, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(FoyerHallsParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.HallCount), p.HallCount.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string>(nameof(p.HallLength), Provenance.Format(p.HallLength)),
new KeyValuePair<string, string>(nameof(p.HallWidth), Provenance.Format(p.HallWidth)),
new KeyValuePair<string, string>(nameof(p.SupportLength), Provenance.Format(p.SupportLength)),
new KeyValuePair<string, string>(nameof(p.FoyerWidth), Provenance.Format(p.FoyerWidth)),
};
}
- [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.FoyerHalls.FoyerHallsGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 26, Skipped: 0, Total: 27, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.FoyerHalls.FoyerHallsGeneratorTests.CanonicalPlanSnapshot
Snapshot 'foyer-halls-canonical' is missing or differs. Review Snapshots/foyer-halls-canonical.received.txt and rename it to foyer-halls-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.received.txt (git-ignored).
- [x] Step 5: Review and accept the received file
Review the received file against the content below; it must be identical: 46 lines, 4 zones, 24 surfaces (10 outdoor walls, each with one window; 6 interzone walls; 8 floors and ceilings), glazing 132 m² south, 84 m² north, 30 m² east, 30 m² west (276 m² in all). Accept it:
Move-Item -Force tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.received.txt tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt
tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt:
footprint area=1800.000000 floorHeight=6.000000 orientation=0.000000
zone FO Lobby "Foyer" area=400.000000 volume=2400.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2444.500000
load Lighting PerFloorArea value=10.000000 annual=5267.000000
load Infiltration AirChangesPerHour value=0.600000 annual=8760.000000
setpoints heatingMean=20.000000 coolingMean=24.000000
zone H1 ActivityHall "Hall 1" area=600.000000 volume=3600.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2444.500000
load Lighting PerFloorArea value=12.000000 annual=5267.000000
load ElectricEquipment PerFloorArea value=2.000000 annual=5267.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=18.000000 coolingMean=25.000000
zone H2 ActivityHall "Hall 2" area=600.000000 volume=3600.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.100000 annual=2444.500000
load Lighting PerFloorArea value=12.000000 annual=5267.000000
load ElectricEquipment PerFloorArea value=2.000000 annual=5267.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=18.000000 coolingMean=25.000000
zone SV Service "Support" area=200.000000 volume=1200.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=6.000000 annual=5267.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=18.000000 coolingMean=26.000000
surface FO/W1 Outdoors area=120.000000 wall (30.000000,0.000000)-(50.000000,0.000000) z=0.000000 facing=South glazing=60.000000 window(offset=2.928932 sill=0.878680 14.142136x4.242641)
surface FO/W2 Interzone adjacent=H1 area=120.000000 wall (50.000000,0.000000)-(50.000000,20.000000) z=0.000000 facing=East glazing=0.000000
surface FO/W3 Interzone adjacent=SV area=120.000000 wall (50.000000,20.000000)-(30.000000,20.000000) z=0.000000 facing=North glazing=0.000000
surface FO/W4 Interzone adjacent=H2 area=120.000000 wall (30.000000,20.000000)-(30.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface FO/F Unresolved area=400.000000 Floor z=0.000000
surface FO/C Unresolved area=400.000000 Ceiling z=6.000000
surface H1/W1 Outdoors area=180.000000 wall (50.000000,0.000000)-(80.000000,0.000000) z=0.000000 facing=South glazing=36.000000 window(offset=8.291796 sill=1.658359 13.416408x2.683282)
surface H1/W2 Outdoors area=120.000000 wall (80.000000,0.000000)-(80.000000,20.000000) z=0.000000 facing=East glazing=24.000000 window(offset=5.527864 sill=1.658359 8.944272x2.683282)
surface H1/W3 Outdoors area=180.000000 wall (80.000000,20.000000)-(50.000000,20.000000) z=0.000000 facing=North glazing=36.000000 window(offset=8.291796 sill=1.658359 13.416408x2.683282)
surface H1/W4 Interzone adjacent=FO area=120.000000 wall (50.000000,20.000000)-(50.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface H1/F Unresolved area=600.000000 Floor z=0.000000
surface H1/C Unresolved area=600.000000 Ceiling z=6.000000
surface H2/W1 Outdoors area=180.000000 wall (0.000000,0.000000)-(30.000000,0.000000) z=0.000000 facing=South glazing=36.000000 window(offset=8.291796 sill=1.658359 13.416408x2.683282)
surface H2/W2 Interzone adjacent=FO area=120.000000 wall (30.000000,0.000000)-(30.000000,20.000000) z=0.000000 facing=East glazing=0.000000
surface H2/W3 Outdoors area=180.000000 wall (30.000000,20.000000)-(0.000000,20.000000) z=0.000000 facing=North glazing=36.000000 window(offset=8.291796 sill=1.658359 13.416408x2.683282)
surface H2/W4 Outdoors area=120.000000 wall (0.000000,20.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=24.000000 window(offset=5.527864 sill=1.658359 8.944272x2.683282)
surface H2/F Unresolved area=600.000000 Floor z=0.000000
surface H2/C Unresolved area=600.000000 Ceiling z=6.000000
surface SV/W1 Interzone adjacent=FO area=120.000000 wall (30.000000,20.000000)-(50.000000,20.000000) z=0.000000 facing=South glazing=0.000000
surface SV/W2 Outdoors area=60.000000 wall (50.000000,20.000000)-(50.000000,30.000000) z=0.000000 facing=East glazing=6.000000 window(offset=3.418861 sill=2.051317 3.162278x1.897367)
surface SV/W3 Outdoors area=120.000000 wall (50.000000,30.000000)-(30.000000,30.000000) z=0.000000 facing=North glazing=12.000000 window(offset=6.837722 sill=2.051317 6.324555x1.897367)
surface SV/W4 Outdoors area=60.000000 wall (30.000000,30.000000)-(30.000000,20.000000) z=0.000000 facing=West glazing=6.000000 window(offset=3.418861 sill=2.051317 3.162278x1.897367)
surface SV/F Unresolved area=200.000000 Floor z=0.000000
surface SV/C Unresolved area=200.000000 Ceiling z=6.000000
- [x] Step 6: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.FoyerHalls.FoyerHallsGeneratorTests"
Expected: Passed! - Failed: 0, Passed: 27, Skipped: 0, Total: 27, Duration: … - Lod.Generators.Tests.dll (net8.0).
- [x] Step 7: Write the integration tests — create
tests/Lod.Integration.Tests/FoyerHallsTypologyTests.cs(full file)
82 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32), EveryCombinationValidatesWithTheFewestAndTheMostHalls (32, 2 and 3 halls), and SemanticMergeKeepsEveryHallApart (2 and 3 halls: every zone from itself). No production code changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.FoyerHalls;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 foyer with halls (SYN-TYP-019, ADR-006): two or three activity halls and a support wing around a foyer on the shared hub-and-arms
/// layout, through every simplifier and aggregator, canonical and placed, with the fewest and the most halls.
/// </summary>
public sealed class FoyerHallsTypologyTests
{
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 and the most halls (ADR-006, D-100).</summary>
private static readonly int[] Counts = { FoyerHallsGenerator.MinimumHalls, FoyerHallsGenerator.MaximumHalls };
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 EveryCombinationValidatesWithTheFewestAndTheMostHalls(string simplifier, string aggregator, int halls)
{
AssertValidates(Plan(FoyerHallsParameters.Default with { HallCount = halls }), simplifier, aggregator);
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void SemanticMergeKeepsEveryHallApart(int halls)
{
IGeneratedPlan plan = Plan(FoyerHallsParameters.Default with { HallCount = halls });
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));
}
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 FoyerHallsPresets Presets() => new(ExampleNonResidentialPresets.Lobby, ExampleNonResidentialPresets.ActivityHall, ExampleNonResidentialPresets.Service);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(FoyerHallsParameters? parameters = null) =>
new FoyerHallsGenerator(Pipeline.Tolerances).Generate(parameters ?? FoyerHallsParameters.Default, Presets()).Value;
}
- [x] Step 8: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.FoyerHallsTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 82, Skipped: 0, Total: 82, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 360 generators, and 1182 integration tests passed on net8.0; git status lists the four new files of this task and no *.received.txt.
- [x] Step 9: Commit
git add src/Lod.Generators/FoyerHalls/FoyerHallsGenerator.cs tests/Lod.Generators.Tests/FoyerHalls/FoyerHallsGeneratorTests.cs tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt tests/Lod.Integration.Tests/FoyerHallsTypologyTests.cs
git commit -m "feature(generators): add the public foyer branching to activity halls"
Task 7: The Perimeter Core precondition on narrow lobes and wings¶
Files:
- Modify: tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs (Task 2), tests/Lod.Integration.Tests/CareHubTypologyTests.cs (Task 5)
Interfaces:
- Consumes: PerimeterCore(ToleranceSettings, double), DiagnosticCodes.PlanTooNarrow (D-087, ADR-008); the generators of Tasks 2 and 5.
- Produces: RadialLobesTypologyTests.LobesNotWiderThanTwiceThePerimeterDepthAreTooNarrowForPerimeterCore (9 m lobes) and CareHubTypologyTests.WingsNotWiderThanTwiceThePerimeterDepthAreTooNarrowForPerimeterCore (3.5 m bedrooms either side of a 2 m corridor, 9 m wings): with the 4.57 m depth, Perimeter Core fails with the single error PlanTooNarrow. These tests check existing behaviour, so they pass at once (note 5); the generators do not impose the precondition, as the enclosed court does not (S8.3).
- [x] Step 1: Replace
tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs(full file)
One test is added after PerimeterCoreLeavesACrossShapedCore; nothing else changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.RadialLobes;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 radial lobes (SYN-TYP-010, ADR-006): a cross or a T of dwelling lobes around a point-access core on the shared hub-and-arms layout,
/// through every simplifier and aggregator, canonical and placed, with the fewest and the most lobes.
/// </summary>
public sealed class RadialLobesTypologyTests
{
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 and the most lobes (ADR-006, D-100).</summary>
private static readonly int[] Counts = { RadialLobesGenerator.MinimumLobes, RadialLobesGenerator.MaximumLobes };
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 EveryCombinationValidatesWithTheFewestAndTheMostLobes(string simplifier, string aggregator, int lobes)
{
AssertValidates(Plan(RadialLobesParameters.Default with { LobeCount = lobes }), simplifier, aggregator);
}
[Fact]
public void SemanticMergeUnitesTheDwellingsOfEachLobeAndKeepsTheCore()
{
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan()).Value;
Assert.Equal(new[] { SpaceType.Stair, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit, SpaceType.DwellingUnit }, floor.Zones.Select(z => z.SpaceType));
Assert.Equal(new[] { "ST" }, floor.Zones[0].SourceZones.Select(z => z.Value));
for (int lobe = 1; lobe <= 4; lobe++)
{
Zone dwellings = floor.Zones[lobe];
Assert.Equal(new[] { $"L{lobe}-U1", $"L{lobe}-U2" }, dwellings.SourceZones.Select(z => z.Value));
Assert.Equal(120.0, dwellings.FloorArea, 6);
Assert.Empty(dwellings.Parts[0].Footprint.Holes);
}
}
[Fact]
public void PerimeterCoreLeavesACrossShapedCore()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan()).Value;
Assert.Equal(
"P-North-1,P-North-2,P-North-3,P-East-1,P-East-2,P-East-3,P-South-1,P-South-2,P-South-3,P-West-1,P-West-2,P-West-3,CORE",
string.Join(",", floor.Zones.Select(z => z.Id.Value)));
Zone core = floor.Zones.Single(z => z.Id.Value == "CORE");
double arm = 12.0 - (2 * 4.57);
Assert.Equal((2 * (32.0 - (2 * 4.57)) * arm) - (arm * arm), core.FloorArea, 6);
Assert.Equal(9, core.SourceZones.Count);
}
[Fact]
public void LobesNotWiderThanTwiceThePerimeterDepthAreTooNarrowForPerimeterCore()
{
// 9 m lobes are narrower than 2 × 4.57 m (D-087, ADR-008); the generator does not impose the simplifier's precondition.
Result<IFloor> floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan(RadialLobesParameters.Default with { LobeWidth = 9.0 }));
Assert.Equal(DiagnosticCodes.PlanTooNarrow, Assert.Single(floor.Diagnostics).Code);
}
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 RadialLobesPresets 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(RadialLobesParameters? parameters = null) =>
new RadialLobesGenerator(Pipeline.Tolerances).Generate(parameters ?? RadialLobesParameters.Default, Presets()).Value;
}
- [x] Step 2: Replace
tests/Lod.Integration.Tests/CareHubTypologyTests.cs(full file)
One test is added after PerimeterCoreGivesEachHubCornerItsOwnSmallPerimeterZone; nothing else changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.CareHub;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// The S8.5 care hub (SYN-TYP-018, ADR-006): two to four wings of bedrooms off a corridor around a communal hub on the shared hub-and-arms layout,
/// with 0.8 m hub corners beside the 14.4 m wings, through every simplifier and aggregator, canonical and placed, with the fewest and the most wings.
/// </summary>
public sealed class CareHubTypologyTests
{
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 and the most wings (ADR-006, D-100).</summary>
private static readonly int[] Counts = { CareHubGenerator.MinimumWings, CareHubGenerator.MaximumWings };
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 EveryCombinationValidatesWithTheFewestAndTheMostWings(string simplifier, string aggregator, int wings)
{
AssertValidates(Plan(CareHubParameters.Default with { WingCount = wings }), simplifier, aggregator);
}
[Fact]
public void SemanticMergeKeepsEveryDepartmentBecauseNoTwoOfOneTypeTouch()
{
IGeneratedPlan plan = Plan();
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));
Assert.Equal(plan.Zones.Select(z => System.Math.Round(z.FloorArea, 6)), floor.Zones.Select(z => System.Math.Round(z.FloorArea, 6)));
}
[Fact]
public void PerimeterCoreGivesEachHubCornerItsOwnSmallPerimeterZone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan()).Value;
Zone[] corners = floor.Zones.Where(z => z.SourceZones.Count == 1 && z.SourceZones[0].Value == "HUB" && z.Id.Value != "CORE" && z.FloorArea < 5.0).ToArray();
Assert.Equal(6, corners.Length);
Assert.All(corners, z => Assert.Equal(0.8 * 4.57, z.FloorArea, 6));
}
[Fact]
public void WingsNotWiderThanTwiceThePerimeterDepthAreTooNarrowForPerimeterCore()
{
// 3.5 m bedrooms either side of a 2 m corridor make 9 m wings, narrower than 2 × 4.57 m (D-087, ADR-008).
Result<IFloor> floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan(CareHubParameters.Default with { BedroomDepth = 3.5, CorridorWidth = 2.0 }));
Assert.Equal(DiagnosticCodes.PlanTooNarrow, Assert.Single(floor.Diagnostics).Code);
}
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 CareHubPresets Presets() => new(ExampleNonResidentialPresets.CareBedroom, ExampleNonResidentialPresets.CareCommunal, ExampleResidentialPresets.Corridor);
/// <summary>The canonical plan: ADR-006 defaults and the example presets, or other parameters.</summary>
private static IGeneratedPlan Plan(CareHubParameters? parameters = null) =>
new CareHubGenerator(Pipeline.Tolerances).Generate(parameters ?? CareHubParameters.Default, Presets()).Value;
}
- [x] Step 3: Run the tests, build, and test
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RadialLobesTypologyTests|FullyQualifiedName~Lod.Integration.Tests.CareHubTypologyTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short
Expected: Passed! - Failed: 0, Passed: 166, Skipped: 0, Total: 166, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 193 core, 360 generators, and 1184 integration tests passed on net8.0; git status --short lists M tests/Lod.Integration.Tests/CareHubTypologyTests.cs and M tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs.
- [x] Step 4: Commit
git add tests/Lod.Integration.Tests/RadialLobesTypologyTests.cs tests/Lod.Integration.Tests/CareHubTypologyTests.cs
git commit -m "test(simplifiers): check the perimeter core precondition on narrow lobes and wings"
- [x] Step 5: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/care-hub-foyer-halls
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/care-hub-foyer-halls
git branch -d feature/care-hub-foyer-halls
git diff --name-status v0.8.3 main -- "tests/*/Snapshots/*"
Expected: VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0; the last command lists only
A tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt
A tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt
A tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt
A tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt
the eighteen existing snapshots are byte-identical. Do not push (D-098).
Slice D — feature/hub-arms-components¶
Task 8: Radial Lobes, Branching Mall, Care Hub, and Foyer Halls components, with icons¶
Files:
- Modify: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file, last changed in S8.4), scripts/make_icons.py (S4.1 file, last changed in S8.4)
- Create (generated): src/Lod.Grasshopper/Icons/RadialLobesComponent.png, BranchingMallComponent.png, CareHubComponent.png, FoyerHallsComponent.png
Interfaces:
- Consumes: RadialLobesGenerator, BranchingMallGenerator, CareHubGenerator, FoyerHallsGenerator, and their records (Tasks 2, 3, 5, and 6); the S8.1 base TypologyGeneratorComponent (panel 2 Generate, one Plan output, AddPreset, TryNumbers, Preset, Output, FloorHeightDescription, OrientationDescription); ComponentSupport.PreviewLocationParameter() (S4.1); IconLibrary.For.
- Produces: four components after Cafeteria 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); Corridor Width is CoW, because Co is the nickname of the Corridor preset input:
| Component (nickname) | Inputs → output | Class | GUID |
|---|---|---|---|
| Radial Lobes (RLobes) | Lobe Count (LC, 4), Lobe Length (LL, 10), Lobe Width (LW, 12), Core Width (CW, 12), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Stair (St), Preview Location (PL) → Plan | RadialLobesComponent |
a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80 |
| Branching Mall (BMall) | Branch Count (BC, 3), Branch Length (BL, 40), Mall Width (MW, 8), Shop Depth (SD, 12), Anchor Depth (AD, 30), Floor Height (FH, 5), Orientation (O, 0), Mall (Ma), Retail (Re), Preview Location (PL) → Plan | BranchingMallComponent |
d886b33f-7b51-41f9-b160-a550e6aadd2b |
| Care Hub (CHub) | Wing Count (WC, 3), Wing Length (WL, 30), Bedroom Depth (BD, 6), Corridor Width (CoW, 2.4), Hub Width (HW, 16), Floor Height (FH, 3), Orientation (O, 0), Care Bedroom (CB), Care Communal (CC), Corridor (Co), Preview Location (PL) → Plan | CareHubComponent |
7d3d1002-c695-47df-afd9-48f50c5b3e90 |
| Foyer Halls (FHalls) | Hall Count (HC, 2), Hall Length (HL, 30), Hall Width (HW, 20), Support Length (SL, 10), Foyer Width (FW, 20), Floor Height (FH, 6), Orientation (O, 0), Lobby (Lo), Activity Hall (AH), Service (Sv), Preview Location (PL) → Plan | FoyerHallsComponent |
6638efe7-c0e8-45b6-89b1-1e9e3f725c15 |
The motifs radial_lobes (a cross of lobes around a dark core), branching_mall (a T of shop wings along a dark branching mall), care_hub (a hub with a table and three narrower wings with dark corridors), and foyer_halls (a white foyer between two halls, a dark support wing above, an entrance arrow below), in the Generate green, mapped after CafeteriaComponent. 50 components, 6 parameters, and 57 icons. The ten earlier typology components and their base class are unchanged.
- [x] Step 1: Create the branch
git switch -c feature/hub-arms-components main
- [x] Step 2: Replace
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs(full file)
Four usings and the four 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.EnclosedCourt;
using Lod.Generators.FoyerHalls;
using Lod.Generators.GalleryBar;
using Lod.Generators.OfficePlate;
using Lod.Generators.OpenCourt;
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)));
}
}
- [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: ['BranchingMallComponent', 'CareHubComponent', 'FoyerHallsComponent', 'RadialLobesComponent']; 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 four motifs are added after small_building, and their entries after Cafeteria 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)
# --- 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)),
"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 57 icons to src\Lod.Grasshopper\Icons; git status --short shows M scripts/make_icons.py, the file of Step 2, and the four 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
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
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: 57 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: Branching Mall is the fourth icon of the first row, after BEMGen Info (a green T of shop wings along a dark mall that branches from the court, with anchors at the branch ends); Care Hub the third icon of the second row, after Care Communal Preset (a green hub with a dark table and three narrower green wings with dark corridor lines); Foyer Halls the second icon of the fourth row, after Floor (a white foyer between two green halls, a dark support wing above, and a dark entrance arrow below); and Radial Lobes the fourth icon of the seventh row, after Program Preset (a green cross of lobes, each split along its axis, around a dark core). Every icon after each of them moves one place on, the 53 icons of S8.4 keep their motifs, and the 57 icons fill nine rows of six and three 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); 57 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 radial lobes, branching mall, care hub, and foyer halls components"
Expected before git add: the two modified files and the four new PNGs; nothing under bin/.
- [x] Step 9: Merge Slice D (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/hub-arms-components
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/hub-arms-components
git branch -d feature/hub-arms-components
Expected: VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0. Do not push (D-098).
Slice E — docs/architecture-hub-arms¶
Task 9: Architecture documents, ADR-006 notes, and the S8.5 smoke-test checklist¶
Files:
- Modify: docs/architecture/pipeline.md, docs/decisions/ADR-006-plan-generation-mechanism.md, docs/research/program-presets.md (last changed in S8.4), docs/research/precedents/syn-typologies.md (S8.4), docs/development/grasshopper-smoke-test.md (append)
Interfaces:
- Consumes: Tasks 1–8 (the layout, the generators, the Perimeter Core change, the components, their GUIDs and inputs).
- Produces: pipeline.md after S8.5: the hub-and-arms layout and the four families in step 2, the Perimeter Core sentence in step 3, "fifteen plan generators", and the heading ## Grasshopper components through S8.5 (anchor #grasshopper-components-through-s85) with the four new rows, which the overview of pipeline.md and program-presets.md link to; ADR-006: its status and decisions lines, the shared-layout paragraph with the exception of 019 and an Implemented (S8.5) note, an Implemented (S8.5) note per family with the default areas and windows, one consequence, and the section Pipeline consequences of S8.5; syn-typologies.md: the generator cells of 010, 014, 018, and 019 name their components; the S8.5 checklist that Task 10 runs.
- [x] Step 1: Create the branch
git switch -c docs/architecture-hub-arms main
- [x] Step 2: 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. 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. 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
EnclosedCourt | OfficePlate | Cafeteria
with
EnclosedCourt | OfficePlate | Cafeteria |
RadialLobes | BranchingMall | CareHub |
FoyerHalls
- 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-s84)).
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-s85)).
- Replace
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, 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*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070, D-082, D-083, D-084, D-093, D-094, D-097, D-098; ADR-002, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md), [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |
with
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, `OfficePlateGenerator`, `CafeteriaGenerator`, `RadialLobesGenerator`, `BranchingMallGenerator`, `CareHubGenerator`, `FoyerHallsGenerator`, 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* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070, D-082, D-083, D-084, D-093, D-094, D-097, D-098, D-100; ADR-002, [ADR-006](../decisions/ADR-006-plan-generation-mechanism.md), [ADR-011](../decisions/ADR-011-minimum-wall-and-window-geometry.md), [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |
- 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.5–S8.7.
with
- Hub and arms (S8.5): the radial lobes, the branching mall, the care hub, and the foyer with halls share `HubArmsLayout` (internal, `Lod.Generators/HubArms/`): a square hub with up to four arms of one width, centred on its sides in the order east, north, west, south; a family with *n* arms uses the first *n* sides (two arms east and north, three a T open to plan south, four a cross), and the bounding box's south-west corner is at the origin. Each arm has its own frame, along from the hub and across from its clockwise side, in which the families cut their zones. An arm must not be wider than the hub, and at most four arms fit the axis-aligned layout (D-100); both are `InvalidParameter`. Arms narrower than the hub leave its corners as short outdoor walls, which get no window below 1 m (`WindowOmitted`). Zones are listed hub first, then arm by arm.
- Radial lobes (`SYN-TYP-010`, S8.5): `LobeCount` (3–4, default 4) lobes, `LobeLength` 10 m and `LobeWidth` 12 m, around a `CoreWidth` 12 m core `ST` (*Core*, a `Stair` zone); each lobe holds two dwellings side by side, `L{i}-U1` (the half clockwise of the lobe's axis) and `L{i}-U2`, both adjoining the core. With three lobes the core's south side is a façade. 624 m², 16 windows.
- Branching mall (`SYN-TYP-014`, S8.5): `BranchCount` (3–4, default 3) branches from a central court as wide as the branches, `2 · ShopDepth + MallWidth` = 32 m; each branch has a `BranchLength` 40 m shop wing on either side of an 8 m mall strip, `B{i}-S1`, `B{i}-S2` (`Retail`), and an `AnchorDepth` 30 m anchor `B{i}-AN` (`Retail`) across its end. The court and every strip are one zone `MALL` (`Mall`), which every shop wing and anchor adjoins. Floor height 5 m; 7744 m², 16 windows; with four branches the mall has no outdoor wall.
- Care hub (`SYN-TYP-018`, S8.5): `WingCount` (2–4, default 3) wings of `WingLength` 30 m around a `HubWidth` 16 m communal hub `HUB` (`CareCommunal`); each wing is a `CorridorWidth` 2.4 m corridor `W{i}-CO` (`Corridor`) from the hub to the wing's end between two bedroom zones `W{i}-B1`, `W{i}-B2` (`CareBedroom`) of `BedroomDepth` 6 m. The wing width `2 · BedroomDepth + CorridorWidth` must not exceed `HubWidth`. 1552 m², 16 windows; the six 0.8 m hub corners get none (`WindowOmitted`).
- Foyer with halls (`SYN-TYP-019`, S8.5): a `FoyerWidth` 20 m foyer `FO` (`Lobby`) with `HallCount` (2–3, default 2) activity halls `H1…Hn` (`ActivityHall`) of `HallLength` 30 m × `HallWidth` 20 m and a `SupportLength` 10 m support wing `SV` (`Service`): hall 1 east, the support wing north, hall 2 west, hall 3 south; zones `FO`, `H1…Hn`, `SV`. With two halls the foyer's south side is its entrance façade. Floor height 6 m; 1800 m², 10 windows.
- 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.
- 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). 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). 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.
- Replace
## Grasshopper components through S8.4
with
## Grasshopper components through S8.5
- Replace
- **Preview Location (D-062).** The eleven 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 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.
- 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.
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*.
- Replace
| 2 Generate | *Cafeteria* (Cafe) | Length (30), Dining Depth (15), Kitchen Depth (8), Floor Height (4), Orientation (0), Kitchen, Dining, Preview Location → Plan | `CafeteriaComponent` | `95956724-ddbc-48ce-9874-0ec6c244c8fe` | S8.4 |
with
| 2 Generate | *Cafeteria* (Cafe) | Length (30), Dining Depth (15), Kitchen Depth (8), Floor Height (4), Orientation (0), Kitchen, Dining, Preview Location → Plan | `CafeteriaComponent` | `95956724-ddbc-48ce-9874-0ec6c244c8fe` | S8.4 |
| 2 Generate | *Radial Lobes* (RLobes) | Lobe Count (4), Lobe Length (10), Lobe Width (12), Core Width (12), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `RadialLobesComponent` | `a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80` | S8.5 |
| 2 Generate | *Branching Mall* (BMall) | Branch Count (3), Branch Length (40), Mall Width (8), Shop Depth (12), Anchor Depth (30), Floor Height (5), Orientation (0), Mall, Retail, Preview Location → Plan | `BranchingMallComponent` | `d886b33f-7b51-41f9-b160-a550e6aadd2b` | S8.5 |
| 2 Generate | *Care Hub* (CHub) | Wing Count (3), Wing Length (30), Bedroom Depth (6), Corridor Width (2.4), Hub Width (16), Floor Height (3), Orientation (0), Care Bedroom, Care Communal, Corridor, Preview Location → Plan | `CareHubComponent` | `7d3d1002-c695-47df-afd9-48f50c5b3e90` | S8.5 |
| 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 |
- [x] Step 3: 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)
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
- Replace
Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084, D-085, D-093 to D-098; applies [ADR-014](ADR-014-program-types-and-department-zoning.md), [ADR-002](ADR-002-geometry.md), [ADR-008](ADR-008-perimeter-core-corners.md), and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)
with
Decisions: D-009, D-026, D-061, D-065, D-082, D-083, D-084, D-085, D-093 to D-098, D-100; applies [ADR-014](ADR-014-program-types-and-department-zoning.md), [ADR-002](ADR-002-geometry.md), [ADR-008](ADR-008-perimeter-core-corners.md), and [ADR-011](ADR-011-minimum-wall-and-window-geometry.md)
- Replace
`HubArmsLayout` (in `Lod.Generators/HubArms/`, internal) lays out a square hub of side `H` and up to four rectangular arms on its sides, in the order east, north, west, south (counter-clockwise from plan east), each arm centred on its side, of length `Lᵢ` away from the hub and width `A` along the side. An arm must not be wider than the hub (`A ≤ H`; a wider arm would overlap its neighbour beyond the hub's corner), which every family validates. The footprint is the union of hub and arms (a T for three arms, a cross for four), placed with the south-west corner of its bounding box at the origin. A hub side without an arm, and the hub's corners when `A < H`, are outdoor walls. The families subdivide the hub and the arms into their zones; zones are listed hub first, then arm by arm in the order above.
with
`HubArmsLayout` (in `Lod.Generators/HubArms/`, internal) lays out a square hub of side `H` and up to four rectangular arms on its sides, in the order east, north, west, south (counter-clockwise from plan east), each arm centred on its side, of length `Lᵢ` away from the hub and width `A` along the side. An arm must not be wider than the hub (`A ≤ H`; a wider arm would overlap its neighbour beyond the hub's corner), which every family validates. The footprint is the union of hub and arms (a T for three arms, a cross for four), placed with the south-west corner of its bounding box at the origin. A hub side without an arm, and the hub's corners when `A < H`, are outdoor walls. The families subdivide the hub and the arms into their zones; zones are listed hub first, then arm by arm in the order above, except that `019` lists its halls before its support wing (its section below).
- Implemented (S8.5): `HubArmsLayout(hubWidth, armWidth, armLengths)` takes one length per arm, so the support wing of `019` can be shorter than its halls. A family with *n* arms uses the first *n* sides in the order east, north, west, south: two arms (`018`) leave the hub east and north (an L), three leave its south side free (a T), four make a cross. Each `HubArm` has its own frame: *along* runs from the root on the hub's side to the tip, *across* from the arm's clockwise side (the right-hand side looking out from the hub: south for the east arm, east for the north arm, north for the west arm, west for the south arm) to its counter-clockwise side. `Piece(along₀, along₁, across₀, across₁)` is a rectangle of that frame, and pieces with the same along or across values share their edges exactly, so the families subdivide the arms without geometry of their own. `HubWithStrips(across₀, across₁, length)` unites the hub with one strip of every arm into one polygon (the mall of `014`). The footprint and that polygon are walked counter-clockwise around the hub, start at their lowest, then leftmost, vertex, and have no repeated or collinear vertices, so an arm as wide as the hub continues the hub's sides without a vertex at the hub's corner. `CountErrors` and `WidthErrors` give every family the same messages, for example `LobeCount must be at most 4 (lobes leave the core only on its four sides, D-100), got 5.` and `LobeWidth 13 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.`; a fifth arm is an `ArgumentOutOfRangeException` of the layout, which the families' validation never lets through. The layout's own tests reach it through `InternalsVisibleTo` from `Lod.Generators` to `Lod.Generators.Tests`.
- Replace
- Not modelled: lobes at other than right angles, lobes of different sizes, more than two dwellings per lobe.
with
- Not modelled: lobes at other than right angles, lobes of different sizes, more than two dwellings per lobe.
- Implemented (S8.5): as specified. Zones `ST` (*Core*), then `L1-U1`, `L1-U2`, …, `L4-U2` (*Lobe 1 Unit 1*, …); by default 624 m², a 144 m² core and eight 60 m² dwellings, with 16 windows of WWR 0.3 (115.2 m² of glazing, 28.8 m² per orientation), two per dwelling (its side and its end) and none on the core, which adjoins every dwelling over 6 m. Three lobes give a T of 504 m² with 13 windows; the core's 12 m south wall gets one window of the stair preset's WWR 0.1. Lobes narrower than the core leave the core's corners as outdoor walls, which get no window below 1 m (11 m lobes on the 12 m core: eight 0.5 m walls, `WindowOmitted`). *Semantic Merge* unites the two dwellings of each lobe (lobes meet only at the core's corners) and keeps the core; *Perimeter Core* gives three zones per orientation, `P-North-1` to `P-West-3`, and a cross-shaped `CORE` of 122.5796 m²; 9 m lobes fail it with `PlanTooNarrow` (D-087).
- Replace
- Not modelled: individual shops and tenancies (D-097), branches at other than right angles, rooflights, servicing yards.
with
- Not modelled: individual shops and tenancies (D-097), branches at other than right angles, rooflights, servicing yards.
- Implemented (S8.5): as specified; the mall is `HubWithStrips` of the shop depth to the shop depth plus the mall width, over the branch length. Zones `MALL` (*Mall*, 1984 m²: the 32 m court and three 40 m × 8 m strips, one polygon of 16 vertices), then per branch `B{i}-S1`, `B{i}-S2` (*Branch i Shops 1*, *Shops 2*, 480 m² each) and `B{i}-AN` (*Branch i Anchor*, 960 m²); 7744 m² by default, with 16 windows (806 m² of glazing): the mall's only outdoor wall is the court's 32 m south side, its entrance, each shop wing has one façade and each anchor three. Every shop wing adjoins the mall over 52 m (40 m along the strip and 12 m at the court) and its anchor over 12 m, and every anchor adjoins the mall over 8 m. With four branches (9984 m²) the mall has no outdoor wall. *Semantic Merge* keeps the mall and unites the two shop wings and the anchor of each branch into one `Retail` zone of 1920 m² around the strip's end; *Perimeter Core* gives `P-North-1` to `P-North-3`, `P-East-1`, `P-East-2`, `P-South`, `P-West-1`, `P-West-2`, and `CORE`.
- Replace
- Not modelled: individual bedrooms (D-097), the care station as a zone of its own, gardens.
with
- Not modelled: individual bedrooms (D-097), the care station as a zone of its own, gardens.
- Implemented (S8.5): as specified; two wings leave the hub east and north. Zones `HUB` (*Communal Hub*, 256 m²), then per wing `W{i}-CO` (*Wing i Corridor*, 72 m²), `W{i}-B1`, `W{i}-B2` (*Wing i Bedrooms 1*, *Bedrooms 2*, 180 m² each); 1552 m² by default. The 14.4 m wings on the 16 m hub leave six 0.8 m hub corners as outdoor walls, which get no window (ADR-011, info `WindowOmitted`), so the default plan has 16 windows (217.92 m² of glazing): one on the hub's 16 m south side, two per bedroom zone (its side and its end), and one at each corridor's 2.4 m end. Every corridor adjoins the hub over 2.4 m, every bedroom zone the hub over 6 m and its corridor over 30 m. *Semantic Merge* keeps every zone, since no two zones of one type touch; *Perimeter Core* gives each hub corner a perimeter zone of its own, 0.8 m × 4.57 m (3.656 m²), and 9 m wings (3.5 m bedrooms and a 2 m corridor) fail it with `PlanTooNarrow` (D-087). The short corners exposed a rounding fault of *Perimeter Core* on rotated plans, fixed in S8.5 ([below](#pipeline-consequences-of-s85)).
- Replace
- Not modelled: individual changing rooms and stores (D-097), spectator galleries, halls of different sizes, and different storey heights for halls and foyer.
with
- Not modelled: individual changing rooms and stores (D-097), spectator galleries, halls of different sizes, and different storey heights for halls and foyer.
- Implemented (S8.5): as specified; the halls are listed before the support wing, as the zone list above says, although the support wing is the second arm. Zones `FO` (*Foyer*, 400 m²), `H1`, `H2` (*Hall 1*, *Hall 2*, 600 m² each), `SV` (*Support*, 200 m²); 1800 m² by default, a T whose short stem is the support wing, with 10 windows (276 m² of glazing): the foyer's 20 m south entrance façade (WWR 0.5), three per hall, and three on the support wing. Every hall and the support wing adjoins the foyer over the hall width and no other zone. Three halls (2400 m²) close the foyer's south side, so the foyer has no outdoor wall; a foyer wider than the halls leaves its corners outdoor (a 24 m foyer with three halls: eight 2 m walls, each with a window). *Semantic Merge* keeps every zone; *Perimeter Core* gives `P-North-1` to `P-North-3`, `P-East-1`, `P-East-2`, `P-South`, `P-West-1`, `P-West-2`, and `CORE`.
#### Pipeline consequences of S8.5
Every plan simplifier and floor aggregator was run on the four families, canonical, at the two rotated placements of the test suite, and with the fewest and the most arms, and, while building the reference, in a sweep of 50 rotations from 0° to 353.5° with translations for each family with its fewest, default, and most arms, 11 m lobes on a 12 m core, and a 24 m foyer with three halls. It found one fault, now fixed:
- *Perimeter Core* united the trapezoids of one orientation bin with a plain union, which rounds every vertex to the clipping grid. On a rotated plan the wall on a footprint edge shorter than about 1 m, such as a 0.8 m hub corner of the care hub, then differs in direction from the source wall by more than the angular tolerance (ADR-004), so *Single Zone per Floor Type* and *Single Zone Building* reported `FacadeNotCovered` for it: in 22, 10, and 26 of 800 combinations of the care hub with 3, 2, and 4 wings in the sweep, and at the test suite's 131.4° placement. The union now keeps the footprint's own vertices; the mitred offset vertices keep their rounding, so every existing result is unchanged ([ADR-008](ADR-008-perimeter-core-corners.md), [ADR-002](ADR-002-geometry.md)). No tolerance or check was relaxed, and after the fix the sweep has no failure.
Nothing else in `Lod.Core` changed. Arms as wide as the hub give the footprint only re-entrant corners, which the bisector rule of *Perimeter Core* handles (S8.2); arms narrower than the hub add short steps at the hub's corners, which it handles too, each step getting a small perimeter zone. Arms not wider than twice the perimeter depth fail *Perimeter Core* with `PlanTooNarrow` (D-087), a precondition of the simplifier that the generators do not impose.
- Replace
- Arms on the sides of a hub are limited to four by the axis-aligned layout (S8.5); counts beyond the limits in the tables are `InvalidParameter`.
with
- Arms on the sides of a hub are limited to four by the axis-aligned layout (S8.5); counts beyond the limits in the tables are `InvalidParameter`.
- 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).
- [x] Step 4: 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-010` | Radial lobes around a point tower core | new | FPMH provisional (1) | provisional | `RadialLobesGenerator` (specified) | S8.5 |
with
| `SYN-TYP-010` | Radial lobes around a point tower core | new | FPMH provisional (1) | provisional | `RadialLobesGenerator` (*Radial Lobes*) | S8.5 |
- Replace
| `SYN-TYP-014` | Branching public mall between retail wings | new | MH recurrent (2) | recurrent | `BranchingMallGenerator` (specified) | S8.5 |
with
| `SYN-TYP-014` | Branching public mall between retail wings | new | MH recurrent (2) | recurrent | `BranchingMallGenerator` (*Branching Mall*) | S8.5 |
- Replace
| `SYN-TYP-018` | Communal hub serving elder-care bedroom groups | new | MH recurrent (2) | recurrent | `CareHubGenerator` (specified) | S8.5 |
| `SYN-TYP-019` | Public foyer branching to distinct activity spaces | new | AD recurrent (2), MH provisional (1) | recurrent | `FoyerHallsGenerator` (specified) | S8.5 |
with
| `SYN-TYP-018` | Communal hub serving elder-care bedroom groups | new | MH recurrent (2) | recurrent | `CareHubGenerator` (*Care Hub*) | S8.5 |
| `SYN-TYP-019` | Public foyer branching to distinct activity spaces | new | AD recurrent (2), MH provisional (1) | recurrent | `FoyerHallsGenerator` (*Foyer Halls*) | S8.5 |
- [x] Step 5: 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-s84).
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-s85).
- [x] Step 6: Append the S8.5 checklist at the end of
docs/development/grasshopper-smoke-test.md
Append this section after the last line of the file (after the S8.4 result), separated by one blank line, so that it nests under ## Stage checklists like the S2–S8.4 checklists:
### S8.5 checklist — branching plans on a hub and its arms (`v0.8.4`)
S8.5 adds four plan generator components in *2 Generate* for the families that branch from a hub on the shared hub-and-arms layout ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)): the radial lobes (`SYN-TYP-010`), the branching mall (`SYN-TYP-014`), the care hub (`SYN-TYP-018`), and the foyer with halls (`SYN-TYP-019`). *Perimeter Core* keeps the footprint's own vertices in its perimeter zones ([ADR-008](../decisions/ADR-008-perimeter-core-corners.md)). No existing component, input, output, or GUID changes.
#### Toolbar and icons
- [ ] *2 Generate* shows, after *Cafeteria*, *Radial Lobes* (a cross of lobes around a dark core), *Branching Mall* (a T of shop wings along a dark branching mall), *Care Hub* (a hub with a table and three narrower wings with dark corridors), and *Foyer Halls* (a white foyer between two halls, a dark support wing above, an entrance arrow below), each with its own green icon.
- [ ] *Radial Lobes* (RLobes) inputs, in order, with their defaults: Lobe Count 4, Lobe Length 10, Lobe Width 12, Core Width 12, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; one output, *Plan*.
- [ ] *Branching Mall* (BMall): Branch Count 3, Branch Length 40, Mall Width 8, Shop Depth 12, Anchor Depth 30, Floor Height 5, Orientation 0, Mall, Retail, Preview Location.
- [ ] *Care Hub* (CHub): Wing Count 3, Wing Length 30, Bedroom Depth 6, Corridor Width 2.4, Hub Width 16, Floor Height 3, Orientation 0, Care Bedroom, Care Communal, Corridor, Preview Location.
- [ ] *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.
- [ ] The count inputs are integers. With a preset input unconnected, each generator shows Grasshopper's missing-input warning and outputs nothing (D-065); a *Retail Preset* on the *Mall* input of *Branching Mall* gives `Error PresetSpaceTypeMismatch`.
#### Default plans
1. Place *Radial Lobes* with *Dwelling Unit Preset* and *Stair Preset* connected; connect *Inspect* to its *Plan*.
- [ ] No runtime message; a cross of 624 m²: the core `ST` at x = y = 10–22 m and four lobes `L1` (east) to `L4` (south), each split along its axis into `L{i}-U1` and `L{i}-U2` of 60 m².
- [ ] 16 windows, 115.2 m² of glazing (28.8 m² per orientation); the core has no outdoor wall.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/RadialLobes/Snapshots/radial-lobes-canonical.txt`.
2. Place *Branching Mall* with *Mall Preset* and *Retail Preset* connected; connect *Inspect*.
- [ ] No runtime message; a T of 7744 m²; `MALL` (1984 m²) is one polygon through the court at x = 70–102 m, y = 0–32 m and the three 8 m strips; `B1-S1`, `B1-S2`, `B1-AN` to `B3-AN` around it.
- [ ] 16 windows, 806 m² of glazing; the mall's only window is on the court's south side.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/BranchingMall/Snapshots/branching-mall-canonical.txt`.
3. Place *Care Hub* with *Care Bedroom Preset*, *Care Communal Preset*, and *Corridor Preset* connected; connect *Inspect*.
- [ ] Six remarks `WindowOmitted`, one per 0.8 m hub corner; a T of 1552 m²: `HUB` at x = 30–46 m, y = 0–16 m and three wings, each `W{i}-CO` between `W{i}-B1` and `W{i}-B2`.
- [ ] 16 windows, 217.92 m² of glazing; every corridor has one window at its end.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/CareHub/Snapshots/care-hub-canonical.txt`.
4. Place *Foyer Halls* with *Lobby Preset*, *Activity Hall Preset*, and *Service Preset* connected; connect *Inspect*.
- [ ] No runtime message; a T of 1800 m²: `FO` at x = 30–50 m, y = 0–20 m, `H1` east, `H2` west, `SV` north; zones listed `FO`, `H1`, `H2`, `SV`.
- [ ] 10 windows, 276 m² of glazing; the foyer's one window is on its south entrance façade.
- [ ] The *Inspect* report equals `tests/Lod.Generators.Tests/FoyerHalls/Snapshots/foyer-halls-canonical.txt`.
#### Combinations
5. 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.
- [ ] Radial lobes: *Semantic Merge* gives the core and four dwelling zones, one per lobe; *Perimeter Core* gives `P-North-1` to `P-West-3` and a cross-shaped `CORE` of 122.5796 m².
- [ ] Branching mall: *Semantic Merge* gives `MALL` and one `Retail` zone of 1920 m² per branch.
- [ ] Care hub: *Semantic Merge* keeps all ten zones; *Perimeter Core* gives six small perimeter zones of 3.656 m² at the hub corners.
- [ ] 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
6. Set each count to its smallest and largest value and repeat step 5 for each: Lobe Count 3 and 4, Branch Count 3 and 4, Wing Count 2 and 4, Hall Count 2 and 3.
- [ ] Lobe Count 3: the core's south wall has a window; Branch Count 4: the mall has no outdoor wall; Wing Count 2: the wings leave the hub east and north; Hall Count 3: hall 3 is south of the foyer, which has no outdoor wall, and the zones are `FO`, `H1`, `H2`, `H3`, `SV`.
- [ ] *Validate* gives Passed = True for every combination.
7. 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.
- [ ] *Validate* gives Passed = True for all 16 combinations of each placement, with the same total glazing as the unrotated plan; *Care Hub* through *Perimeter Core* and *Single Zone Building* at 131.4° passes (it failed with `FacadeNotCovered` before S8.5).
#### Parameters
8. Change inputs one at a time and set each back afterwards.
- [ ] *Radial Lobes* Lobe Count 5: `Error InvalidParameter: LobeCount must be at most 4 (lobes leave the core only on its four sides, D-100), got 5.`; Lobe Count 2: `Error InvalidParameter: LobeCount must be at least 3 (two lobes would be a bar through the core), got 2.`
- [ ] *Radial Lobes* Lobe Width 13: `Error InvalidParameter: LobeWidth 13 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.`
- [ ] *Branching Mall* Branch Count 2: `Error InvalidParameter: BranchCount must be at least 3 (two branches are a bend, not a branching), got 2.`
- [ ] *Care Hub* Bedroom Depth 8: `Error InvalidParameter: The wing width 2·BedroomDepth + CorridorWidth 18.4 m is wider than HubWidth 16 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.`; Hub Width 14.4: no `WindowOmitted`.
- [ ] *Foyer Halls* Hall Count 4: `Error InvalidParameter: HallCount must be at most 3 (the support wing takes one of the foyer's four sides, D-100), got 4.`
- [ ] *Radial Lobes* Lobe Width 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 7: Check the links and commit
git grep -n "grasshopper-components-through-s84" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s85" -- docs/architecture/pipeline.md docs/research/program-presets.md
git grep -c -e "Implemented (S8.5)" -e "### Pipeline consequences of S8.5" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git add docs/architecture/pipeline.md docs/decisions/ADR-006-plan-generation-mechanism.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 hub-and-arms layout, its four families, 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:6 from the third.
Task 10: Smoke test (the controller headless, D-056; viewport items for a person)¶
Files: none (the result goes into docs/development/grasshopper-smoke-test.md and the body of the close-out commit, Task 11)
- [x] Step 1: Build the branch and check that it is the tested code
dotnet build BEMGen.sln -c Release
git rev-parse HEAD
git diff --stat 12a7e8318bb6f54dcffd59636ca2fe8e11113d6d 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 12a7e83 on which the controller ran the headless session (D-056) whose values Step 2 lists (note 10). 12a7e83 is the tip of the local reference branch ref/s8.5 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.5 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; 54 scenarios) on the verified reference 12a7e83 in Rhino 8.25.25314.11001 (.NET 8.0.31); the reference assembly reported the informational version 0.8.3+12a7e8318bb6f54dcffd59636ca2fe8e11113d6d (the bump to 0.8.4 comes in Task 11). "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. 50 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, all primary: Radial Lobes RLobes
a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80, Branching Mall BMalld886b33f-7b51-41f9-b160-a550e6aadd2b, Care Hub CHub7d3d1002-c695-47df-afd9-48f50c5b3e90, and Foyer Halls FHalls6638efe7-c0e8-45b6-89b1-1e9e3f725c15. Every other component and parameter as in S8.4. - Inputs. The inputs, nicknames, and defaults of the table of Task 8, in that order: the count inputs
Param_Integerwith persistent values 4, 3, 3, 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 DU failed to collect dataandInput parameter St failed to collect data(Radial Lobes),MaandRe(Branching Mall),CB,CC, andCo(Care Hub),Lo,AH, andSv(Foyer Halls), and no output. - Radial lobes defaults. Radial Lobes with Dwelling Unit Preset and Stair 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)–(32, 32, 3); Inspect
footprint area=624.000000 floorHeight=3.000000 orientation=0.000000; floors of 5, 13, and 1 zones; Boundaries Adiabatic 24, Ground 9, Outdoors 25; Windows 16 (none on the core, two on each dwelling). Zones: the core a valid closed Brep of 6 faces, volume 432, box (10, 10, 0)–(22, 22, 3); eight dwellings of volume 180. Perimeter Core: 12 perimeter Breps of volume 101.865 or 137.1 and a cross-shaped core of 14 faces, volume 367.739, box (4.57, 4.57, 0)–(27.43, 27.43, 3). Single Zone per Floor: one Brep of 14 faces, volume 1872. Single Zone Building (1, 3, 1) → Validate: Passed =True; the preview one solid (0, 0, 0)–(32, 32, 15). - Branching mall defaults. The same with Mall Preset and Retail Preset: no runtime message; Inspect
footprint area=7744.000000 floorHeight=5.000000 orientation=0.000000; floors of 4, 9, and 1 zones; Boundaries Adiabatic 42, Ground 10, Outdoors 26; Windows 16 (one on the mall, one on each shop wing, three on each anchor). Zones: the mall a valid closed Brep of 18 faces, volume 9920, box (30, 0, 0)–(142, 72, 5); six shop wings of volume 2400 and three anchors of 4800, all valid closed Breps of 6 faces. The plan preview's clipping box reports y = −0.72, becausePreviewGoo.ClippingBoxuses Rhino's fast, approximate box of the untrimmed planar surfaces of the mall zone; the geometry and the Breps are not affected. Single Zone Building (1, 3, 1): Validate Passed =True; the preview one solid (0, 0, 0)–(172, 102, 25). - Care hub defaults. The same with Care Bedroom Preset, Care Communal Preset, and Corridor Preset: the remark state with six infos
WindowOmittedforHUB/W2,HUB/W6,HUB/W7,HUB/W11,HUB/W12, andHUB/W16(Outdoor wall HUB/W2 is 0.8 m long, below the 1 m minimum for a window (ADR-011); it has no window (0.96 m² of glazing not placed).); plan box (0, 0, 0)–(76, 46, 3); Inspectfootprint area=1552.000000 floorHeight=3.000000 orientation=0.000000; floors of 10, 17, and 1 zones; Boundaries Adiabatic 30, Ground 10, Outdoors 32; Windows 16 (one on the hub, one on each corridor, two on each bedroom group). Zones: the hub volume 768, box (30, 0, 0)–(46, 16, 3); corridors of 216 and bedroom groups of 540, all valid closed Breps of 6 faces. Single Zone Building (1, 3, 1): Validate Passed =True; the preview one solid (0, 0, 0)–(76, 46, 15). - Foyer halls defaults. The same with Lobby Preset, Activity Hall Preset, and Service Preset: no runtime message; plan box (0, 0, 0)–(80, 30, 6); Inspect
footprint area=1800.000000 floorHeight=6.000000 orientation=0.000000; floors of 4, 9, and 1 zones; Boundaries Adiabatic 6, Ground 4, Outdoors 14; Windows 10 (one on the foyer, three on each hall and on the support wing). Zones: the foyer volume 2400, box (30, 0, 0)–(50, 20, 6); the halls 3600 each, east and west; the support wing 1200, box (30, 20, 0)–(50, 30, 6), all valid closed Breps of 6 faces. Single Zone Building (1, 3, 1): Validate Passed =True; the preview one solid (0, 0, 0)–(80, 30, 30). - Every combination, also rotated, and with the fewest and most arms. 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 most arms (3 and 4 lobes, 3 and 4 branches, 2 and 4 wings, 2 and 3 halls) 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 other than the care hub generator's six
WindowOmittedremarks, and Validate Passed =Truefor all 320 buildings. The 89 zone Breps inspected in items 3 to 6 are valid closed solids; no Brep has a negative volume in any scenario of items 3 to 7. - Parameters. Lobe Count 2 and 5:
Error InvalidParameter: LobeCount must be at least 3 (two lobes would be a bar through the core), got 2.andError InvalidParameter: LobeCount must be at most 4 (lobes leave the core only on its four sides, D-100), got 5.; Branch Count 2 and 5, Wing Count 1 and 5, and Hall Count 1 and 4 likewise, with the messages of Tasks 3, 5, and 6; Lobe Width 14:Error InvalidParameter: LobeWidth 14 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.; Care Hub Hub Width 12:Error InvalidParameter: The wing width 2·BedroomDepth + CorridorWidth 14.4 m is wider than HubWidth 12 m; …; Foyer Halls Hall Width 25:Error InvalidParameter: HallWidth 25 m is wider than FoyerWidth 20 m; …; Branching Mall Branch Length 0:Error InvalidParameter: BranchLength must be positive, got 0.; Radial Lobes Lobe Width 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 cross, the T, the mall through its branches, 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.5; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).
If Step 1 printed a difference, or a person or a repeated session finds a failure, the stage stops: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Task 11 records this result in the guide and in the close-out commit, following the guide's "Recording the result".
- [x] Step 3: Merge Slice E (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/architecture-hub-arms
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-hub-arms
git branch -d docs/architecture-hub-arms
Expected: VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0. Do not push (D-098).
Stage close-out¶
Task 11: Version 0.8.4, S8.5 records, tag v0.8.4¶
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.5-hub-and-arms-typologies.md (this plan: checkboxes and status, D-051)
Interfaces:
- Consumes: the merged Slices A–E; the smoke-test record of Task 10; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump.
- Produces: the tag v0.8.4; the S8.5 records: the project state, the status, the roadmap's Progress line and detailed-plans row, the smoke-test result in the guide, and the decision-log entries D-102 (the layout choices of S8.5: two arms east and north, the foyer with halls listing its support wing after the halls, InternalsVisibleTo for the layout tests) and D-103 (S8.5 complete).
The decision-log entries of Step 8 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.4 close-outs do.
- [x] Step 1: Create the branch and run the gate
git switch -c chore/repo-s8.5-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
Expected: VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0.
- [x] Step 2: Bump the version in
Directory.Build.props
Replace
<Version>0.8.3</Version>
with
<Version>0.8.4</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.4</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.4"
- [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:
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), and S8.5 (tag `v0.8.4`, D-103). The pipeline runs end to end for fifteen 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; and the hub-and-arms families on the shared `HubArmsLayout`, a square hub with up to four arms (D-100, D-102): `RadialLobesGenerator` 010, `BranchingMallGenerator` 014, `CareHubGenerator` 018, and `FoyerHallsGenerator` 019; the 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 ([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, and S8.5 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 and `010`, `014`, `018`, `019` in S8.5, the hub-and-arms families with at most four arms because the shared layout is axis-aligned (D-100); `015`, `011` (S8.6) and `012` (S8.7) are specified in ADR-006 and built before S6 (D-098).
Not implemented yet: the four-source families of S8.6–S8.7, `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:
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`), and S8.5 (tag `v0.8.4`). In Rhino 8 the plugin runs the whole pipeline for fifteen 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`), and *Foyer Halls* (`SYN-TYP-019`), 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 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.6–S8.7 (the remaining four-source families, 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, and the S8.4 office plate and cafeteria)
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, and the S8.5 hub-and-arms families)
- [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.5 Progress line below the S8.4 Progress line (the last > **Progress:** line), with a > separator line between them; the second adds the S8.5 row of the detailed-plans table.
- Replace
> **Progress:** 2026-10-02 · S8.4 Non-residential programs, office plate and cafeteria complete (tag `v0.8.3`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.5. The four-source records are pinned (`v0.3.1-research`, `7538ed3`) and paraphrased in `syn-typologies.md`, and BEMGen documents use the `SYN-TYP-*` IDs (D-099); the study covers every program type (D-094) in the brief, spec, README, AGENTS.md, and roadmap; ADR-014 adds twelve non-residential space types (`Core`, `Lobby`, and `Service` reused), fifteen illustrative presets in `ExampleNonResidentialPresets`, their default preset components, and the department zoning of every new family (D-097); ADR-006 specifies all nine new families, the hub-and-arms families with at most four arms (D-100); new generators and components *Office Plate* (017, the first zone with a hole in a footprint without one, needing no pipeline change) and *Cafeteria* (016); existing snapshots byte-identical, two new; 193 core, 224 generator, and 704 integration tests pass on `net8.0`.
with
> **Progress:** 2026-10-02 · S8.4 Non-residential programs, office plate and cafeteria complete (tag `v0.8.3`; `main` and the tag pushed after the fresh-clone gate, D-098); next: S8.5. The four-source records are pinned (`v0.3.1-research`, `7538ed3`) and paraphrased in `syn-typologies.md`, and BEMGen documents use the `SYN-TYP-*` IDs (D-099); the study covers every program type (D-094) in the brief, spec, README, AGENTS.md, and roadmap; ADR-014 adds twelve non-residential space types (`Core`, `Lobby`, and `Service` reused), fifteen illustrative presets in `ExampleNonResidentialPresets`, their default preset components, and the department zoning of every new family (D-097); ADR-006 specifies all nine new families, the hub-and-arms families with at most four arms (D-100); new generators and components *Office Plate* (017, the first zone with a hole in a footprint without one, needing no pipeline change) and *Cafeteria* (016); existing snapshots byte-identical, two new; 193 core, 224 generator, and 704 integration tests pass on `net8.0`.
>
> **Progress:** <date> · 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`.
- Replace
| S8.4 | [2026-10-02-s8.4-non-residential-programs.md](2026-10-02-s8.4-non-residential-programs.md) | 193 core, 224 generators, 704 integration |
with
| S8.4 | [2026-10-02-s8.4-non-residential-programs.md](2026-10-02-s8.4-non-residential-programs.md) | 193 core, 224 generators, 704 integration |
| 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 |
The S8.5 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 this section at the end of docs/development/grasshopper-smoke-test.md (after the S8.5 checklist of Task 9), separated by one blank line; it records Task 10 as the guide's "Recording the result" asks, and the commit body of Step 9 repeats it:
#### Result (S8.5)
Run by the controller as a scripted headless Grasshopper session (D-056) on 2026-10-02, on the verified reference of this sub-stage (`12a7e8318bb6f54dcffd59636ca2fe8e11113d6d`, 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.3+12a7e8318bb6f54dcffd59636ca2fe8e11113d6d` (the version becomes 0.8.4 at the close-out); 54 scenarios.
- Passed: *Radial Lobes* (RLobes), *Branching Mall* (BMall), *Care Hub* (CHub), and *Foyer Halls* (FHalls) in *2 Generate*, all primary, with the GUIDs of the S8.5 plan and 24 × 24 icons; the integer count input first, the presets in record order, *Preview Location* last; unconnected presets give Grasshopper's missing-input warnings.
- Passed: *Radial Lobes* defaults: box (0, 0, 0)–(32, 32, 3), footprint 624 m²; *Semantic Merge* 5 zones, *Perimeter Core* 13, *Single Zone per Floor* 1; *Stack Floors* (one floor) boundaries Adiabatic 24, Ground 9, Outdoors 25. *Branching Mall*: footprint 7744 m², height 5 m; 4, 9, and 1 zones; Adiabatic 42, Ground 10, Outdoors 26. *Care Hub*: box (0, 0, 0)–(76, 46, 3), 1552 m², six `WindowOmitted` remarks for the 0.8 m hub corners; 10, 17, and 1 zones; Adiabatic 30, Ground 10, Outdoors 32. *Foyer Halls*: box (0, 0, 0)–(80, 30, 6), 1800 m²; 4, 9, and 1 zones; Adiabatic 6, Ground 4, Outdoors 14. The *Branching Mall* preview clipping box reports y = −0.72, because `PreviewGoo.ClippingBox` uses Rhino's fast, approximate box of the untrimmed planar surfaces of the mall zone; the geometry and the Breps are not affected.
- Passed: the 89 zone Breps inspected are valid solids, with no negative volume anywhere; *Single Zone Building* (1, 3, 1) previews one solid per family, (0, 0, 0)–(32, 32, 15), (172, 102, 25), (76, 46, 15), and (80, 30, 30), and *Validate* Passed = `True`.
- Passed: all 320 combinations (the four 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 most arms at 131.4°, each through the four simplifiers and the four aggregators with 1, 3, 1): *Validate* Passed = `True`, no error.
- Passed: the counts below and above their ranges (`Error InvalidParameter: LobeCount must be at least 3 (two lobes would be a bar through the core), got 2.`, `… at most 4 (lobes leave the core only on its four sides, D-100), got 5.`, and Branch Count 2 and 5, Wing Count 1 and 5, Hall Count 1 and 4 likewise); arms wider than the hub (`Error InvalidParameter: LobeWidth 14 m is wider than CoreWidth 12 m; an arm must not be wider than the hub it leaves, or it would overlap its neighbour beyond the hub's corner.`, the care hub's 14.4 m wings on a 12 m hub, the foyer's 25 m halls on a 20 m foyer); `Error InvalidParameter: BranchLength must be positive, got 0.`; 9 m lobes 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: Append the decision-log entries (template; fill
<date>now)
Append at the end of docs/decisions/decision-log.md, after a blank line, the two entries below, separated by one blank line. The log ends with D-101 when this plan is approved, so the entries are D-102 and D-103; if the log ends with another number, use the next free numbers, change the references to them (D-103 cites D-102; Tasks 1, 5, and 6 of this plan cite D-102 for the layout choices; AGENTS.md of Step 5 cites D-103) to match, and say so in the commit body.
### D-102 — The layout choices of the hub-and-arms families
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Why: [ADR-006](ADR-006-plan-generation-mechanism.md) fixes the arm order of the shared hub-and-arms layout (east, north, west, south) but not which sides a family with fewer than four arms uses, and its shared zone-order rule (hub first, then arm by arm) differs from the zone order of its section on `SYN-TYP-019`; the S8.5 reference settled both and made the layout's internals visible to their tests.
- A family with *n* arms uses the first *n* sides in the arm order, so two care-hub wings (`SYN-TYP-018`) leave the hub east and north, an L, not a bar through the hub; this is the only rule consistent with the foyer with halls (hall 1 east, the support wing north, hall 2 west, hall 3 south) and with three arms whose free hub side faces south. A straight bar for two arms would change only the side selection of `HubArmsLayout` and ADR-006.
- The foyer with halls (`SYN-TYP-019`) lists its zones `FO`, `H1…Hn`, `SV`, as its ADR-006 section says: its support wing comes after the halls, the one exception to the arm-by-arm order, which ADR-006's shared paragraph records.
- `Lod.Generators.csproj` makes its internals visible to `Lod.Generators.Tests` (`InternalsVisibleTo`), the first use of the item in the repository, so the internal `HubArmsLayout` is tested directly and not only through its four generators.
### D-103 — S8.5 complete
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-098) · **Status:** Provisional
- Sub-stage S8.5 (D-098) is complete and tagged `v0.8.4`; after its gate and a fresh clone of the tag pass `scripts/verify.ps1`, `main` and `v0.8.4` are pushed (D-098). The four families that branch from a hub are implemented as [ADR-006](ADR-006-plan-generation-mechanism.md) specifies them on one shared, axis-aligned layout, `HubArmsLayout` (internal, `Lod.Generators/HubArms/`): a square hub with up to four arms of one width centred on its sides in the order east, north, west, south, a family with *n* arms using the first *n* sides (D-102), each arm with its own frame in which the families cut their zones; an arm wider than the hub and more than four arms are `InvalidParameter` (D-100). New generators and components in *2 Generate*, with icons: *Radial Lobes* (`RadialLobesGenerator`, `SYN-TYP-010`, RLobes, `a9f132c2-40c3-40b9-ba6a-3f3e1f2ced80`): 3–4 lobes of two dwellings each around a `Stair` core; *Branching Mall* (`BranchingMallGenerator`, `SYN-TYP-014`, BMall, `d886b33f-7b51-41f9-b160-a550e6aadd2b`): one `Mall` zone through a central court and 3–4 branches of `Retail` shop wings and anchors; *Care Hub* (`CareHubGenerator`, `SYN-TYP-018`, CHub, `7d3d1002-c695-47df-afd9-48f50c5b3e90`): 2–4 wings of a `Corridor` between two `CareBedroom` zones around a `CareCommunal` hub, whose 0.8 m corners beside the default wings get no window (ADR-011); *Foyer Halls* (`FoyerHallsGenerator`, `SYN-TYP-019`, FHalls, `6638efe7-c0e8-45b6-89b1-1e9e3f725c15`): 2–3 `ActivityHall` zones and a `Service` wing around a `Lobby` foyer, zones `FO`, `H1…Hn`, `SV` (D-102). No record or default of ADR-006 changed; ADR-006 records the details settled in S8.5.
- *Perimeter Core* keeps the footprint's own vertices in its perimeter zones: it united each orientation bin's trapezoids with a plain union, whose grid rounding turned a footprint edge shorter than about 1 m (the care hub's corners) by more than the angular tolerance on rotated plans, so the single-zone aggregators reported `FacadeNotCovered` (up to 26 of 800 combinations in a sweep, and at the test suite's 131.4° placement). Fixed test-first and documented in [ADR-008](ADR-008-perimeter-core-corners.md) and [ADR-002](ADR-002-geometry.md); no tolerance or check was relaxed, and every existing result is unchanged.
- The eighteen existing snapshots are byte-identical; four canonical snapshots are new. 193 core, 360 generator, and 1184 integration tests pass on `net8.0`, including every family 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, and with its fewest and most arms; arms not wider than twice the perimeter depth fail *Perimeter Core* with `PlanTooNarrow` (D-087).
- Smoke test (D-056): the controller's scripted headless Grasshopper session on the verified reference (`12a7e83`, whose `src/`, `tests/`, and `scripts/` equal the executed branch) in Rhino 8.25.25314.11001 (.NET 8.0.31) passed every performed item: the 4 generator components registered with their GUIDs, integer count inputs, presets in record order, *Preview Location* last, and 24 × 24 icons; the default radial lobes (624 m²), branching mall (7744 m²), care hub (1552 m², six `WindowOmitted` corners), and foyer with halls (1800 m²) with every zone a valid solid; all 320 combinations (four families, five placements and counts, four simplifiers, four aggregators, 1/3/1) with *Validate* passing and no negative volume; 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).
- S8.6 (families 015 and 011) 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 9: 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 10 and 11 below) and every step of Task 12, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 9, Step 6 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.5-hub-and-arms-typologies.md
git commit -m "docs(repo): record s8.5 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.5 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 12a7e8318bb6f54dcffd59636ca2fe8e11113d6d (the verified reference; src, tests, and scripts identical to this branch), Version output '0.8.3+12a7e8318bb6f54dcffd59636ca2fe8e11113d6d', 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 10 of the S8.5 plan." -m "Ticked per D-051: every completed checkbox of Tasks 1 to 10 and of Task 11 up to this commit (Steps 1 to 9). Left unticked: Task 11 Step 10 (merge) and Step 11 (tag v0.8.4), which run after this commit, and every step of Task 12 (fresh-clone gate and push), which the controller performs after the tag (D-098). The checklist items inside Task 9 are file content of grasshopper-smoke-test.md and stay unticked."
- [ ] Step 10: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s8.5-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.5-close-out
git branch -d chore/repo-s8.5-close-out
Expected: VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0.
- [ ] Step 11: Tag
git tag -a v0.8.4 -m "S8.5 branching plans on a hub and its arms complete"
git describe --tags
Expected: v0.8.4. Nothing is pushed before Task 12.
Task 12: 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.4^{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 49efa75 when this plan was written); the tags v0.8.0 to v0.8.4; 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.4 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.3, report it to the person; this task pushes only main and v0.8.4.
- [ ] Step 2: Verify a fresh clone of the tag
git clone --branch v0.8.4 . ../BEMGen-v0.8.4-clone
powershell -NoProfile -ExecutionPolicy Bypass -File ../BEMGen-v0.8.4-clone/scripts/verify.ps1
git -C ../BEMGen-v0.8.4-clone describe --tags
Expected: the clone reports a detached HEAD at v0.8.4; verify builds the clone with 0 Warning(s) and 0 Error(s) and ends with VERIFY PASSED, with 193 core, 360 generators, and 1184 integration tests passed on net8.0; describe prints v0.8.4. Then delete the clone (rm -rf ../BEMGen-v0.8.4-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.4
git ls-remote --tags origin "v0.8.4*"
git status -sb
Expected: each push succeeds without --force (main as a fast-forward, the tag as [new tag]); ls-remote lists v0.8.4 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¶
HubArmsLayoutlays out a square hub with up to four arms of one width in the order east, north, west, south, with arm frames whose pieces share their edges exactly and a counter-clockwise footprint at the origin; it is tested directly byHubArmsLayoutTests(27 tests), which failed to build on the code before it.RadialLobesGenerator(010),BranchingMallGenerator(014),CareHubGenerator(018), andFoyerHallsGenerator(019) implement ADR-006 with their defaults, zone IDs and order, and validation (3–4 lobes, 3–4 branches, 2–4 wings, 2–3 halls, D-100; no arm wider than the hub); each has tiling, adjacency, window, count, invalid-parameter, preset, determinism, provenance, and snapshot tests that failed to build on the code before them.- Perimeter Core keeps the footprint's own vertices in its perimeter zones, tested by
PerimeterCoreShortEdgeTests(150 cases), 58 of which failed on the code before it; every existing result is unchanged; ADR-008 and ADR-002 record the rule. - Every family validates with every plan simplifier and floor aggregator, on its canonical plan, at two rotated and moved placements, and with its fewest and most arms (82 cases each); 9 m lobes and 9 m care wings fail Perimeter Core with
PlanTooNarrow(D-087). - Four generator components in 2 Generate, with the GUIDs of this plan, their defaults, integer count inputs, required preset inputs, Preview Location last, and icons (57 icons, the existing ones byte-identical); no existing component, input, or GUID changed.
- Snapshots: the eighteen existing snapshots byte-identical; four new canonical snapshots.
- ADR-006,
pipeline.md,syn-typologies.md, andprogram-presets.mddescribe S8.5; the S8.5 smoke-test checklist and its headless result are in the guide. scripts/verify.ps1printsVERIFY PASSED; tests: 193 core, 360 generators, 1184 integration onnet8.0; 0 warnings.- Version 0.8.4, tag
v0.8.4; AGENTS.md, README, the roadmap Progress line and detailed-plans row, and the decision-log entries D-102 and D-103 are written; this plan is ticked and marked Done. - After a fresh clone of
v0.8.4passesscripts/verify.ps1,mainandv0.8.4are pushed without force (D-098).
Notes for the reviewer¶
- Source. Every code block, snapshot, and documentation replacement of Tasks 1 to 9 was extracted programmatically from a verified reference implementation built on
mainat49efa75, 9 commits from184fbb9to12a7e83, 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 and the close-out of Task 11 (adapted from the reference's proposed texts, with the decision numbers of note 13). Every task was then replayed on a worktree ofmainat49efa75(its branch standing in formain; nothing fetched, pulled, tagged, or pushed), taking each file content and each replacement from this plan's text: the red build errors of Tasks 1, 2, 3, 5, and 6 (with line and column numbers and in the compiler's order), the 58 failing cases of Task 4 and their messages, the single failing snapshot test of Tasks 2, 3, 5, and 6 and the received snapshots (byte-identical to the expected content), the failure ofmake_icons.pybefore Task 8 and the 57 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 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.5 replay/s8.5limited to the 34 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 11, Step 7;Directory.Build.props(0.8.4), 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.5; this plan uses five slices and a close-out, named after D-004:
feature/hub-arms-lobes-mall(Tasks 1–3),fix/perimeter-core-footprint-vertices(Task 4),feature/care-hub-foyer-halls(Tasks 5–7),feature/hub-arms-components(Task 8),docs/architecture-hub-arms(Tasks 9–10), andchore/repo-s8.5-close-out. Merges rebase onto localmain. As in S8.4, the stage is pushed at its end (D-098): Task 12 is the controller's, after the tag and a fresh clone of it passscripts/verify.ps1.origin/mainwas at49efa75when 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 3, 4, 7, 9, 10, and 11). Task 9 renames the heading
## Grasshopper components through S8.4ofpipeline.mdto… through S8.5and updates its two links (inpipeline.mdandprogram-presets.md) in the same commit. - The baseline is 193 core, 224 generators, and 704 integration tests (D-101).
- Tests that were not test-first, and why. The integration matrices of Tasks 2, 3, 5, and 6 (328 cases) passed at once in the reference and in the replay: the surface builder, the four simplifiers, the four aggregators, and validation already handle the L, T, and cross footprints with their re-entrant corners, and the care hub's matrix passes because Task 4 comes first (note 6); each task says to stop if a case fails. The two tests of Task 7 check the existing Perimeter Core precondition (D-087) on the new families, so they pass at once by design. Task 8 is Grasshopper code, checked by the build, the embedded icons, and Task 10. The build-error red phases list only declaration-level errors: the compiler stops before binding method bodies.
- Why Task 4 comes before the care hub, and what it repairs. The reference found the defect through the care hub: its integration matrix failed at the 131.4° placement for Perimeter Core × Single Zone per Floor Type and × Single Zone Building (
FacadeNotCoveredon a 0.8 m hub corner). The fix is tested on a plan of its own (a block with an 18.4 m wing on a 20 m side), so it can come first and the care hub's matrix passes at once. The replay confirmed it: withStrategies.csof Task 3 put back after Task 5,CareHubTypologyTestsreportedFailed! - Failed: 2, Passed: 80, Skipped: 0, Total: 82, the failing casesEveryCombinationValidatesOnARotatedAndTranslatedPlan(simplifier: "PerimeterCore", aggregator: "SingleZonePerFloorType", placement: 1)andEveryCombinationValidatesOnARotatedAndTranslatedPlan(simplifier: "PerimeterCore", aggregator: "SingleZoneBuilding", placement: 1), each withFacadeNotCoveredonP-West-2/W2of every storey (Rebuilt façades cover 0 m of this 0.8 m wall (D-041).); with Task 4 restored, all 82 passed again. 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 plan) found 22, 10, and 26 failures for the care hub with 3, 2, and 4 wings before the fix and none after it, and none for the radial lobes (3 and 4 lobes, and 11 m lobes on a 12 m core), the branching mall (3 and 4), the care hub, the foyer with halls (2 and 3, and 3 halls on a 24 m foyer), and, as a regression sweep, the linear plan, the point plate, the winged band, the open court, the enclosed court, and the office plate; the sweep was not replayed. The reference first united the trapezoids withPolygonOps.UnionTiles, which also restores the offset vertices;scripts/verify.ps1then failed two snapshot tests (AggregatorTests.MultiplierSnapshotandSimplifierTests.CanonicalSnapshots(PerimeterCore): the canonical linear plan's 45° corner wallP-South/W4flipped itsfacing=from North to East), so only footprint vertices are restored (ADR-008); no snapshot was accepted with a change. - Layout choices of the reference (D-102). ADR-006 fixes the arm order but not which sides fewer than four arms use; n arms take the first n sides, the only rule consistent with the foyer with halls (hall 1 east, the support wing north, hall 2 west, hall 3 south) and with the T whose free side faces south, so two care-hub wings form an L (east and north), not a bar (east and west). If the controller prefers east and west for two arms, only the side selection of
HubArmsLayoutand ADR-006 change. The foyer with halls listsFO,H1…Hn,SVas its own ADR-006 section says, not arm by arm (FO,H1,SV,H2, …) as the shared paragraph said; Task 9 adds the exception to the shared paragraph.InternalsVisibleTois new in the repository (no project used it;StairBayLayoutis tested only through its generators); the brief asked for direct layout tests, which need it. The close-out records the three as the provisional controller decision D-102. - One width per plan. All arms of a plan have one width (ADR-006 gives one
Aper family), and each arm its own length (the support wing of019is shorter than its halls). The care hub reports a wing wider than its hub through the shared width message,The wing width 2·BedroomDepth + CorridorWidth … m is wider than HubWidth … m; …, which states ADR-006's condition2·BedroomDepth + CorridorWidth ≤ HubWidthin the words of an arm. More than four arms reachingHubArmsLayoutthrowArgumentOutOfRangeException: a programming error, unreachable through the families' validation (D-100). - Short hub corners. An arm narrower than the hub by a small amount leaves correspondingly short hub-corner walls, which get no window below 1 m (ADR-011); the tests cover 0.5 m (11 m lobes on a 12 m core, in the reference's sweep) and 0.8 m corners. Corners near the clipping grid may meet the known limit of D-090; they are not tested.
- Smoke-test values. The values of Task 10 were observed in one scripted headless Rhino 8.25 session (D-056) on the reference's last commit
12a7e83, which reported the informational version0.8.3+12a7e83…; they were not re-observed on the replay. Task 10, 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 53 existing PNGs are byte-identical after Task 8 (Pillow 12.3.0); the four new ones equal the reference's bytes. The contact-sheet description is from the replay's sheet.
- 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-102 for the completion entry; this plan uses D-102 for the layout choices of note 7, which Tasks 1, 5, and 6 and the completion entry cite, and D-103 for the completion.
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.4.