S8.3 — Enclosed courtyard and footprints with holes Implementation Plan¶
Status: Done (controller, D-084; pending the owner's reading) · Date: 2026-10-02 · Roadmap: S8.3 · Decisions: D-009, D-026, D-041, D-061, D-064, D-065, D-076, D-079, D-082, D-083, D-084, D-087, D-088, D-090, D-091; ADR-006, ADR-011, ADR-002 (Tasks 1 and 9), ADR-008 (Tasks 3 and 9), ADR-013 (Tasks 4 and 9)
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.3 of D-084: footprints with holes in every pipeline step, and the plan generator of the last High-Density Housing family, HDHM-TYP-005 (Enclosed Court, four wings of stair bays around one court closed on all four sides), as ADR-006 specifies it, with its Grasshopper component and icon. The court is a hole of the footprint, outside the building: its edges are outdoor walls with windows that face into it, and it has no zone, floor, ceiling, ground slab, or roof. The surface builder now also requires every footprint edge, court edges included, to be covered by outdoor zone edges, so a zone filling a court is an error (UnmatchedEdge); Perimeter Core zones the court façades in their own zones P-Court-<Bin> and leaves a core that is a ring around the court, with its width precondition (D-087) applying to every wing between the outer and a court façade. No Simplification, Semantic Merge, Single Zone per Floor, the four floor aggregators, and validation already handle a hole; tests on a ring footprint show it. The enclosed court validates with every plan simplifier and floor aggregator, on its canonical plan and rotated and moved by two placements. The headless Rhino check of the reference found that RhinoGeometry built every zone solid inward-oriented and every floor facing up, on every plan released up to v0.8.1; S8.3 applies the orientation rule of D-091 (zone solids outward, every surface and window facing out of its zone). Every existing plan, floor, building, and snapshot stays unchanged. S8.3 ends with version 0.8.2 and the local tag v0.8.2; nothing is pushed in this run (D-084).
Architecture: LayoutSurfaceBuilder gains a check after edge classification: the outdoor segments on each edge of every footprint ring must add up to the edge's length (ToleranceSettings.AreaEquals, as façade coverage is compared). PerimeterCore offsets every ring of the footprint with the ADR-008 mitre formula (the outer ring counter-clockwise and the holes clockwise, so the floor always lies on the ring's left), checks each offset ring, and, with holes, checks that clipping the offset holes from the offset outer ring gives one polygon of the core's area; the trapezoids of the hole edges are united per bin into P-Court-<Bin> zones, listed after the outer zones and before CORE. The new family is a folder Lod.Generators/EnclosedCourt/ with a parameter record (a static Default holding the ADR-006 defaults), a preset record with one slot per space type, and a generator whose four wings are laid out by the S8.2 StairBayLayout.Wing, Fit, and FitErrors; the footprint is Polygon2.Create(outer, new[] { court }). In Lod.Grasshopper one component derives from the S8.1 TypologyGeneratorComponent; scripts/make_icons.py draws its icon in the Generate green; RhinoGeometry builds planar faces unreversed with the facing they need (rings reversed for a face facing down), extrudes zone solids from the downward floor, and guards both with Outward and Facing.
Tech Stack: C# 12 (LangVersion 12.0); .NET SDK ≥ 8 (global.json pins 8.0.100 with rollForward: latestMajor); netstandard2.0 core libraries; Clipper2 2.0.0; RhinoCommon and Grasshopper 8.19.25132.1001 (plugin targets net7.0); xUnit 2.9.3, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 17.14.1 (tests target net8.0); Windows PowerShell 5.1 for scripts/verify.ps1; Python 3.10 or later with Pillow for scripts/make_icons.py (the icons in this plan were drawn with Pillow 12.3.0).
Global Constraints¶
- Gate: D-090 and D-091 are recorded on
main(87bd272), and this plan is committed tomaintogether with the roadmap's S8.3 stage edits (see Roadmap edits) before Task 1 starts. ADR-006 and ADR-011, which specify the enclosed court and the minimum wall and window geometry, were accepted in S8.1 (roadmap S8 gate, D-084); S8.3 changes no record or default of ADR-006. S8.3 depends on S8.2, which is taggedv0.8.1locally. 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 enclosed court lists its south and north wings (west to east), then its west and east wings (south to north), each wing stairs first, then dwellings, numbered along the wing's axis; Perimeter Core lists the outer zones North, East, South, West, then the court zones North, East, South, West, then
CORE. - Numerical tolerances come only from
ToleranceSettings(ADR-004); S8.3 adds none and changes none. The boundary coverage of Task 1 compares lengths with the existing relative tolerance (AreaEquals), as façade coverage does (D-041); the1e-6margins in the test helpers that find court walls are test geometry, not tolerances of the code.ToleranceSettings.csis not touched. The minimum wall and window geometry stays inGeometryLimits(ADR-011), unchanged. - Existing outputs do not change. The fifteen existing snapshots (
linear-plan-canonical,stack-two-storeys, the threesimplifier-*, the threeaggregator-*, the four S8.1 canonical snapshotsstair-bay-bar-canonical,stair-pair-canonical,gallery-bar-canonical,terrace-row-canonical, and the three S8.2 canonical snapshotspoint-plate-canonical,winged-band-canonical,open-court-canonical) stay byte-identical, and every existing test keeps passing, except the one test that pinned the behaviour S8.3 replaces:SimplifierTests.FootprintsWithHolesAreNotSupportedYetis removed in Task 3, because Perimeter Core now zones footprints with holes (D-084). The check of Task 1 rejects only layouts that leave a footprint edge uncovered; no existing plan does. - Never weaken a check (AGENTS.md rules 11, 12). Task 1 adds a check; Task 3 keeps every check of Perimeter Core and adds one for holes. Each code change in
Lod.CoreandLod.Generatorsstarts from a test that fails the way the task says; Tasks 2, 4, 5, 6, and 8 add tests that pass at once, because the steps they cover already handle holes (each says so and says to stop if a case fails). No tolerance is widened, no validation check is relaxed, and no other test is weakened or removed. - Report, never repair (GLOBAL.md quality rule 6). A length that is not positive and finite, and stairs that leave no dwelling width in a wing, are the error
InvalidParameter, every one reported at once. A footprint that Perimeter Core cannot zone, including a wing between the outer and a court façade that is not wider than twice the depth, fails withPlanTooNarrow; it is never repaired. The generator does not impose Perimeter Core's precondition (D-087): it belongs to that simplifier. Zones about 1 m wide on rotated plans remain the known limit of D-090; S8.3 does not change it. - Orientation only (D-091). Task 13 changes the orientation of the Breps
RhinoGeometrycreates (zone solids, walls, floors, ceilings, windows, and the merged preview volumes), never their geometry. It applies to every plan generator, not only the enclosed court, and repairs output released up tov0.8.1. The Convert2BEM output layout is unchanged. - Typed records per family (AGENTS.md): the enclosed court has its own parameter and preset records; there is no catch-all parameter class and no dictionary of parameters. Its space types are the existing
DwellingUnitandStair, so the existing default preset components serve it. - Source contract (D-082). No book text, page images, or copied drawings enter BEMGen; ADR-006 and the precedent page paraphrase the family with its stable ID. The source repository is not opened or modified while this plan is executed; every text the tasks need is given here.
- No backward compatibility (D-064). One component is added with a new GUID: Enclosed Court
b9765cd0-a62b-483d-9470-86d5fda287e8. No existing component, input, or GUID changes; the outputs of existing components keep their layout, and only the orientation of the Breps of Convert2BEM and of the previews changes (D-091). - Grasshopper components are thin adaptors (AGENTS.md): every rule lives in
Lod.CoreorLod.Generators; the component reads inputs, calls the generator, and reports its diagnostics. - Research equivalence rules are not changed (GLOBAL.md scientific rule 7): a court façade counts in the glazing of the orientation it faces, as every façade does. The documents that state the rules S8.3 extends (ADR-002 for the boundary coverage, ADR-008 for the court perimeter zones, ADR-013 for the floor aggregators) are updated in Task 9, in the same slice as the code, once the enclosed court they cite exists; the slice is merged only after Task 9.
- 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-084). Nothing is pushed in this run: slices are rebased onto local
main, and the stage is taggedv0.8.2locally. Task 18 (the push) is not performed in this run; it waits for the owner. .ghexample definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S8.3 adds none.- S8.3 changes these earlier files:
LayoutSurfaceBuilder.cs,LayoutSurfaceBuilderTests.cs(S2, last changed in S8.2),Strategies.cs,SimplifierTests.cs(S3, last changed in S8.2),StairBayLayout.cs,TypologyGeneratorComponents.cs(S8.1, last changed in S8.2),RhinoGeometry.cs(S2, last changed by4b07283afterv0.5.0),scripts/make_icons.py(S4.1, last changed in S8.2), andDirectory.Build.props(S0, last changed in S8.2); and the documents ADR-002, ADR-008, ADR-013 (last changed in S8.2), ADR-006 (S8.1, last changed by34deeaaafterv0.8.1),docs/architecture/convert2bem.md(S2, last changed by4b07283),docs/architecture/pipeline.md,docs/research/precedents/hdhm-typologies.md,docs/research/program-presets.md, anddocs/development/grasshopper-smoke-test.md(last changed in S8.2), anddocs/architecture/validation.md(S3, last changed in S8.1).
Files¶
| Path | Change | Responsibility |
|---|---|---|
src/Lod.Core/Layout/LayoutSurfaceBuilder.cs |
Modify (S2 file, last changed in S8.2) | Every footprint edge, holes included, covered by outdoor zone edges |
src/Lod.Core/Simplification/Strategies.cs |
Modify (S3 file, last changed in S8.2) | Perimeter Core on every ring: court perimeter zones P-Court-<Bin>, a ring core, the check between the offset rings |
src/Lod.Generators/EnclosedCourt/EnclosedCourtGenerator.cs |
Create | EnclosedCourtParameters, EnclosedCourtPresets, EnclosedCourtGenerator (005) |
src/Lod.Generators/StairBayBar/StairBayLayout.cs |
Modify (S8.1 file, last changed in S8.2) | Documentation names the enclosed court |
tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs |
Modify (S2 file, last changed in S8.2) | Court walls, a zone with a hole, a zone filling the court, a rotated ring |
tests/Lod.Integration.Tests/RingPlan.cs |
Create | A ring footprint of four wings around a court, as a test plan generator |
tests/Lod.Integration.Tests/RingFootprintTests.cs |
Create | Every simplifier and aggregator on the ring footprint, canonical and placed; glazing per orientation; Perimeter Core precondition cases |
tests/Lod.Integration.Tests/SimplifierTests.cs |
Modify (S3 file, last changed in S8.2) | FootprintsWithHolesAreNotSupportedYet removed |
tests/Lod.Generators.Tests/EnclosedCourt/EnclosedCourtGeneratorTests.cs |
Create | Defaults, layout and zone IDs, the court as the footprint's hole, tiling, court façades, stairs, windows, invalid parameters, presets, determinism, provenance, snapshot |
tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt |
Create | Canonical plan of the family |
tests/Lod.Integration.Tests/EnclosedCourtTypologyTests.cs |
Create | The enclosed court × every simplifier × every aggregator, canonical and at two placements; Semantic Merge and Perimeter Core zones |
src/Lod.Grasshopper/Convert/RhinoGeometry.cs |
Modify (S2 file, last changed by 4b07283) |
How Breps carry holes (Task 10); the orientation rule (Task 13, D-091) |
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs |
Modify (S8.1 file, last changed in S8.2) | Enclosed Court |
scripts/make_icons.py |
Modify (S4.1 file, last changed in S8.2) | Motif for the new component |
src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png |
Create (generated) | 36 icons |
docs/decisions/ADR-002-geometry.md |
Modify | Holes on the plan boundary, the boundary coverage, Footprints with holes (S8.3); status line |
docs/decisions/ADR-006-plan-generation-mechanism.md |
Modify | 005 as implemented; pipeline consequences of S8.3 |
docs/decisions/ADR-008-perimeter-core-corners.md |
Modify | Every ring offset; the check between the offset rings; court perimeter zones |
docs/decisions/ADR-013-vertical-aggregation-methods.md |
Modify | Footprints with holes (S8.3) |
docs/architecture/convert2bem.md |
Modify | Orientation of the Zones, Surfaces, and Windows Breps (D-091) |
docs/architecture/pipeline.md, docs/architecture/validation.md, docs/research/precedents/hdhm-typologies.md, docs/research/program-presets.md |
Modify | The generator, holes in every step, the component table through S8.3 and the link to it |
docs/development/grasshopper-smoke-test.md |
Modify | Append the S8.3 checklist |
Directory.Build.props |
Modify | Version 0.8.2 |
AGENTS.md, README.md |
Modify | "Project state" and "Status" after S8.3; README layout line |
docs/plans/2026-09-30-implementation-roadmap.md |
Modify | Progress line and detailed-plans row (the S8.3 stage edits are committed with this plan) |
docs/decisions/decision-log.md |
Modify | S8.3 completion entry D-092 |
Test counts on net8.0 at the start of S8.3: 154 core, 159 generators, 474 integration. After Task 1: 158 / 159 / 474. After Task 2: 158 / 159 / 484. After Task 3: 158 / 159 / 492. After Task 4: 158 / 159 / 546. After Task 5: 158 / 159 / 548. After Task 6: 158 / 159 / 549. After Task 7: 158 / 181 / 549. After Task 8 and at the end: 158 core, 181 generators, 601 integration (core: +4 LayoutSurfaceBuilderTests; generators: +22 EnclosedCourtGeneratorTests; integration: +76 RingFootprintTests, +52 EnclosedCourtTypologyTests, −1 SimplifierTests). One test is removed (Task 3, SimplifierTests.FootprintsWithHolesAreNotSupportedYet); no other test is removed or changed. Grasshopper code has no unit tests; Tasks 10 to 13 are checked by a 0-warning build and the smoke test of Task 16.
Roadmap edits¶
The roadmap edits that describe S8.3 as executed are committed together with this plan, before Task 1: the S8.3 bullet of the S8 section names the pipeline consequences (the boundary coverage, the court perimeter zones and D-087, and the orientation rule of D-091), and the risk row on rounding on the clipping grid cites D-090. The close-out (Task 17) adds the Progress line and the S8.3 row of the detailed-plans table. The Lod.Generators/ lines of §2 (target repository layout) already name EnclosedCourt/ (S8.1) and stay as they are. After both, the roadmap differs from the S8.2 close-out only by these edits.
Task assignment (D-048, execution handoff §6)¶
| Task | Agent | Why |
|---|---|---|
| 1 Every footprint edge covered outdoors | bemgen-complex |
Geometry check in the layout surface builder, test-first; every existing snapshot must stay byte-identical |
| 2 Ring footprint through three simplifiers | bemgen-complex |
Plan simplifiers on a footprint with a hole, canonical and rotated (handoff §6: plan simplifiers) |
| 3 Perimeter Core zones court façades | bemgen-complex |
Geometry of a plan simplifier, test-first; removes a test whose behaviour is replaced |
| 4 Every floor aggregator on the ring | bemgen-complex |
Floor aggregators and exposure on a footprint with a hole (handoff §6: floor aggregators) |
| 5 Glazing on court façades | bemgen-complex |
Validation invariants per orientation (handoff §6: validation) |
| 6 A court offset crossing the outer offset | bemgen-complex |
A precondition branch of a plan simplifier |
| 7 Enclosed court (005) | bemgen-complex |
New plan generator geometry, the first footprint with a hole; new snapshot |
| 8 Enclosed court matrix | bemgen-complex |
Every simplifier and aggregator, rotated and moved (handoff §6: combination matrix) |
| 9 ADRs | bemgen-bounded |
Documents fully given by the plan |
| 10 How Breps carry holes | bemgen-bounded |
Documentation comments in Grasshopper code, fully given |
| 11 Enclosed Court component | bemgen-bounded |
Thin Grasshopper component |
| 12 Icon | bemgen-complex |
Generated binary and a visual check |
| 13 Orientation of Rhino geometry | bemgen-complex |
Rhino geometry conversion (handoff §6, S2 Task 7), checked only in Rhino |
| 14 Architecture documents | bemgen-bounded |
Documents |
| 15 S8.3 smoke-test checklist | bemgen-bounded |
Documents |
| 16 Smoke test in Rhino 8 | Manual (person), or the controller headless (D-056) | Needs Rhino 8 |
| 17 Close-out | bemgen-bounded |
Bookkeeping |
| 18 Push | Owner (not performed in this run, D-084) | A push is never delegated, and D-084 leaves it to the owner |
The end-of-stage review is done by bemgen-complex. A bounded task escalates to bemgen-complex after two failures or any deviation from this plan's expected output.
Slice A — feature/footprints-with-holes¶
The tasks of this slice make every pipeline step correct for a footprint with holes, first on a simple ring footprint (four wings around a square court, RingPlan) and then on the enclosed court generator, and record the rules in the ADRs (Task 9) before the slice is merged. Tasks 1 and 3 change code, each from a test that fails the way the task says; Tasks 2, 4, 5, and 6 add tests that pass at once, because the steps they cover already handle holes; if one of them fails, stop and report: a further pipeline change needs its own decision and test-first task.
Task 1: Every footprint edge, court edges included, is covered by outdoor zone edges¶
Files:
- Modify: src/Lod.Core/Layout/LayoutSurfaceBuilder.cs (S2 file, last changed in S8.2)
- Test: modify tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs (S2 file, last changed in S8.2)
Interfaces:
- Consumes: LayoutSurfaceBuilder.Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height) : Result<IReadOnlyList<Surface>> (S2; Classify marks a segment on any footprint ring as outdoors, S8.2 VertexGrid.IsOnSegment), Polygon2.Create(IEnumerable<Point2> outer, IEnumerable<IEnumerable<Point2>>? holes) (holes stored clockwise), Polygon2.Rings, ToleranceSettings.AreaEquals(double expected, double actual) (relative), PlanMotion(double rotationDegrees, double translationX, double translationY) and Polygon2.Transform(PlanMotion) (S4.2), Orientation.Bin, DiagnosticCodes.UnmatchedEdge (S2).
- Produces: after classification, LayoutSurfaceBuilder checks every edge of every footprint ring: the outdoor segments lying on it must add up to its length within AreaEquals; otherwise the error UnmatchedEdge with the message Plan boundary edge (x, y)-(x, y) is the outdoor edge of zones over {covered} m of its {length} m (a zone lies outside the footprint, such as in a court, or the zones leave a gap). The surfaces of every valid layout are unchanged.
- [x] Step 1: Create the branch
git switch -c feature/footprints-with-holes main
- [x] Step 2: Write the failing tests — replace
tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs(full file)
Four new tests on a 30 m × 30 m footprint with a 10 m × 10 m court and four 10 m deep wings: CourtEdgesBecomeOutdoorWallsFacingIntoTheCourt (the four court walls belong to S, N, W, E and face north, south, east, and west, 10 m each; 480 m² of outdoor wall; 8 interzone walls), AZoneWithAHoleHasOutdoorWallsOnEveryRing (one zone equal to the footprint has 8 outdoor walls, four of them on the court facing every direction, and its floor is the ring), AZoneFillingTheCourtIsAnError (a fifth zone in the court, every edge shared with a wing, gives UnmatchedEdge), and ARotatedRingKeepsItsCourtWalls (rotated by 131.4° and moved by (15.08, −153.9) m: 12 outdoor walls, 480 m²). Every other test is unchanged.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Xunit;
namespace Lod.Core.Tests.Layout;
public sealed class LayoutSurfaceBuilderTests
{
private readonly LayoutSurfaceBuilder _builder = new(ToleranceSettings.Default);
[Fact]
public void SingleZoneHasFourOutdoorWallsAndUnresolvedFloorAndCeiling()
{
IReadOnlyList<Surface> surfaces = Build(Polygon2.Rectangle(0, 0, 10, 5), Zone("A", 0, 0, 10, 5)).Value;
WallSurface[] walls = surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(4, walls.Length);
Assert.All(walls, w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
Assert.Equal(30.0 * 3.0, walls.Sum(w => w.Area), 9);
HorizontalSurface[] horizontal = surfaces.OfType<HorizontalSurface>().ToArray();
Assert.Equal(new[] { HorizontalKind.Floor, HorizontalKind.Ceiling }, horizontal.Select(h => h.Kind));
Assert.All(horizontal, h => Assert.Equal(BoundaryCondition.Unresolved, h.Boundary));
Assert.Equal(3.0, horizontal[1].Elevation);
}
[Fact]
public void SharedEdgeBecomesAPairOfInterzoneWalls()
{
IReadOnlyList<Surface> surfaces = Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 10, 5), Zone("B", 10, 0, 20, 5)).Value;
WallSurface ab = surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "A" && w.Boundary == BoundaryCondition.Interzone);
WallSurface ba = surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "B" && w.Boundary == BoundaryCondition.Interzone);
Assert.Equal("B", ab.AdjacentZone!.Value.Value);
Assert.Equal("A", ba.AdjacentZone!.Value.Value);
Assert.Equal(ab.Start, ba.End);
Assert.Equal(ab.End, ba.Start);
}
[Fact]
public void EdgesAreSplitWhereAnotherZoneTouchesThem()
{
// A long zone above two short ones: its south edge is shared with both.
IReadOnlyList<Surface> surfaces = Build(
Polygon2.Rectangle(0, 0, 20, 10),
Zone("TOP", 0, 5, 20, 10),
Zone("L", 0, 0, 12, 5),
Zone("R", 12, 0, 20, 5)).Value;
WallSurface[] shared = surfaces.OfType<WallSurface>().Where(w => w.Zone.Value == "TOP" && w.Boundary == BoundaryCondition.Interzone).ToArray();
Assert.Equal(2, shared.Length);
Assert.Equal(new[] { 12.0, 8.0 }.OrderBy(x => x), shared.Select(w => w.Length).OrderBy(x => x));
}
[Fact]
public void CollinearPiecesOfOneEdgeMergeIntoOneWall()
{
// The plan boundary has an extra vertex halfway along the north edge, which splits the zone's edge; the pieces merge back.
Polygon2 footprint = Polygon2.Create(new[] { new Point2(0, 0), new Point2(20, 0), new Point2(20, 5), new Point2(10, 5), new Point2(0, 5) }).Value;
IReadOnlyList<Surface> surfaces = Build(footprint, Zone("A", 0, 0, 20, 5)).Value;
WallSurface[] walls = surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(4, walls.Length);
Assert.Contains(walls, w => w.Start == new Point2(20, 5) && w.End == new Point2(0, 5));
}
[Fact]
public void GapsAreErrors()
{
Result<IReadOnlyList<Surface>> result = Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 9, 5), Zone("B", 10, 0, 20, 5));
Assert.False(result.IsSuccess);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.UnmatchedEdge);
}
[Fact]
public void OverlapsAreErrors()
{
Result<IReadOnlyList<Surface>> result = Build(Polygon2.Rectangle(0, 0, 10, 5), Zone("A", 0, 0, 10, 5), Zone("B", 0, 0, 10, 5));
Assert.False(result.IsSuccess);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.OverlappingZones);
}
[Fact]
public void WindowsAreSizedByEachZonesRatio()
{
IReadOnlyList<Surface> surfaces = LayoutMeasures.PlaceWindowsByRatio(
Build(Polygon2.Rectangle(0, 0, 20, 5), Zone("A", 0, 0, 10, 5), Zone("B", 10, 0, 20, 5)).Value,
id => id.Value == "A" ? 0.5 : 0.1,
GeometryLimits.Default,
ToleranceSettings.Default).Value;
WallSurface[] outdoor = surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.All(outdoor, w => Assert.Equal((w.Zone.Value == "A" ? 0.5 : 0.1) * w.Area, w.GlazedArea, 9));
Assert.All(surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Interzone), w => Assert.Empty(w.Windows));
}
[Fact]
public void RotatedTJunctionsOnTheBoundaryAndBetweenZonesAreRecognised()
{
// A pinwheel of four 8 m deep zones around a 6 m core, rotated by 131.4° and moved: the zone corners at (14, 0), (22, 14), (8, 22), and
// (0, 8) lie on the boundary between its corners, and snapped to the distance grid the one at (8, 22) lies 1.10 grid units off the snapped
// boundary edge although it is on the exact edge; the edge test measures the original points against the distance tolerance.
var motion = new PlanMotion(131.4, 15.08, -153.9);
LayoutZone Moved(string id, double x0, double y0, double x1, double y1) => new(new ZoneId(id), Polygon2.Rectangle(x0, y0, x1, y1).Transform(motion));
Result<IReadOnlyList<Surface>> result = _builder.Build(
Polygon2.Rectangle(0, 0, 22, 22).Transform(motion),
new[] { Moved("ST", 8, 8, 14, 14), Moved("U1", 0, 0, 14, 8), Moved("U2", 14, 0, 22, 14), Moved("U3", 8, 14, 22, 22), Moved("U4", 0, 8, 8, 22) },
3.0);
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
WallSurface[] walls = result.Value.OfType<WallSurface>().ToArray();
Assert.Equal(8, walls.Count(w => w.Boundary == BoundaryCondition.Outdoors));
Assert.Equal(22.0 * 4 * 3.0, walls.Where(w => w.Boundary == BoundaryCondition.Outdoors).Sum(w => w.Area), 6);
Assert.Equal(16, walls.Count(w => w.Boundary == BoundaryCondition.Interzone));
}
[Fact]
public void CourtEdgesBecomeOutdoorWallsFacingIntoTheCourt()
{
// Four 10 m deep wings around a 10 m × 10 m court: the court's edges lie on the footprint's hole and face into the court.
IReadOnlyList<Surface> surfaces = Build(Ring(), RingWings()).Value;
WallSurface[] court = surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w)).ToArray();
Assert.Equal(new[] { ("S", OrientationBin.North), ("N", OrientationBin.South), ("W", OrientationBin.East), ("E", OrientationBin.West) }, court.Select(w => (w.Zone.Value, Orientation.Bin(w.PlanAzimuth))));
Assert.All(court, w => Assert.Equal(10.0, w.Length, 12));
Assert.Equal((120.0 + 40.0) * 3.0, surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).Sum(w => w.Area), 9);
Assert.Equal(8, surfaces.OfType<WallSurface>().Count(w => w.Boundary == BoundaryCondition.Interzone));
}
[Fact]
public void AZoneWithAHoleHasOutdoorWallsOnEveryRing()
{
Polygon2 ring = Ring();
IReadOnlyList<Surface> surfaces = Build(ring, new LayoutZone(new ZoneId("A"), ring)).Value;
WallSurface[] walls = surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(8, walls.Length);
Assert.All(walls, w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
Assert.Equal(
new[] { OrientationBin.North, OrientationBin.East, OrientationBin.South, OrientationBin.West }.OrderBy(b => b),
walls.Where(OnCourt).Select(w => Orientation.Bin(w.PlanAzimuth)).OrderBy(b => b));
Assert.Equal(ring, Assert.IsType<HorizontalSurface>(surfaces.Single(s => s.Id.Value == "A/F")).Polygon);
}
[Fact]
public void AZoneFillingTheCourtIsAnError()
{
// Every edge of a zone in the court is shared with a wing, so only the footprint's hole edges, which no zone covers outdoors, reveal it.
Result<IReadOnlyList<Surface>> result = Build(Ring(), RingWings().Append(Zone("C", 10, 10, 20, 20)).ToArray());
Assert.False(result.IsSuccess);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.UnmatchedEdge);
}
[Fact]
public void ARotatedRingKeepsItsCourtWalls()
{
var motion = new PlanMotion(131.4, 15.08, -153.9);
Result<IReadOnlyList<Surface>> result = _builder.Build(
Ring().Transform(motion),
RingWings().Select(z => z with { Footprint = z.Footprint.Transform(motion) }).ToArray(),
3.0);
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
WallSurface[] outdoor = result.Value.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(12, outdoor.Length);
Assert.Equal((120.0 + 40.0) * 3.0, outdoor.Sum(w => w.Area), 6);
}
/// <summary>A 30 m × 30 m footprint with a 10 m × 10 m court in its middle.</summary>
private static Polygon2 Ring() =>
Polygon2.Create(Polygon2.Rectangle(0, 0, 30, 30).Outer, new[] { Polygon2.Rectangle(10, 10, 20, 20).Outer }).Value;
/// <summary>The four wings of <see cref="Ring"/>: south and north full width, west and east between them.</summary>
private static LayoutZone[] RingWings() =>
new[] { Zone("S", 0, 0, 30, 10), Zone("N", 0, 20, 30, 30), Zone("W", 0, 10, 10, 20), Zone("E", 20, 10, 30, 20) };
private static bool OnCourt(WallSurface wall) =>
new[] { wall.Start, wall.End }.All(p => p.X is >= 10 and <= 20 && p.Y is >= 10 and <= 20);
private static LayoutZone Zone(string id, double x0, double y0, double x1, double y1) => new(new ZoneId(id), Polygon2.Rectangle(x0, y0, x1, y1));
private Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, params LayoutZone[] zones) => _builder.Build(footprint, zones, 3.0);
}
- [x] Step 3: Run the tests and see one fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests"
Expected on net8.0: Failed! - Failed: 1, Passed: 11, Skipped: 0, Total: 12, Duration: … - Lod.Core.Tests.dll (net8.0). Three of the new tests pass already: the surface builder treats the court's edges as plan boundary since S2. The fourth fails, because a zone in the court shares every edge with a wing, so edge matching alone accepts it and the court's edges become interzone walls:
Failed Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests.AZoneFillingTheCourtIsAnError
Assert.False() Failure
Expected: False
Actual: True
- [x] Step 4: Implement — replace
src/Lod.Core/Layout/LayoutSurfaceBuilder.cs(full file)
CheckBoundaryCovered is added and called after Classify; the class summary names the check. Nothing else changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Core.Layout;
/// <summary>A zone footprint to build surfaces for.</summary>
/// <param name="Id">Zone ID.</param>
/// <param name="Footprint">Zone floor polygon.</param>
public sealed record LayoutZone(ZoneId Id, Polygon2 Footprint);
/// <summary>
/// Builds the surfaces of a single-storey layout: walls split wherever another zone's vertex touches them, paired across zones
/// (interzone), or on the plan boundary (outdoors), then merged into maximal collinear runs; plus one unresolved floor and ceiling per zone.
/// Edges that are neither shared nor on the boundary indicate gaps or overlaps and are errors, and so is a boundary edge that zones do not cover
/// outdoors over its full length. The boundary includes the footprint's holes: the edges of a court are outdoor walls facing into it.
/// </summary>
public sealed class LayoutSurfaceBuilder
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the builder.</summary>
/// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.Distance"/> is the vertex matching grid.</param>
public LayoutSurfaceBuilder(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <summary>Builds walls (without windows), floors, and ceilings.</summary>
/// <param name="footprint">Plan boundary; walls on it are outdoors.</param>
/// <param name="zones">Zones tiling the footprint.</param>
/// <param name="height">Floor-to-floor height, m.</param>
/// <returns>Surfaces grouped by zone (walls, floor, ceiling), or errors for gaps and overlaps.</returns>
public Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height)
{
var grid = new VertexGrid(_tolerances.Distance);
foreach (Point2 p in footprint.Rings.SelectMany(r => r).Concat(zones.SelectMany(z => z.Footprint.Rings.SelectMany(r => r))))
{
grid.Register(p);
}
GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
var segments = new List<Segment>();
for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
{
int ringIndex = 0;
foreach (IReadOnlyList<Point2> ring in zones[zoneIndex].Footprint.Rings)
{
AddRingSegments(grid, vertices, zoneIndex, ringIndex++, ring, segments);
}
}
var diagnostics = new List<Diagnostic>();
Classify(grid, footprint, zones, segments, diagnostics);
CheckBoundaryCovered(grid, footprint, segments, diagnostics);
if (diagnostics.Count > 0)
{
return Result.Failure<IReadOnlyList<Surface>>(diagnostics);
}
var surfaces = new List<Surface>();
for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
{
LayoutZone zone = zones[zoneIndex];
int wallNumber = 1;
foreach (IGrouping<int, Segment> ring in segments.Where(s => s.Zone == zoneIndex).GroupBy(s => s.Ring))
{
foreach (List<Segment> run in MergeRuns(ring.ToList()))
{
Segment first = run[0];
surfaces.Add(new WallSurface(
new SurfaceId($"{zone.Id.Value}/W{wallNumber++}"),
zone.Id,
grid.Original(first.A),
grid.Original(run[run.Count - 1].B),
elevation: 0.0,
height,
first.Boundary,
first.Adjacent is int adjacent ? zones[adjacent].Id : null,
Array.Empty<Window>()));
}
}
surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/F"), zone.Id, HorizontalKind.Floor, zone.Footprint, 0.0, BoundaryCondition.Unresolved, null));
surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/C"), zone.Id, HorizontalKind.Ceiling, zone.Footprint, height, BoundaryCondition.Unresolved, null));
}
return Result.Success<IReadOnlyList<Surface>>(surfaces);
}
private static void AddRingSegments(VertexGrid grid, GridPoint[] vertices, int zone, int ring, IReadOnlyList<Point2> points, List<Segment> segments)
{
for (int i = 0; i < points.Count; i++)
{
GridPoint a = grid.Key(points[i]);
GridPoint b = grid.Key(points[(i + 1) % points.Count]);
if (a == b)
{
continue;
}
GridPoint[] cuts = vertices
.Where(v => v != a && v != b && grid.IsStrictlyInside(a, b, v))
.OrderBy(v => VertexGrid.Parameter(a, b, v))
.ToArray();
GridPoint previous = a;
foreach (GridPoint cut in cuts.Concat(new[] { b }))
{
segments.Add(new Segment(zone, ring, previous, cut));
previous = cut;
}
}
}
private static void Classify(VertexGrid grid, Polygon2 footprint, IReadOnlyList<LayoutZone> zones, List<Segment> segments, List<Diagnostic> diagnostics)
{
var byDirection = segments.GroupBy(s => (s.A, s.B)).ToDictionary(g => g.Key, g => g.ToList());
GridPoint[][] boundaryRings = footprint.Rings.Select(r => r.Select(grid.Key).ToArray()).ToArray();
foreach (Segment segment in segments)
{
string subject = zones[segment.Zone].Id.Value;
if (byDirection[(segment.A, segment.B)].Count > 1)
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.OverlappingZones, $"Edge {Describe(grid, segment)} is used by more than one zone in the same direction.", subject));
continue;
}
if (byDirection.TryGetValue((segment.B, segment.A), out List<Segment>? opposite))
{
segment.Boundary = BoundaryCondition.Interzone;
segment.Adjacent = opposite[0].Zone;
}
else if (boundaryRings.Any(ring => OnRing(grid, ring, segment.A, segment.B)))
{
segment.Boundary = BoundaryCondition.Outdoors;
}
else
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.UnmatchedEdge, $"Edge {Describe(grid, segment)} is neither shared with another zone nor on the plan boundary (gap or overlap).", subject));
}
}
}
/// <summary>
/// Every edge of the plan boundary, outer ring and holes, must be covered over its full length by outdoor zone edges (within the relative
/// tolerance, as façade coverage is compared). Edge matching alone misses a zone lying outside the footprint whose every edge is shared with
/// another zone, such as a zone filling a court: the court's edges are then interzone instead of outdoors.
/// </summary>
private void CheckBoundaryCovered(VertexGrid grid, Polygon2 footprint, List<Segment> segments, List<Diagnostic> diagnostics)
{
Segment[] outdoor = segments.Where(s => s.Boundary == BoundaryCondition.Outdoors).ToArray();
foreach (GridPoint[] ring in footprint.Rings.Select(r => r.Select(grid.Key).ToArray()))
{
for (int i = 0; i < ring.Length; i++)
{
GridPoint p = ring[i];
GridPoint q = ring[(i + 1) % ring.Length];
if (p == q)
{
continue;
}
double length = grid.Original(p).DistanceTo(grid.Original(q));
double covered = outdoor.Where(s => grid.IsOnSegment(p, q, s.A) && grid.IsOnSegment(p, q, s.B)).Sum(s => grid.Original(s.A).DistanceTo(grid.Original(s.B)));
if (!_tolerances.AreaEquals(length, covered))
{
Point2 a = grid.Original(p);
Point2 b = grid.Original(q);
diagnostics.Add(Diagnostic.Error(
DiagnosticCodes.UnmatchedEdge,
$"Plan boundary edge ({a.X:0.###}, {a.Y:0.###})-({b.X:0.###}, {b.Y:0.###}) is the outdoor edge of zones over {covered:0.###} m of its {length:0.###} m (a zone lies outside the footprint, such as in a court, or the zones leave a gap)."));
}
}
}
}
private IEnumerable<List<Segment>> MergeRuns(List<Segment> ring)
{
int start = Enumerable.Range(0, ring.Count).FirstOrDefault(i => !CanMerge(ring[(i + ring.Count - 1) % ring.Count], ring[i]));
List<Segment>? run = null;
for (int k = 0; k < ring.Count; k++)
{
Segment segment = ring[(start + k) % ring.Count];
if (run is not null && CanMerge(run[run.Count - 1], segment))
{
run.Add(segment);
continue;
}
if (run is not null)
{
yield return run;
}
run = new List<Segment> { segment };
}
if (run is not null)
{
yield return run;
}
}
private bool CanMerge(Segment previous, Segment next)
{
if (previous.B != next.A || previous.Boundary != next.Boundary || previous.Adjacent != next.Adjacent)
{
return false;
}
double ax = previous.B.X - previous.A.X;
double ay = previous.B.Y - previous.A.Y;
double bx = next.B.X - next.A.X;
double by = next.B.Y - next.A.Y;
double lengths = Math.Sqrt((ax * ax) + (ay * ay)) * Math.Sqrt((bx * bx) + (by * by));
double sine = ((ax * by) - (ay * bx)) / lengths;
double cosine = ((ax * bx) + (ay * by)) / lengths;
return cosine > 0 && Math.Abs(sine) <= Math.Sin(_tolerances.Angle);
}
private static bool OnRing(VertexGrid grid, GridPoint[] ring, GridPoint a, GridPoint b)
{
for (int i = 0; i < ring.Length; i++)
{
GridPoint p = ring[i];
GridPoint q = ring[(i + 1) % ring.Length];
if (grid.IsOnSegment(p, q, a) && grid.IsOnSegment(p, q, b))
{
return true;
}
}
return false;
}
private static string Describe(VertexGrid grid, Segment segment)
{
Point2 a = grid.Original(segment.A);
Point2 b = grid.Original(segment.B);
return $"({a.X:0.###}, {a.Y:0.###})-({b.X:0.###}, {b.Y:0.###})";
}
private sealed class Segment
{
public Segment(int zone, int ring, GridPoint a, GridPoint b)
{
Zone = zone;
Ring = ring;
A = a;
B = b;
}
public int Zone { get; }
public int Ring { get; }
public GridPoint A { get; }
public GridPoint B { get; }
public BoundaryCondition Boundary { get; set; } = BoundaryCondition.Unresolved;
public int? Adjacent { get; set; }
}
}
- [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Layout.LayoutSurfaceBuilderTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12, Duration: … - Lod.Core.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 474 integration tests passed on net8.0; git status lists exactly M src/Lod.Core/Layout/LayoutSurfaceBuilder.cs and M tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs, and no *.received.txt: every existing plan covers its footprint edges, so every snapshot is unchanged.
- [x] Step 6: Commit
git add src/Lod.Core/Layout/LayoutSurfaceBuilder.cs tests/Lod.Core.Tests/Layout/LayoutSurfaceBuilderTests.cs
git commit -m "fix(core): require zones to cover every footprint edge outdoors, court edges included"
Task 2: No Simplification, Semantic Merge, and Single Zone per Floor on a ring footprint¶
Files:
- Test: create tests/Lod.Integration.Tests/RingPlan.cs, tests/Lod.Integration.Tests/RingFootprintTests.cs
Interfaces:
- Consumes: PlanGenerator<TParameters, TPresets> (S2–S8.1: Validate, Layout, Describe), PlanParameters(double FloorHeight, double OrientationDegrees), PlanPresets.Slots, PresetSlot, PlanLayout, PlannedZone, ExampleResidentialPresets.DwellingUnit; PlanTransform.Apply (S4.2); NoSimplification, SemanticMerge(ToleranceSettings) (S8.2: groups united as tiles, so a merged zone may have a hole), SingleZonePerFloor(ToleranceSettings); FloorValidator(ToleranceSettings).Validate(IFloor) : ValidationReport; test support Pipeline.Tolerances (S4.2); Task 1.
- Produces: RingPlan (internal test support): Generate(double wing = 10, double court = 10) : IGeneratedPlan (a 2 · wing + court square footprint with a court × court hole, the dwelling zones S and N full width and W and E between them, 3 m storeys) and Placed(double rotationDegrees, double dx, double dy, …); RingFootprintTests with the placements canonical, 30° and (5.3, −2.1) m, and 131.4° and (15.08, −153.9) m, and the helpers Plan, CourtWalls (outdoor walls on the court's edges, found in the plan frame before the placement), AssertOneCentredWindowPerOutdoorWall, and AssertFloorValidates. No production code changes.
This task has no red phase: it shows that the three simplifiers already handle a footprint with a hole (the court is outside every zone, the united zones keep the hole). If a case fails, stop and report.
- [x] Step 1: Create the ring plan — create
tests/Lod.Integration.Tests/RingPlan.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
namespace Lod.Integration.Tests;
/// <summary>
/// The simplest footprint with a hole: four dwelling wings of one depth around a square court, south and north full width, west and east between
/// them. Used to test every pipeline step on a court before the enclosed courtyard generator exists.
/// </summary>
internal static class RingPlan
{
/// <summary>A plan of four <paramref name="wing"/> deep wings around a <paramref name="court"/> × <paramref name="court"/> court, 3 m storeys.</summary>
public static IGeneratedPlan Generate(double wing = 10.0, double court = 10.0) =>
new RingGenerator().Generate(new RingParameters(wing, court), new RingPresets(ExampleResidentialPresets.DwellingUnit)).Value;
/// <summary>The plan moved by one of the placements used for rotated plans.</summary>
public static IGeneratedPlan Placed(double rotationDegrees, double dx, double dy, double wing = 10.0, double court = 10.0) =>
PlanTransform.Apply(Generate(wing, court), rotationDegrees, dx, dy).Value;
private sealed record RingParameters(double Wing, double Court) : PlanParameters(3.0, 0.0);
private sealed record RingPresets(ProgramPreset DwellingUnit) : PlanPresets
{
public override IReadOnlyList<PresetSlot> Slots => new[] { new PresetSlot(nameof(DwellingUnit), SpaceType.DwellingUnit, DwellingUnit) };
}
private sealed class RingGenerator : PlanGenerator<RingParameters, RingPresets>
{
public RingGenerator()
: base(ToleranceSettings.Default)
{
}
public override string Name => nameof(RingGenerator);
protected override IEnumerable<Diagnostic> Validate(RingParameters parameters) => Enumerable.Empty<Diagnostic>();
protected override PlanLayout Layout(RingParameters p)
{
double d = p.Wing;
double side = (2.0 * d) + p.Court;
PlannedZone Zone(string id, double x0, double y0, double x1, double y1) =>
new(new ZoneId(id), id, SpaceType.DwellingUnit, Polygon2.Rectangle(x0, y0, x1, y1));
Polygon2 footprint = Polygon2.Create(Polygon2.Rectangle(0, 0, side, side).Outer, new[] { Polygon2.Rectangle(d, d, d + p.Court, d + p.Court).Outer }).Value;
return new PlanLayout(footprint, new[]
{
Zone("S", 0, 0, side, d),
Zone("N", 0, d + p.Court, side, side),
Zone("W", 0, d, d, d + p.Court),
Zone("E", d + p.Court, d, side, d + p.Court),
});
}
protected override IEnumerable<KeyValuePair<string, string>> Describe(RingParameters p) => Enumerable.Empty<KeyValuePair<string, string>>();
}
}
- [x] Step 2: Write the tests — create
tests/Lod.Integration.Tests/RingFootprintTests.cs(full file)
10 cases: TheRingPlanHasOutdoorCourtWallsWithWindows (four court walls, of S, N, W, E, each with one window; 800 m²), and at each of the three placements NoSimplificationKeepsTheCourtWalls, SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt (one zone with one hole, 800 m², 8 outdoor walls each with one centred window, 4 of them on the court), and SingleZonePerFloorKeepsTheCourtAsAHole; every floor validates.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// Footprints with a hole through the pipeline (S8.3) on <see cref="RingPlan"/>: a 30 m × 30 m footprint of four 10 m deep dwelling wings around a
/// 10 m × 10 m court. The court is outside the footprint; its edges are outdoor walls facing into it.
/// </summary>
public sealed class RingFootprintTests
{
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="Lod.Core.Plans.PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 0.0, 0.0, 0.0 }, new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
public static IEnumerable<object[]> PlacementIndices() => Enumerable.Range(0, Placements.Length).Select(i => new object[] { i });
[Fact]
public void TheRingPlanHasOutdoorCourtWallsWithWindows()
{
IGeneratedPlan plan = RingPlan.Generate();
WallSurface[] court = CourtWalls(plan.Surfaces);
Assert.Equal(new[] { "S", "N", "W", "E" }, court.Select(w => w.Zone.Value));
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(800.0, plan.Footprint.Area, 9);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void NoSimplificationKeepsTheCourtWalls(int placement)
{
IGeneratedPlan plan = Plan(placement);
IFloor floor = new NoSimplification().Simplify(plan).Value;
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
AssertFloorValidates(floor);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt(int placement)
{
Result<IFloor> result = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone ring = Assert.Single(result.Value.Zones);
Assert.Single(ring.Parts[0].Footprint.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(800.0, ring.FloorArea), $"Area {ring.FloorArea}");
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SingleZonePerFloorKeepsTheCourtAsAHole(int placement)
{
Result<IFloor> result = new SingleZonePerFloor(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone floor = Assert.Single(result.Value.Zones);
Assert.Single(floor.Parts[0].Footprint.Holes);
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
private static IGeneratedPlan Plan(int placement)
{
double[] p = Placements[placement];
return placement == 0 ? RingPlan.Generate() : RingPlan.Placed(p[0], p[1], p[2]);
}
/// <summary>The outdoor walls on the court's edges, found in the plan frame before the placement.</summary>
private static WallSurface[] CourtWalls(IEnumerable<Surface> surfaces, int placement = 0)
{
double[] p = Placements[placement];
var back = new PlanMotion(-p[0], 0.0, 0.0);
Point2 Unplace(Point2 q) => back.Apply(new Point2(q.X - p[1], q.Y - p[2]));
bool OnCourt(Point2 q) => Unplace(q) is var u && u.X is > 10 - 1e-6 and < 20 + 1e-6 && u.Y is > 10 - 1e-6 and < 20 + 1e-6;
return surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w.Start) && OnCourt(w.End)).ToArray();
}
private static void AssertOneCentredWindowPerOutdoorWall(IFloor floor, int outdoorWalls, int placement)
{
WallSurface[] outdoor = floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(outdoorWalls, outdoor.Length);
Assert.All(outdoor, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
}
private static void AssertFloorValidates(IFloor floor)
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
}
- [x] Step 3: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests"
Expected: Passed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 4: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/RingPlan.cs tests/Lod.Integration.Tests/RingFootprintTests.cs
git commit -m "test(simplifiers): cover no simplification, semantic merge, and single zone per floor on a ring footprint"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 484 integration tests passed on net8.0; git status lists only ?? tests/Lod.Integration.Tests/RingFootprintTests.cs and ?? tests/Lod.Integration.Tests/RingPlan.cs.
Task 3: Perimeter Core zones the court façades of footprints with holes¶
Files:
- Modify: src/Lod.Core/Simplification/Strategies.cs (S3 file, last changed in S8.2)
- Test: modify tests/Lod.Integration.Tests/RingFootprintTests.cs (Task 2), tests/Lod.Integration.Tests/SimplifierTests.cs (S3 file, last changed in S8.2)
Interfaces:
- Consumes: PerimeterCore(ToleranceSettings, double depth) (S3; ADR-008: each footprint edge owns the trapezoid between the edge and its mitred inward offset, trapezoids of one orientation bin united into P-<Bin>, the offset ring the core CORE; S8.2: the PlanTooNarrow message ends with the D-087 precondition), Polygon2.SignedArea(IReadOnlyList<Point2>), PolygonOps.Difference(Polygon2, IEnumerable<Polygon2>), PolygonOps.Union, Orientation.OutwardAzimuth, DiagnosticCodes.PlanTooNarrow, DiagnosticCodes.NotSupported (S3); RingPlan and the helpers of Task 2.
- Produces: PerimeterCore offsets every ring of the footprint with Mitre (Offset), requires each offset ring to keep its edges' directions (KeepsDirections), its ring's orientation (positive signed area for the outer ring, negative for a hole), and to be a valid polygon, and the core Polygon2.Create(offset outer, offset holes) to be valid; with holes, clipping the offset holes from the offset outer ring must give one polygon of the core's area, else PlanTooNarrow with Perimeter depth {d} m leaves no valid core between the outer and the court façades: and the precondition. The trapezoids of the outer ring give P-<Bin> (Perimeter P-Court-<Bin> (Perimeter Court -1, -2 as for the outer zones), listed after the outer zones; the core CORE has a hole for each court. NotSupported is no longer emitted by Perimeter Core (the constant stays in DiagnosticCodes). Footprints without holes are zoned exactly as before.
- [x] Step 1: Write the failing tests — replace
tests/Lod.Integration.Tests/RingFootprintTests.cs(full file)
Nine new cases: PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore (zones P-North, P-East, P-South, P-West, P-Court-North, P-Court-East, P-Court-South, P-Court-West, CORE; outer trapezoids (60 − 2d)·d/2 and court trapezoids (20 + 2d)·d/2 with d = 4.57 m; the core (30 − 2d)² − (10 + 2d)² with one hole; 800 m² in all), CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone, PerimeterCoreOfARingValidates at the three placements, WingsNotWiderThanTwiceTheDepthAreTooNarrow (wings of 8 m and 9.14 m around a 10 m court, and 4 m around a 30 m court), and AWingJustWiderThanTwiceTheDepthIsZoned (9.2 m wings: a 0.06 m wide core ring). using System; and the constant PerimeterDepth are added; the tests of Task 2 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.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// Footprints with a hole through the pipeline (S8.3) on <see cref="RingPlan"/>: a 30 m × 30 m footprint of four 10 m deep dwelling wings around a
/// 10 m × 10 m court. The court is outside the footprint; its edges are outdoor walls facing into it.
/// </summary>
public sealed class RingFootprintTests
{
private const double PerimeterDepth = 4.57;
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="Lod.Core.Plans.PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 0.0, 0.0, 0.0 }, new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
public static IEnumerable<object[]> PlacementIndices() => Enumerable.Range(0, Placements.Length).Select(i => new object[] { i });
[Fact]
public void TheRingPlanHasOutdoorCourtWallsWithWindows()
{
IGeneratedPlan plan = RingPlan.Generate();
WallSurface[] court = CourtWalls(plan.Surfaces);
Assert.Equal(new[] { "S", "N", "W", "E" }, court.Select(w => w.Zone.Value));
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(800.0, plan.Footprint.Area, 9);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void NoSimplificationKeepsTheCourtWalls(int placement)
{
IGeneratedPlan plan = Plan(placement);
IFloor floor = new NoSimplification().Simplify(plan).Value;
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
AssertFloorValidates(floor);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt(int placement)
{
Result<IFloor> result = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone ring = Assert.Single(result.Value.Zones);
Assert.Single(ring.Parts[0].Footprint.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(800.0, ring.FloorArea), $"Area {ring.FloorArea}");
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SingleZonePerFloorKeepsTheCourtAsAHole(int placement)
{
Result<IFloor> result = new SingleZonePerFloor(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone floor = Assert.Single(result.Value.Zones);
Assert.Single(floor.Parts[0].Footprint.Holes);
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Fact]
public void PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore()
{
// Bisector trapezoids on the outer ring and on the court's ring (ADR-008); the court's façades get their own perimeter zones, named by the
// direction they face, and the core is what is left between them: a ring around the court.
const double d = PerimeterDepth;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, d).Simplify(RingPlan.Generate()).Value;
Assert.Equal(
new[] { "P-North", "P-East", "P-South", "P-West", "P-Court-North", "P-Court-East", "P-Court-South", "P-Court-West", "CORE" },
floor.Zones.Select(z => z.Id.Value));
Assert.Equal("Perimeter Court North", floor.Zones.Single(z => z.Id.Value == "P-Court-North").Name);
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-") && !z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((30.0 + 30.0 - (2 * d)) * d / 2.0, z.FloorArea, 6));
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((10.0 + 10.0 + (2 * d)) * d / 2.0, z.FloorArea, 6));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Equal(Math.Pow(30.0 - (2 * d), 2) - Math.Pow(10.0 + (2 * d), 2), core.Area, 6);
Assert.Single(core.Holes);
Assert.Equal(800.0, floor.Zones.Sum(z => z.FloorArea), 6);
}
[Fact]
public void CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate()).Value;
WallSurface[] court = CourtWalls(floor.Surfaces);
Assert.Equal(
new[] { ("P-Court-North", OrientationBin.North), ("P-Court-East", OrientationBin.East), ("P-Court-South", OrientationBin.South), ("P-Court-West", OrientationBin.West) },
court.Select(w => (w.Zone.Value, Orientation.Bin(w.PlanAzimuth))));
Assert.All(floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Zone.Value.StartsWith("P-Court")), w => Assert.Contains(w, court));
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
AssertOneCentredWindowPerOutdoorWall(floor, 8, 0);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void PerimeterCoreOfARingValidates(int placement)
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(9, result.Value.Zones.Count);
AssertFloorValidates(result.Value);
}
[Theory]
[InlineData(8.0, 10.0)]
[InlineData(9.14, 10.0)]
[InlineData(4.0, 30.0)]
public void WingsNotWiderThanTwiceTheDepthAreTooNarrow(double wing, double court)
{
// D-087 applies to every wing between the outer and the court façades: the outer and court perimeter bands would overlap.
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(wing, court));
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.EndsWith("every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.", error.Message);
}
[Fact]
public void AWingJustWiderThanTwiceTheDepthIsZoned()
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(9.2, 10.0));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(0.06 * 4 * (10.0 + (2 * 4.57) + 0.06), result.Value.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
}
private static IGeneratedPlan Plan(int placement)
{
double[] p = Placements[placement];
return placement == 0 ? RingPlan.Generate() : RingPlan.Placed(p[0], p[1], p[2]);
}
/// <summary>The outdoor walls on the court's edges, found in the plan frame before the placement.</summary>
private static WallSurface[] CourtWalls(IEnumerable<Surface> surfaces, int placement = 0)
{
double[] p = Placements[placement];
var back = new PlanMotion(-p[0], 0.0, 0.0);
Point2 Unplace(Point2 q) => back.Apply(new Point2(q.X - p[1], q.Y - p[2]));
bool OnCourt(Point2 q) => Unplace(q) is var u && u.X is > 10 - 1e-6 and < 20 + 1e-6 && u.Y is > 10 - 1e-6 and < 20 + 1e-6;
return surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w.Start) && OnCourt(w.End)).ToArray();
}
private static void AssertOneCentredWindowPerOutdoorWall(IFloor floor, int outdoorWalls, int placement)
{
WallSurface[] outdoor = floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(outdoorWalls, outdoor.Length);
Assert.All(outdoor, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
}
private static void AssertFloorValidates(IFloor floor)
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
}
- [x] Step 2: Remove the test of the old behaviour — replace
tests/Lod.Integration.Tests/SimplifierTests.cs(full file)
FootprintsWithHolesAreNotSupportedYet is removed: it asserted NotSupported for a ring footprint, which this task replaces by zoning it (D-084). Every other test is unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.Linear;
using Lod.Generators.OpenCourt;
using Lod.Generators.PointPlate;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class SimplifierTests
{
private const double PerimeterDepth = 4.57;
[Fact]
public void SemanticMergeJoinsConnectedZonesOfTheSameType()
{
IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));
Assert.Equal(new[] { "Stair-1", "Corridor-1", "DwellingUnit-1", "DwellingUnit-2" }, floor.Zones.Select(z => z.Id.Value));
Zone south = floor.Zones.Single(z => z.Id.Value == "DwellingUnit-1");
Assert.Equal(160.0, south.FloorArea, 6);
Assert.Equal(new[] { "US1", "US2" }, south.SourceZones.Select(s => s.Value));
Assert.Equal(5.0, south.Program.Find(LoadType.Lighting, LoadBasis.PerFloorArea)!.Value, 9);
}
[Fact]
public void MergedZonesLoseTheirSharedPartition()
{
IFloor floor = Pipeline.Floor(new SemanticMerge(Pipeline.Tolerances));
Assert.DoesNotContain(floor.Surfaces, s => s.Zone == s.AdjacentZone);
Assert.Equal(2, floor.Surfaces.OfType<WallSurface>().Count(w => w.Zone.Value == "DwellingUnit-1" && w.Boundary == BoundaryCondition.Outdoors));
}
[Fact]
public void SemanticMergeUnitesZonesMeetingAtTJunctionsOnARotatedPlan()
{
// The point plate's dwellings meet each other and the core at T-junctions; rotated off the axes, a plain union of their footprints leaves
// slits where clipping-grid rounding moves a T-junction off its edge, so the zones are united as tiles (ADR-013).
IGeneratedPlan plate = new PointPlateGenerator(Pipeline.Tolerances)
.Generate(PointPlateParameters.Default, new PointPlatePresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;
IGeneratedPlan plan = PlanTransform.Apply(plate, 30.0, 5.3, -2.1).Value;
Result<IFloor> floor = new SemanticMerge(Pipeline.Tolerances).Simplify(plan);
Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
Assert.Equal(new[] { "Stair-1", "DwellingUnit-1" }, floor.Value.Zones.Select(z => z.Id.Value));
Polygon2 ring = floor.Value.Zones.Single(z => z.Id.Value == "DwellingUnit-1").Parts[0].Footprint;
Assert.Single(ring.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(4 * 112.0, ring.Area), $"Area {ring.Area}");
}
[Fact]
public void PerimeterCoreHasFourPerimeterZonesAndACore()
{
IFloor floor = Pipeline.Floor(new PerimeterCore(Pipeline.Tolerances, PerimeterDepth));
Assert.Equal(new[] { "P-North", "P-East", "P-South", "P-West", "CORE" }, floor.Zones.Select(z => z.Id.Value));
Assert.Equal((24.0 - (2 * PerimeterDepth)) * (18.0 - (2 * PerimeterDepth)), floor.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
}
[Fact]
public void PerimeterSouthHasOneCentredWindowWithTheSouthFacadeGlazing()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Pipeline.Plan()).Value;
WallSurface south = floor.Surfaces.OfType<WallSurface>().Single(w => w.Zone.Value == "P-South" && w.Boundary == BoundaryCondition.Outdoors);
Window window = Assert.Single(south.Windows);
Assert.Equal(1.2 + 9.0 + 9.0, window.Area, 9);
Assert.Equal((24.0 - window.Width) / 2.0, window.Offset, 9);
Assert.Equal((3.0 - window.Height) / 2.0, window.SillHeight, 9);
Assert.Equal(window.Width / 24.0, window.Height / 3.0, 12);
}
[Fact]
public void PerimeterDepthTooLargeIsAnError()
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, 10.0).Simplify(Pipeline.Plan());
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.PlanTooNarrow);
}
[Fact]
public void WingsNarrowerThanTwiceThePerimeterDepthAreAnActionableError()
{
// 8 m deep wings around an open court: the 4.57 m perimeters of a wing's two façades would overlap (ADR-008 precondition).
IGeneratedPlan court = new OpenCourtGenerator(Pipeline.Tolerances)
.Generate(OpenCourtParameters.Default with { Depth = 8.0 }, new OpenCourtPresets(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair)).Value;
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(court);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.Equal(
"Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.",
error.Message);
}
[Theory]
[InlineData(0.0)]
[InlineData(-1.0)]
public void NonPositivePerimeterDepthIsAnError(double depth)
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, depth).Simplify(Pipeline.Plan());
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.InvalidParameter);
}
[Fact]
public void SingleZonePerFloorHasOnlyOutdoorWalls()
{
IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
Zone zone = Assert.Single(floor.Zones);
Assert.Equal(SpaceType.Mixed, zone.SpaceType);
Assert.Equal(6, zone.SourceZones.Count);
Assert.All(floor.Surfaces.OfType<WallSurface>(), w => Assert.Equal(BoundaryCondition.Outdoors, w.Boundary));
Assert.Equal(4, floor.Surfaces.OfType<WallSurface>().Count());
}
[Fact]
public void SetpointsAreFloorAreaWeightedAndRecorded()
{
IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
Thermostat thermostat = floor.Zones[0].Program.Thermostat!;
Assert.Equal(21.0, thermostat.HeatingSetpoint[0], 9);
Assert.Equal(Lod.Core.Aggregation.AggregationMethod.FloorAreaWeighted, thermostat.HeatingSetpoint.Aggregation!.Method);
Assert.DoesNotContain(new ZoneId("ST"), thermostat.HeatingSetpoint.Aggregation.Sources);
Assert.Equal(5, thermostat.HeatingSetpoint.Aggregation.Sources.Count);
}
[Theory]
[InlineData("SemanticMerge", 1)]
[InlineData("SemanticMerge", 2)]
[InlineData("SemanticMerge", 3)]
[InlineData("PerimeterCore", 1)]
[InlineData("PerimeterCore", 2)]
[InlineData("PerimeterCore", 3)]
[InlineData("PerimeterCore", 4)]
[InlineData("SingleZonePerFloor", 1)]
[InlineData("SingleZonePerFloor", 2)]
public void EverySimplifierSatisfiesTheInvariantsForRandomPlans(string simplifier, int seed)
{
LinearPlanParameters parameters = RandomParameters(new Random(seed));
IFloor floor = Simplifier(simplifier).Simplify(Pipeline.Plan(parameters)).Value;
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MergingTheUnconditionedStairEnlargesConditionedAreaAndIsReported()
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances)));
CheckResult conditioned = report.Checks.Single(c => c.Check == "ConditionedFloorArea");
Assert.False(conditioned.Passed);
Assert.False(conditioned.Enforced);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void ValidationCatchesAnUncoveredFacade()
{
IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
Surface[] withoutSouthWall = floor.Surfaces.Where(s => !(s is WallSurface w && w.Start.Y == 0.0 && w.End.Y == 0.0)).ToArray();
var broken = new Floor(floor.Source, floor.Zones, withoutSouthWall, floor.Mapping, floor.Provenance);
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);
Assert.Contains(report.Checks, c => c.Check == "FacadeCoverage" && !c.Passed && c.Enforced);
Assert.False(report.Passed);
}
[Fact]
public void NoSimplificationPassesValidation()
{
Assert.True(new FloorValidator(Pipeline.Tolerances).Validate(Pipeline.DetailedFloor()).Passed);
}
[Fact]
public void ValidationCatchesAChangedProgram()
{
IFloor floor = Pipeline.Floor(new SingleZonePerFloor(Pipeline.Tolerances));
Zone zone = floor.Zones[0];
var tampered = new Zone(zone.Id, zone.Name, zone.SpaceType, zone.Parts, ExampleResidentialPresets.DwellingUnit.Program, zone.SourceZones);
var broken = new Floor(floor.Source, new[] { tampered }, floor.Surfaces, floor.Mapping, floor.Provenance);
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(broken);
Assert.False(report.Passed);
Assert.Contains(report.Checks, c => c.Check == "Installed.Lighting" && !c.Passed);
Assert.Contains(report.Checks, c => c.Check == "Traceability" && !c.Passed);
}
[Theory]
[InlineData("SemanticMerge")]
[InlineData("PerimeterCore")]
[InlineData("SingleZonePerFloor")]
public void SimplifiersAreDeterministic(string simplifier)
{
Assert.Equal(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))));
}
[Theory]
[InlineData("SemanticMerge")]
[InlineData("PerimeterCore")]
[InlineData("SingleZonePerFloor")]
public void CanonicalSnapshots(string simplifier)
{
Snapshot.Match(TextReport.Describe(Pipeline.Floor(Simplifier(simplifier))), $"simplifier-{simplifier}");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, PerimeterDepth),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static LinearPlanParameters RandomParameters(Random random) => new(
Length: 18.0 + (random.NextDouble() * 42.0),
UnitDepth: 6.0 + (random.NextDouble() * 4.0),
CorridorWidth: 1.5 + (random.NextDouble() * 1.5),
TargetUnitWidth: 5.0 + (random.NextDouble() * 7.0),
StairLength: 3.0 + (random.NextDouble() * 3.0),
FloorHeight: 2.8 + (random.NextDouble() * 0.7),
OrientationDegrees: random.NextDouble() * 360.0);
}
- [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests"
Expected on net8.0: Failed! - Failed: 9, Passed: 10, Skipped: 0, Total: 19, Duration: … - Lod.Integration.Tests.dll (net8.0). The ten tests of Task 2 pass; the nine new ones fail because Perimeter Core still rejects every footprint with holes, the three precondition cases because they expect PlanTooNarrow instead (in the order the runner reports them):
Failed Lod.Integration.Tests.RingFootprintTests.PerimeterCoreOfARingValidates(placement: 2)
Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
Failed Lod.Integration.Tests.RingFootprintTests.PerimeterCoreOfARingValidates(placement: 1)
Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
Failed Lod.Integration.Tests.RingFootprintTests.PerimeterCoreOfARingValidates(placement: 0)
Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
Failed Lod.Integration.Tests.RingFootprintTests.WingsNotWiderThanTwiceTheDepthAreTooNarrow(wing: 8, court: 10)
Assert.Equal() Failure: Strings differ
↓ (pos 0)
Expected: "PlanTooNarrow"
Actual: "NotSupported"
↑ (pos 0)
Failed Lod.Integration.Tests.RingFootprintTests.WingsNotWiderThanTwiceTheDepthAreTooNarrow(wing: 9.1400000000000006, court: 10)
Assert.Equal() Failure: Strings differ
↓ (pos 0)
Expected: "PlanTooNarrow"
Actual: "NotSupported"
↑ (pos 0)
Failed Lod.Integration.Tests.RingFootprintTests.WingsNotWiderThanTwiceTheDepthAreTooNarrow(wing: 4, court: 30)
Assert.Equal() Failure: Strings differ
↓ (pos 0)
Expected: "PlanTooNarrow"
Actual: "NotSupported"
↑ (pos 0)
Failed Lod.Integration.Tests.RingFootprintTests.CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone
System.InvalidOperationException : Result has no value: Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
Failed Lod.Integration.Tests.RingFootprintTests.PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore
System.InvalidOperationException : Result has no value: Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
Failed Lod.Integration.Tests.RingFootprintTests.AWingJustWiderThanTwiceTheDepthIsZoned
Error NotSupported: Perimeter/core of footprints with holes is not supported yet (S8, typologies).
- [x] Step 4: Implement — replace
src/Lod.Core/Simplification/Strategies.cs(full file)
Only PerimeterCore changes: its summary, the offset of every ring with the checks, the trapezoids and zones per ring, and the helpers Offset and KeepsDirections; SemanticMerge and SingleZonePerFloor are unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
namespace Lod.Core.Simplification;
/// <summary>
/// Z1: merges zones of the same space type that are connected through shared walls. Each connected group becomes one zone, the union of its
/// footprints as tiles (<see cref="PolygonOps.UnionTiles"/>), so zones meeting at T-junctions unite exactly on rotated plans; the union may have
/// holes, where the group surrounds zones of another type.
/// </summary>
public sealed class SemanticMerge : PlanSimplifier
{
/// <summary>Creates the simplifier.</summary>
/// <param name="tolerances">Tolerances.</param>
public SemanticMerge(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(SemanticMerge);
/// <inheritdoc />
protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
{
Dictionary<ZoneId, Zone> byId = plan.Zones.ToDictionary(z => z.Id);
ILookup<ZoneId, ZoneId> neighbours = plan.Surfaces
.Where(s => s.Boundary == BoundaryCondition.Interzone)
.ToLookup(s => s.Zone, s => s.AdjacentZone!.Value);
var ops = new PolygonOps(Tolerances);
var visited = new HashSet<ZoneId>();
var targets = new List<TargetZone>();
var counters = new Dictionary<SpaceType, int>();
foreach (Zone seed in plan.Zones.Where(z => !visited.Contains(z.Id)))
{
var group = new List<Zone>();
var queue = new Queue<Zone>(new[] { seed });
visited.Add(seed.Id);
while (queue.Count > 0)
{
Zone current = queue.Dequeue();
group.Add(current);
foreach (ZoneId next in neighbours[current.Id].Where(n => !visited.Contains(n) && byId[n].SpaceType == seed.SpaceType))
{
visited.Add(next);
queue.Enqueue(byId[next]);
}
}
IReadOnlyList<Polygon2> union = ops.UnionTiles(group.Select(z => z.Parts[0].Footprint).ToArray());
if (union.Count != 1)
{
return Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(
DiagnosticCodes.DisconnectedGroup,
$"Merged group {string.Join(",", group.Select(z => z.Id.Value))} is not one polygon.",
seed.Id.Value));
}
int number = counters.TryGetValue(seed.SpaceType, out int n) ? n + 1 : 1;
counters[seed.SpaceType] = number;
targets.Add(new TargetZone(new ZoneId($"{seed.SpaceType}-{number}"), $"{seed.SpaceType} {number}", seed.SpaceType, union[0]));
}
return Result.Success<IReadOnlyList<TargetZone>>(targets);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
/// <summary>
/// Z2: perimeter zones of a configurable depth plus a core (ADR-008). Each footprint edge owns the trapezoid between the edge and its inward
/// mitred offset, so corners are split along the bisectors; trapezoids in the same orientation bin are merged. 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);
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 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));
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;
});
}
private Point2 Mitre(Point2 previous, Point2 vertex, Point2 next)
{
(double X, double Y) a = InwardNormal(previous, vertex);
(double X, double Y) b = InwardNormal(vertex, next);
double scale = _depth / (1.0 + (a.X * b.X) + (a.Y * b.Y));
return new Point2(vertex.X + ((a.X + b.X) * scale), vertex.Y + ((a.Y + b.Y) * scale));
}
private static (double X, double Y) InwardNormal(Point2 from, Point2 to)
{
double length = from.DistanceTo(to);
return (-(to.Y - from.Y) / length, (to.X - from.X) / length);
}
private static Result<IReadOnlyList<TargetZone>> Fail(string code, string message) =>
Result.Failure<IReadOnlyList<TargetZone>>(Diagnostic.Error(code, message));
}
/// <summary>Z3: the whole floor as one zone.</summary>
public sealed class SingleZonePerFloor : PlanSimplifier
{
/// <summary>Creates the simplifier.</summary>
/// <param name="tolerances">Tolerances.</param>
public SingleZonePerFloor(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(SingleZonePerFloor);
/// <inheritdoc />
protected override Result<IReadOnlyList<TargetZone>> ProposeZones(IGeneratedPlan plan)
{
SpaceType[] types = plan.Zones.Select(z => z.SpaceType).Distinct().ToArray();
SpaceType type = types.Length == 1 ? types[0] : SpaceType.Mixed;
return Result.Success<IReadOnlyList<TargetZone>>(new[] { new TargetZone(new ZoneId("FLOOR"), "Floor", type, plan.Footprint) });
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe() => Enumerable.Empty<KeyValuePair<string, string>>();
}
- [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests|FullyQualifiedName~Lod.Integration.Tests.SimplifierTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 49, Skipped: 0, Total: 49, Duration: … - Lod.Integration.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 492 integration tests passed on net8.0 (nine added, one removed); git status lists exactly M src/Lod.Core/Simplification/Strategies.cs, M tests/Lod.Integration.Tests/RingFootprintTests.cs, and M tests/Lod.Integration.Tests/SimplifierTests.cs, and no *.received.txt: simplifier-PerimeterCore.txt and every other snapshot are unchanged, because a footprint without holes has one ring and is zoned as before.
- [x] Step 6: Commit
git add src/Lod.Core/Simplification/Strategies.cs tests/Lod.Integration.Tests/RingFootprintTests.cs tests/Lod.Integration.Tests/SimplifierTests.cs
git commit -m "feature(simplifiers): zone the court facades of footprints with holes in perimeter core"
Task 4: Every floor aggregator on a ring footprint¶
Files:
- Test: modify tests/Lod.Integration.Tests/RingFootprintTests.cs (Task 3)
Interfaces:
- Consumes: test support Pipeline.Simplifier(string name), Pipeline.Aggregator(string name) (S4.2); FloorEntry(IFloor, int), IFloorAggregator.Aggregate, BuildingValidator(ToleranceSettings).Validate, Totals.Of(zones, surfaces, orientationDegrees) (GroundArea, RoofArea, ExposedFloorArea), PolygonOps.IntersectionArea, HorizontalSurface, DiagnosticSeverity.Warning; Tasks 2 and 3.
- Produces: the ring through every simplifier × every aggregator (floors 1, 3, 1) at the three placements builds without warnings and validates; the court has no ground slab and no roof (ground and roof are the footprint's 800 m², not the outer square's 900 m², and no ground, roof, or exposed floor piece overlaps the court); the single-zone methods keep the court as a hole in every part and rebuild the 20 court walls of the five storeys with one centred window each, holding five times the source court glazing. No production code changes.
This task has no red phase: the floor aggregators already handle the hole (ADR-013: the designation on the stack gives the court no floor or ceiling piece, and the storey outlines are unions of tiles that keep it). If a case fails, stop and report.
- [x] Step 1: Write the tests — replace
tests/Lod.Integration.Tests/RingFootprintTests.cs(full file)
54 new cases: EveryAggregatorBuildsAndValidatesARingBuilding (48: four simplifiers × four aggregators × three placements), TheCourtHasNoGroundSlabAndNoRoof (4: one per aggregator, with the storeys modelled, 5, 3, 5, 5), and SingleZoneMethodsRebuildTheCourtWallsOfEveryStoreyWithCentredWindows (2: zones E0,E1,E2 and BUILDING). using Lod.Core.Buildings; and the helpers Entries and Overlap are added; every other test is unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// Footprints with a hole through the pipeline (S8.3) on <see cref="RingPlan"/>: a 30 m × 30 m footprint of four 10 m deep dwelling wings around a
/// 10 m × 10 m court. The court is outside the footprint; its edges are outdoor walls facing into it.
/// </summary>
public sealed class RingFootprintTests
{
private const double PerimeterDepth = 4.57;
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="Lod.Core.Plans.PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 0.0, 0.0, 0.0 }, new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };
public static IEnumerable<object[]> PlacementIndices() => Enumerable.Range(0, Placements.Length).Select(i => new object[] { i });
public static IEnumerable<object[]> Matrix() =>
from simplifier in Simplifiers
from aggregator in Aggregators
from placement in Enumerable.Range(0, Placements.Length)
select new object[] { simplifier, aggregator, placement };
[Fact]
public void TheRingPlanHasOutdoorCourtWallsWithWindows()
{
IGeneratedPlan plan = RingPlan.Generate();
WallSurface[] court = CourtWalls(plan.Surfaces);
Assert.Equal(new[] { "S", "N", "W", "E" }, court.Select(w => w.Zone.Value));
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(800.0, plan.Footprint.Area, 9);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void NoSimplificationKeepsTheCourtWalls(int placement)
{
IGeneratedPlan plan = Plan(placement);
IFloor floor = new NoSimplification().Simplify(plan).Value;
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
AssertFloorValidates(floor);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt(int placement)
{
Result<IFloor> result = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone ring = Assert.Single(result.Value.Zones);
Assert.Single(ring.Parts[0].Footprint.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(800.0, ring.FloorArea), $"Area {ring.FloorArea}");
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SingleZonePerFloorKeepsTheCourtAsAHole(int placement)
{
Result<IFloor> result = new SingleZonePerFloor(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone floor = Assert.Single(result.Value.Zones);
Assert.Single(floor.Parts[0].Footprint.Holes);
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Fact]
public void PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore()
{
// Bisector trapezoids on the outer ring and on the court's ring (ADR-008); the court's façades get their own perimeter zones, named by the
// direction they face, and the core is what is left between them: a ring around the court.
const double d = PerimeterDepth;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, d).Simplify(RingPlan.Generate()).Value;
Assert.Equal(
new[] { "P-North", "P-East", "P-South", "P-West", "P-Court-North", "P-Court-East", "P-Court-South", "P-Court-West", "CORE" },
floor.Zones.Select(z => z.Id.Value));
Assert.Equal("Perimeter Court North", floor.Zones.Single(z => z.Id.Value == "P-Court-North").Name);
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-") && !z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((30.0 + 30.0 - (2 * d)) * d / 2.0, z.FloorArea, 6));
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((10.0 + 10.0 + (2 * d)) * d / 2.0, z.FloorArea, 6));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Equal(Math.Pow(30.0 - (2 * d), 2) - Math.Pow(10.0 + (2 * d), 2), core.Area, 6);
Assert.Single(core.Holes);
Assert.Equal(800.0, floor.Zones.Sum(z => z.FloorArea), 6);
}
[Fact]
public void CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate()).Value;
WallSurface[] court = CourtWalls(floor.Surfaces);
Assert.Equal(
new[] { ("P-Court-North", OrientationBin.North), ("P-Court-East", OrientationBin.East), ("P-Court-South", OrientationBin.South), ("P-Court-West", OrientationBin.West) },
court.Select(w => (w.Zone.Value, Orientation.Bin(w.PlanAzimuth))));
Assert.All(floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Zone.Value.StartsWith("P-Court")), w => Assert.Contains(w, court));
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
AssertOneCentredWindowPerOutdoorWall(floor, 8, 0);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void PerimeterCoreOfARingValidates(int placement)
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(9, result.Value.Zones.Count);
AssertFloorValidates(result.Value);
}
[Theory]
[InlineData(8.0, 10.0)]
[InlineData(9.14, 10.0)]
[InlineData(4.0, 30.0)]
public void WingsNotWiderThanTwiceTheDepthAreTooNarrow(double wing, double court)
{
// D-087 applies to every wing between the outer and the court façades: the outer and court perimeter bands would overlap.
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(wing, court));
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.EndsWith("every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.", error.Message);
}
[Fact]
public void AWingJustWiderThanTwiceTheDepthIsZoned()
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(9.2, 10.0));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(0.06 * 4 * (10.0 + (2 * 4.57) + 0.06), result.Value.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
}
[Theory]
[MemberData(nameof(Matrix))]
public void EveryAggregatorBuildsAndValidatesARingBuilding(string simplifier, string aggregator, int placement)
{
IFloor floor = Pipeline.Simplifier(simplifier).Simplify(Plan(placement)).Value;
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor));
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack", 5)]
[InlineData("StackedFloorZoneMultiplier", 3)]
[InlineData("SingleZonePerFloorType", 5)]
[InlineData("SingleZoneBuilding", 5)]
public void TheCourtHasNoGroundSlabAndNoRoof(string aggregator, int storeysModelled)
{
// The court is outside the footprint: ground and roof are the footprint's 800 m², not the outer rectangle's 900 m².
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(new NoSimplification().Simplify(RingPlan.Generate()).Value)).Value;
Totals totals = Totals.Of(building.Zones, building.Surfaces.ToArray(), building.OrientationDegrees);
Assert.Equal(800.0, totals.GroundArea, 6);
Assert.Equal(800.0, totals.RoofArea, 6);
Assert.Equal(0.0, totals.ExposedFloorArea, 6);
Assert.All(building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Interzone), h => Assert.Equal(0.0, Overlap(h.Polygon, Polygon2.Rectangle(10, 10, 20, 20)), 9));
Assert.Equal(storeysModelled, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation).Distinct().Count());
}
[Theory]
[InlineData("SingleZonePerFloorType", "E0,E1,E2")]
[InlineData("SingleZoneBuilding", "BUILDING")]
public void SingleZoneMethodsRebuildTheCourtWallsOfEveryStoreyWithCentredWindows(string aggregator, string zones)
{
IFloor floor = new NoSimplification().Simplify(RingPlan.Generate()).Value;
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor)).Value;
Assert.Equal(zones, string.Join(",", building.Zones.Select(z => z.Id.Value)));
Assert.All(building.Zones.SelectMany(z => z.Parts), p => Assert.Single(p.Footprint.Holes));
WallSurface[] court = CourtWalls(building.Surfaces);
Assert.Equal(5 * 4, court.Length);
double sourceCourtGlazing = CourtWalls(floor.Surfaces).Sum(w => w.GlazedArea);
Assert.All(court, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(5 * sourceCourtGlazing, court.Sum(w => w.GlazedArea), 9);
}
private static FloorEntry[] Entries(IFloor floor) => new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) };
private static double Overlap(Polygon2 a, Polygon2 b) => new PolygonOps(Pipeline.Tolerances).IntersectionArea(a, b);
private static IGeneratedPlan Plan(int placement)
{
double[] p = Placements[placement];
return placement == 0 ? RingPlan.Generate() : RingPlan.Placed(p[0], p[1], p[2]);
}
/// <summary>The outdoor walls on the court's edges, found in the plan frame before the placement.</summary>
private static WallSurface[] CourtWalls(IEnumerable<Surface> surfaces, int placement = 0)
{
double[] p = Placements[placement];
var back = new PlanMotion(-p[0], 0.0, 0.0);
Point2 Unplace(Point2 q) => back.Apply(new Point2(q.X - p[1], q.Y - p[2]));
bool OnCourt(Point2 q) => Unplace(q) is var u && u.X is > 10 - 1e-6 and < 20 + 1e-6 && u.Y is > 10 - 1e-6 and < 20 + 1e-6;
return surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w.Start) && OnCourt(w.End)).ToArray();
}
private static void AssertOneCentredWindowPerOutdoorWall(IFloor floor, int outdoorWalls, int placement)
{
WallSurface[] outdoor = floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(outdoorWalls, outdoor.Length);
Assert.All(outdoor, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
}
private static void AssertFloorValidates(IFloor floor)
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
}
- [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests"
Expected: Passed! - Failed: 0, Passed: 73, Skipped: 0, Total: 73, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 3: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/RingFootprintTests.cs
git commit -m "test(aggregators): cover every floor aggregator on a ring footprint"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 546 integration tests passed on net8.0; git status lists only M tests/Lod.Integration.Tests/RingFootprintTests.cs.
Task 5: Glazing per orientation counts the court façades¶
Files:
- Test: modify tests/Lod.Integration.Tests/RingFootprintTests.cs (Task 4)
Interfaces:
- Consumes: Totals.Of (GlazingByOrientation, Glazing, ExteriorWallArea, FloorArea), FloorValidator, Floor(IGeneratedPlan, zones, surfaces, mapping, provenance), WallSurface.WithWindows, ExampleResidentialPresets.DwellingUnit.WindowToWallRatio; Task 4.
- Produces: on the ring, every orientation holds the glazing of a 30 m outer and a 10 m court façade (the south wing's court façade faces north), 480 m² of exterior wall, and 800 m² of floor; a floor whose court walls lose their windows fails exactly the checks Glazing, Glazing.North, Glazing.East, Glazing.South, Glazing.West. No production code changes.
This task has no red phase: validation already counts every outdoor wall by the direction it faces. If a case fails, stop and report.
- [x] Step 1: Write the tests — replace
tests/Lod.Integration.Tests/RingFootprintTests.cs(full file)
Two new tests, GlazingPerOrientationCountsTheCourtFacades and AFloorThatLosesTheCourtGlazingFailsValidation; using Lod.Core.Programs; is added; every other test is unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// Footprints with a hole through the pipeline (S8.3) on <see cref="RingPlan"/>: a 30 m × 30 m footprint of four 10 m deep dwelling wings around a
/// 10 m × 10 m court. The court is outside the footprint; its edges are outdoor walls facing into it.
/// </summary>
public sealed class RingFootprintTests
{
private const double PerimeterDepth = 4.57;
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="Lod.Core.Plans.PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 0.0, 0.0, 0.0 }, new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };
public static IEnumerable<object[]> PlacementIndices() => Enumerable.Range(0, Placements.Length).Select(i => new object[] { i });
public static IEnumerable<object[]> Matrix() =>
from simplifier in Simplifiers
from aggregator in Aggregators
from placement in Enumerable.Range(0, Placements.Length)
select new object[] { simplifier, aggregator, placement };
[Fact]
public void TheRingPlanHasOutdoorCourtWallsWithWindows()
{
IGeneratedPlan plan = RingPlan.Generate();
WallSurface[] court = CourtWalls(plan.Surfaces);
Assert.Equal(new[] { "S", "N", "W", "E" }, court.Select(w => w.Zone.Value));
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(800.0, plan.Footprint.Area, 9);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void NoSimplificationKeepsTheCourtWalls(int placement)
{
IGeneratedPlan plan = Plan(placement);
IFloor floor = new NoSimplification().Simplify(plan).Value;
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
AssertFloorValidates(floor);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt(int placement)
{
Result<IFloor> result = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone ring = Assert.Single(result.Value.Zones);
Assert.Single(ring.Parts[0].Footprint.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(800.0, ring.FloorArea), $"Area {ring.FloorArea}");
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SingleZonePerFloorKeepsTheCourtAsAHole(int placement)
{
Result<IFloor> result = new SingleZonePerFloor(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone floor = Assert.Single(result.Value.Zones);
Assert.Single(floor.Parts[0].Footprint.Holes);
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Fact]
public void PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore()
{
// Bisector trapezoids on the outer ring and on the court's ring (ADR-008); the court's façades get their own perimeter zones, named by the
// direction they face, and the core is what is left between them: a ring around the court.
const double d = PerimeterDepth;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, d).Simplify(RingPlan.Generate()).Value;
Assert.Equal(
new[] { "P-North", "P-East", "P-South", "P-West", "P-Court-North", "P-Court-East", "P-Court-South", "P-Court-West", "CORE" },
floor.Zones.Select(z => z.Id.Value));
Assert.Equal("Perimeter Court North", floor.Zones.Single(z => z.Id.Value == "P-Court-North").Name);
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-") && !z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((30.0 + 30.0 - (2 * d)) * d / 2.0, z.FloorArea, 6));
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((10.0 + 10.0 + (2 * d)) * d / 2.0, z.FloorArea, 6));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Equal(Math.Pow(30.0 - (2 * d), 2) - Math.Pow(10.0 + (2 * d), 2), core.Area, 6);
Assert.Single(core.Holes);
Assert.Equal(800.0, floor.Zones.Sum(z => z.FloorArea), 6);
}
[Fact]
public void CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate()).Value;
WallSurface[] court = CourtWalls(floor.Surfaces);
Assert.Equal(
new[] { ("P-Court-North", OrientationBin.North), ("P-Court-East", OrientationBin.East), ("P-Court-South", OrientationBin.South), ("P-Court-West", OrientationBin.West) },
court.Select(w => (w.Zone.Value, Orientation.Bin(w.PlanAzimuth))));
Assert.All(floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Zone.Value.StartsWith("P-Court")), w => Assert.Contains(w, court));
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
AssertOneCentredWindowPerOutdoorWall(floor, 8, 0);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void PerimeterCoreOfARingValidates(int placement)
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(9, result.Value.Zones.Count);
AssertFloorValidates(result.Value);
}
[Theory]
[InlineData(8.0, 10.0)]
[InlineData(9.14, 10.0)]
[InlineData(4.0, 30.0)]
public void WingsNotWiderThanTwiceTheDepthAreTooNarrow(double wing, double court)
{
// D-087 applies to every wing between the outer and the court façades: the outer and court perimeter bands would overlap.
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(wing, court));
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.EndsWith("every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.", error.Message);
}
[Fact]
public void AWingJustWiderThanTwiceTheDepthIsZoned()
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(9.2, 10.0));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(0.06 * 4 * (10.0 + (2 * 4.57) + 0.06), result.Value.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
}
[Theory]
[MemberData(nameof(Matrix))]
public void EveryAggregatorBuildsAndValidatesARingBuilding(string simplifier, string aggregator, int placement)
{
IFloor floor = Pipeline.Simplifier(simplifier).Simplify(Plan(placement)).Value;
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor));
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack", 5)]
[InlineData("StackedFloorZoneMultiplier", 3)]
[InlineData("SingleZonePerFloorType", 5)]
[InlineData("SingleZoneBuilding", 5)]
public void TheCourtHasNoGroundSlabAndNoRoof(string aggregator, int storeysModelled)
{
// The court is outside the footprint: ground and roof are the footprint's 800 m², not the outer rectangle's 900 m².
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(new NoSimplification().Simplify(RingPlan.Generate()).Value)).Value;
Totals totals = Totals.Of(building.Zones, building.Surfaces.ToArray(), building.OrientationDegrees);
Assert.Equal(800.0, totals.GroundArea, 6);
Assert.Equal(800.0, totals.RoofArea, 6);
Assert.Equal(0.0, totals.ExposedFloorArea, 6);
Assert.All(building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Interzone), h => Assert.Equal(0.0, Overlap(h.Polygon, Polygon2.Rectangle(10, 10, 20, 20)), 9));
Assert.Equal(storeysModelled, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation).Distinct().Count());
}
[Theory]
[InlineData("SingleZonePerFloorType", "E0,E1,E2")]
[InlineData("SingleZoneBuilding", "BUILDING")]
public void SingleZoneMethodsRebuildTheCourtWallsOfEveryStoreyWithCentredWindows(string aggregator, string zones)
{
IFloor floor = new NoSimplification().Simplify(RingPlan.Generate()).Value;
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor)).Value;
Assert.Equal(zones, string.Join(",", building.Zones.Select(z => z.Id.Value)));
Assert.All(building.Zones.SelectMany(z => z.Parts), p => Assert.Single(p.Footprint.Holes));
WallSurface[] court = CourtWalls(building.Surfaces);
Assert.Equal(5 * 4, court.Length);
double sourceCourtGlazing = CourtWalls(floor.Surfaces).Sum(w => w.GlazedArea);
Assert.All(court, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(5 * sourceCourtGlazing, court.Sum(w => w.GlazedArea), 9);
}
[Fact]
public void GlazingPerOrientationCountsTheCourtFacades()
{
// The court façade of the south wing faces north: north glazing is that of the 30 m outer and the 10 m court façade.
IGeneratedPlan plan = RingPlan.Generate();
double ratio = ExampleResidentialPresets.DwellingUnit.WindowToWallRatio;
Totals totals = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees);
Assert.All(totals.GlazingByOrientation.Values, g => Assert.Equal(ratio * (30.0 + 10.0) * 3.0, g, 9));
Assert.Equal(ratio * 160.0 * 3.0, totals.Glazing, 9);
Assert.Equal(160.0 * 3.0, totals.ExteriorWallArea, 9);
Assert.Equal(800.0, totals.FloorArea, 9);
}
[Fact]
public void AFloorThatLosesTheCourtGlazingFailsValidation()
{
IGeneratedPlan plan = RingPlan.Generate();
IFloor detailed = new NoSimplification().Simplify(plan).Value;
WallSurface[] court = CourtWalls(detailed.Surfaces);
var floor = new Floor(
plan,
detailed.Zones,
detailed.Surfaces.Select(s => s is WallSurface w && court.Contains(w) ? w.WithWindows(Array.Empty<Window>()) : s),
detailed.Mapping,
detailed.Provenance);
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.False(report.Passed);
Assert.Equal(
new[] { "Glazing", "Glazing.North", "Glazing.East", "Glazing.South", "Glazing.West" },
report.Checks.Where(c => !c.Passed).Select(c => c.Check));
}
private static FloorEntry[] Entries(IFloor floor) => new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) };
private static double Overlap(Polygon2 a, Polygon2 b) => new PolygonOps(Pipeline.Tolerances).IntersectionArea(a, b);
private static IGeneratedPlan Plan(int placement)
{
double[] p = Placements[placement];
return placement == 0 ? RingPlan.Generate() : RingPlan.Placed(p[0], p[1], p[2]);
}
/// <summary>The outdoor walls on the court's edges, found in the plan frame before the placement.</summary>
private static WallSurface[] CourtWalls(IEnumerable<Surface> surfaces, int placement = 0)
{
double[] p = Placements[placement];
var back = new PlanMotion(-p[0], 0.0, 0.0);
Point2 Unplace(Point2 q) => back.Apply(new Point2(q.X - p[1], q.Y - p[2]));
bool OnCourt(Point2 q) => Unplace(q) is var u && u.X is > 10 - 1e-6 and < 20 + 1e-6 && u.Y is > 10 - 1e-6 and < 20 + 1e-6;
return surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w.Start) && OnCourt(w.End)).ToArray();
}
private static void AssertOneCentredWindowPerOutdoorWall(IFloor floor, int outdoorWalls, int placement)
{
WallSurface[] outdoor = floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(outdoorWalls, outdoor.Length);
Assert.All(outdoor, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
}
private static void AssertFloorValidates(IFloor floor)
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
}
- [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests"
Expected: Passed! - Failed: 0, Passed: 75, Skipped: 0, Total: 75, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 3: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/RingFootprintTests.cs
git commit -m "test(validation): check glazing per orientation on court facades"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 548 integration tests passed on net8.0; git status lists only M tests/Lod.Integration.Tests/RingFootprintTests.cs.
Task 6: A court offset that crosses the outer offset is too narrow¶
Files:
- Test: modify tests/Lod.Integration.Tests/RingFootprintTests.cs (Task 5)
Interfaces:
- Consumes: PerimeterCore (Task 3), GeneratedPlan(Polygon2 footprint, double floorHeight, double orientationDegrees, zones, surfaces, Provenance), Provenance.Of.
- Produces: a test of the check between the offset rings of Task 3: a 30 m × 30 m footprint with an off-centre 10 m × 10 m court that leaves a 2 m west wing; the offset court crosses the offset outer ring although the core polygon built from the offset rings is valid with a positive area, and Perimeter Core fails with the single error PlanTooNarrow, Perimeter depth 4.57 m leaves no valid core between the outer and the court façades: every wing…. No production code changes.
This task has no red phase: it covers a branch of the code of Task 3 that the cases of Task 3 do not reach (each of them fails an earlier check). If it fails, stop and report.
- [x] Step 1: Write the test — replace
tests/Lod.Integration.Tests/RingFootprintTests.cs(full file)
One new test, ACourtWhoseOffsetLeavesTheOuterOffsetIsTooNarrow; every other test is unchanged.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>
/// Footprints with a hole through the pipeline (S8.3) on <see cref="RingPlan"/>: a 30 m × 30 m footprint of four 10 m deep dwelling wings around a
/// 10 m × 10 m court. The court is outside the footprint; its edges are outdoor walls facing into it.
/// </summary>
public sealed class RingFootprintTests
{
private const double PerimeterDepth = 4.57;
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="Lod.Core.Plans.PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 0.0, 0.0, 0.0 }, new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };
public static IEnumerable<object[]> PlacementIndices() => Enumerable.Range(0, Placements.Length).Select(i => new object[] { i });
public static IEnumerable<object[]> Matrix() =>
from simplifier in Simplifiers
from aggregator in Aggregators
from placement in Enumerable.Range(0, Placements.Length)
select new object[] { simplifier, aggregator, placement };
[Fact]
public void TheRingPlanHasOutdoorCourtWallsWithWindows()
{
IGeneratedPlan plan = RingPlan.Generate();
WallSurface[] court = CourtWalls(plan.Surfaces);
Assert.Equal(new[] { "S", "N", "W", "E" }, court.Select(w => w.Zone.Value));
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(800.0, plan.Footprint.Area, 9);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void NoSimplificationKeepsTheCourtWalls(int placement)
{
IGeneratedPlan plan = Plan(placement);
IFloor floor = new NoSimplification().Simplify(plan).Value;
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
AssertFloorValidates(floor);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SemanticMergeUnitesTheWingsIntoOneZoneAroundTheCourt(int placement)
{
Result<IFloor> result = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone ring = Assert.Single(result.Value.Zones);
Assert.Single(ring.Parts[0].Footprint.Holes);
Assert.True(Pipeline.Tolerances.AreaEquals(800.0, ring.FloorArea), $"Area {ring.FloorArea}");
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void SingleZonePerFloorKeepsTheCourtAsAHole(int placement)
{
Result<IFloor> result = new SingleZonePerFloor(Pipeline.Tolerances).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Zone floor = Assert.Single(result.Value.Zones);
Assert.Single(floor.Parts[0].Footprint.Holes);
AssertOneCentredWindowPerOutdoorWall(result.Value, 8, placement);
AssertFloorValidates(result.Value);
}
[Fact]
public void PerimeterCoreZonesTheOuterAndTheCourtFacadesAroundARingCore()
{
// Bisector trapezoids on the outer ring and on the court's ring (ADR-008); the court's façades get their own perimeter zones, named by the
// direction they face, and the core is what is left between them: a ring around the court.
const double d = PerimeterDepth;
IFloor floor = new PerimeterCore(Pipeline.Tolerances, d).Simplify(RingPlan.Generate()).Value;
Assert.Equal(
new[] { "P-North", "P-East", "P-South", "P-West", "P-Court-North", "P-Court-East", "P-Court-South", "P-Court-West", "CORE" },
floor.Zones.Select(z => z.Id.Value));
Assert.Equal("Perimeter Court North", floor.Zones.Single(z => z.Id.Value == "P-Court-North").Name);
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-") && !z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((30.0 + 30.0 - (2 * d)) * d / 2.0, z.FloorArea, 6));
Assert.All(floor.Zones.Where(z => z.Id.Value.StartsWith("P-Court")), z => Assert.Equal((10.0 + 10.0 + (2 * d)) * d / 2.0, z.FloorArea, 6));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Equal(Math.Pow(30.0 - (2 * d), 2) - Math.Pow(10.0 + (2 * d), 2), core.Area, 6);
Assert.Single(core.Holes);
Assert.Equal(800.0, floor.Zones.Sum(z => z.FloorArea), 6);
}
[Fact]
public void CourtPerimeterZonesHoldTheCourtFacadesAndTheCoreHasNone()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate()).Value;
WallSurface[] court = CourtWalls(floor.Surfaces);
Assert.Equal(
new[] { ("P-Court-North", OrientationBin.North), ("P-Court-East", OrientationBin.East), ("P-Court-South", OrientationBin.South), ("P-Court-West", OrientationBin.West) },
court.Select(w => (w.Zone.Value, Orientation.Bin(w.PlanAzimuth))));
Assert.All(floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && w.Zone.Value.StartsWith("P-Court")), w => Assert.Contains(w, court));
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
AssertOneCentredWindowPerOutdoorWall(floor, 8, 0);
}
[Theory]
[MemberData(nameof(PlacementIndices))]
public void PerimeterCoreOfARingValidates(int placement)
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(Plan(placement));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(9, result.Value.Zones.Count);
AssertFloorValidates(result.Value);
}
[Theory]
[InlineData(8.0, 10.0)]
[InlineData(9.14, 10.0)]
[InlineData(4.0, 30.0)]
public void WingsNotWiderThanTwiceTheDepthAreTooNarrow(double wing, double court)
{
// D-087 applies to every wing between the outer and the court façades: the outer and court perimeter bands would overlap.
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(wing, court));
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.EndsWith("every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.", error.Message);
}
[Fact]
public void ACourtWhoseOffsetLeavesTheOuterOffsetIsTooNarrow()
{
// An off-centre court with a 2 m west wing: the offset court crosses the offset outer ring on the west, although the core polygon built from
// the offset rings still has a positive area; clipping the offset court from the offset outer ring reveals it.
Polygon2 footprint = Polygon2.Create(Polygon2.Rectangle(0, 0, 30, 30).Outer, new[] { Polygon2.Rectangle(2, 10, 12, 20).Outer }).Value;
var plan = new GeneratedPlan(footprint, 3.0, 0.0, Array.Empty<Zone>(), Array.Empty<Surface>(), Provenance.Of("Test", Array.Empty<KeyValuePair<string, string>>()));
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(plan);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, error.Code);
Assert.StartsWith("Perimeter depth 4.57 m leaves no valid core between the outer and the court façades: every wing", error.Message);
}
[Fact]
public void AWingJustWiderThanTwiceTheDepthIsZoned()
{
Result<IFloor> result = new PerimeterCore(Pipeline.Tolerances, PerimeterDepth).Simplify(RingPlan.Generate(9.2, 10.0));
Assert.True(result.IsSuccess, string.Join("; ", result.Diagnostics));
Assert.Equal(0.06 * 4 * (10.0 + (2 * 4.57) + 0.06), result.Value.Zones.Single(z => z.Id.Value == "CORE").FloorArea, 6);
}
[Theory]
[MemberData(nameof(Matrix))]
public void EveryAggregatorBuildsAndValidatesARingBuilding(string simplifier, string aggregator, int placement)
{
IFloor floor = Pipeline.Simplifier(simplifier).Simplify(Plan(placement)).Value;
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor));
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack", 5)]
[InlineData("StackedFloorZoneMultiplier", 3)]
[InlineData("SingleZonePerFloorType", 5)]
[InlineData("SingleZoneBuilding", 5)]
public void TheCourtHasNoGroundSlabAndNoRoof(string aggregator, int storeysModelled)
{
// The court is outside the footprint: ground and roof are the footprint's 800 m², not the outer rectangle's 900 m².
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(new NoSimplification().Simplify(RingPlan.Generate()).Value)).Value;
Totals totals = Totals.Of(building.Zones, building.Surfaces.ToArray(), building.OrientationDegrees);
Assert.Equal(800.0, totals.GroundArea, 6);
Assert.Equal(800.0, totals.RoofArea, 6);
Assert.Equal(0.0, totals.ExposedFloorArea, 6);
Assert.All(building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Interzone), h => Assert.Equal(0.0, Overlap(h.Polygon, Polygon2.Rectangle(10, 10, 20, 20)), 9));
Assert.Equal(storeysModelled, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation).Distinct().Count());
}
[Theory]
[InlineData("SingleZonePerFloorType", "E0,E1,E2")]
[InlineData("SingleZoneBuilding", "BUILDING")]
public void SingleZoneMethodsRebuildTheCourtWallsOfEveryStoreyWithCentredWindows(string aggregator, string zones)
{
IFloor floor = new NoSimplification().Simplify(RingPlan.Generate()).Value;
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(Entries(floor)).Value;
Assert.Equal(zones, string.Join(",", building.Zones.Select(z => z.Id.Value)));
Assert.All(building.Zones.SelectMany(z => z.Parts), p => Assert.Single(p.Footprint.Holes));
WallSurface[] court = CourtWalls(building.Surfaces);
Assert.Equal(5 * 4, court.Length);
double sourceCourtGlazing = CourtWalls(floor.Surfaces).Sum(w => w.GlazedArea);
Assert.All(court, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(5 * sourceCourtGlazing, court.Sum(w => w.GlazedArea), 9);
}
[Fact]
public void GlazingPerOrientationCountsTheCourtFacades()
{
// The court façade of the south wing faces north: north glazing is that of the 30 m outer and the 10 m court façade.
IGeneratedPlan plan = RingPlan.Generate();
double ratio = ExampleResidentialPresets.DwellingUnit.WindowToWallRatio;
Totals totals = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees);
Assert.All(totals.GlazingByOrientation.Values, g => Assert.Equal(ratio * (30.0 + 10.0) * 3.0, g, 9));
Assert.Equal(ratio * 160.0 * 3.0, totals.Glazing, 9);
Assert.Equal(160.0 * 3.0, totals.ExteriorWallArea, 9);
Assert.Equal(800.0, totals.FloorArea, 9);
}
[Fact]
public void AFloorThatLosesTheCourtGlazingFailsValidation()
{
IGeneratedPlan plan = RingPlan.Generate();
IFloor detailed = new NoSimplification().Simplify(plan).Value;
WallSurface[] court = CourtWalls(detailed.Surfaces);
var floor = new Floor(
plan,
detailed.Zones,
detailed.Surfaces.Select(s => s is WallSurface w && court.Contains(w) ? w.WithWindows(Array.Empty<Window>()) : s),
detailed.Mapping,
detailed.Provenance);
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.False(report.Passed);
Assert.Equal(
new[] { "Glazing", "Glazing.North", "Glazing.East", "Glazing.South", "Glazing.West" },
report.Checks.Where(c => !c.Passed).Select(c => c.Check));
}
private static FloorEntry[] Entries(IFloor floor) => new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) };
private static double Overlap(Polygon2 a, Polygon2 b) => new PolygonOps(Pipeline.Tolerances).IntersectionArea(a, b);
private static IGeneratedPlan Plan(int placement)
{
double[] p = Placements[placement];
return placement == 0 ? RingPlan.Generate() : RingPlan.Placed(p[0], p[1], p[2]);
}
/// <summary>The outdoor walls on the court's edges, found in the plan frame before the placement.</summary>
private static WallSurface[] CourtWalls(IEnumerable<Surface> surfaces, int placement = 0)
{
double[] p = Placements[placement];
var back = new PlanMotion(-p[0], 0.0, 0.0);
Point2 Unplace(Point2 q) => back.Apply(new Point2(q.X - p[1], q.Y - p[2]));
bool OnCourt(Point2 q) => Unplace(q) is var u && u.X is > 10 - 1e-6 and < 20 + 1e-6 && u.Y is > 10 - 1e-6 and < 20 + 1e-6;
return surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w.Start) && OnCourt(w.End)).ToArray();
}
private static void AssertOneCentredWindowPerOutdoorWall(IFloor floor, int outdoorWalls, int placement)
{
WallSurface[] outdoor = floor.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors).ToArray();
Assert.Equal(outdoorWalls, outdoor.Length);
Assert.All(outdoor, w => Assert.Equal((w.Length - Assert.Single(w.Windows).Width) / 2.0, w.Windows[0].Offset, 9));
Assert.Equal(4, CourtWalls(floor.Surfaces, placement).Length);
}
private static void AssertFloorValidates(IFloor floor)
{
ValidationReport report = new FloorValidator(Pipeline.Tolerances).Validate(floor);
Assert.True(report.Passed, report.Describe());
}
}
- [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.RingFootprintTests"
Expected: Passed! - Failed: 0, Passed: 76, Skipped: 0, Total: 76, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 3: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/RingFootprintTests.cs
git commit -m "test(simplifiers): cover a court offset that crosses the outer offset in perimeter core"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 159 generators, and 549 integration tests passed on net8.0; git status lists only M tests/Lod.Integration.Tests/RingFootprintTests.cs.
Task 7: Enclosed court generator (HDHM-TYP-005)¶
Files:
- Create: src/Lod.Generators/EnclosedCourt/EnclosedCourtGenerator.cs
- Modify: src/Lod.Generators/StairBayBar/StairBayLayout.cs (S8.1 file, last changed in S8.2)
- Test: create tests/Lod.Generators.Tests/EnclosedCourt/EnclosedCourtGeneratorTests.cs
- Snapshot: create tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt
Interfaces:
- Consumes (S2–S8.2): PlanGenerator<TParameters, TPresets> (protected PlanGenerator(ToleranceSettings), Validate, Layout, Describe, protected static bool IsPositive(double)), PlanParameters, PlanPresets.Slots, PresetSlot, PlanLayout(Polygon2 Footprint, IReadOnlyList<PlannedZone> Zones), Polygon2.Create(outer, holes), StairBayLayout.Wing(string prefix, string namePrefix, double x0, double y0, double length, double depth, bool alongY, int bays, double unitWidth, double stairWidth), StairBayLayout.Fit(double length, double unitWidth, double stairWidth) : (int Bays, double UnitWidth), StairBayLayout.FitErrors(string wing, double length, double unitWidth, double stairWidth) (S8.2, internal), Provenance.Format, DiagnosticCodes.InvalidParameter, PresetSpaceTypeMismatch; test support PlanAssertions (TilesTheFootprint, HasOneCentredWindowPerOutdoorWall, Neighbours, OutdoorFacings), Snapshot.Match, TextReport.Describe, ExampleResidentialPresets; Task 1.
- Produces: public sealed record EnclosedCourtParameters(double CourtWidth, double CourtDepth, double Depth, double UnitWidth, double StairWidth, double FloorHeight, double OrientationDegrees) : PlanParameters with static Default = (24, 24, 12, 7, 3, 3, 0); public sealed record EnclosedCourtPresets(ProgramPreset DwellingUnit, ProgramPreset Stair) : PlanPresets (slots DwellingUnit, Stair); public sealed class EnclosedCourtGenerator(ToleranceSettings), Name = EnclosedCourtGenerator: with D the depth, w the court width, and c the court depth, the south wing [0, 2D + w] × [0, D] and the north wing [0, 2D + w] × [D + c, 2D + c] of stair bays along plan x (zones S-ST1…, S-U1…, N-…, South Stair 1, North Unit 3, …), the west wing [0, D] × [D, D + c] and the east wing [D + w, 2D + w] × [D, D + c] along plan y (W-…, E-…, south to north); the footprint is the outer rectangle with the court [D, D + w] × [D, D + c] as its one hole; errors InvalidParameter for each length that is not positive and finite ({name} must be positive, got {value}.) and, only when those pass, for the long or the side wings whose stairs leave no dwelling width (StairWidth 30 m leaves no dwelling width in each 48 m south and north wing: its 2 stair bays need 60 m of stairs; reduce StairWidth or UnitWidth., and … in each 24 m west and east wing: its one stair bay needs 30 m of stairs; …); provenance keys CourtWidth, CourtDepth, Depth, UnitWidth, StairWidth. StairBayLayout's summary names the enclosed court; its code is unchanged.
- [x] Step 1: Write the failing tests — create
tests/Lod.Generators.Tests/EnclosedCourt/EnclosedCourtGeneratorTests.cs(full file)
22 tests: the ADR-006 defaults; the canonical plan (24 zone IDs, 1728 m², names, stairs 36 m², long-wing dwellings 6.5 m × 12 m, side-wing dwellings 10.5 m × 12 m, corners of S-ST2, N-U3, W-ST1, E-U2, no source zones); the footprint as the 48 m square with the court ring (12, 12), (12, 36), (36, 36), (36, 12) as its one hole, not overlapping the court; tiling for three court sizes; dwellings on every side of the court (the court façades of S-U2, S-U3, S-ST2, S-U4, S-U5 face north, and so on; 24 m of court façade in each direction); every dwelling adjoining a stair; the window rule (44 windows, no diagnostic); six invalid lengths; stairs that leave no dwelling width in either wing; every invalid parameter reported at once; preset slots; swapped presets; determinism; provenance; the canonical snapshot.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Generators.EnclosedCourt;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Generators.Tests.EnclosedCourt;
public sealed class EnclosedCourtGeneratorTests
{
/// <summary>The example presets, one per slot.</summary>
public static readonly EnclosedCourtPresets Examples = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);
private static readonly string[] CanonicalZones =
{
"S-ST1", "S-ST2", "S-ST3", "S-U1", "S-U2", "S-U3", "S-U4", "S-U5", "S-U6",
"N-ST1", "N-ST2", "N-ST3", "N-U1", "N-U2", "N-U3", "N-U4", "N-U5", "N-U6",
"W-ST1", "W-U1", "W-U2", "E-ST1", "E-U1", "E-U2",
};
private readonly EnclosedCourtGenerator _generator = new(ToleranceSettings.Default);
[Fact]
public void DefaultsAreTheAdr006Defaults()
{
Assert.Equal(
new EnclosedCourtParameters(CourtWidth: 24.0, CourtDepth: 24.0, Depth: 12.0, UnitWidth: 7.0, StairWidth: 3.0, FloorHeight: 3.0, OrientationDegrees: 0.0),
EnclosedCourtParameters.Default);
}
[Fact]
public void CanonicalPlanIsFourStairBayWingsAroundACourt()
{
IGeneratedPlan plan = Generate(EnclosedCourtParameters.Default);
Assert.Equal(CanonicalZones, plan.Zones.Select(z => z.Id.Value));
Assert.Equal((48.0 * 48.0) - (24.0 * 24.0), plan.Footprint.Area, 9);
Assert.Equal(new[] { "South Stair 2", "North Unit 3", "West Unit 2", "East Stair 1" }, plan.Zones.Where(z => z.Id.Value is "S-ST2" or "N-U3" or "W-U2" or "E-ST1").Select(z => z.Name));
Assert.All(plan.Zones.Where(z => z.SpaceType == SpaceType.Stair), z => Assert.Equal(36.0, z.FloorArea, 9));
Assert.All(plan.Zones.Where(z => z.Id.Value.StartsWith("S-U") || z.Id.Value.StartsWith("N-U")), z => Assert.Equal(6.5 * 12.0, z.FloorArea, 9));
Assert.All(plan.Zones.Where(z => z.Id.Value.StartsWith("W-U") || z.Id.Value.StartsWith("E-U")), z => Assert.Equal(10.5 * 12.0, z.FloorArea, 9));
Assert.Equal(new[] { new Point2(22.5, 0.0), new Point2(25.5, 0.0), new Point2(25.5, 12.0), new Point2(22.5, 12.0) }, Outer(plan, "S-ST2"));
Assert.Equal(new[] { new Point2(16.0, 36.0), new Point2(22.5, 36.0), new Point2(22.5, 48.0), new Point2(16.0, 48.0) }, Outer(plan, "N-U3"));
Assert.Equal(new[] { new Point2(0.0, 22.5), new Point2(12.0, 22.5), new Point2(12.0, 25.5), new Point2(0.0, 25.5) }, Outer(plan, "W-ST1"));
Assert.Equal(new[] { new Point2(36.0, 25.5), new Point2(48.0, 25.5), new Point2(48.0, 36.0), new Point2(36.0, 36.0) }, Outer(plan, "E-U2"));
Assert.All(plan.Zones, z => Assert.Empty(z.SourceZones));
}
[Fact]
public void TheFootprintIsARectangleWithTheCourtAsItsOneHole()
{
IGeneratedPlan plan = Generate(EnclosedCourtParameters.Default);
Assert.Equal(new[] { new Point2(0, 0), new Point2(48, 0), new Point2(48, 48), new Point2(0, 48) }, plan.Footprint.Outer);
IReadOnlyList<Point2> court = Assert.Single(plan.Footprint.Holes);
Assert.Equal(new[] { new Point2(12, 12), new Point2(12, 36), new Point2(36, 36), new Point2(36, 12) }, court);
Assert.Equal(0.0, new PolygonOps(ToleranceSettings.Default).IntersectionArea(plan.Footprint, Polygon2.Rectangle(12, 12, 36, 36)), 9);
}
[Theory]
[InlineData(24.0, 24.0)]
[InlineData(6.0, 40.0)]
[InlineData(50.0, 4.0)]
public void ZonesTileTheFootprint(double courtWidth, double courtDepth)
{
IGeneratedPlan plan = Generate(EnclosedCourtParameters.Default with { CourtWidth = courtWidth, CourtDepth = courtDepth });
PlanAssertions.TilesTheFootprint(plan);
Assert.Single(plan.Footprint.Holes);
Assert.Equal(new[] { "S", "N", "W", "E" }, plan.Zones.Select(z => z.Id.Value.Split('-')[0]).Distinct());
}
[Fact]
public void DwellingsLieOnEverySideOfTheClosedCourt()
{
IGeneratedPlan plan = Generate(EnclosedCourtParameters.Default);
WallSurface[] court = plan.Surfaces.OfType<WallSurface>()
.Where(w => w.Boundary == BoundaryCondition.Outdoors && new[] { w.Start, w.End }.All(p => p.X is >= 12.0 and <= 36.0 && p.Y is >= 12.0 and <= 36.0))
.ToArray();
Assert.Equal(new[] { "S-U2", "S-U3", "S-ST2", "S-U4", "S-U5" }, Facing(court, OrientationBin.North).OrderBy(w => w.End.X).Select(w => w.Zone.Value));
Assert.Equal(new[] { "N-U2", "N-U3", "N-ST2", "N-U4", "N-U5" }, Facing(court, OrientationBin.South).OrderBy(w => w.End.X).Select(w => w.Zone.Value));
Assert.Equal(new[] { "W-U1", "W-ST1", "W-U2" }, Facing(court, OrientationBin.East).OrderBy(w => w.Start.Y).Select(w => w.Zone.Value));
Assert.Equal(new[] { "E-U1", "E-ST1", "E-U2" }, Facing(court, OrientationBin.West).OrderBy(w => w.End.Y).Select(w => w.Zone.Value));
Assert.All(new[] { OrientationBin.North, OrientationBin.East, OrientationBin.South, OrientationBin.West }, b => Assert.Equal(24.0, Facing(court, b).Sum(w => w.Length), 9));
}
[Fact]
public void EveryDwellingAdjoinsAStair()
{
IGeneratedPlan plan = Generate(EnclosedCourtParameters.Default);
Assert.All(
plan.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit),
z => Assert.Contains(PlanAssertions.Neighbours(plan, z.Id.Value), n => plan.Zones.Single(s => s.Id.Value == n).SpaceType == SpaceType.Stair));
Assert.Equal(new[] { "S-ST1", "S-U1", "S-U2", "W-ST1" }, PlanAssertions.Neighbours(plan, "W-U1"));
Assert.Equal(new[] { "E-ST1", "N-ST3", "N-U5", "N-U6" }, PlanAssertions.Neighbours(plan, "E-U2"));
Assert.Equal(new[] { "S-U1", "S-U2", "W-U1" }, PlanAssertions.Neighbours(plan, "S-ST1"));
Assert.Equal(new[] { OrientationBin.East, OrientationBin.West }, PlanAssertions.OutdoorFacings(plan, "W-U1"));
Assert.Equal(new[] { OrientationBin.North, OrientationBin.South }, PlanAssertions.OutdoorFacings(plan, "S-U3"));
}
[Fact]
public void EveryOutdoorWallHasOneCentredWindowSizedByItsPreset()
{
Result<IGeneratedPlan> result = _generator.Generate(EnclosedCourtParameters.Default, Examples);
Assert.Empty(result.Diagnostics);
PlanAssertions.HasOneCentredWindowPerOutdoorWall(result.Value, Examples);
Assert.Equal(44, result.Value.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
}
[Theory]
[InlineData(0.0, 24.0, 12.0, 7.0, 3.0, "CourtWidth")]
[InlineData(24.0, -24.0, 12.0, 7.0, 3.0, "CourtDepth")]
[InlineData(24.0, 24.0, 0.0, 7.0, 3.0, "Depth")]
[InlineData(24.0, 24.0, 12.0, double.NaN, 3.0, "UnitWidth")]
[InlineData(24.0, 24.0, 12.0, 7.0, -3.0, "StairWidth")]
[InlineData(double.PositiveInfinity, 24.0, 12.0, 7.0, 3.0, "CourtWidth")]
public void InvalidParametersAreErrors(double courtWidth, double courtDepth, double depth, double unitWidth, double stairWidth, string name)
{
Result<IGeneratedPlan> result = _generator.Generate(new EnclosedCourtParameters(courtWidth, courtDepth, depth, unitWidth, stairWidth, 3.0, 0.0), Examples);
Assert.False(result.IsSuccess);
Diagnostic error = Assert.Single(result.Diagnostics);
Assert.Equal(DiagnosticCodes.InvalidParameter, error.Code);
Assert.StartsWith(name, error.Message);
}
[Fact]
public void StairsThatLeaveNoRoomForDwellingsAreAnError()
{
// 24 m west and east wings with 30 m stairs: one bay needs more than the wing's length; the 48 m south and north wings (round(48 / 31) = 2
// bays, 60 m of stairs) do not fit either.
Result<IGeneratedPlan> result = _generator.Generate(EnclosedCourtParameters.Default with { UnitWidth = 0.5, StairWidth = 30.0 }, Examples);
Assert.Equal(
new[]
{
"StairWidth 30 m leaves no dwelling width in each 48 m south and north wing: its 2 stair bays need 60 m of stairs; reduce StairWidth or UnitWidth.",
"StairWidth 30 m leaves no dwelling width in each 24 m west and east wing: its one stair bay needs 30 m of stairs; reduce StairWidth or UnitWidth.",
},
result.Diagnostics.Select(d => d.Message));
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.InvalidParameter, d.Code));
}
[Fact]
public void EveryInvalidParameterIsReported()
{
Result<IGeneratedPlan> result = _generator.Generate(new EnclosedCourtParameters(0.0, 0.0, -12.0, 0.0, 0.0, 3.0, 0.0), Examples);
Assert.Equal(5, result.Diagnostics.Count(d => d.Code == DiagnosticCodes.InvalidParameter));
}
[Fact]
public void PresetsHaveOneSlotPerSpaceTypeInOrder()
{
Assert.Equal(new[] { ("DwellingUnit", SpaceType.DwellingUnit), ("Stair", SpaceType.Stair) }, Examples.Slots.Select(s => (s.Input, s.SpaceType)));
Assert.Equal(new[] { Examples.DwellingUnit, Examples.Stair }, Examples.Slots.Select(s => s.Preset));
}
[Fact]
public void SwappedPresetsAreAnErrorPerWrongSlot()
{
Result<IGeneratedPlan> result = _generator.Generate(EnclosedCourtParameters.Default, new EnclosedCourtPresets(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(EnclosedCourtParameters.Default)), TextReport.Describe(Generate(EnclosedCourtParameters.Default)));
}
[Fact]
public void ProvenanceRecordsParametersAndPresets()
{
Provenance provenance = Generate(EnclosedCourtParameters.Default).Provenance;
Assert.Equal(nameof(EnclosedCourtGenerator), provenance.Operation);
Assert.Equal(
new[] { "CourtDepth=24", "CourtWidth=24", "Depth=12", "FloorHeight=3", "OrientationDegrees=0", "Presets=DwellingUnit:Example Dwelling Unit;Stair:Example Stair", "StairWidth=3", "UnitWidth=7" },
provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
}
[Fact]
public void CanonicalPlanSnapshot()
{
Snapshot.Match(TextReport.Describe(Generate(EnclosedCourtParameters.Default)), "enclosed-court-canonical");
}
private static WallSurface[] Facing(WallSurface[] walls, OrientationBin bin) => walls.Where(w => Orientation.Bin(w.PlanAzimuth) == bin).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(EnclosedCourtParameters 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.EnclosedCourt.EnclosedCourtGeneratorTests"
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\EnclosedCourt\EnclosedCourtGeneratorTests.cs(7,22): error CS0234: The type or namespace name 'EnclosedCourt' does not exist in the namespace 'Lod.Generators' (are you missing an assembly reference?)
tests\Lod.Generators.Tests\EnclosedCourt\EnclosedCourtGeneratorTests.cs(201,37): error CS0246: The type or namespace name 'EnclosedCourtParameters' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\EnclosedCourt\EnclosedCourtGeneratorTests.cs(16,28): error CS0246: The type or namespace name 'EnclosedCourtPresets' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Generators.Tests\EnclosedCourt\EnclosedCourtGeneratorTests.cs(25,22): error CS0246: The type or namespace name 'EnclosedCourtGenerator' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — replace
src/Lod.Generators/StairBayBar/StairBayLayout.cs(full file)
Only the class summary changes; Bays, Wing, Fit, and FitErrors are unchanged, and so are the S8.1 and S8.2 plans and snapshots.
using System;
using System.Collections.Generic;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
namespace Lod.Generators.StairBayBar;
/// <summary>
/// The stair bay of ADR-006: dwelling | stair | dwelling side by side along a bar, each through the full depth, the stair serving the two dwellings
/// beside it. Shared by <see cref="StairBayBarGenerator"/> (HDHM-TYP-001), the stair pair (HDHM-TYP-008), and the wings of the winged band
/// (HDHM-TYP-003), the open court (HDHM-TYP-004), and the enclosed court (HDHM-TYP-005), so the bay geometry exists once.
/// </summary>
internal static class StairBayLayout
{
/// <summary>
/// Lays out <paramref name="bays"/> stair bays west to east from the origin. Zones: <c>ST1…STn</c> (stairs, west to east), then <c>U1…U2n</c>
/// (dwellings, west to east); bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
/// </summary>
/// <param name="bays">Number of bays, at least 1.</param>
/// <param name="unitWidth">Dwelling width along the bar, m.</param>
/// <param name="depth">Bar depth, m.</param>
/// <param name="stairWidth">Stair width along the bar, m.</param>
/// <returns>The footprint and the zones.</returns>
public static PlanLayout Bays(int bays, double unitWidth, double depth, double stairWidth)
{
double length = bays * ((2.0 * unitWidth) + stairWidth);
return new PlanLayout(Polygon2.Rectangle(0.0, 0.0, length, depth), Wing(string.Empty, string.Empty, 0.0, 0.0, length, depth, alongY: false, bays, unitWidth, stairWidth));
}
/// <summary>
/// The stair bays that fill a wing of length <paramref name="length"/> exactly (ADR-006): <c>n = max(1, round(L / (2U + S)))</c> bays, rounded
/// half away from zero, with the dwelling width adjusted to <c>(L − n·S) / (2n)</c>, which is not positive when the stairs leave no room.
/// </summary>
/// <param name="length">Wing length, m; positive.</param>
/// <param name="unitWidth">Target dwelling width, m; positive.</param>
/// <param name="stairWidth">Stair width, m; positive.</param>
/// <returns>The bay count and the adjusted dwelling width.</returns>
public static (int Bays, double UnitWidth) Fit(double length, double unitWidth, double stairWidth)
{
double bays = Math.Max(1.0, Math.Round(length / ((2.0 * unitWidth) + stairWidth), MidpointRounding.AwayFromZero));
int count = bays >= int.MaxValue ? int.MaxValue : (int)bays;
return (count, (length - (count * stairWidth)) / (2.0 * count));
}
/// <summary>
/// The error for a wing whose fitted stair bays leave no dwelling width (<see cref="Fit"/>), or none; the message names the wing, such as
/// <c>each 17 m wing</c>, and the stairs it would need.
/// </summary>
/// <param name="wing">The wing as named in the message.</param>
/// <param name="length">Wing length, m.</param>
/// <param name="unitWidth">Target dwelling width, m.</param>
/// <param name="stairWidth">Stair width, m.</param>
/// <returns>Zero or one <see cref="DiagnosticCodes.InvalidParameter"/> error.</returns>
public static IEnumerable<Diagnostic> FitErrors(string wing, double length, double unitWidth, double stairWidth)
{
(int bays, double width) = Fit(length, unitWidth, stairWidth);
if (width > 0)
{
yield break;
}
string need = bays == 1
? $"its one stair bay needs {Provenance.Format(stairWidth)} m of stairs"
: $"its {bays} stair bays need {Provenance.Format(bays * stairWidth)} m of stairs";
yield return Diagnostic.Error(
DiagnosticCodes.InvalidParameter,
$"{nameof(StairBayBarParameters.StairWidth)} {Provenance.Format(stairWidth)} m leaves no dwelling width in {wing}: {need}; "
+ $"reduce {nameof(StairBayBarParameters.StairWidth)} or {nameof(StairBayBarParameters.UnitWidth)}.");
}
/// <summary>
/// Lays out <paramref name="bays"/> stair bays along a wing that starts at (<paramref name="x0"/>, <paramref name="y0"/>) and runs along +x, or
/// along +y when <paramref name="alongY"/>; the last bay ends exactly at the wing's length. Zones: <c>{prefix}ST1…</c>, then <c>{prefix}U1…</c>,
/// numbered along the wing's axis; bay <c>i</c> is <c>U(2i−1)</c>, <c>STi</c>, <c>U(2i)</c>.
/// </summary>
/// <param name="prefix">Zone ID prefix, such as <c>B-</c>.</param>
/// <param name="namePrefix">Display name prefix, such as <c>Band </c>.</param>
/// <param name="x0">Wing start, plan x, m.</param>
/// <param name="y0">Wing start, plan y, m.</param>
/// <param name="length">Wing length along its axis, m.</param>
/// <param name="depth">Wing depth across its axis, m.</param>
/// <param name="alongY">Whether the wing runs along +y instead of +x.</param>
/// <param name="bays">Number of bays, at least 1.</param>
/// <param name="unitWidth">Dwelling width along the wing, m.</param>
/// <param name="stairWidth">Stair width along the wing, m.</param>
/// <returns>The stairs, then the dwellings.</returns>
public static IReadOnlyList<PlannedZone> Wing(
string prefix,
string namePrefix,
double x0,
double y0,
double length,
double depth,
bool alongY,
int bays,
double unitWidth,
double stairWidth)
{
double bayWidth = (2.0 * unitWidth) + stairWidth;
double start = alongY ? y0 : x0;
Polygon2 Piece(double from, double to) => alongY ? Polygon2.Rectangle(x0, from, x0 + depth, to) : Polygon2.Rectangle(from, y0, to, y0 + depth);
var stairs = new List<PlannedZone>();
var units = new List<PlannedZone>();
for (int i = 0; i < bays; i++)
{
double west = start + (i * bayWidth);
double stairWest = west + unitWidth;
double stairEast = stairWest + stairWidth;
double east = i == bays - 1 ? start + length : west + bayWidth;
stairs.Add(new PlannedZone(new ZoneId($"{prefix}ST{i + 1}"), $"{namePrefix}Stair {i + 1}", SpaceType.Stair, Piece(stairWest, stairEast)));
units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 1}"), $"{namePrefix}Unit {(2 * i) + 1}", SpaceType.DwellingUnit, Piece(west, stairWest)));
units.Add(new PlannedZone(new ZoneId($"{prefix}U{(2 * i) + 2}"), $"{namePrefix}Unit {(2 * i) + 2}", SpaceType.DwellingUnit, Piece(stairEast, east)));
}
stairs.AddRange(units);
return stairs;
}
}
- [x] Step 4: Implement — create
src/Lod.Generators/EnclosedCourt/EnclosedCourtGenerator.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Generators.StairBayBar;
namespace Lod.Generators.EnclosedCourt;
/// <summary>Parameters of the enclosed courtyard block (HDHM-TYP-005, ADR-006).</summary>
/// <param name="CourtWidth">Width of the court between the west and east wings, m.</param>
/// <param name="CourtDepth">Depth of the court between the south and north wings, which is also the west and east wings' length, m.</param>
/// <param name="Depth">Depth of every wing, which every dwelling and stair spans, m.</param>
/// <param name="UnitWidth">Target dwelling width along the wings, m; adjusted so the stair bays fill each wing exactly.</param>
/// <param name="StairWidth">Width of each stair along the wings, m.</param>
/// <param name="FloorHeight">Floor-to-floor height, m.</param>
/// <param name="OrientationDegrees">Clockwise rotation of plan north from true north, degrees.</param>
public sealed record EnclosedCourtParameters(
double CourtWidth,
double CourtDepth,
double Depth,
double UnitWidth,
double StairWidth,
double FloorHeight,
double OrientationDegrees)
: PlanParameters(FloorHeight, OrientationDegrees)
{
/// <summary>The ADR-006 defaults: a 24 m × 24 m court, 12 m deep wings, 7 m dwellings and 3 m stairs, 3 m storeys, orientation 0°.</summary>
public static EnclosedCourtParameters Default { get; } = new(24.0, 24.0, 12.0, 7.0, 3.0, 3.0, 0.0);
}
/// <summary>The program presets of the enclosed courtyard block, one per space type it places (D-061).</summary>
/// <param name="DwellingUnit">Preset of the dwellings; space type <see cref="SpaceType.DwellingUnit"/>.</param>
/// <param name="Stair">Preset of the stairs; space type <see cref="SpaceType.Stair"/>.</param>
public sealed record EnclosedCourtPresets(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>
/// Enclosed courtyard residential block (HDHM-TYP-005, ADR-006): four wings of stair bays around one court. Dwellings lie on every edge of the court
/// (R001), and the court is closed on all four sides (R002): the footprint is the outer rectangle with the court as its one hole, so the court is
/// outside the building and its façades are outdoor walls facing into it. The south and north wings run the full width and hold the corners; the
/// west and east wings run between them. Each wing holds <c>n = max(1, round(L / (2U + S)))</c> bays with the dwelling width adjusted to fill its
/// length <c>L</c>. One zone per dwelling unit (D-009).
/// </summary>
/// <remarks>
/// With <c>D</c> the depth, <c>w</c> the court width, and <c>c</c> the court depth: south wing <c>[0, 2D + w] × [0, D]</c>, north wing
/// <c>[0, 2D + w] × [D + c, 2D + c]</c>, west wing <c>[0, D] × [D, D + c]</c>, east wing <c>[D + w, 2D + w] × [D, D + c]</c>, court
/// <c>[D, D + w] × [D, D + c]</c>. Zone IDs: <c>S-ST1…</c>, <c>S-U1…</c> and <c>N-…</c> (west to east), then <c>W-…</c> and <c>E-…</c> (south to
/// north); display names <c>South Unit 3</c>, <c>West Stair 1</c>. The generator does not impose the precondition of <i>Perimeter Core</i> (D-087):
/// its default 12 m wings are wider than twice the 4.57 m default perimeter depth.
/// </remarks>
public sealed class EnclosedCourtGenerator : PlanGenerator<EnclosedCourtParameters, EnclosedCourtPresets>
{
/// <summary>Creates the generator.</summary>
/// <param name="tolerances">Tolerances.</param>
public EnclosedCourtGenerator(ToleranceSettings tolerances)
: base(tolerances)
{
}
/// <inheritdoc />
public override string Name => nameof(EnclosedCourtGenerator);
/// <inheritdoc />
protected override IEnumerable<Diagnostic> Validate(EnclosedCourtParameters parameters)
{
var values = new (string Name, double Value)[]
{
(nameof(EnclosedCourtParameters.CourtWidth), parameters.CourtWidth),
(nameof(EnclosedCourtParameters.CourtDepth), parameters.CourtDepth),
(nameof(EnclosedCourtParameters.Depth), parameters.Depth),
(nameof(EnclosedCourtParameters.UnitWidth), parameters.UnitWidth),
(nameof(EnclosedCourtParameters.StairWidth), parameters.StairWidth),
};
var errors = values
.Where(v => !IsPositive(v.Value))
.Select(v => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{v.Name} must be positive, got {v.Value}."))
.ToList();
if (errors.Count == 0)
{
double width = Width(parameters);
errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(width)} m south and north wing", width, parameters.UnitWidth, parameters.StairWidth));
errors.AddRange(StairBayLayout.FitErrors($"each {Provenance.Format(parameters.CourtDepth)} m west and east wing", parameters.CourtDepth, parameters.UnitWidth, parameters.StairWidth));
}
return errors;
}
/// <inheritdoc />
protected override PlanLayout Layout(EnclosedCourtParameters p)
{
double d = p.Depth;
double width = Width(p);
double north = d + p.CourtDepth;
double east = d + p.CourtWidth;
(int longBays, double longUnit) = StairBayLayout.Fit(width, p.UnitWidth, p.StairWidth);
(int sideBays, double sideUnit) = StairBayLayout.Fit(p.CourtDepth, p.UnitWidth, p.StairWidth);
var zones = new List<PlannedZone>();
zones.AddRange(StairBayLayout.Wing("S-", "South ", 0.0, 0.0, width, d, alongY: false, longBays, longUnit, p.StairWidth));
zones.AddRange(StairBayLayout.Wing("N-", "North ", 0.0, north, width, d, alongY: false, longBays, longUnit, p.StairWidth));
zones.AddRange(StairBayLayout.Wing("W-", "West ", 0.0, d, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
zones.AddRange(StairBayLayout.Wing("E-", "East ", east, d, p.CourtDepth, d, alongY: true, sideBays, sideUnit, p.StairWidth));
Point2[] outer = { new(0.0, 0.0), new(width, 0.0), new(width, north + d), new(0.0, north + d) };
Point2[] court = { new(d, d), new(d, north), new(east, north), new(east, d) };
return new PlanLayout(Polygon2.Create(outer, new[] { court }).Value, zones);
}
/// <inheritdoc />
protected override IEnumerable<KeyValuePair<string, string>> Describe(EnclosedCourtParameters p) => new[]
{
new KeyValuePair<string, string>(nameof(p.CourtWidth), Provenance.Format(p.CourtWidth)),
new KeyValuePair<string, string>(nameof(p.CourtDepth), Provenance.Format(p.CourtDepth)),
new KeyValuePair<string, string>(nameof(p.Depth), Provenance.Format(p.Depth)),
new KeyValuePair<string, string>(nameof(p.UnitWidth), Provenance.Format(p.UnitWidth)),
new KeyValuePair<string, string>(nameof(p.StairWidth), Provenance.Format(p.StairWidth)),
};
private static double Width(EnclosedCourtParameters p) => p.Depth + p.CourtWidth + p.Depth;
}
- [x] Step 5: Run the tests and see the snapshot fail
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.EnclosedCourt.EnclosedCourtGeneratorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 21, Skipped: 0, Total: 22, Duration: … - Lod.Generators.Tests.dll (net8.0)
with the only failing test and its message:
Failed Lod.Generators.Tests.EnclosedCourt.EnclosedCourtGeneratorTests.CanonicalPlanSnapshot
Snapshot 'enclosed-court-canonical' is missing or differs. Review Snapshots/enclosed-court-canonical.received.txt and rename it to enclosed-court-canonical.txt to accept.
and the file tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.received.txt (git-ignored by *.received.*).
- [x] Step 6: Review and accept the received file
Review the received file against the content below; it must be identical: 285 lines, 24 zones, 156 surfaces (44 outdoor walls, each with one window; 64 interzone walls; 48 floors and ceilings), glazing 57.6 m² south, 57.6 m² north, 61.2 m² east, 61.2 m² west (237.6 m² in all); 16 of the outdoor walls lie on the court and face into it. Accept it:
Move-Item -Force tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.received.txt tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt
tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt:
footprint area=1728.000000 floorHeight=3.000000 orientation=0.000000
zone S-ST1 Stair "South Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone S-ST2 Stair "South Stair 2" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone S-ST3 Stair "South Stair 3" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone S-U1 DwellingUnit "South Unit 1" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone S-U2 DwellingUnit "South Unit 2" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone S-U3 DwellingUnit "South Unit 3" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone S-U4 DwellingUnit "South Unit 4" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone S-U5 DwellingUnit "South Unit 5" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone S-U6 DwellingUnit "South Unit 6" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-ST1 Stair "North Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone N-ST2 Stair "North Stair 2" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone N-ST3 Stair "North Stair 3" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone N-U1 DwellingUnit "North Unit 1" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U2 DwellingUnit "North Unit 2" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U3 DwellingUnit "North Unit 3" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U4 DwellingUnit "North Unit 4" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U5 DwellingUnit "North Unit 5" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone N-U6 DwellingUnit "North Unit 6" area=78.000000 volume=234.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone W-ST1 Stair "West Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone W-U1 DwellingUnit "West Unit 1" area=126.000000 volume=378.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone W-U2 DwellingUnit "West Unit 2" area=126.000000 volume=378.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone E-ST1 Stair "East Stair 1" area=36.000000 volume=108.000000 parts=1 multiplier=1 sources=[]
load Lighting PerFloorArea value=3.000000 annual=8760.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
unconditioned
zone E-U1 DwellingUnit "East Unit 1" area=126.000000 volume=378.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone E-U2 DwellingUnit "East Unit 2" area=126.000000 volume=378.000000 parts=1 multiplier=1 sources=[]
load Occupancy PerFloorArea value=0.030000 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=2627.800000
load ElectricEquipment PerFloorArea value=5.000000 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
surface S-ST1/W1 Outdoors area=9.000000 wall (6.500000,0.000000)-(9.500000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST1/W2 Interzone adjacent=S-U2 area=36.000000 wall (9.500000,0.000000)-(9.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST1/W3 Interzone adjacent=W-U1 area=9.000000 wall (9.500000,12.000000)-(6.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-ST1/W4 Interzone adjacent=S-U1 area=36.000000 wall (6.500000,12.000000)-(6.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST2/W1 Outdoors area=9.000000 wall (22.500000,0.000000)-(25.500000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST2/W2 Interzone adjacent=S-U4 area=36.000000 wall (25.500000,0.000000)-(25.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST2/W3 Outdoors area=9.000000 wall (25.500000,12.000000)-(22.500000,12.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST2/W4 Interzone adjacent=S-U3 area=36.000000 wall (22.500000,12.000000)-(22.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST2/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-ST3/W1 Outdoors area=9.000000 wall (38.500000,0.000000)-(41.500000,0.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface S-ST3/W2 Interzone adjacent=S-U6 area=36.000000 wall (41.500000,0.000000)-(41.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-ST3/W3 Interzone adjacent=E-U1 area=9.000000 wall (41.500000,12.000000)-(38.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-ST3/W4 Interzone adjacent=S-U5 area=36.000000 wall (38.500000,12.000000)-(38.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-ST3/F Unresolved area=36.000000 Floor z=0.000000
surface S-ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface S-U1/W1 Outdoors area=19.500000 wall (0.000000,0.000000)-(6.500000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U1/W2 Interzone adjacent=S-ST1 area=36.000000 wall (6.500000,0.000000)-(6.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U1/W3 Interzone adjacent=W-U1 area=19.500000 wall (6.500000,12.000000)-(0.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U1/W4 Outdoors area=36.000000 wall (0.000000,12.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface S-U1/F Unresolved area=78.000000 Floor z=0.000000
surface S-U1/C Unresolved area=78.000000 Ceiling z=3.000000
surface S-U2/W1 Outdoors area=19.500000 wall (9.500000,0.000000)-(16.000000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U2/W2 Interzone adjacent=S-U3 area=36.000000 wall (16.000000,0.000000)-(16.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U2/W3 Outdoors area=12.000000 wall (16.000000,12.000000)-(12.000000,12.000000) z=0.000000 facing=North glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface S-U2/W4 Interzone adjacent=W-U1 area=7.500000 wall (12.000000,12.000000)-(9.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U2/W5 Interzone adjacent=S-ST1 area=36.000000 wall (9.500000,12.000000)-(9.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U2/F Unresolved area=78.000000 Floor z=0.000000
surface S-U2/C Unresolved area=78.000000 Ceiling z=3.000000
surface S-U3/W1 Outdoors area=19.500000 wall (16.000000,0.000000)-(22.500000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U3/W2 Interzone adjacent=S-ST2 area=36.000000 wall (22.500000,0.000000)-(22.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U3/W3 Outdoors area=19.500000 wall (22.500000,12.000000)-(16.000000,12.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U3/W4 Interzone adjacent=S-U2 area=36.000000 wall (16.000000,12.000000)-(16.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U3/F Unresolved area=78.000000 Floor z=0.000000
surface S-U3/C Unresolved area=78.000000 Ceiling z=3.000000
surface S-U4/W1 Outdoors area=19.500000 wall (25.500000,0.000000)-(32.000000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U4/W2 Interzone adjacent=S-U5 area=36.000000 wall (32.000000,0.000000)-(32.000000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U4/W3 Outdoors area=19.500000 wall (32.000000,12.000000)-(25.500000,12.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U4/W4 Interzone adjacent=S-ST2 area=36.000000 wall (25.500000,12.000000)-(25.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U4/F Unresolved area=78.000000 Floor z=0.000000
surface S-U4/C Unresolved area=78.000000 Ceiling z=3.000000
surface S-U5/W1 Outdoors area=19.500000 wall (32.000000,0.000000)-(38.500000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U5/W2 Interzone adjacent=S-ST3 area=36.000000 wall (38.500000,0.000000)-(38.500000,12.000000) z=0.000000 facing=East glazing=0.000000
surface S-U5/W3 Interzone adjacent=E-U1 area=7.500000 wall (38.500000,12.000000)-(36.000000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U5/W4 Outdoors area=12.000000 wall (36.000000,12.000000)-(32.000000,12.000000) z=0.000000 facing=North glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface S-U5/W5 Interzone adjacent=S-U4 area=36.000000 wall (32.000000,12.000000)-(32.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U5/F Unresolved area=78.000000 Floor z=0.000000
surface S-U5/C Unresolved area=78.000000 Ceiling z=3.000000
surface S-U6/W1 Outdoors area=19.500000 wall (41.500000,0.000000)-(48.000000,0.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface S-U6/W2 Outdoors area=36.000000 wall (48.000000,0.000000)-(48.000000,12.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface S-U6/W3 Interzone adjacent=E-U1 area=19.500000 wall (48.000000,12.000000)-(41.500000,12.000000) z=0.000000 facing=North glazing=0.000000
surface S-U6/W4 Interzone adjacent=S-ST3 area=36.000000 wall (41.500000,12.000000)-(41.500000,0.000000) z=0.000000 facing=West glazing=0.000000
surface S-U6/F Unresolved area=78.000000 Floor z=0.000000
surface S-U6/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-ST1/W1 Interzone adjacent=W-U2 area=9.000000 wall (6.500000,36.000000)-(9.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST1/W2 Interzone adjacent=N-U2 area=36.000000 wall (9.500000,36.000000)-(9.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST1/W3 Outdoors area=9.000000 wall (9.500000,48.000000)-(6.500000,48.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST1/W4 Interzone adjacent=N-U1 area=36.000000 wall (6.500000,48.000000)-(6.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-ST2/W1 Outdoors area=9.000000 wall (22.500000,36.000000)-(25.500000,36.000000) z=0.000000 facing=South glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST2/W2 Interzone adjacent=N-U4 area=36.000000 wall (25.500000,36.000000)-(25.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST2/W3 Outdoors area=9.000000 wall (25.500000,48.000000)-(22.500000,48.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST2/W4 Interzone adjacent=N-U3 area=36.000000 wall (22.500000,48.000000)-(22.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST2/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST2/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-ST3/W1 Interzone adjacent=E-U2 area=9.000000 wall (38.500000,36.000000)-(41.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-ST3/W2 Interzone adjacent=N-U6 area=36.000000 wall (41.500000,36.000000)-(41.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-ST3/W3 Outdoors area=9.000000 wall (41.500000,48.000000)-(38.500000,48.000000) z=0.000000 facing=North glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface N-ST3/W4 Interzone adjacent=N-U5 area=36.000000 wall (38.500000,48.000000)-(38.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-ST3/F Unresolved area=36.000000 Floor z=0.000000
surface N-ST3/C Unresolved area=36.000000 Ceiling z=3.000000
surface N-U1/W1 Interzone adjacent=W-U2 area=19.500000 wall (0.000000,36.000000)-(6.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U1/W2 Interzone adjacent=N-ST1 area=36.000000 wall (6.500000,36.000000)-(6.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U1/W3 Outdoors area=19.500000 wall (6.500000,48.000000)-(0.000000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U1/W4 Outdoors area=36.000000 wall (0.000000,48.000000)-(0.000000,36.000000) z=0.000000 facing=West glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface N-U1/F Unresolved area=78.000000 Floor z=0.000000
surface N-U1/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U2/W1 Interzone adjacent=W-U2 area=7.500000 wall (9.500000,36.000000)-(12.000000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U2/W2 Outdoors area=12.000000 wall (12.000000,36.000000)-(16.000000,36.000000) z=0.000000 facing=South glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface N-U2/W3 Interzone adjacent=N-U3 area=36.000000 wall (16.000000,36.000000)-(16.000000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U2/W4 Outdoors area=19.500000 wall (16.000000,48.000000)-(9.500000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U2/W5 Interzone adjacent=N-ST1 area=36.000000 wall (9.500000,48.000000)-(9.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U2/F Unresolved area=78.000000 Floor z=0.000000
surface N-U2/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U3/W1 Outdoors area=19.500000 wall (16.000000,36.000000)-(22.500000,36.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U3/W2 Interzone adjacent=N-ST2 area=36.000000 wall (22.500000,36.000000)-(22.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U3/W3 Outdoors area=19.500000 wall (22.500000,48.000000)-(16.000000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U3/W4 Interzone adjacent=N-U2 area=36.000000 wall (16.000000,48.000000)-(16.000000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U3/F Unresolved area=78.000000 Floor z=0.000000
surface N-U3/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U4/W1 Outdoors area=19.500000 wall (25.500000,36.000000)-(32.000000,36.000000) z=0.000000 facing=South glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U4/W2 Interzone adjacent=N-U5 area=36.000000 wall (32.000000,36.000000)-(32.000000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U4/W3 Outdoors area=19.500000 wall (32.000000,48.000000)-(25.500000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U4/W4 Interzone adjacent=N-ST2 area=36.000000 wall (25.500000,48.000000)-(25.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U4/F Unresolved area=78.000000 Floor z=0.000000
surface N-U4/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U5/W1 Outdoors area=12.000000 wall (32.000000,36.000000)-(36.000000,36.000000) z=0.000000 facing=South glazing=3.600000 window(offset=0.904555 sill=0.678416 2.190890x1.643168)
surface N-U5/W2 Interzone adjacent=E-U2 area=7.500000 wall (36.000000,36.000000)-(38.500000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U5/W3 Interzone adjacent=N-ST3 area=36.000000 wall (38.500000,36.000000)-(38.500000,48.000000) z=0.000000 facing=East glazing=0.000000
surface N-U5/W4 Outdoors area=19.500000 wall (38.500000,48.000000)-(32.000000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U5/W5 Interzone adjacent=N-U4 area=36.000000 wall (32.000000,48.000000)-(32.000000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U5/F Unresolved area=78.000000 Floor z=0.000000
surface N-U5/C Unresolved area=78.000000 Ceiling z=3.000000
surface N-U6/W1 Interzone adjacent=E-U2 area=19.500000 wall (41.500000,36.000000)-(48.000000,36.000000) z=0.000000 facing=South glazing=0.000000
surface N-U6/W2 Outdoors area=36.000000 wall (48.000000,36.000000)-(48.000000,48.000000) z=0.000000 facing=East glazing=10.800000 window(offset=2.713665 sill=0.678416 6.572671x1.643168)
surface N-U6/W3 Outdoors area=19.500000 wall (48.000000,48.000000)-(41.500000,48.000000) z=0.000000 facing=North glazing=5.850000 window(offset=1.469902 sill=0.678416 3.560197x1.643168)
surface N-U6/W4 Interzone adjacent=N-ST3 area=36.000000 wall (41.500000,48.000000)-(41.500000,36.000000) z=0.000000 facing=West glazing=0.000000
surface N-U6/F Unresolved area=78.000000 Floor z=0.000000
surface N-U6/C Unresolved area=78.000000 Ceiling z=3.000000
surface W-ST1/W1 Interzone adjacent=W-U1 area=36.000000 wall (0.000000,22.500000)-(12.000000,22.500000) z=0.000000 facing=South glazing=0.000000
surface W-ST1/W2 Outdoors area=9.000000 wall (12.000000,22.500000)-(12.000000,25.500000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W-ST1/W3 Interzone adjacent=W-U2 area=36.000000 wall (12.000000,25.500000)-(0.000000,25.500000) z=0.000000 facing=North glazing=0.000000
surface W-ST1/W4 Outdoors area=9.000000 wall (0.000000,25.500000)-(0.000000,22.500000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface W-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface W-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface W-U1/W1 Interzone adjacent=S-U1 area=19.500000 wall (0.000000,12.000000)-(6.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W2 Interzone adjacent=S-ST1 area=9.000000 wall (6.500000,12.000000)-(9.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W3 Interzone adjacent=S-U2 area=7.500000 wall (9.500000,12.000000)-(12.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface W-U1/W4 Outdoors area=31.500000 wall (12.000000,12.000000)-(12.000000,22.500000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U1/W5 Interzone adjacent=W-ST1 area=36.000000 wall (12.000000,22.500000)-(0.000000,22.500000) z=0.000000 facing=North glazing=0.000000
surface W-U1/W6 Outdoors area=31.500000 wall (0.000000,22.500000)-(0.000000,12.000000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U1/F Unresolved area=126.000000 Floor z=0.000000
surface W-U1/C Unresolved area=126.000000 Ceiling z=3.000000
surface W-U2/W1 Interzone adjacent=W-ST1 area=36.000000 wall (0.000000,25.500000)-(12.000000,25.500000) z=0.000000 facing=South glazing=0.000000
surface W-U2/W2 Outdoors area=31.500000 wall (12.000000,25.500000)-(12.000000,36.000000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U2/W3 Interzone adjacent=N-U2 area=7.500000 wall (12.000000,36.000000)-(9.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W4 Interzone adjacent=N-ST1 area=9.000000 wall (9.500000,36.000000)-(6.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W5 Interzone adjacent=N-U1 area=19.500000 wall (6.500000,36.000000)-(0.000000,36.000000) z=0.000000 facing=North glazing=0.000000
surface W-U2/W6 Outdoors area=31.500000 wall (0.000000,36.000000)-(0.000000,25.500000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface W-U2/F Unresolved area=126.000000 Floor z=0.000000
surface W-U2/C Unresolved area=126.000000 Ceiling z=3.000000
surface E-ST1/W1 Interzone adjacent=E-U1 area=36.000000 wall (36.000000,22.500000)-(48.000000,22.500000) z=0.000000 facing=South glazing=0.000000
surface E-ST1/W2 Outdoors area=9.000000 wall (48.000000,22.500000)-(48.000000,25.500000) z=0.000000 facing=East glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface E-ST1/W3 Interzone adjacent=E-U2 area=36.000000 wall (48.000000,25.500000)-(36.000000,25.500000) z=0.000000 facing=North glazing=0.000000
surface E-ST1/W4 Outdoors area=9.000000 wall (36.000000,25.500000)-(36.000000,22.500000) z=0.000000 facing=West glazing=0.900000 window(offset=1.025658 sill=1.025658 0.948683x0.948683)
surface E-ST1/F Unresolved area=36.000000 Floor z=0.000000
surface E-ST1/C Unresolved area=36.000000 Ceiling z=3.000000
surface E-U1/W1 Interzone adjacent=S-U5 area=7.500000 wall (36.000000,12.000000)-(38.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W2 Interzone adjacent=S-ST3 area=9.000000 wall (38.500000,12.000000)-(41.500000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W3 Interzone adjacent=S-U6 area=19.500000 wall (41.500000,12.000000)-(48.000000,12.000000) z=0.000000 facing=South glazing=0.000000
surface E-U1/W4 Outdoors area=31.500000 wall (48.000000,12.000000)-(48.000000,22.500000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U1/W5 Interzone adjacent=E-ST1 area=36.000000 wall (48.000000,22.500000)-(36.000000,22.500000) z=0.000000 facing=North glazing=0.000000
surface E-U1/W6 Outdoors area=31.500000 wall (36.000000,22.500000)-(36.000000,12.000000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U1/F Unresolved area=126.000000 Floor z=0.000000
surface E-U1/C Unresolved area=126.000000 Ceiling z=3.000000
surface E-U2/W1 Interzone adjacent=E-ST1 area=36.000000 wall (36.000000,25.500000)-(48.000000,25.500000) z=0.000000 facing=South glazing=0.000000
surface E-U2/W2 Outdoors area=31.500000 wall (48.000000,25.500000)-(48.000000,36.000000) z=0.000000 facing=East glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U2/W3 Interzone adjacent=N-U6 area=19.500000 wall (48.000000,36.000000)-(41.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W4 Interzone adjacent=N-ST3 area=9.000000 wall (41.500000,36.000000)-(38.500000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W5 Interzone adjacent=N-U5 area=7.500000 wall (38.500000,36.000000)-(36.000000,36.000000) z=0.000000 facing=North glazing=0.000000
surface E-U2/W6 Outdoors area=31.500000 wall (36.000000,36.000000)-(36.000000,25.500000) z=0.000000 facing=West glazing=9.450000 window(offset=2.374457 sill=0.678416 5.751087x1.643168)
surface E-U2/F Unresolved area=126.000000 Floor z=0.000000
surface E-U2/C Unresolved area=126.000000 Ceiling z=3.000000
- [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.EnclosedCourt.EnclosedCourtGeneratorTests"
dotnet test tests/Lod.Generators.Tests -c Release --filter "FullyQualifiedName~Lod.Generators.Tests.StairBayBar|FullyQualifiedName~Lod.Generators.Tests.StairPair|FullyQualifiedName~Lod.Generators.Tests.WingedBand|FullyQualifiedName~Lod.Generators.Tests.OpenCourt"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22, Duration: … - Lod.Generators.Tests.dll (net8.0) and Passed! - Failed: 0, Passed: 83, Skipped: 0, Total: 83, Duration: … - Lod.Generators.Tests.dll (net8.0); Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 181 generators, and 549 integration tests passed on net8.0; git status lists M src/Lod.Generators/StairBayBar/StairBayLayout.cs and the three new files of this task, and no *.received.txt.
- [x] Step 8: Commit
git add src/Lod.Generators/EnclosedCourt/EnclosedCourtGenerator.cs src/Lod.Generators/StairBayBar/StairBayLayout.cs tests/Lod.Generators.Tests/EnclosedCourt/EnclosedCourtGeneratorTests.cs tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt
git commit -m "feature(generators): add the enclosed court generator (hdhm-typ-005)"
Task 8: The enclosed court with every simplifier and aggregator¶
Files:
- Test: create tests/Lod.Integration.Tests/EnclosedCourtTypologyTests.cs
Interfaces:
- Consumes: EnclosedCourtGenerator and EnclosedCourtParameters.Default (Task 7); test support Pipeline.Simplifier, Pipeline.Aggregator, Pipeline.Tolerances; PlanTransform.Apply(IGeneratedPlan plan, double rotationDegrees, double translationX, double translationY) : Result<IGeneratedPlan> (S4.2); SemanticMerge, PerimeterCore(ToleranceSettings, double) (Task 3), SingleZoneBuilding(ToleranceSettings), FloorEntry(IFloor, int), BuildingValidator(ToleranceSettings).Validate.
- Produces: the S8 test of the roadmap for the family with a hole: four simplifiers × four aggregators (floors 1, 3, 1) build and validate without warnings, on the canonical plan and on the plan rotated by 30° and moved by (5.3, −2.1) m and rotated by 131.4° and moved by (15.08, −153.9) m; Semantic Merge gives four L-shaped dwelling zones of 3 × 78 + 126 = 360 m², one around each corner of the court, and the eight stairs, none with a hole; Perimeter Core gives the eight perimeter zones and a ring core of 38.86² − 33.14² m², the court zones holding the court glazing; 9 m wings fail Perimeter Core with PlanTooNarrow; Single Zone Building keeps the court as a hole in every part, rebuilds the 20 court walls with one window each, and has 1728 m² of ground. No production code changes.
This task has no red phase: it checks that the simplifiers and aggregators, with Tasks 1 and 3, accept the new family. If any case fails, stop and report: a further pipeline change needs its own decision and test-first task.
- [x] Step 1: Write the tests — create
tests/Lod.Integration.Tests/EnclosedCourtTypologyTests.cs(full file)
52 cases: EveryCombinationValidates (16), EveryCombinationValidatesOnARotatedAndTranslatedPlan (32), SemanticMergeJoinsTheDwellingsAroundEachCornerOfTheCourt, PerimeterCoreZonesTheOuterAndTheCourtFacades, WingsNarrowerThanTwiceThePerimeterDepthAreTooNarrow, and SingleZoneBuildingRebuildsTheCourtWallsOnEveryStorey (1 each).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Generators.EnclosedCourt;
using Xunit;
namespace Lod.Integration.Tests;
/// <summary>The S8.3 family with a hole (ADR-006): the enclosed courtyard block through every simplifier and aggregator, canonical and placed.</summary>
public sealed class EnclosedCourtTypologyTests
{
private static readonly string[] Simplifiers = { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" };
private static readonly string[] Aggregators = { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" };
/// <summary>Rotations (degrees) and translations (m) applied by <see cref="PlanTransform"/>.</summary>
private static readonly double[][] Placements = { new[] { 30.0, 5.3, -2.1 }, new[] { 131.4, 15.08, -153.9 } };
public static IEnumerable<object[]> Matrix() =>
from 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();
[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);
}
[Fact]
public void SemanticMergeJoinsTheDwellingsAroundEachCornerOfTheCourt()
{
// The stairs of the west and east wings and the middle stairs of the south and north wings separate the dwellings into four L-shaped groups,
// one around each corner of the court; stairs never adjoin each other and stay apart.
IFloor floor = new SemanticMerge(Pipeline.Tolerances).Simplify(Plan()).Value;
Assert.Equal(
"DwellingUnit-1,DwellingUnit-2,DwellingUnit-3,DwellingUnit-4,Stair-1,Stair-2,Stair-3,Stair-4,Stair-5,Stair-6,Stair-7,Stair-8",
string.Join(",", floor.Zones.Select(z => z.Id.Value).OrderBy(id => id, StringComparer.Ordinal)));
Assert.All(floor.Zones.Where(z => z.SpaceType == SpaceType.DwellingUnit), z => Assert.Equal((3 * 78.0) + 126.0, z.FloorArea, 6));
Assert.All(floor.Zones, z => Assert.Empty(z.Parts[0].Footprint.Holes));
}
[Fact]
public void PerimeterCoreZonesTheOuterAndTheCourtFacades()
{
IFloor floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(Plan()).Value;
Assert.Equal(
"P-North,P-East,P-South,P-West,P-Court-North,P-Court-East,P-Court-South,P-Court-West,CORE",
string.Join(",", floor.Zones.Select(z => z.Id.Value)));
Polygon2 core = floor.Zones.Single(z => z.Id.Value == "CORE").Parts[0].Footprint;
Assert.Single(core.Holes);
Assert.Equal(Math.Pow(48.0 - (2 * 4.57), 2) - Math.Pow(24.0 + (2 * 4.57), 2), core.Area, 6);
Assert.DoesNotContain(floor.Surfaces.OfType<WallSurface>(), w => w.Zone.Value == "CORE" && w.Boundary == BoundaryCondition.Outdoors);
double courtGlazing = Plan().Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w)).Sum(w => w.GlazedArea);
Assert.Equal(courtGlazing, floor.Surfaces.OfType<WallSurface>().Where(w => w.Zone.Value.StartsWith("P-Court")).Sum(w => w.GlazedArea), 9);
}
[Fact]
public void WingsNarrowerThanTwiceThePerimeterDepthAreTooNarrow()
{
// D-087 applies to the wings between the outer and the court façades: 9 m wings with the 4.57 m default depth leave no core.
IGeneratedPlan plan = new EnclosedCourtGenerator(Pipeline.Tolerances).Generate(EnclosedCourtParameters.Default with { Depth = 9.0 }, Presets()).Value;
Result<IFloor> floor = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(plan);
Assert.Equal(DiagnosticCodes.PlanTooNarrow, Assert.Single(floor.Diagnostics).Code);
}
[Fact]
public void SingleZoneBuildingRebuildsTheCourtWallsOnEveryStorey()
{
IFloor floor = new NoSimplification().Simplify(Plan()).Value;
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 3), new FloorEntry(floor, 1) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.All(zone.Parts, p => Assert.Single(p.Footprint.Holes));
WallSurface[] court = building.Surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors && OnCourt(w)).ToArray();
Assert.Equal(5 * 4, court.Length);
Assert.All(court, w => Assert.Single(w.Windows));
Assert.Equal(1728.0, building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Ground).Sum(h => h.Area), 6);
}
private static bool OnCourt(WallSurface wall) => new[] { wall.Start, wall.End }.All(p => p.X is >= 12.0 and <= 36.0 && p.Y is >= 12.0 and <= 36.0);
private static void AssertValidates(IGeneratedPlan plan, string simplifier, string aggregator)
{
Result<IFloor> floor = Pipeline.Simplifier(simplifier).Simplify(plan);
Assert.True(floor.IsSuccess, string.Join("; ", floor.Diagnostics));
FloorEntry[] entries = { new(floor.Value, 1), new(floor.Value, 3), new(floor.Value, 1) };
Result<IGeneratedBuilding> building = Pipeline.Aggregator(aggregator).Aggregate(entries);
Assert.True(building.IsSuccess, string.Join("; ", building.Diagnostics));
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building.Value);
Assert.True(report.Passed, report.Describe());
Assert.DoesNotContain(building.Diagnostics, d => d.Severity == DiagnosticSeverity.Warning);
}
private static EnclosedCourtPresets Presets() => new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Stair);
/// <summary>The canonical plan: ADR-006 defaults and the example presets.</summary>
private static IGeneratedPlan Plan() => new EnclosedCourtGenerator(Pipeline.Tolerances).Generate(EnclosedCourtParameters.Default, Presets()).Value;
}
- [x] Step 2: Run the tests
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.EnclosedCourtTypologyTests"
Expected: Passed! - Failed: 0, Passed: 52, Skipped: 0, Total: 52, Duration: … - Lod.Integration.Tests.dll (net8.0).
- [x] Step 3: Build, test, and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add tests/Lod.Integration.Tests/EnclosedCourtTypologyTests.cs
git commit -m "test(integration): validate the enclosed court with every simplifier and aggregator"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 181 generators, and 601 integration tests passed on net8.0; git status lists only ?? tests/Lod.Integration.Tests/EnclosedCourtTypologyTests.cs.
Task 9: ADR-006, ADR-002, ADR-008, and ADR-013 record the enclosed court and footprints with holes¶
Files:
- Modify: docs/decisions/ADR-006-plan-generation-mechanism.md (S8.1 document, last changed by 34deeaa), docs/decisions/ADR-002-geometry.md, docs/decisions/ADR-008-perimeter-core-corners.md, docs/decisions/ADR-013-vertical-aggregation-methods.md (last changed in S8.2)
Interfaces:
- Consumes: Tasks 1–8 (the boundary coverage, the court perimeter zones and the check between the offset rings, the aggregators on a hole, zone IDs, defaults, and messages of the generator).
- Produces: ADR-006 after S8.3: the heading Families implemented in S8.2 and S8.3, 005 marked (S8.3, implemented) with an Implemented (S8.3) item, the section Pipeline consequences of S8.3, and two consequences on the court; ADR-002: the plan boundary includes the holes, the boundary coverage, and the section Footprints with holes (S8.3), the out-of-scope sentence on holes removed, and the status line; ADR-008: every ring offset (the floor on the ring's left), the offset ring keeping its ring's orientation, the check between the offset rings, the court perimeter zones P-Court-<Bin> and their order, the consequence on footprints with holes with the numbers of the default enclosed court, and the status line; ADR-013: the section Footprints with holes (S8.3) and the status line. No record, default, or rule of ADR-006 changes; the four ADRs keep their acceptance status (ADR-006 and ADR-013 accepted by the controller, pending the owner's reading).
- [x] Step 1: 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. The sentence of Pipeline consequences of S8.2 corrected on main by 34deeaa ("found three faults on rotated plans; the second …") is not touched.
- Replace
### Families implemented in S8.2 and specified for S8.3
with
### Families implemented in S8.2 and S8.3
- Replace
Records, defaults, and zoning are fixed here; the layouts are implemented, and may be refined, in their sub-stage, which records any change here. S8.2 implemented 007, 003, and 004 as specified, without changing a record or a default; the details it settled are recorded below each family. Each wing below is a bar of stair bays: `n = max(1, round(L / (2U + S)))` bays, rounded half away from zero, with the unit width adjusted to `(L − n·S) / (2n)` so the bays fill the wing's length `L` exactly. Validation additionally requires that adjusted width to be positive: stairs that leave no room for dwellings are `InvalidParameter`, for example `StairWidth 10 m leaves no dwelling width in each 17 m wing: its 2 stair bays need 20 m of stairs; reduce StairWidth or UnitWidth.` Within a wing, zones are listed stairs first, then dwellings, numbered along the wing's axis, and each wing's last bay ends exactly at the wing's end.
with
Records, defaults, and zoning are fixed here; the layouts are implemented, and may be refined, in their sub-stage, which records any change here. S8.2 implemented 007, 003, and 004 and S8.3 implemented 005 as specified, without changing a record or a default; the details each sub-stage settled are recorded below each family. Each wing below is a bar of stair bays: `n = max(1, round(L / (2U + S)))` bays, rounded half away from zero, with the unit width adjusted to `(L − n·S) / (2n)` so the bays fill the wing's length `L` exactly. Validation additionally requires that adjusted width to be positive: stairs that leave no room for dwellings are `InvalidParameter`, for example `StairWidth 10 m leaves no dwelling width in each 17 m wing: its 2 stair bays need 20 m of stairs; reduce StairWidth or UnitWidth.` Within a wing, zones are listed stairs first, then dwellings, numbered along the wing's axis, and each wing's last bay ends exactly at the wing's end.
- Replace
#### `HDHM-TYP-005` Enclosed courtyard residential block: `EnclosedCourtGenerator` (S8.3)
with
#### `HDHM-TYP-005` Enclosed courtyard residential block: `EnclosedCourtGenerator` (S8.3, implemented)
- Replace
- Realisation: `R001` dwellings on every edge of the court; `R002` the court is closed on all four sides (the footprint has exactly one hole). Validation: lengths positive. Requires footprints with holes in every plan simplifier and floor aggregator (S8.3, D-084).
with
- Realisation: `R001` dwellings on every edge of the court; `R002` the court is closed on all four sides (the footprint has exactly one hole). Validation: lengths positive. Requires footprints with holes in every plan simplifier and floor aggregator (S8.3, D-084).
- Implemented (S8.3): zones `S-ST1…S-ST3`, `S-U1…S-U6` (*South Stair 1*, *South Unit 1*, …; three bays of 6.5 m dwellings in the 48 m south wing), the same for `N-…`, then `W-ST1`, `W-U1`, `W-U2` and `E-ST1`, `E-U1`, `E-U2` (one bay of 10.5 m dwellings in each 24 m side wing, south to north); 1728 m² (48 m × 48 m less the 24 m × 24 m court), 44 windows, 16 of them on the court façades (79.2 m² of the 237.6 m² of glazing). The footprint's hole is the court ring `(D, D)`, `(D, D + c)`, `(D + w, D + c)`, `(D + w, D)`, stored clockwise. Validation also fits the stair bays to each wing as for the open court (`StairWidth 30 m leaves no dwelling width in each 48 m south and north wing: …`). The generator does not impose the *Perimeter Core* precondition of D-087, which belongs to that simplifier: the default 12 m wings are wider than twice the 4.57 m default perimeter depth, and 9 m wings fail *Perimeter Core* with `PlanTooNarrow`. *Semantic Merge* gives four L-shaped dwelling zones, one around each corner of the court (the side wings' stairs and the middle stairs of the long wings separate them), and the eight stairs; no merged zone has a hole.
- Replace
The bisector rule of *Perimeter Core* needed no change: it handles the re-entrant corners of the comb and the U. Its precondition, that every wing or other part of the footprint be wider than twice the perimeter depth, is stated in its `PlanTooNarrow` message and in [ADR-008](ADR-008-perimeter-core-corners.md); with the defaults (12 m deep wings and plates of 22 m, 4.57 m depth) every family passes.
with
The bisector rule of *Perimeter Core* needed no change: it handles the re-entrant corners of the comb and the U. Its precondition, that every wing or other part of the footprint be wider than twice the perimeter depth, is stated in its `PlanTooNarrow` message and in [ADR-008](ADR-008-perimeter-core-corners.md); with the defaults (12 m deep wings and plates of 22 m, 4.57 m depth) every family passes.
### Pipeline consequences of S8.3
The enclosed court is the first footprint with a hole. Tests on a simple ring footprint (four wings around a square court) and on the generator, canonical and at the two rotated placements, show what every step needed:
- The surface builder already treated the hole's edges as plan boundary, so the court's edges are outdoor walls facing into the court. It now also requires every edge of the plan boundary, outer ring and holes, to be covered by outdoor zone edges over its full length (`UnmatchedEdge`): a zone filling the court shares every edge with a wing and was not detected by edge matching alone ([ADR-002](ADR-002-geometry.md)).
- *Perimeter Core* rejected holes (`NotSupported`). It now offsets every ring by the bisector rule, gives the court façades their own zones `P-Court-<Bin>`, and leaves a core that is a ring around the court; its precondition (D-087) applies to every wing between the outer and a court façade ([ADR-008](ADR-008-perimeter-core-corners.md)).
- *No Simplification*, *Semantic Merge*, *Single Zone per Floor*, the four floor aggregators, validation, and the window rule needed no change: united zones and storey outlines keep the hole, the court has neither a ground slab nor a roof because it is outside every footprint, rebuilt court walls get one centred window each (D-079), and glazing per orientation counts the court façades by the direction they face ([ADR-013](ADR-013-vertical-aggregation-methods.md)). Every existing snapshot is byte-identical.
- Replace
- Wings and plates narrower than twice the perimeter depth cannot be zoned by *Perimeter Core* (`PlanTooNarrow`); the user chooses a smaller depth or another simplifier.
with
- Wings and plates narrower than twice the perimeter depth, including the wings between the outer and the court façades of `HDHM-TYP-005`, cannot be zoned by *Perimeter Core* (`PlanTooNarrow`); the user chooses a smaller depth or another simplifier.
- The court of `HDHM-TYP-005` is outside the building: its façades are outdoor walls with windows, and it has no ground slab, roof, or zone.
- [x] Step 2: 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
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
- Replace
**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall. Whether a vertex lies on an edge, to split the edge there or to put a segment on the plan boundary, is measured between the original positions of the vertices against the `Distance` tolerance, not between their grid cells (S8.2): snapping a T-junction and both ends of its edge independently can move it up to √2 grid units off the snapped edge, so on a rotated plan a zone corner exactly on the boundary, such as a stair corner on a façade, was not recognised and the rebuilt surfaces of *Semantic Merge* failed with `UnmatchedEdge`.
with
**Edge matching.** Building the surfaces of a zoned plan snaps every vertex to the `Distance` grid, splits zone edges at other zones' vertices, pairs reversed edges of two zones as interzone walls, and marks edges on the plan boundary as outdoors. Any other edge indicates a gap or an overlap and is an error; geometry is never repaired silently (GLOBAL.md quality rule 6). Consecutive collinear segments with the same boundary merge into one wall. Whether a vertex lies on an edge, to split the edge there or to put a segment on the plan boundary, is measured between the original positions of the vertices against the `Distance` tolerance, not between their grid cells (S8.2): snapping a T-junction and both ends of its edge independently can move it up to √2 grid units off the snapped edge, so on a rotated plan a zone corner exactly on the boundary, such as a stair corner on a façade, was not recognised and the rebuilt surfaces of *Semantic Merge* failed with `UnmatchedEdge`. The plan boundary is every ring of the footprint: the edges of a hole (a court) are outdoor walls whose outward side faces into the court. Every edge of the boundary, outer ring and holes, must also be covered by outdoor zone edges over its full length, within the relative tolerance with which façade coverage is compared (S8.3); otherwise the error is `UnmatchedEdge`. Edge matching alone cannot see a zone outside the footprint whose every edge is shared with another zone, such as a zone filling a court: the court's edges are then interzone walls and no edge is left unmatched.
- Replace
**Out of scope:** sloped roofs, non-vertical walls, and curved geometry. Footprints with holes are supported by the types, but the perimeter/core simplifier rejects them until S8 (courtyard plans).
with
**Footprints with holes (S8.3).** A court is a hole of the footprint, outside the building: its edges are outdoor walls with windows by the generator's rule, it has no zone, and no floor or ceiling covers it, so it adds neither ground nor roof area. Every plan simplifier and floor aggregator supports such footprints ([ADR-008](ADR-008-perimeter-core-corners.md) for *Perimeter Core*, [ADR-013](ADR-013-vertical-aggregation-methods.md) for the floor aggregators). In Grasshopper a polygon with holes is one planar face with an inner loop per hole, and a zone part one closed extrusion of that face.
**Out of scope:** sloped roofs, non-vertical walls, and curved geometry.
- [x] Step 3: Edit
docs/decisions/ADR-008-perimeter-core-corners.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
Status: Accepted; superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls)
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
- Replace
2. Footprints with holes fail with `NotSupported` until S8 adds courtyard plans.
3. For the counter-clockwise outer ring `v₀ … vₙ₋₁`, the core vertex of `vᵢ` is its mitred inward offset `oᵢ = vᵢ + (a + b)·d / (1 + a·b)`, where `a` and `b` are the inward unit normals of the edges entering and leaving `vᵢ`. `oᵢ` lies on the bisector of the corner, at distance `d` from both edges. The offset is computed directly from this formula, not by a general polygon offset, so core vertex `i` belongs to footprint vertex `i` (ADR-002 leaves the inward offset to this ADR).
with
2. Footprints with holes (courts, S8.3) are offset on every ring by the same rule. Until S8.3 they failed with `NotSupported`.
3. For a ring `v₀ … vₙ₋₁` (the counter-clockwise outer ring or a clockwise hole, so the floor always lies on the ring's left), the core vertex of `vᵢ` is its mitred inward offset `oᵢ = vᵢ + (a + b)·d / (1 + a·b)`, where `a` and `b` are the inward unit normals of the edges entering and leaving `vᵢ`. `oᵢ` lies on the bisector of the corner, at distance `d` from both edges. The offset is computed directly from this formula, not by a general polygon offset, so core vertex `i` belongs to footprint vertex `i` (ADR-002 leaves the inward offset to this ADR).
- Replace
- The offset ring `o₀ … oₙ₋₁` is a valid polygon and counter-clockwise (positive signed area).
with
- The offset ring `o₀ … oₙ₋₁` is a valid polygon and keeps its ring's orientation: counter-clockwise (positive signed area) for the outer ring, clockwise for a hole.
- 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. Zones are listed North, East, South, West, then the core `CORE` ("Core").
with
- With holes (S8.3): the core is the polygon with the offset outer ring as its outer ring and the offset holes as its holes. Clipping the offset holes from the offset outer ring must give one polygon of the core's area within `RelativeArea`, so every offset hole lies inside the offset outer ring and apart from the other offset holes. Otherwise a wing between the outer and a court façade, or between two courts, is not wider than `2d`, the perimeter bands of the two façades overlap, and the area sum of the previous check cannot show it, because the signed areas still add up.
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.
- Replace
- Footprints with holes are rejected until S8, which must extend this rule to courtyard façades or supersede it.
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.
- [x] Step 4: Edit
docs/decisions/ADR-013-vertical-aggregation-methods.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-075; provisional pending the owner's reading); superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls)
with
Status: Accepted (by the controller under D-075; provisional pending the owner's reading); superseded in part by [ADR-012](ADR-012-centred-windows.md) (windows on rebuilt walls); footprints with holes noted in S8.3
- Replace
`SingleZoneBuilding` is exactly the former `SingleZoneMerged` (provenance operation `SingleZoneBuilding`). `SingleZonePerFloorType` with a single entry gives the same geometry as `SingleZoneBuilding` with zone `E0` instead of `BUILDING`. Both record the parameter `Multipliers`. The viewport preview draws each zone as one merged volume instead of one solid per part; the data and the *Convert2BEM* outputs keep one part per storey outline.
with
`SingleZoneBuilding` is exactly the former `SingleZoneMerged` (provenance operation `SingleZoneBuilding`). `SingleZonePerFloorType` with a single entry gives the same geometry as `SingleZoneBuilding` with zone `E0` instead of `BUILDING`. Both record the parameter `Multipliers`. The viewport preview draws each zone as one merged volume instead of one solid per part; the data and the *Convert2BEM* outputs keep one part per storey outline.
### Footprints with holes (S8.3)
The four methods need no rule of their own for a footprint with holes (a court, [ADR-002](ADR-002-geometry.md)). The court lies outside every zone footprint, so the designation on the stack gives it no floor or ceiling piece: the ground and roof areas are the footprint's area without the court, and a storey with the same court as the storey below is fully supported. The storey outlines of the single-zone methods are unions of tiles that keep the hole, one part per outline with an inner ring per court, and their rebuilt walls include the court walls of every storey, which must cover the source court façades (D-041) and get one centred window each with the glazed area they cover (D-079). Every combination of a plan simplifier and a floor aggregator validates for the enclosed court of `HDHM-TYP-005`, canonical and at the two rotated placements of the test suite.
- [x] Step 5: Check and commit
git grep -c -e "(S8.3, implemented)" -e "### Pipeline consequences of S8.3" -- docs/decisions/ADR-006-plan-generation-mechanism.md
git grep -c -e "Footprints with holes (S8.3)" -- docs/decisions/ADR-002-geometry.md docs/decisions/ADR-013-vertical-aggregation-methods.md
git grep -c "P-Court-<Bin>" -- docs/decisions/ADR-008-perimeter-core-corners.md
git add docs/decisions/ADR-006-plan-generation-mechanism.md docs/decisions/ADR-002-geometry.md docs/decisions/ADR-008-perimeter-core-corners.md docs/decisions/ADR-013-vertical-aggregation-methods.md
git commit -m "docs(decisions): record the enclosed court and footprints with holes in adr-006, adr-002, adr-008, and adr-013"
Expected: docs/decisions/ADR-006-plan-generation-mechanism.md:2 (the family heading and the new section) from the first command, docs/decisions/ADR-002-geometry.md:1 and docs/decisions/ADR-013-vertical-aggregation-methods.md:1 from the second, and docs/decisions/ADR-008-perimeter-core-corners.md:1 from the third.
- [x] Step 6: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/footprints-with-holes
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/footprints-with-holes
git branch -d feature/footprints-with-holes
git diff --name-status v0.8.1 main -- "tests/*/Snapshots/*"
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0; the last command lists only A tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt: the fifteen existing snapshots are byte-identical. Do not push (D-084).
Slice B — feature/grasshopper-enclosed-court¶
Grasshopper code cannot be unit-tested without Rhino: Tasks 10 to 13 are written, built with 0 warnings, committed, and checked in Rhino by the smoke test of Task 16.
Task 10: Zone and surface Breps carry the holes of court footprints¶
Files:
- Modify: src/Lod.Grasshopper/Convert/RhinoGeometry.cs (S2 file, last changed by 4b07283)
Interfaces:
- Consumes: RhinoGeometry.PartBreps, HorizontalBreps, and the private PlanarBreps (S2, S4.2, 4b07283), which already build one planar face per polygon from all of its rings with Brep.CreatePlanarBreps.
- Produces: documentation only: PartBreps, HorizontalBreps, and PlanarBreps state that a polygon with holes is one planar face with an inner loop per hole, so a zone part with a court is one closed extrusion whose inner walls face into the court. No code changes; Task 13 changes the code of this file.
- [x] Step 1: Create the branch
git switch -c feature/grasshopper-enclosed-court main
- [x] Step 2: Replace
src/Lod.Grasshopper/Convert/RhinoGeometry.cs(full file)
Three summaries change; nothing else.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Rhino;
using Rhino.Geometry;
namespace Lod.Grasshopper.Convert;
/// <summary>
/// Converts pipeline geometry (metres) to Rhino geometry in the active document's model units. The only place Rhino geometry is created.
/// </summary>
internal static class RhinoGeometry
{
private static double Tolerance => ToleranceSettings.Default.Distance;
/// <summary>Metres to model units of the active document (1 without a document).</summary>
public static double UnitScale => RhinoDoc.ActiveDoc is { } doc ? RhinoMath.UnitScale(UnitSystem.Meters, doc.ModelUnitSystem) : 1.0;
/// <summary>One closed Brep per zone part.</summary>
public static IEnumerable<Brep> ZoneBreps(Zone zone) => PartBreps(zone.Parts);
/// <summary>
/// A zone as merged volumes, for the viewport preview of zones spanning storeys (D-071): parts stacked with the same outline form one vertical
/// run (<see cref="VerticalRuns"/>), extruded once over the run's height, so no boolean union of touching solids is needed. Only where outlines
/// differ between runs is a boolean union of the runs tried, with coplanar faces merged; if it fails, the runs are shown. Data outputs keep one
/// Brep per part.
/// </summary>
public static IEnumerable<Brep> MergedZoneBreps(Zone zone)
{
Brep[] runs = PartBreps(VerticalRuns.Of(zone.Parts, ToleranceSettings.Default)).ToArray();
if (runs.Length < 2)
{
return runs;
}
double tolerance = Tolerance * UnitScale;
Brep[]? union = Brep.CreateBooleanUnion(runs, tolerance);
if (union is null || union.Length == 0)
{
return runs;
}
foreach (Brep brep in union)
{
brep.MergeCoplanarFaces(tolerance);
}
return union;
}
/// <summary>
/// One closed Brep per part: its floor polygon extruded by its height. A merged zone's outline keeps the corners of the zones it was merged
/// from as collinear vertices, so the extrusion would show one face per former edge; coplanar side faces are merged, so a façade the walls
/// treat as one wall is one face. The geometry is unchanged. A footprint with holes (a court) is one planar face with an inner loop per hole
/// (<see cref="PlanarBreps"/>), so the extrusion is one closed Brep whose inner walls face into the court.
/// </summary>
public static IEnumerable<Brep> PartBreps(IEnumerable<ZonePart> parts)
{
foreach (ZonePart part in parts)
{
foreach (Brep planar in PlanarBreps(part.Footprint, part.Elevation))
{
Brep? solid = planar.Faces[0].CreateExtrusion(new LineCurve(Point3d.Origin, new Point3d(0, 0, part.Height)), true);
if (solid is not null)
{
solid.MergeCoplanarFaces(Tolerance);
yield return Scaled(solid);
}
}
}
}
/// <summary>The wall as a planar rectangle.</summary>
public static Brep? WallBrep(WallSurface wall) =>
Rectangle(wall, offset: 0.0, width: wall.Length, bottom: wall.Elevation, height: wall.Height);
/// <summary>Every window of a wall at its explicit position.</summary>
public static IEnumerable<Brep> WindowBreps(WallSurface wall) =>
wall.Windows
.Select(w => Rectangle(wall, offset: w.Offset, width: w.Width, bottom: wall.Elevation + w.SillHeight, height: w.Height))
.Where(b => b is not null)
.Select(b => b!);
/// <summary>A floor or ceiling as planar Breps; a polygon with holes is one face with an inner loop per hole.</summary>
public static IEnumerable<Brep> HorizontalBreps(HorizontalSurface surface) => PlanarBreps(surface.Polygon, surface.Elevation).Select(Scaled);
/// <summary>
/// The planar Breps bounded by every ring of a polygon: <see cref="Brep.CreatePlanarBreps(IEnumerable{Curve}, double)"/> nests the hole rings inside
/// the outer ring, so a polygon with holes gives one face with inner loops, not a face per ring.
/// </summary>
private static IEnumerable<Brep> PlanarBreps(Polygon2 polygon, double elevation)
{
Curve[] curves = polygon.Rings
.Select(ring => (Curve)new PolylineCurve(ring.Select(p => new Point3d(p.X, p.Y, elevation)).Concat(new[] { new Point3d(ring[0].X, ring[0].Y, elevation) })))
.ToArray();
return Brep.CreatePlanarBreps(curves, Tolerance) ?? Enumerable.Empty<Brep>();
}
private static Brep? Rectangle(WallSurface wall, double offset, double width, double bottom, double height)
{
double length = wall.Length;
var direction = new Vector3d((wall.End.X - wall.Start.X) / length, (wall.End.Y - wall.Start.Y) / length, 0);
var origin = new Point3d(wall.Start.X, wall.Start.Y, bottom) + (direction * offset);
Point3d a = origin;
Point3d b = origin + (direction * width);
Point3d c = b + new Vector3d(0, 0, height);
Point3d d = a + new Vector3d(0, 0, height);
Brep? brep = Brep.CreateFromCornerPoints(a, b, c, d, Tolerance);
return brep is null ? null : Scaled(brep);
}
private static Brep Scaled(Brep brep)
{
double scale = UnitScale;
if (scale != 1.0)
{
brep.Transform(Transform.Scale(Point3d.Origin, scale));
}
return brep;
}
}
- [x] Step 3: Build and commit
dotnet build BEMGen.sln -c Release
git status --short --untracked-files=all
git add src/Lod.Grasshopper/Convert/RhinoGeometry.cs
git commit -m "docs(convert): state how zone and surface breps carry the holes of court footprints"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); M src/Lod.Grasshopper/Convert/RhinoGeometry.cs only.
Task 11: Enclosed Court component¶
Files:
- Modify: src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs (S8.1 file, last changed in S8.2)
Interfaces:
- Consumes: EnclosedCourtGenerator and its records (Task 7); the S8.1 base TypologyGeneratorComponent (panel 2 Generate, one Plan output, AddPreset, TryNumbers, Preset, Output, FloorHeightDescription, OrientationDescription); ComponentSupport.PreviewLocationParameter() (S4.1); IconLibrary.For (a missing icon gives Grasshopper's default icon until Task 12).
- Produces: one component after Open Court in the file, with its number inputs defaulting to EnclosedCourtParameters.Default, Floor Height, Orientation, one required preset input per space type (an unconnected one is Grasshopper's missing-input warning, D-065), and Preview Location last (D-062):
| Component (nickname) | Inputs → output | Class | GUID |
|---|---|---|---|
| Enclosed Court (ECourt) | Court Width (CW, 24), Court Depth (CD, 24), Depth (D, 12), Unit Width (UW, 7), Stair Width (SW, 3), Floor Height (FH, 3), Orientation (O, 0), Dwelling Unit (DU), Stair (St), Preview Location (PL) → Plan | EnclosedCourtComponent |
b9765cd0-a62b-483d-9470-86d5fda287e8 |
The seven earlier typology components and their base class are unchanged.
- [x] Step 1: Replace
src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs(full file)
One using and the component class 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.EnclosedCourt;
using Lod.Generators.GalleryBar;
using Lod.Generators.OpenCourt;
using Lod.Generators.PointPlate;
using Lod.Generators.StairBayBar;
using Lod.Generators.StairPair;
using Lod.Generators.TerraceRow;
using Lod.Generators.WingedBand;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
namespace Lod.Grasshopper.Components;
/// <summary>
/// Shared inputs and output of the typology plan generator components (ADR-006): number inputs with the family's defaults, one required preset input
/// per space type (D-061, D-065), the Preview Location last (D-062), and one Plan output.
/// </summary>
public abstract class TypologyGeneratorComponent : GH_Component
{
protected const string FloorHeightDescription = "Floor-to-floor height, m.";
protected const string OrientationDescription = "Clockwise rotation of plan north from true north, degrees.";
protected const string Metres = " All lengths in metres regardless of model units.";
protected TypologyGeneratorComponent(string name, string nickname, string description)
: base(name, nickname, description + Metres, ComponentCategories.Category, ComponentCategories.Generate)
{
}
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The detailed plan.", GH_ParamAccess.item);
}
/// <summary>Adds the required preset input of one space type.</summary>
protected static void AddPreset(GH_InputParamManager pManager, string name, string nickname, SpaceType spaceType, string role) =>
pManager.AddParameter(new ProgramPresetParameter(), name, nickname, $"Program preset of {role}; space type {spaceType}.", GH_ParamAccess.item);
/// <summary>Reads <paramref name="count"/> number inputs from <paramref name="first"/>; <c>false</c> when one is missing.</summary>
protected static bool TryNumbers(IGH_DataAccess DA, int first, int count, out double[] values)
{
values = new double[count];
for (int i = 0; i < count; i++)
{
if (!DA.GetData(first + i, ref values[i]))
{
return false;
}
}
return true;
}
/// <summary>Reads a preset input; <c>null</c> when it is unconnected, which Grasshopper reports as a missing input (D-065).</summary>
protected static ProgramPreset? Preset(IGH_DataAccess DA, int index)
{
ProgramPresetGoo? goo = null;
return DA.GetData(index, ref goo) && goo is not null ? goo.Value : null;
}
/// <summary>Reports the generator's diagnostics and outputs the plan with this component's preview location.</summary>
protected void Output(IGH_DataAccess DA, Result<IGeneratedPlan> result)
{
IGeneratedPlan? plan = ComponentSupport.Unwrap(this, result);
if (plan is not null)
{
DA.SetData(0, new PlanGoo(plan, ComponentSupport.PreviewLocation(this, DA)));
}
}
}
public sealed class StairBayBarComponent : TypologyGeneratorComponent
{
public StairBayBarComponent()
: base(
"Stair Bay Bar",
"SBar",
"Repeated stair bays in a linear bar (HDHM-TYP-001, ADR-006): bays of dwelling | stair | dwelling through the full depth, each stair serving the two dwellings beside it; no corridor. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("911c3204-42bb-4023-bdb7-2931ea1caad3");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
StairBayBarParameters d = StairBayBarParameters.Default;
pManager.AddIntegerParameter("Bay Count", "BC", "Number of stair bays along the bar; at least 2.", GH_ParamAccess.item, d.BayCount);
pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling along the bar, m.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Depth", "D", "Bar depth, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the bar, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int bays = 0;
if (!DA.GetData(0, ref bays) || !TryNumbers(DA, 1, 5, out double[] v) || Preset(DA, 6) is not { } dwellingUnit || Preset(DA, 7) is not { } stair)
{
return;
}
var parameters = new StairBayBarParameters(bays, v[0], v[1], v[2], v[3], v[4]);
Output(DA, new StairBayBarGenerator(ToleranceSettings.Default).Generate(parameters, new StairBayBarPresets(dwellingUnit, stair)));
}
}
public sealed class StairPairComponent : TypologyGeneratorComponent
{
public StairPairComponent()
: base(
"Stair Pair",
"SPair",
"One stair between a dwelling pair (HDHM-TYP-008, ADR-006): one stair bay of the Stair Bay Bar as its own building, the stair serving both dwellings. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("5669d39b-45e6-47ea-9250-f2304d005335");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
StairPairParameters d = StairPairParameters.Default;
pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling, m.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Depth", "D", "Plate depth, which both dwellings and the stair span, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of the stair between the dwellings, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the two dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stair");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 5, out double[] v) || Preset(DA, 5) is not { } dwellingUnit || Preset(DA, 6) is not { } stair)
{
return;
}
var parameters = new StairPairParameters(v[0], v[1], v[2], v[3], v[4]);
Output(DA, new StairPairGenerator(ToleranceSettings.Default).Generate(parameters, new StairPairPresets(dwellingUnit, stair)));
}
}
public sealed class GalleryBarComponent : TypologyGeneratorComponent
{
public GalleryBarComponent()
: base(
"Gallery Bar",
"GBar",
"Continuous external gallery beside a linear bar (HDHM-TYP-002, ADR-006): a row of dwellings through the bar depth with a stair at each end. The gallery is not modelled (D-083): no gallery zone or geometry; its façade is an ordinary outdoor façade. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("a58df462-b3c2-46b7-be3d-7ea9f4629280");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
GalleryBarParameters d = GalleryBarParameters.Default;
pManager.AddIntegerParameter("Unit Count", "UC", "Number of dwellings along the bar; at least 2.", GH_ParamAccess.item, d.UnitCount);
pManager.AddNumberParameter("Unit Width", "UW", "Width of each dwelling along the bar, m.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Depth", "D", "Bar depth, which every dwelling and both stairs span, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each end stair along the bar, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the two end stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int units = 0;
if (!DA.GetData(0, ref units) || !TryNumbers(DA, 1, 5, out double[] v) || Preset(DA, 6) is not { } dwellingUnit || Preset(DA, 7) is not { } stair)
{
return;
}
var parameters = new GalleryBarParameters(units, v[0], v[1], v[2], v[3], v[4]);
Output(DA, new GalleryBarGenerator(ToleranceSettings.Default).Generate(parameters, new GalleryBarPresets(dwellingUnit, stair)));
}
}
public sealed class TerraceRowComponent : TypologyGeneratorComponent
{
public TerraceRowComponent()
: base(
"Terrace Row",
"Terr",
"Attached narrow terrace row (HDHM-TYP-006, ADR-006): a row of attached house plots sharing dividing walls, one dwelling zone per plot and storey; each house's own stair is inside the dwelling and not zoned.")
{
}
public override Guid ComponentGuid => new("f694e595-dba2-4b98-9d1e-f1492e3edf39");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
TerraceRowParameters d = TerraceRowParameters.Default;
pManager.AddIntegerParameter("Plot Count", "PC", "Number of attached house plots along the row; at least 2.", GH_ParamAccess.item, d.PlotCount);
pManager.AddNumberParameter("Plot Width", "PW", "Width of each plot along the row, m.", GH_ParamAccess.item, d.PlotWidth);
pManager.AddNumberParameter("Depth", "D", "Row depth, which every house spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the houses");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int plots = 0;
if (!DA.GetData(0, ref plots) || !TryNumbers(DA, 1, 4, out double[] v) || Preset(DA, 5) is not { } dwellingUnit)
{
return;
}
var parameters = new TerraceRowParameters(plots, v[0], v[1], v[2], v[3]);
Output(DA, new TerraceRowGenerator(ToleranceSettings.Default).Generate(parameters, new TerraceRowPresets(dwellingUnit)));
}
}
public sealed class PointPlateComponent : TypologyGeneratorComponent
{
public PointPlateComponent()
: base(
"Point Plate",
"PPlate",
"Compact point-access tower plate (HDHM-TYP-007, ADR-006): a square plate with one central core, a Stair zone without outdoor walls, and four dwellings in a pinwheel around it, each adjoining the core and with two façades. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("8ac9044a-8fbe-4964-a9de-2ef8be35094a");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
PointPlateParameters d = PointPlateParameters.Default;
pManager.AddNumberParameter("Dwelling Depth", "DD", "Depth of each dwelling from its façade to the core, m; the plate side is Core Width + 2 × Dwelling Depth.", GH_ParamAccess.item, d.DwellingDepth);
pManager.AddNumberParameter("Core Width", "CW", "Side of the square core, m.", GH_ParamAccess.item, d.CoreWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the four dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the core");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 4, out double[] v) || Preset(DA, 4) is not { } dwellingUnit || Preset(DA, 5) is not { } stair)
{
return;
}
var parameters = new PointPlateParameters(v[0], v[1], v[2], v[3]);
Output(DA, new PointPlateGenerator(ToleranceSettings.Default).Generate(parameters, new PointPlatePresets(dwellingUnit, stair)));
}
}
public sealed class WingedBandComponent : TypologyGeneratorComponent
{
public WingedBandComponent()
: base(
"Winged Band",
"WBand",
"Projecting wings joined by a common band (HDHM-TYP-003, ADR-006): a band of stair bays with wings of stair bays projecting to plan north, each joined to the band along its whole root; the comb outline gives the band short façade fragments between the wings. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("e285811d-2fdb-4d31-8b02-62c270b609cf");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
WingedBandParameters d = WingedBandParameters.Default;
pManager.AddIntegerParameter("Wing Count", "WC", "Number of wings projecting from the band; at least 2.", GH_ParamAccess.item, d.WingCount);
pManager.AddNumberParameter("Wing Length", "WL", "Length of each wing from the band to its tip, m.", GH_ParamAccess.item, d.WingLength);
pManager.AddNumberParameter("Wing Spacing", "WS", "Clear distance between neighbouring wings along the band, m.", GH_ParamAccess.item, d.WingSpacing);
pManager.AddNumberParameter("Depth", "D", "Depth of the band and of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill the band and each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the band and the wings, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
int wings = 0;
if (!DA.GetData(0, ref wings) || !TryNumbers(DA, 1, 7, out double[] v) || Preset(DA, 8) is not { } dwellingUnit || Preset(DA, 9) is not { } stair)
{
return;
}
var parameters = new WingedBandParameters(wings, v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
Output(DA, new WingedBandGenerator(ToleranceSettings.Default).Generate(parameters, new WingedBandPresets(dwellingUnit, stair)));
}
}
public sealed class OpenCourtComponent : TypologyGeneratorComponent
{
public OpenCourtComponent()
: base(
"Open Court",
"OCourt",
"Open U-shaped court with stair-served wings (HDHM-TYP-004, ADR-006): west, east, and north wings of stair bays around a court open to plan south, every dwelling adjoining a stair. One zone per dwelling unit.")
{
}
public override Guid ComponentGuid => new("223014ea-3c01-4d2b-b8ba-92ebdc4a7327");
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
OpenCourtParameters d = OpenCourtParameters.Default;
pManager.AddNumberParameter("Court Width", "CW", "Width of the court between the side wings, m.", GH_ParamAccess.item, d.CourtWidth);
pManager.AddNumberParameter("Court Depth", "CD", "Depth of the court from its open side to the north wing, which is also the side wings' length, m.", GH_ParamAccess.item, d.CourtDepth);
pManager.AddNumberParameter("Depth", "D", "Depth of every wing, which every dwelling and stair spans, m.", GH_ParamAccess.item, d.Depth);
pManager.AddNumberParameter("Unit Width", "UW", "Target dwelling width, m; adjusted so the stair bays fill each wing exactly.", GH_ParamAccess.item, d.UnitWidth);
pManager.AddNumberParameter("Stair Width", "SW", "Width of each stair along the wings, m.", GH_ParamAccess.item, d.StairWidth);
pManager.AddNumberParameter("Floor Height", "FH", FloorHeightDescription, GH_ParamAccess.item, d.FloorHeight);
pManager.AddNumberParameter("Orientation", "O", OrientationDescription, GH_ParamAccess.item, d.OrientationDegrees);
AddPreset(pManager, "Dwelling Unit", "DU", SpaceType.DwellingUnit, "the dwellings");
AddPreset(pManager, "Stair", "St", SpaceType.Stair, "the stairs");
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void SolveInstance(IGH_DataAccess DA)
{
if (!TryNumbers(DA, 0, 7, out double[] v) || Preset(DA, 7) is not { } dwellingUnit || Preset(DA, 8) is not { } stair)
{
return;
}
var parameters = new OpenCourtParameters(v[0], v[1], v[2], v[3], v[4], v[5], v[6]);
Output(DA, new OpenCourtGenerator(ToleranceSettings.Default).Generate(parameters, new OpenCourtPresets(dwellingUnit, stair)));
}
}
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)));
}
}
- [x] Step 2: Build
dotnet build BEMGen.sln -c Release
git status --short --untracked-files=all
Expected: Build succeeded., 0 Warning(s), 0 Error(s); M src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs only. The icon does not exist yet; Task 12 draws it (the build embeds whatever PNGs exist and does not need it).
- [x] Step 3: Commit
git add src/Lod.Grasshopper/Components/TypologyGeneratorComponents.cs
git commit -m "feature(grasshopper): add the enclosed court component"
Task 12: Icon for the Enclosed Court component¶
Files:
- Modify: scripts/make_icons.py (S4.1 file, last changed in S8.2)
- Create (generated): src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png
Interfaces:
- Consumes: the component class of Task 11 (29 components, 6 parameters, 1 assembly info); IconLibrary.For and the Icons\*.png embedding of Lod.Grasshopper.csproj (S4.1, unchanged).
- Produces: the motif enclosed_court (a closed square ring in plan in the accent colour around a transparent court, with dark stairs: two across each of the south and north wings and one across each of the west and east wings), in the Generate green; the icon table maps EnclosedCourtComponent after OpenCourtComponent. 36 icons.
- [x] Step 1: Check Python and see the script fail
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"
python scripts/make_icons.py
git status --short
Expected: True 12.3.0; the script stops with exit code 1 and the message Classes without an icon: ['EnclosedCourtComponent']; icons without a class: [], and writes nothing (git status --short prints nothing). With another Pillow version the script still runs, but the PNG bytes may differ from Step 3's hashes; then Step 4 decides.
- [x] Step 2: Replace
scripts/make_icons.py(full file)
The motif is added after open_court, and the icon entry after Open Court in the Generate group; every other motif and entry is unchanged.
"""Draws the BEMGen Grasshopper icons (D-063).
Every icon is drawn at 96 x 96 pixels on a transparent background and downsampled to 24 x 24 with LANCZOS, then written to
src/Lod.Grasshopper/Icons/<Name>.png, where <Name> is the class name of the component, parameter, or assembly info that shows it.
Each icon is a dark glyph plus the accent colour of its toolbar panel; a parameter uses the panel of the component that makes its object.
Shapes only, no fonts, so the output does not depend on installed fonts.
Run from the repository root: python scripts/make_icons.py
Requires Python 3.10+ and Pillow. Generated with Pillow 12.3.0; the same Pillow version gives the same pixels.
The script stops with an error when an icon has no class in src/Lod.Grasshopper or a class has no icon.
"""
from __future__ import annotations
import re
import sys
from collections.abc import Callable
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "src" / "Lod.Grasshopper"
OUTPUT = SOURCE / "Icons"
CANVAS = 96
SIZE = 24
LINE = 8 # 2 px after downsampling
DARK = (38, 38, 38, 255)
WHITE = (255, 255, 255, 255)
# One accent per toolbar panel (ComponentCategories).
INFO = (96, 125, 139, 255)
PROGRAM = (230, 140, 30, 255)
GENERATE = (46, 158, 79, 255)
SIMPLIFY = (47, 123, 216, 255)
AGGREGATE = (138, 79, 200, 255)
CONVERT = (208, 69, 58, 255)
INSPECT = (24, 150, 150, 255)
Colour = tuple[int, int, int, int]
Draw = ImageDraw.ImageDraw
Motif = Callable[[Draw, Colour], None]
Painter = Callable[[Image.Image, Colour], None]
def box(d: Draw, xy: tuple[float, float, float, float], fill=None, outline=DARK, width: int = LINE) -> None:
d.rectangle(xy, fill=fill, outline=outline, width=width)
def line(d: Draw, points: list[tuple[float, float]], width: int = LINE, fill=DARK) -> None:
d.line(points, fill=fill, width=width, joint="curve")
def arrow_head(d: Draw, tip: tuple[float, float], direction: str, size: float, fill=DARK) -> None:
x, y = tip
if direction == "right":
d.polygon([(x, y), (x - size, y - size), (x - size, y + size)], fill=fill)
elif direction == "down":
d.polygon([(x, y), (x - size, y - size), (x + size, y - size)], fill=fill)
# --- motifs: each draws inside the 96 x 96 canvas -------------------------------------------------------------
def info(d: Draw, accent) -> None:
d.ellipse((6, 6, 90, 90), fill=accent, outline=DARK, width=LINE)
d.rectangle((42, 40, 54, 74), fill=WHITE)
d.rectangle((42, 20, 54, 32), fill=WHITE)
def assembly(d: Draw, accent) -> None:
# A stepped block: the building at three levels of detail.
box(d, (8, 52, 88, 88), fill=accent)
box(d, (8, 30, 60, 56), fill=accent)
box(d, (8, 8, 36, 34), fill=accent)
def schedule(d: Draw, accent) -> None:
# A 24-hour profile as bars on a time axis.
heights = [16, 48, 72, 36, 60, 76, 24]
for i, h in enumerate(heights):
x = 12 + i * 12
d.rectangle((x, 84 - h, x + 7, 83), fill=accent)
d.rectangle((4, 84, 92, 91), fill=DARK)
d.rectangle((4, 4, 11, 91), fill=DARK)
def load(d: Draw, accent) -> None:
bolt = [(58, 4), (18, 54), (44, 54), (34, 92), (78, 38), (52, 38), (64, 4)]
d.polygon(bolt, fill=accent, outline=DARK, width=6)
def preset_tag(d: Draw, accent) -> None:
tag = [(8, 30), (30, 8), (88, 8), (88, 88), (30, 88), (8, 66)]
d.polygon(tag, fill=accent, outline=DARK, width=LINE)
d.ellipse((24, 40, 40, 56), fill=WHITE, outline=DARK, width=4)
line(d, [(52, 34), (76, 34)], width=6)
line(d, [(52, 48), (76, 48)], width=6)
line(d, [(52, 62), (76, 62)], width=6)
def house(d: Draw, accent) -> None:
d.polygon([(48, 6), (90, 44), (6, 44)], fill=accent, outline=DARK, width=LINE)
box(d, (16, 42, 80, 90), fill=accent)
d.rectangle((40, 60, 56, 90), fill=DARK)
def corridor(d: Draw, accent) -> None:
box(d, (4, 30, 92, 66), fill=accent)
line(d, [(16, 48), (60, 48)], width=8)
arrow_head(d, (78, 48), "right", 12)
def stair(d: Draw, accent) -> None:
steps = [(6, 90), (6, 70), (28, 70), (28, 50), (50, 50), (50, 30), (72, 30), (72, 10), (90, 10), (90, 90)]
d.polygon(steps, fill=accent, outline=DARK, width=LINE)
def linear_generator(d: Draw, accent) -> None:
# Footprint with a west stair, a corridor band, and dwelling-unit divisions on both sides.
box(d, (4, 12, 91, 83))
d.rectangle((12, 20, 23, 75), fill=DARK)
d.rectangle((28, 40, 87, 55), fill=accent)
for x in (48, 68):
line(d, [(x, 12), (x, 40)])
line(d, [(x, 55), (x, 83)])
def stair_bay_bar(d: Draw, accent) -> None:
# A long bar of two stair bays: dwelling (accent) | stair (dark) | dwelling, repeated, with a party wall between the bays.
box(d, (2, 30, 93, 66), fill=accent)
for x in (20, 66):
d.rectangle((x, 30, x + 10, 66), fill=DARK)
line(d, [(48, 30), (48, 66)], width=4)
def stair_pair(d: Draw, accent) -> None:
# One compact plate: two dwellings (accent) around one full-depth stair (dark).
box(d, (14, 22, 82, 74), fill=accent)
d.rectangle((42, 26, 54, 70), fill=DARK)
def gallery_bar(d: Draw, accent) -> None:
# A bar of dwellings (accent) between two end stairs (dark); the gallery along it is dashed, as it is not modelled.
box(d, (4, 34, 91, 76), fill=accent)
d.rectangle((8, 38, 18, 72), fill=DARK)
d.rectangle((77, 38, 87, 72), fill=DARK)
for x in (36, 54):
line(d, [(x, 34), (x, 76)], width=6)
for x in range(6, 90, 16):
line(d, [(x, 20), (x + 9, 20)], width=6)
def terrace_row(d: Draw, accent) -> None:
# Attached houses in elevation: three gables sharing their side walls.
houses = [(6, 34), (34, 62), (62, 90)]
for left, right in houses:
middle = (left + right) / 2
d.polygon([(left, 46), (middle, 22), (right, 46), (right, 88), (left, 88)], fill=accent, outline=DARK, width=6)
for left, right in houses:
d.rectangle((left + 10, 68, right - 10, 88), fill=DARK)
def point_plate(d: Draw, accent) -> None:
# A square plate: four dwellings (accent) in a pinwheel around one central core (dark) without façades.
box(d, (6, 6, 90, 90), fill=accent)
d.rectangle((36, 36, 60, 60), fill=DARK)
for a, b in [((6, 36), (60, 36)), ((60, 6), (60, 60)), ((36, 60), (90, 60)), ((36, 36), (36, 90))]:
line(d, [a, b], width=6)
def winged_band(d: Draw, accent) -> None:
# A comb in plan: a band (bottom) with three wings projecting north (up), every wing joined to the band along its root; stairs dark.
d.polygon(
[(4, 90), (92, 90), (92, 8), (70, 8), (70, 62), (59, 62), (59, 8), (37, 8), (37, 62), (26, 62), (26, 8), (4, 8)],
fill=accent,
outline=DARK,
width=6,
)
for left, right in ((4, 26), (37, 59), (70, 92)):
d.rectangle((left + 3, 30, right - 3, 38), fill=DARK)
for x in (20, 48, 76):
d.rectangle((x, 66, x + 6, 87), fill=DARK)
def open_court(d: Draw, accent) -> None:
# A U in plan: a north wing (top) across the full width and two side wings, around a court open to the south (bottom); stairs dark.
d.polygon([(4, 92), (28, 92), (28, 34), (68, 34), (68, 92), (92, 92), (92, 6), (4, 6)], fill=accent, outline=DARK, width=6)
for x in (24, 45, 66):
d.rectangle((x, 9, x + 6, 31), fill=DARK)
for left in (4, 68):
d.rectangle((left + 3, 58, left + 21, 66), fill=DARK)
def 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 transform_plan(d: Draw, accent) -> None:
# A footprint outline and the same footprint rotated and moved, joined by a turning arrow.
box(d, (6, 52, 50, 90), width=6)
d.polygon([(66, 12), (92, 28), (76, 56), (50, 40)], fill=accent, outline=DARK, width=6)
d.arc((18, 20, 70, 72), start=180, end=270, fill=DARK, width=8)
arrow_head(d, (58, 24), "right", 10)
def z0(d: Draw, accent) -> None:
# Many cells: every unit its own zone.
box(d, (4, 4, 91, 91), fill=accent)
for k in (32, 60):
line(d, [(k, 4), (k, 91)])
line(d, [(4, k), (91, k)])
def z1(d: Draw, accent) -> None:
# Cells of one space type merged: two unit rows and a corridor.
box(d, (4, 10, 92, 40), fill=accent)
box(d, (4, 56, 92, 86), fill=accent)
box(d, (4, 36, 92, 60))
def z2(d: Draw, accent) -> None:
# Perimeter ring split on the corner bisectors, plus a core.
box(d, (4, 4, 92, 92))
d.rectangle((30, 30, 66, 66), fill=accent, outline=DARK, width=LINE)
for a, b in [((4, 4), (30, 30)), ((92, 4), (66, 30)), ((4, 92), (30, 66)), ((92, 92), (66, 66))]:
line(d, [a, b], width=6)
def z3(d: Draw, accent) -> None:
box(d, (6, 16, 90, 80), fill=accent)
def slab(d: Draw, accent, y: float, height: float = 16, left: float = 6, right: float = 90) -> None:
box(d, (left, y, right, y + height), fill=accent, width=6)
def stack(d: Draw, accent) -> None:
for y in (8, 36, 64):
slab(d, accent, y, 24)
def multiplier(d: Draw, accent) -> None:
slab(d, accent, 6, 24, 6, 90)
line(d, [(18, 44), (50, 84)], width=10)
line(d, [(50, 44), (18, 84)], width=10)
for y in (44, 58, 72):
d.rectangle((62, y, 90, y + 8), fill=DARK)
def single_zone_per_floor_type(d: Draw, accent) -> None:
# One zone per floor entry: a one-storey zone below a three-storey zone.
box(d, (24, 66, 71, 91), fill=accent)
box(d, (24, 4, 71, 58), fill=accent)
def single_zone_building(d: Draw, accent) -> None:
box(d, (24, 4, 71, 91), fill=accent)
def convert(d: Draw, accent) -> None:
d.polygon([(40, 44), (90, 44), (90, 92), (40, 92)], fill=accent, outline=DARK, width=LINE)
line(d, [(6, 14), (44, 14), (64, 34), (64, 56)], width=10)
arrow_head(d, (64, 74), "down", 16)
def inspect(d: Draw, accent) -> None:
d.ellipse((6, 6, 64, 64), fill=accent, outline=DARK, width=LINE)
d.ellipse((18, 18, 38, 38), fill=WHITE)
line(d, [(56, 56), (88, 88)], width=16)
def mini_grid(d: Draw, origin: tuple[float, float], size: float, cells: int, accent) -> None:
x0, y0 = origin
box(d, (x0, y0, x0 + size, y0 + size), fill=accent, width=6)
step = size / cells
for k in range(1, cells):
line(d, [(x0 + k * step, y0), (x0 + k * step, y0 + size)], width=4)
line(d, [(x0, y0 + k * step), (x0 + size, y0 + k * step)], width=4)
def map_source_target(d: Draw, accent) -> None:
mini_grid(d, (4, 4), 40, 2, accent)
box(d, (52, 52, 92, 92), fill=accent, width=6)
line(d, [(32, 56), (32, 72), (40, 72)], width=8)
arrow_head(d, (52, 72), "right", 12)
line(d, [(56, 26), (72, 26), (72, 36)], width=8)
arrow_head(d, (72, 50), "down", 12)
def validate(d: Draw, accent) -> None:
d.ellipse((4, 4, 92, 92), fill=accent, outline=DARK, width=LINE)
line(d, [(24, 50), (42, 68), (74, 30)], width=14, fill=WHITE)
# Parameter motifs: small versions of their object's motif, drawn inside the hexagon (x and y in 20..76).
def small_schedule(d: Draw, accent) -> None:
for x, h in ((28, 16), (40, 36), (52, 24), (64, 40)):
d.rectangle((x, 72 - h, x + 7, 71), fill=accent)
d.rectangle((24, 72, 75, 75), fill=DARK)
def small_load(d: Draw, accent) -> None:
d.polygon([(54, 20), (30, 52), (46, 52), (40, 76), (68, 44), (52, 44), (60, 20)], fill=accent, outline=DARK, width=4)
def small_preset(d: Draw, accent) -> None:
d.polygon([(20, 36), (32, 24), (76, 24), (76, 72), (32, 72), (20, 60)], fill=accent, outline=DARK, width=4)
d.ellipse((30, 42, 42, 54), fill=WHITE, outline=DARK, width=4)
def small_plan(d: Draw, accent) -> None:
box(d, (20, 24, 75, 71), width=4)
d.rectangle((24, 44, 71, 51), fill=accent)
d.rectangle((24, 28, 31, 67), fill=DARK)
for x in (48,):
line(d, [(x, 24), (x, 44)], width=4)
line(d, [(x, 52), (x, 71)], width=4)
def small_floor(d: Draw, accent) -> None:
box(d, (16, 36, 79, 59), fill=accent, width=4)
def small_building(d: Draw, accent) -> None:
box(d, (32, 16, 63, 79), fill=accent, width=4)
for y in (36, 56):
line(d, [(32, y), (63, y)], width=4)
# --- icons ----------------------------------------------------------------------------------------------------
def component(motif: Motif) -> Painter:
"""A component: its motif at full size."""
def paint(image: Image.Image, accent: Colour) -> None:
motif(ImageDraw.Draw(image), accent)
return paint
def parameter(motif: Motif) -> Painter:
"""A parameter: a small version of its object's motif inside a hexagon (the Grasshopper parameter shape)."""
def paint(image: Image.Image, accent: Colour) -> None:
d = ImageDraw.Draw(image)
d.polygon([(24, 4), (72, 4), (94, 48), (72, 92), (24, 92), (2, 48)], fill=WHITE, outline=DARK, width=4)
motif(d, accent)
return paint
ICONS: dict[str, tuple[Colour, Painter]] = {
"BemGenAssemblyInfo": (INFO, component(assembly)),
"BemGenInfoComponent": (INFO, component(info)),
"ScheduleComponent": (PROGRAM, component(schedule)),
"LoadComponent": (PROGRAM, component(load)),
"ProgramPresetComponent": (PROGRAM, component(preset_tag)),
"DwellingUnitPresetComponent": (PROGRAM, component(house)),
"CorridorPresetComponent": (PROGRAM, component(corridor)),
"StairPresetComponent": (PROGRAM, component(stair)),
"LinearPlanGeneratorComponent": (GENERATE, component(linear_generator)),
"StairBayBarComponent": (GENERATE, component(stair_bay_bar)),
"StairPairComponent": (GENERATE, component(stair_pair)),
"GalleryBarComponent": (GENERATE, component(gallery_bar)),
"TerraceRowComponent": (GENERATE, component(terrace_row)),
"PointPlateComponent": (GENERATE, component(point_plate)),
"WingedBandComponent": (GENERATE, component(winged_band)),
"OpenCourtComponent": (GENERATE, component(open_court)),
"EnclosedCourtComponent": (GENERATE, component(enclosed_court)),
"TransformPlanComponent": (GENERATE, component(transform_plan)),
"NoSimplificationComponent": (SIMPLIFY, component(z0)),
"SemanticMergeComponent": (SIMPLIFY, component(z1)),
"PerimeterCoreComponent": (SIMPLIFY, component(z2)),
"SingleZonePerFloorComponent": (SIMPLIFY, component(z3)),
"StackFloorsComponent": (AGGREGATE, component(stack)),
"StackedFloorZoneMultiplierComponent": (AGGREGATE, component(multiplier)),
"SingleZonePerFloorTypeComponent": (AGGREGATE, component(single_zone_per_floor_type)),
"SingleZoneBuildingComponent": (AGGREGATE, component(single_zone_building)),
"Convert2BemComponent": (CONVERT, component(convert)),
"InspectComponent": (INSPECT, component(inspect)),
"MapSourceToTargetComponent": (INSPECT, component(map_source_target)),
"ValidateComponent": (INSPECT, component(validate)),
"ScheduleParameter": (PROGRAM, parameter(small_schedule)),
"LoadParameter": (PROGRAM, parameter(small_load)),
"ProgramPresetParameter": (PROGRAM, parameter(small_preset)),
"PlanParameter": (GENERATE, parameter(small_plan)),
"FloorParameter": (SIMPLIFY, parameter(small_floor)),
"BuildingParameter": (AGGREGATE, parameter(small_building)),
}
def render(name: str) -> Image.Image:
accent, paint = ICONS[name]
image = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
paint(image, accent)
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
def classes_with_icons() -> set[str]:
"""Concrete classes that show an icon: components, parameters, and the assembly info."""
pattern = re.compile(r"public sealed class (\w+) : (\w+)")
names = set()
for path in SOURCE.rglob("*.cs"):
if "obj" in path.parts or "bin" in path.parts:
continue
for name, _ in pattern.findall(path.read_text(encoding="utf-8")):
if name.endswith(("Component", "Parameter")) or name == "BemGenAssemblyInfo":
names.add(name)
return names
def main() -> int:
expected = classes_with_icons()
missing = sorted(expected - ICONS.keys())
unknown = sorted(ICONS.keys() - expected)
if missing or unknown:
print(f"Classes without an icon: {missing}; icons without a class: {unknown}", file=sys.stderr)
return 1
OUTPUT.mkdir(parents=True, exist_ok=True)
stale = sorted(p.name for p in OUTPUT.glob("*.png") if p.stem not in ICONS)
if stale:
print(f"Icons without a class in {OUTPUT}: {stale}", file=sys.stderr)
return 1
for name in sorted(ICONS):
render(name).save(OUTPUT / f"{name}.png", optimize=False)
print(f"Wrote {len(ICONS)} icons to {OUTPUT.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())
- [x] Step 3: Draw the icons and check the hashes
python scripts/make_icons.py
git status --short
sha256sum src/Lod.Grasshopper/Icons/*.png
Expected: Wrote 36 icons to src\Lod.Grasshopper\Icons; git status --short shows M scripts/make_icons.py and the new PNG (?? src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png), and no existing PNG modified; the hashes (Pillow 12.3.0):
606a11f2bbd6ee6cb7edede403166ae0f1b39231fcf5882dff360717df990cae *src/Lod.Grasshopper/Icons/BemGenAssemblyInfo.png
e33ae284b7f017ee11851986e79e4b8449e1b5027ffedf41cad68ed2abbfb15d *src/Lod.Grasshopper/Icons/BemGenInfoComponent.png
431104585e8745167567797eefef526bbf664ba6777101e3677281cb6976bfde *src/Lod.Grasshopper/Icons/BuildingParameter.png
edc0ff9b82e6161bf8bdf3b5acebc8f4a43644d42d845827a101593d91d1a110 *src/Lod.Grasshopper/Icons/Convert2BemComponent.png
c8a815ff220dafff9f84af05411eab8c43191b4b6f4be2f0b4750385e1d95489 *src/Lod.Grasshopper/Icons/CorridorPresetComponent.png
755de6f9d4122d567040d0df98ec3232c76f4f1c0af9da934276fe33585dc528 *src/Lod.Grasshopper/Icons/DwellingUnitPresetComponent.png
04caf35a33a9b6443b7fa808766237aecb235db70ffe0f869002d9d3287223d8 *src/Lod.Grasshopper/Icons/EnclosedCourtComponent.png
324ffc55b8f3aed81902fdaea4ac6293654a194e9f58aa97c9aa088547e584be *src/Lod.Grasshopper/Icons/FloorParameter.png
edc98c6c83c0040d935633c0e8fa014ebeeeeae5ef8f596207fd385ad09a0dbe *src/Lod.Grasshopper/Icons/GalleryBarComponent.png
a7191b8f5e30874eaf7d50f0d4555b3565b77157f83ae584393ab9c3b1c3a792 *src/Lod.Grasshopper/Icons/InspectComponent.png
75b4d98c4ad7cffbec53284781d3ad544ccd8ba762c7fd5c33bfd6d6845e42d4 *src/Lod.Grasshopper/Icons/LinearPlanGeneratorComponent.png
acf5163f017f01a1ebb394861f61750e54f9ae8c730a0a6a614f2120f59feadc *src/Lod.Grasshopper/Icons/LoadComponent.png
5045ceb77bc754553aa717110fab061978ef7b51d0c05e0f7f93788a8158f1d5 *src/Lod.Grasshopper/Icons/LoadParameter.png
438a45a6b5f3071e4918cb6c0f7e6112e4d0ab9ca0ad1aacdc189b4e5de9ecb8 *src/Lod.Grasshopper/Icons/MapSourceToTargetComponent.png
4a702c6b6ae3c4773678a3594228ce4d1e06c2623bc9eeb5bf1fdaaee8e50c30 *src/Lod.Grasshopper/Icons/NoSimplificationComponent.png
e9504a1cea0d69fe7da893cd10e220358f439b119c3ea86efe6e4d3a9cc29e3e *src/Lod.Grasshopper/Icons/OpenCourtComponent.png
ac5d421e2cded0be0f7797510b864cdfcdd019dd46caed4147251923602184b8 *src/Lod.Grasshopper/Icons/PerimeterCoreComponent.png
a4bd3109301f4fccec8fa698ceca5dcfbb22e4dc839d850dd5aa4241b5cca95b *src/Lod.Grasshopper/Icons/PlanParameter.png
cb207d9cb3ccc6df62da1fbfc077921526b91ffb8e329ee225c97163e7014c3b *src/Lod.Grasshopper/Icons/PointPlateComponent.png
13741aac4525cf0c0d991bf3b673e55cd22612324fdc3ef3a8aaaaf0e66dec45 *src/Lod.Grasshopper/Icons/ProgramPresetComponent.png
bd0a92bf623dd4b3f6b9c4279e425adc44a7287751d97e8206a26e3c26e7e400 *src/Lod.Grasshopper/Icons/ProgramPresetParameter.png
90646f46d781eca01dcdf67554987ee04fdd80c450e5f94647858b5f7e7b613d *src/Lod.Grasshopper/Icons/ScheduleComponent.png
693d5513ffe5f813ed3a77dc7b68cfce31e008b407e6410ac34c9ed374c6bfaa *src/Lod.Grasshopper/Icons/ScheduleParameter.png
8217bd3bbe904a6026ef7434606168b5d466341f356280a623f92278908a4068 *src/Lod.Grasshopper/Icons/SemanticMergeComponent.png
682ee28436ffab1bc47382ff9915e27f2c774d0ef22448422d2316d739b5727f *src/Lod.Grasshopper/Icons/SingleZoneBuildingComponent.png
380ce788eef6adc20c2f45723deae73b4b747d84ffe5e2664001e46d21157c49 *src/Lod.Grasshopper/Icons/SingleZonePerFloorComponent.png
b8bb1b2890ce6ac6273f49e863b3a25ae0c05c87dc37e33b5483913e6ac52162 *src/Lod.Grasshopper/Icons/SingleZonePerFloorTypeComponent.png
5859a90fd17bb2072b1971cd31caa0df678f29bf2ad9f279c910dc59c66ca0f9 *src/Lod.Grasshopper/Icons/StackFloorsComponent.png
aa115c4ba9c51350ff291f45c6e16db41be505bc5153b6ef8159165f3286a37f *src/Lod.Grasshopper/Icons/StackedFloorZoneMultiplierComponent.png
9ffba933d6cd515b59259979ce8ce666f1a40ed906af5d5b25875f25458115f9 *src/Lod.Grasshopper/Icons/StairBayBarComponent.png
d25e67dce5eceed6cb2379c55755d38c495b4ee227069ac54bed185ed751910c *src/Lod.Grasshopper/Icons/StairPairComponent.png
939d76b08d77d2d099bd823473f1d86eb8b16983840230cf995f8ade9d0debcc *src/Lod.Grasshopper/Icons/StairPresetComponent.png
ab59722b25ff1a3407cf7344e1c4595142b7a3bed05b7f952d58f92b51ffae34 *src/Lod.Grasshopper/Icons/TerraceRowComponent.png
3ce5e835f0921d1dee8feeaf23a45aecd36c3aedb55be5a8386fdb3e540e5246 *src/Lod.Grasshopper/Icons/TransformPlanComponent.png
20a372402d7ca8ae673db6a0a1299d7a3b6dcae9141d29de2b535cc543d950ce *src/Lod.Grasshopper/Icons/ValidateComponent.png
61af5011fa771797ae48e99ba73cbb7d1cb165a2ec11784187a94dd0fbfbd704 *src/Lod.Grasshopper/Icons/WingedBandComponent.png
Run python scripts/make_icons.py a second time: the hashes do not change.
- [x] Step 4: Look at the icons
Draw a contact sheet of all icons at 8× (nearest neighbour) into the build folder, which git ignores:
python - <<'EOF'
from pathlib import Path
from PIL import Image
icons = sorted(Path("src/Lod.Grasshopper/Icons").glob("*.png"))
cell = 24 * 8 + 16
sheet = Image.new("RGBA", (6 * cell, -(-len(icons) // 6) * cell), (235, 235, 235, 255))
for i, path in enumerate(icons):
big = Image.open(path).convert("RGBA").resize((24 * 8, 24 * 8), Image.Resampling.NEAREST)
sheet.alpha_composite(big, ((i % 6) * cell + 8, (i // 6) * cell + 8))
Path("src/Lod.Grasshopper/bin").mkdir(parents=True, exist_ok=True)
sheet.save("src/Lod.Grasshopper/bin/icon-sheet.png")
print(len(icons), "icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png")
EOF
Expected: 36 icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png. Open the image. In the order of sorted() on Windows (case-insensitive), six per row, the 35 icons of S8.2 look as in the S8.2 plan (Task 10, Step 4), and the new one is added, which moves the icons after it: Enclosed Court is now the first icon of the second row, after Dwelling Unit Preset: a green closed square ring around a court that shows the sheet's grey background (the hole is transparent), with two dark stair bands across each of the south and north wings and one across each of the west and east wings. Every icon that followed Dwelling Unit Preset in S8.2 moves one place on, from Floor (now the second icon of the second row) to Winged Band (now the last icon); the 36 icons fill six rows of six, and every other icon keeps its motif.
If the hashes of Step 3 differed (another Pillow version), this check decides: when every icon matches the description above, continue and record the Pillow version in the commit message body; otherwise stop and report the difference. The script is never edited to match hashes.
- [x] Step 5: Build and check the embedded icons
dotnet build BEMGen.sln -c Release
git grep -c "IconLibrary.For(this)" -- src/Lod.Grasshopper
powershell -NoProfile -Command "([Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path 'src/Lod.Grasshopper/bin/Release/net7.0/BEMGen.gha').Path)).GetManifestResourceNames() | Sort-Object"
Expected: Build succeeded., 0 Warning(s), 0 Error(s); counts BemGenAssemblyInfo.cs:1, BemGenInfoComponent.cs:1, Convert2BemComponent.cs:1, PipelineComponents.cs:4, PresetComponents.cs:1, ProgramComponents.cs:3, SimplifierComponents.cs:3, TransformPlanComponent.cs:1, TypologyGeneratorComponents.cs:1, BemParameters.cs:1 (with their paths; the new component inherits the icon of TypologyGeneratorComponent, as the other typology components do); and the 36 resource names Lod.Grasshopper.Icons.<name>.png, one for each PNG of Step 3, including Lod.Grasshopper.Icons.EnclosedCourtComponent.png.
- [x] Step 6: Commit
git add scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): draw the icon of the enclosed court component"
Expected before git add: scripts/make_icons.py modified and the new PNG; nothing under bin/.
- [x] Step 7: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-enclosed-court
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-enclosed-court
git branch -d feature/grasshopper-enclosed-court
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0. Do not push (D-084).
Slice C — fix/grasshopper-orientation¶
Task 13: Zone solids outward, surfaces and windows out of their zones (D-091)¶
The headless Rhino check of the reference (D-056) read Brep.GetVolume and BrepFace.NormalAt on the Convert2BEM outputs and found, on every plan of v0.8.1 and earlier, every zone solid inward-oriented (negative volume) and every floor facing up like a ceiling: PlanarBreps gave every face the normal of the counter-clockwise outer ring (up), and the extrusion of an upward floor is an inward solid. D-091 fixes the rule; this task applies it. There is no unit test (Grasshopper code); the smoke test of Task 16 checks it with the values given there.
Files:
- Modify: src/Lod.Grasshopper/Convert/RhinoGeometry.cs (Task 10), docs/architecture/convert2bem.md (S2 document, last changed by 4b07283)
Interfaces:
- Consumes: RhinoGeometry (Task 10): MergedZoneBreps (the merged preview volumes, S4.2), PartBreps, WallBrep, WindowBreps, HorizontalBreps, PlanarBreps, Rectangle; HorizontalSurface.Kind and HorizontalKind.Floor; RhinoCommon Brep.SolidOrientation, BrepSolidOrientation.Inward, Brep.Flip, BrepFace.NormalAt (which already includes OrientationIsReversed).
- Produces: PlanarBreps(Polygon2, double elevation, bool up) reverses every ring for a face facing down, so the face is built unreversed with the facing it needs; PartBreps extrudes the downward floor (an outward solid) and MergedZoneBreps and PartBreps call Outward (flip a solid whose orientation is inward); HorizontalBreps builds a floor facing down and a ceiling or roof facing up; Rectangle (walls and windows) faces the wall's outward side, (direction.Y, −direction.X), the zone lying on the left from start to end; Facing flips a planar Brep whose normal points against the wanted direction, as a guard. The class remarks state the rule. Geometry is unchanged; only orientation changes, for every plan generator. convert2bem.md states the orientation in the Zones, Surfaces, and Windows rows.
- [x] Step 1: Create the branch
git switch -c fix/grasshopper-orientation main
- [x] Step 2: Replace
src/Lod.Grasshopper/Convert/RhinoGeometry.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Rhino;
using Rhino.Geometry;
namespace Lod.Grasshopper.Convert;
/// <summary>
/// Converts pipeline geometry (metres) to Rhino geometry in the active document's model units. The only place Rhino geometry is created.
/// </summary>
/// <remarks>
/// Orientation rule: zone solids are outward-oriented (positive volume); every surface and window faces out of the zone that owns it: walls and
/// windows towards the wall's outward side (the zone lies on the left from start to end), floors down, and ceilings up. Flipping a Brep changes
/// only its orientation, never its geometry.
/// </remarks>
internal static class RhinoGeometry
{
private static double Tolerance => ToleranceSettings.Default.Distance;
/// <summary>Metres to model units of the active document (1 without a document).</summary>
public static double UnitScale => RhinoDoc.ActiveDoc is { } doc ? RhinoMath.UnitScale(UnitSystem.Meters, doc.ModelUnitSystem) : 1.0;
/// <summary>One closed Brep per zone part.</summary>
public static IEnumerable<Brep> ZoneBreps(Zone zone) => PartBreps(zone.Parts);
/// <summary>
/// A zone as merged volumes, for the viewport preview of zones spanning storeys (D-071): parts stacked with the same outline form one vertical
/// run (<see cref="VerticalRuns"/>), extruded once over the run's height, so no boolean union of touching solids is needed. Only where outlines
/// differ between runs is a boolean union of the runs tried, with coplanar faces merged; if it fails, the runs are shown. Data outputs keep one
/// Brep per part.
/// </summary>
public static IEnumerable<Brep> MergedZoneBreps(Zone zone)
{
Brep[] runs = PartBreps(VerticalRuns.Of(zone.Parts, ToleranceSettings.Default)).ToArray();
if (runs.Length < 2)
{
return runs;
}
double tolerance = Tolerance * UnitScale;
Brep[]? union = Brep.CreateBooleanUnion(runs, tolerance);
if (union is null || union.Length == 0)
{
return runs;
}
foreach (Brep brep in union)
{
brep.MergeCoplanarFaces(tolerance);
Outward(brep);
}
return union;
}
/// <summary>
/// One closed Brep per part: its floor polygon extruded by its height. A merged zone's outline keeps the corners of the zones it was merged
/// from as collinear vertices, so the extrusion would show one face per former edge; coplanar side faces are merged, so a façade the walls
/// treat as one wall is one face. The geometry is unchanged. A footprint with holes (a court) is one planar face with an inner loop per hole
/// (<see cref="PlanarBreps"/>), so the extrusion is one closed Brep whose inner walls face into the court. The solid is outward-oriented: the
/// extruded face is the floor, facing down, and a solid that still comes out inward (negative volume) is flipped.
/// </summary>
public static IEnumerable<Brep> PartBreps(IEnumerable<ZonePart> parts)
{
foreach (ZonePart part in parts)
{
foreach (Brep planar in PlanarBreps(part.Footprint, part.Elevation, up: false))
{
Brep? solid = planar.Faces[0].CreateExtrusion(new LineCurve(Point3d.Origin, new Point3d(0, 0, part.Height)), true);
if (solid is not null)
{
solid.MergeCoplanarFaces(Tolerance);
Outward(solid);
yield return Scaled(solid);
}
}
}
}
/// <summary>The wall as a planar rectangle facing out of its zone.</summary>
public static Brep? WallBrep(WallSurface wall) =>
Rectangle(wall, offset: 0.0, width: wall.Length, bottom: wall.Elevation, height: wall.Height);
/// <summary>Every window of a wall at its explicit position, facing the way its wall faces.</summary>
public static IEnumerable<Brep> WindowBreps(WallSurface wall) =>
wall.Windows
.Select(w => Rectangle(wall, offset: w.Offset, width: w.Width, bottom: wall.Elevation + w.SillHeight, height: w.Height))
.Where(b => b is not null)
.Select(b => b!);
/// <summary>
/// A floor or ceiling as planar Breps facing out of its zone, a floor down and a ceiling up; a polygon with holes is one face with an inner loop
/// per hole.
/// </summary>
public static IEnumerable<Brep> HorizontalBreps(HorizontalSurface surface)
{
bool up = surface.Kind != HorizontalKind.Floor;
return PlanarBreps(surface.Polygon, surface.Elevation, up).Select(b => Scaled(Facing(b, new Vector3d(0, 0, up ? 1 : -1))));
}
/// <summary>
/// The planar Breps bounded by every ring of a polygon: <see cref="Brep.CreatePlanarBreps(IEnumerable{Curve}, double)"/> nests the hole rings inside
/// the outer ring, so a polygon with holes gives one face with inner loops, not a face per ring. The face's normal follows the direction of the
/// outer ring: counter-clockwise seen from above, as stored, faces up, so for a face facing down every ring is reversed. The face is then not
/// reversed (<see cref="BrepFace.OrientationIsReversed"/> is false), whichever way it faces.
/// </summary>
private static IEnumerable<Brep> PlanarBreps(Polygon2 polygon, double elevation, bool up)
{
Curve[] curves = polygon.Rings
.Select(ring => up ? ring : ring.Reverse().ToArray())
.Select(ring => (Curve)new PolylineCurve(ring.Select(p => new Point3d(p.X, p.Y, elevation)).Concat(new[] { new Point3d(ring[0].X, ring[0].Y, elevation) })))
.ToArray();
return Brep.CreatePlanarBreps(curves, Tolerance) ?? Enumerable.Empty<Brep>();
}
private static Brep? Rectangle(WallSurface wall, double offset, double width, double bottom, double height)
{
double length = wall.Length;
var direction = new Vector3d((wall.End.X - wall.Start.X) / length, (wall.End.Y - wall.Start.Y) / length, 0);
var origin = new Point3d(wall.Start.X, wall.Start.Y, bottom) + (direction * offset);
Point3d a = origin;
Point3d b = origin + (direction * width);
Point3d c = b + new Vector3d(0, 0, height);
Point3d d = a + new Vector3d(0, 0, height);
Brep? brep = Brep.CreateFromCornerPoints(a, b, c, d, Tolerance);
var outward = new Vector3d(direction.Y, -direction.X, 0);
return brep is null ? null : Scaled(Facing(brep, outward));
}
/// <summary>Flips a closed Brep whose faces point into its volume (negative volume), so it is outward-oriented.</summary>
private static void Outward(Brep solid)
{
if (solid.SolidOrientation == BrepSolidOrientation.Inward)
{
solid.Flip();
}
}
/// <summary>
/// Flips a planar Brep whose face normal points against <paramref name="wanted"/>, as a guard: the constructions above already give the wanted
/// direction. <see cref="BrepFace.NormalAt"/> already takes <see cref="BrepFace.OrientationIsReversed"/> into account (checked in Rhino 8: after
/// <see cref="Brep.Flip"/> it returns the reversed normal); the normal of a planar face is the same everywhere.
/// </summary>
private static Brep Facing(Brep brep, Vector3d wanted)
{
BrepFace face = brep.Faces[0];
Vector3d normal = face.NormalAt(face.Domain(0).Mid, face.Domain(1).Mid);
if (normal * wanted < 0)
{
brep.Flip();
}
return brep;
}
private static Brep Scaled(Brep brep)
{
double scale = UnitScale;
if (scale != 1.0)
{
brep.Transform(Transform.Scale(Point3d.Origin, scale));
}
return brep;
}
}
- [x] Step 3: Edit
docs/architecture/convert2bem.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
| 0 | Zones | Z | `{i}`: one closed Brep per zone part | Zone volume: each part's floor polygon extruded by its floor-to-floor height, with coplanar faces merged so that a merged zone shows no seam where its source zones met. Zones have one part, except in *Single Zone per Floor Type* and *Single Zone Building* buildings, which have one part per storey outline (the viewport preview merges them into one volume; this output does not). | model units |
with
| 0 | Zones | Z | `{i}`: one closed Brep per zone part | Zone volume: each part's floor polygon extruded by its floor-to-floor height, with coplanar faces merged so that a merged zone shows no seam where its source zones met. Zones have one part, except in *Single Zone per Floor Type* and *Single Zone Building* buildings, which have one part per storey outline (the viewport preview merges them into one volume; this output does not). Every Brep is outward-oriented: its faces point out of the volume, so its volume is positive. | model units |
- Replace
| 11 | Surfaces | S | `{i}`: Breps | The zone's surfaces in building surface order (for *Stack Floors*: its walls, then its floors and ceilings). A wall is one planar rectangle; a floor or ceiling gives the planar Breps of its polygon. | model units |
with
| 11 | Surfaces | S | `{i}`: Breps | The zone's surfaces in building surface order (for *Stack Floors*: its walls, then its floors and ceilings). A wall is one planar rectangle; a floor or ceiling gives the planar Breps of its polygon, one face with an inner loop per hole. Every surface faces out of its zone: a wall towards its outward side (its azimuth), a floor down, a ceiling (or roof) up, also where the surface is interzone. | model units |
- Replace
| 13 | Windows | W | `{i}`: Breps | Every explicit window of the zone's walls, as a rectangle in the wall's plane at its offset along the wall and its sill height (D-039). That is one centred window per glazed outdoor wall: as the generator placed it (D-026) where walls are not rebuilt, and with the glazed area of the source windows the wall covers where zones are merged (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)). | model units |
with
| 13 | Windows | W | `{i}`: Breps | Every explicit window of the zone's walls, as a rectangle in the wall's plane at its offset along the wall and its sill height (D-039). That is one centred window per glazed outdoor wall: as the generator placed it (D-026) where walls are not rebuilt, and with the glazed area of the source windows the wall covers where zones are merged (D-079, [ADR-012](../decisions/ADR-012-centred-windows.md)). A window faces the way its wall faces, out of the zone. | model units |
- [x] Step 4: Build and commit
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short --untracked-files=all
git add src/Lod.Grasshopper/Convert/RhinoGeometry.cs docs/architecture/convert2bem.md
git commit -m "fix(grasshopper): orient zone solids outward and surfaces and windows out of their zones"
Expected before git add: Build succeeded., 0 Warning(s), 0 Error(s); 158 core, 181 generators, and 601 integration tests passed on net8.0 (no core code changed); git status lists exactly M docs/architecture/convert2bem.md and M src/Lod.Grasshopper/Convert/RhinoGeometry.cs.
- [x] Step 5: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch fix/grasshopper-orientation
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only fix/grasshopper-orientation
git branch -d fix/grasshopper-orientation
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0. Do not push (D-084).
Slice D — docs/architecture-enclosed-court¶
Task 14: Architecture documents¶
Files:
- Modify: docs/architecture/pipeline.md (S4, last changed in S8.2), docs/architecture/validation.md (S3, last changed in S8.1), docs/research/precedents/hdhm-typologies.md (S8.1, last changed in S8.2), docs/research/program-presets.md (S1, last changed in S8.2)
Interfaces:
- Consumes: Tasks 1–13 (the generator, holes in every step, the component).
- Produces: the pipeline description after S8.3; the heading ## Grasshopper components through S8.3 (anchor #grasshopper-components-through-s83), which the overview of pipeline.md and program-presets.md link to; the S8.3 row removed from ## Not implemented yet; NotSupported removed from the simplifier preconditions of pipeline.md and validation.md; the court in validation.md; the precedent page naming Enclosed Court as implemented.
- [x] Step 1: Create the branch
git switch -c docs/architecture-enclosed-court 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. The Preview Location link points at the renamed heading; the last replacement removes the S8.3 row from the open items.
- 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. 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. 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
PointPlate | WingedBand | OpenCourt
with
PointPlate | WingedBand | OpenCourt |
EnclosedCourt
- 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-s82)).
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-s83)).
- Replace
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, each with its `<Family>Parameters` and `<Family>Presets`; `GeometryLimits`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, *Terrace Row*, *Point Plate*, *Winged Band*, *Open Court*, *Transform Plan* |
with
| Types | `PlanGenerator<TParameters, TPresets>` → `IGeneratedPlan`: `LinearPlanGenerator`, `StairBayBarGenerator`, `StairPairGenerator`, `GalleryBarGenerator`, `TerraceRowGenerator`, `PointPlateGenerator`, `WingedBandGenerator`, `OpenCourtGenerator`, `EnclosedCourtGenerator`, 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*, *Transform Plan* |
- Replace
- Surfaces come from `LayoutSurfaceBuilder`: an edge shared by two zones becomes an interzone wall, an edge on the plan boundary an outdoor wall; any other edge (a gap or an overlap) is an error. Whether a vertex lies on an edge, to split it there or to place it on the boundary, is measured between the vertices' original positions against the distance tolerance, not between their snapped grid cells, so T-junctions are recognised on rotated plans (ADR-002, S8.2). Floors and ceilings stay `Unresolved` until floors are aggregated.
with
- Enclosed court (`HDHM-TYP-005`): four wings of stair bays, `Depth` 12 m deep, around a court `CourtWidth` 24 m wide and `CourtDepth` 24 m deep that is closed on all four sides: south and north wings across the full width `2 · Depth + CourtWidth` that hold the corners, and west and east wings of length `CourtDepth` between them. The footprint is the outer rectangle with the court as its one hole, so the court is outside the building and its façades are outdoor walls with windows, facing into it. Bays are fitted to each wing as in the winged band (by default three bays of 6.5 m dwellings in each 48 m long wing and one bay of 10.5 m dwellings in each side wing). Zones `S-…`, `N-…` (west to east), then `W-…` and `E-…` (south to north); 1728 m², 44 windows, 16 of them on the court façades.
- Surfaces come from `LayoutSurfaceBuilder`: an edge shared by two zones becomes an interzone wall, an edge on the plan boundary an outdoor wall; any other edge (a gap or an overlap) is an error. The boundary includes the footprint's holes, whose edges face into the court, and every boundary edge must be covered by outdoor zone edges over its full length, so a zone placed in a court is an error too (`UnmatchedEdge`, ADR-002, S8.3). Whether a vertex lies on an edge, to split it there or to place it on the boundary, is measured between the vertices' original positions against the distance tolerance, not between their snapped grid cells, so T-junctions are recognised on rotated plans (ADR-002, S8.2). Floors and ceilings stay `Unresolved` until floors are aggregated.
- 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…`. Every wing or other part of the footprint must be wider than twice the depth, or the simplifier fails with `PlanTooNarrow`, whose message says so. *Single Zone per Floor* makes the floor one zone.
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). 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
The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines whose vertices are the zones' own vertices (S8.2). Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).
with
The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines whose vertices are the zones' own vertices (S8.2). An outline keeps the footprint's holes, its rebuilt walls include the court walls of every storey, and the court has no floor or ceiling piece, so it adds neither ground nor roof area (S8.3). Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).
- Replace
| Plan generator | Parameters (`InvalidParameter`, including the family minimum counts of ADR-006); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`); polygons (`DegeneratePolygon`). A wall or window below the ADR-011 minimum gets no window (`WindowOmitted`) | Error; no plan (`WindowOmitted` is a remark; the plan is produced) |
with
| Plan generator | Parameters (`InvalidParameter`, including the family minimum counts of ADR-006); every preset input holds a preset (`MissingPreset`) of its space type (`PresetSpaceTypeMismatch`, D-061); a preset for every space type the layout uses (`MissingPreset`); zones tile the footprint (`OverlappingZones`, `UnmatchedEdge`, also for a boundary edge, holes included, that zones do not cover outdoors); polygons (`DegeneratePolygon`). A wall or window below the ADR-011 minimum gets no window (`WindowOmitted`) | Error; no plan (`WindowOmitted` is a remark; the plan is produced) |
- Replace
| Plan simplifiers | Strategy preconditions (`InvalidParameter`, `PlanTooNarrow`, `NotSupported`, `DisconnectedGroup`); façade coverage (`FacadeNotCovered`); guards of the centred-window rule, which hold by construction once the façades are covered (D-079): every window contained by one target wall (`WindowNotHosted`), glazing of each target wall smaller than its area (`GlazingExceedsWall`) | Error; no floor |
with
| Plan simplifiers | Strategy preconditions (`InvalidParameter`, `PlanTooNarrow`, `DisconnectedGroup`); façade coverage (`FacadeNotCovered`); guards of the centred-window rule, which hold by construction once the façades are covered (D-079): every window contained by one target wall (`WindowNotHosted`), glazing of each target wall smaller than its area (`GlazingExceedsWall`) | Error; no floor |
- Replace
## Grasshopper components through S8.2
with
## Grasshopper components through S8.3
- Replace
- **Preview Location (D-062).** The eight plan generators, *Transform Plan*, the four plan simplifiers, and the four floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.
with
- **Preview Location (D-062).** The nine 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.
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`).
- Replace
| 2 Generate | *Open Court* (OCourt) | Court Width (24), Court Depth (17), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `OpenCourtComponent` | `223014ea-3c01-4d2b-b8ba-92ebdc4a7327` | S8.2 |
with
| 2 Generate | *Open Court* (OCourt) | Court Width (24), Court Depth (17), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `OpenCourtComponent` | `223014ea-3c01-4d2b-b8ba-92ebdc4a7327` | S8.2 |
| 2 Generate | *Enclosed Court* (ECourt) | Court Width (24), Court Depth (24), Depth (12), Unit Width (7), Stair Width (3), Floor Height (3), Orientation (0), Dwelling Unit, Stair, Preview Location → Plan | `EnclosedCourtComponent` | `b9765cd0-a62b-483d-9470-86d5fda287e8` | S8.3 |
- Replace
| Convert2IDF: an EnergyPlus-ready IDF from `IGeneratedBuilding`, including constructions, unconditioned zones, and how zone multipliers and internal mass are written | S6 | ADR-010 |
| Enclosed courtyard *Enclosed Court* (`HDHM-TYP-005`): footprints with holes in every plan simplifier and floor aggregator | S8.3 | ADR-006 |
with
| Convert2IDF: an EnergyPlus-ready IDF from `IGeneratedBuilding`, including constructions, unconditioned zones, and how zone multipliers and internal mass are written | S6 | ADR-010 |
- [x] Step 3: Edit
docs/architecture/validation.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file when it is applied.
- Replace
Validation applies to objects that exist. A simplifier that cannot produce a valid floor fails earlier, with its own diagnostics (`PlanTooNarrow`, `NotSupported`, `DisconnectedGroup`, `FacadeNotCovered`, the centred-window guards `WindowNotHosted` and `GlazingExceedsWall` (D-079), gaps and overlaps from the surface builder, aggregation errors), and returns no floor.
with
Validation applies to objects that exist. A simplifier that cannot produce a valid floor fails earlier, with its own diagnostics (`PlanTooNarrow`, `DisconnectedGroup`, `FacadeNotCovered`, the centred-window guards `WindowNotHosted` and `GlazingExceedsWall` (D-079), gaps and overlaps from the surface builder, aggregation errors), and returns no floor.
- Replace
| Scheduled magnitude | per load type and hour (8760 values), Σ multiplier × design magnitude × schedule fraction |
with
| Scheduled magnitude | per load type and hour (8760 values), Σ multiplier × design magnitude × schedule fraction |
A court (a hole of the footprint, S8.3) is outside the building: its façades are outdoor walls and count in the exterior wall area and in the glazing of the orientation they face (into the court: the south wing's court façade faces north), and the court adds no floor, ground, or roof area. No check changes.
- [x] Step 4: Edit
docs/research/precedents/hdhm-typologies.md
Make this replacement; the "Replace" text occurs exactly once in the file.
- Replace
| `HDHM-TYP-005` | Enclosed courtyard residential block | provisional | `EnclosedCourtGenerator` (specified in ADR-006) | S8.3 |
with
| `HDHM-TYP-005` | Enclosed courtyard residential block | provisional | `EnclosedCourtGenerator` (*Enclosed Court*) | S8.3 |
- [x] Step 5: Edit
docs/research/program-presets.md
Make this replacement; the "Replace" text occurs exactly once in the file. Only the link anchor changes, so that the link follows the heading renamed in Step 2.
- Replace
S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s82).
with
S4.1 adds one default preset component per space type of the linear plan, *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset*, and deletes *Example Residential Presets* (D-061). Every input of a default preset component has a default, the values of §2, so the component works with nothing connected: Name, WWR, Conditioned, Heating (°C), Cooling (°C), and per load a value input (people/m², W/m², or 1/h, as in its description) and an optional schedule input that replaces the built-in schedule when connected. Each shows the remark "Illustrative values; not sourced from DOE prototypes or standards." Their inputs and outputs are listed in [pipeline.md](../architecture/pipeline.md#grasshopper-components-through-s83).
- [x] Step 6: Check the links and commit
git grep -n "grasshopper-components-through-s82" -- docs/architecture docs/research
git grep -c "grasshopper-components-through-s83" -- docs/architecture/pipeline.md docs/research/program-presets.md
git add docs/architecture/pipeline.md docs/architecture/validation.md docs/research/precedents/hdhm-typologies.md docs/research/program-presets.md
git commit -m "docs(architecture): document the enclosed court, footprints with holes, and its component"
Expected: no output from the first command; docs/architecture/pipeline.md:1 and docs/research/program-presets.md:1 from the second.
Task 15: S8.3 smoke-test checklist¶
Files:
- Modify: docs/development/grasshopper-smoke-test.md (append)
Interfaces: - Consumes: the plugin built from Slices A–C. - Produces: the checklist that Task 16 runs.
- [x] Step 1: Append the S8.3 checklist at the end of
docs/development/grasshopper-smoke-test.md
Append this section after the last line of the file (after the S8.2 checklist), separated by one blank line, so that it nests under ## Stage checklists like the S2–S8.2 checklists:
### S8.3 checklist — enclosed courtyard and footprints with holes (`v0.8.2`)
S8.3 adds one plan generator component in *2 Generate* for the enclosed courtyard ([ADR-006](../decisions/ADR-006-plan-generation-mechanism.md)), the first footprint with a hole, and makes *Perimeter Core* zone court façades ([ADR-008](../decisions/ADR-008-perimeter-core-corners.md)). No existing component, input, output, or GUID changes. Use *Dwelling Unit Preset* and *Stair Preset* with nothing connected.
#### Toolbar and icons
- [ ] *2 Generate* shows *Enclosed Court* next to *Open Court*, with its own green icon (a closed square ring around a court, dark stairs).
- [ ] Inputs, in order, with their defaults: *Enclosed Court* (ECourt) Court Width 24, Court Depth 24, Depth 12, Unit Width 7, Stair Width 3, Floor Height 3, Orientation 0, Dwelling Unit, Stair, Preview Location; one output, *Plan*.
- [ ] With a preset input unconnected, the component shows Grasshopper's missing-input warning and outputs nothing (D-065).
#### Default plan
1. Place *Enclosed Court* with its presets connected and nothing else changed; connect *Inspect* to its *Plan*.
- [ ] No runtime message; 24 zones `S-ST1`–`S-ST3`, `S-U1`–`S-U6`, `N-ST1`–`N-ST3`, `N-U1`–`N-U6`, `W-ST1`, `W-U1`, `W-U2`, `E-ST1`, `E-U1`, `E-U2`; a 48 m × 48 m block (1728 m²) around a 24 m × 24 m court at x, y = 12–36 m, which the preview shows as a hole.
- [ ] 44 windows, 237.6 m² of glazing (57.6 north, 57.6 south, 61.2 east, 61.2 west in *Validate* on its *No Simplification* floor); 16 of the windows (79.2 m²) are on the court façades and face into the court.
- [ ] The *Inspect* report equals the snapshot `tests/Lod.Generators.Tests/EnclosedCourt/Snapshots/enclosed-court-canonical.txt`.
#### Combinations
2. Connect the plan to the four plan simplifiers (*Perimeter Core* Depth 4.57) and each floor three times, Multipliers 1, 3, 1, to the four floor aggregators.
- [ ] *Semantic Merge* gives 12 zones: four L-shaped `DwellingUnit-…` zones, one around each corner of the court, and eight `Stair-…` zones.
- [ ] *Perimeter Core* gives 9 zones `P-North`, `P-East`, `P-South`, `P-West`, `P-Court-North`, `P-Court-East`, `P-Court-South`, `P-Court-West`, `CORE`; the court zones hold the court façades and their windows, and `CORE` is a ring around the court (411.84 m²) without outdoor walls.
- [ ] *Single Zone per Floor* gives one zone `FLOOR` with the court as a hole and 8 windows (4 outer, 4 court).
- [ ] *Single Zone Building* gives one zone `BUILDING` with five parts, each with the court as a hole, previewed as one merged volume with the court open from ground to roof; 20 court walls, each with one centred window.
- [ ] For all 16 combinations, *Validate* gives Passed = True (only `ConditionedFloorArea` may read `note`), ground and roof area are 1728 m² each (not 2304 m²), no aggregator shows a warning, and *Convert2BEM* with Override = False converts without an error.
- [ ] *Convert2BEM* *Zones* are closed Breps with inner walls along the court, and the floor and ceiling Breps of the zones that surround the court (`FLOOR`, `CORE`, `BUILDING`, `E0`–`E2`) are single faces with an inner loop for the court.
- [ ] Orientation (all generators, not only this one): every *Convert2BEM* *Zones* Brep and every zone preview solid has a positive volume (outward-oriented); in *Surfaces* every wall normal points to the wall's facing, every floor normal down, and every ceiling normal up, interzone surfaces included; every *Windows* normal points the way its wall faces. Read a face's normal with `BrepFace.NormalAt`, which already includes `OrientationIsReversed` (negating it again for a reversed face gives the wrong direction); every planar face is built unreversed.
#### Rotated placement
3. Put the plan through *Transform Plan* with a 30° rotation about the world z axis and a move by 5.3, −2.1, 0, then through each simplifier and each aggregator (Multipliers 1, 3, 1); repeat with a 131.4° rotation and a move by 15.08, −153.9, 0.
- [ ] *Validate* gives Passed = True for all 16 combinations of each placement, with the same total glazing as the unrotated plan.
#### Parameters and preconditions
4. Change inputs one at a time and set each back afterwards.
- [ ] Court Width 0: `Error InvalidParameter: CourtWidth must be positive, got 0.` and no plan.
- [ ] Unit Width 0.5 and Stair Width 30: two errors, `Error InvalidParameter: StairWidth 30 m leaves no dwelling width in each 48 m south and north wing: its 2 stair bays need 60 m of stairs; reduce StairWidth or UnitWidth.` and the same for `each 24 m west and east wing: its one stair bay needs 30 m of stairs`.
- [ ] Depth 9 through *Perimeter Core* (Depth 4.57): `Error PlanTooNarrow: Perimeter depth 4.57 m leaves no valid core for this footprint: every wing and every part of the plan must be wider than twice the depth (9.14 m). Use a smaller depth or another simplifier.` and no floor; the other three simplifiers still work.
- [x] Step 2: Commit
git add docs/development/grasshopper-smoke-test.md
git commit -m "docs(development): add the s8.3 smoke-test checklist"
Task 16: Smoke test in Rhino 8 (manual, person; or the controller headless, D-056)¶
Files: none (the result goes into the body of the close-out commit, Task 17, Step 8)
- [x] Step 1: Build the branch
dotnet build BEMGen.sln -c Release
git rev-parse HEAD
Expected: Build succeeded., 0 Warning(s), 0 Error(s). Note the SHA.
- [x] Step 2: Run the S8.3 checklist (person in Rhino 8, or the controller headless)
Load the plugin as docs/development/grasshopper-smoke-test.md describes and tick every item of the S8.3 checklist. In this run (D-084, under the rules of D-066) the controller runs the checks that do not need a viewport as a scripted headless Grasshopper session (D-056: Rhino 8 /nosplash /netcore with RHINO_PACKAGE_DIRS pointing at src/Lod.Grasshopper/bin/Release/net7.0/, components placed by GUID, states, messages, outputs, data trees, bounding boxes, Brep face, inner-loop, and validity counts, Brep.GetVolume, and BrepFace.NormalAt at each face's domain centre written to a file). The following values are expected; they were observed in two such sessions on the verified reference (Rhino 8.25.25314.11001, .NET 8.0.31; the reference assembly reported the informational version 0.5.0+<sha>, because the reference keeps the S5 version number; on this branch BEMGen Info shows 0.8.1+<sha>, and the bump to 0.8.2 comes in Task 17). "The presets" are the default Dwelling Unit Preset and Stair Preset with nothing connected; "1, 3, 1" means a simplified floor given three times to an aggregator with those multipliers; a volume is that of Brep.GetVolume, positive for an outward solid.
- Registration. 29 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. In 2 Generate, all primary: Enclosed Court ECourt
b9765cd0-a62b-483d-9470-86d5fda287e8, Gallery Bar GBara58df462-b3c2-46b7-be3d-7ea9f4629280, Linear Plan Generator Lineara4a9131e-0a0f-49e0-99a4-f26760f6810f, Open Court OCourt223014ea-3c01-4d2b-b8ba-92ebdc4a7327, Point Plate PPlate8ac9044a-8fbe-4964-a9de-2ef8be35094a, Stair Bay Bar SBar911c3204-42bb-4023-bdb7-2931ea1caad3, Stair Pair SPair5669d39b-45e6-47ea-9250-f2304d005335, Terrace Row Terrf694e595-dba2-4b98-9d1e-f1492e3edf39, Transform Plan TPlan57c17b9c-04b4-4595-9150-bb923877d95e, Winged Band WBande285811d-2fdb-4d31-8b02-62c270b609cf. Every other component and parameter as in S8.2. - Inputs. In order, with their persistent defaults (model in metres): Enclosed Court Court Width (CW) 24, Court Depth (CD) 24, Depth (D) 12, Unit Width (UW) 7, Stair Width (SW) 3, Floor Height (FH) 3, Orientation (O) 0, Dwelling Unit (DU), Stair (St), Preview Location (PL, optional)
{0,0,0}. Every input except Preview Location is required. With the preset inputs unconnected the component is in the warning state withInput parameter DU failed to collect dataandInput parameter St failed to collect dataand outputs nothing. - Default plan. Enclosed Court with the presets → Inspect; → No Simplification → Stack Floors (one floor) → Convert2BEM; and → Semantic Merge, Perimeter Core (default depth), and Single Zone per Floor: no runtime message on the generator, Inspect, a simplifier, Stack Floors, or Convert2BEM (the preset components show only their remark). Plan bounding box (0, 0, 0)–(48, 48, 3); Inspect
footprint area=1728.000000 floorHeight=3.000000 orientation=0.000000; Boundaries Adiabatic 64, Ground 24, Outdoors 68; Windows 44 Breps in 24 branches (1, 2, 1, then 2 in each of branches 3–8, then 1, 2, 1, then 2 in each of branches 12–23); Zones 24 valid closed Breps of 6 faces without inner loops, volumes 108 (stairs), 234 (long-wing dwellings), and 378 (side-wing dwellings); the floors of Semantic Merge, Perimeter Core, and Single Zone per Floor have 12, 9, and 1 zones. - Breps with holes. Single Zone per Floor → Stack Floors (one floor) → Convert2BEM: one Zones Brep, a valid closed solid of 10 faces, 2 of them (floor and ceiling) with an inner loop, volume 5184 (the court not filled), box (0, 0, 0)–(48, 48, 3); 10 Surfaces. Perimeter Core → Stack Floors → Convert2BEM: 9 valid closed Zones Breps,
P-North,P-East,P-South,P-Westof 6 faces and volume 595.425 each, the four court zones of 6 faces and volume 391.695 each, andCOREof 10 faces, 2 with an inner loop, volume 1235.52, box (4.57, 4.57, 0)–(43.43, 43.43, 3); 58 Surfaces (6 per perimeter zone, 10 forCORE). Single Zone per Floor (three times) → Single Zone Building (1, 3, 1) → Convert2BEM and Validate: 5 Zones Breps in one branch, each of 10 faces, 2 with an inner loop, volume 5184, at z = 0–3, 3–6, 6–9, 9–12, and 12–15; the preview one merged solid (0, 0, 0)–(48, 48, 15) and 40 window Breps; 40 Windows; Validate Passed =True, withBuilding.GroundArea: reference 1728.000000, actual 1728.000000and the same forBuilding.RoofArea. - Every combination. The plan through each of the four plan simplifiers (Perimeter Core with its default depth), each floor given 1, 3, 1 to each of the four aggregators, each building to Validate: no runtime message on any simplifier, aggregator, or Validate, and Validate Passed =
Truefor all 16 buildings. - Rotated plan. The plan through Transform Plan with the transform of a 30° rotation about the world z axis followed by a move by (5.3, −2.1, 0), then the 16 combinations of item 5; and again with a 131.4° rotation and a move by (15.08, −153.9, 0): no runtime message (including Transform Plan) and Validate Passed =
Truefor all 32. - Orientation (D-091). No face of any Brep below is reversed for its facing, and each normal is read with
BrepFace.NormalAtdirectly. Linear Plan Generator with its three default presets (Dwelling Unit, Corridor, Stair) → No Simplification → Stack Floors (two floors) → Convert2BEM: 12 Zones Breps of volume 216, 120, 240, 240, 240, 240 on each storey; of the 80 Surfaces, the 56 walls face their wall's outward side (away from their zone, also where interzone), the 12 floors (0, 0, −1), and the 12 ceilings (0, 0, 1); the 20 Windows face as their walls. Enclosed Court → Single Zone per Floor → Stack Floors (one floor) → Convert2BEM: the Zones Brep has volume 5184; the 10 Surfaces are the outer walls facing (0, −1, 0) at y = 0, (1, 0, 0) at x = 48, (0, 1, 0) at y = 48, and (−1, 0, 0) at x = 0, the court walls facing into the court, (1, 0, 0) at x = 12, (0, −1, 0) at y = 36, (−1, 0, 0) at x = 36, and (0, 1, 0) at y = 12, the floor (0, 0, −1), and the ceiling (0, 0, 1); the 8 Windows face as their walls. Linear Plan Generator → Transform Plan (30°, then (5.3, −2.1, 0)) → Perimeter Core (three times) → Single Zone Building (1, 3, 1) → Convert2BEM: 5 Zones Breps of volume 1296 each; the 20 Windows face (0.866, 0.5, 0), (−0.5, 0.866, 0), (−0.866, −0.5, 0), and (0.5, −0.866, 0), four per storey. No Brep has a negative volume in any scenario of items 3 to 7. - Parameters and preconditions. Court Width 0: error state,
Error InvalidParameter: CourtWidth must be positive, got 0., no plan. Unit Width 0.5 and Stair Width 30: error state with the two errorsError InvalidParameter: StairWidth 30 m leaves no dwelling width in each 48 m south and north wing: its 2 stair bays need 60 m of stairs; reduce StairWidth or UnitWidth.andError InvalidParameter: StairWidth 30 m leaves no dwelling width in each 24 m west and east wing: its one stair bay needs 30 m of stairs; reduce StairWidth or UnitWidth.Depth 9 → Perimeter Core (default depth): error state withError 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 icon, the viewport previews (the court as a hole, the merged Single Zone Building volume open from ground to roof, the court zones of Perimeter Core), the Inspect report compared with the canonical snapshot (the generator test checks it), the zone IDs of Semantic Merge and Perimeter Core and the L-shaped dwelling groups (EnclosedCourtTypologyTests checks them; the headless session recorded the zone counts), the glazing values of Validate (only Passed and the ground and roof areas were recorded; the generator and ring tests check the glazing per orientation), Convert2BEM with Override = False on each of the 16 combinations (the headless session converted four of them), the orientation of the Breps of the other plan generators (the rule is in the shared RhinoGeometry; the session checked the linear plan and the enclosed court), and the other three simplifiers on the 9 m enclosed court. No example definition is part of S8.3; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).
Any failure stops the stage: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Write down, following the guide's "Recording the result": who tested and how (person in Rhino, or the controller headless per D-056), when, the Rhino version and runtime, the SHA, the BEMGen Info version text, pass or fail for each item, and the items not performed.
- [x] Step 3: Merge Slice D (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/architecture-enclosed-court
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-enclosed-court
git branch -d docs/architecture-enclosed-court
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0. Do not push (D-084).
Stage close-out¶
Task 17: Version 0.8.2, S8.3 records, tag v0.8.2¶
Files:
- Modify: Directory.Build.props, AGENTS.md, README.md, docs/plans/2026-09-30-implementation-roadmap.md, docs/decisions/decision-log.md, docs/plans/2026-10-02-s8.3-enclosed-courtyard.md (this plan: checkboxes and status, D-051)
Interfaces:
- Consumes: the merged Slices A–D; the smoke-test record of Task 16; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump.
- Produces: the local tag v0.8.2; the S8.3 records.
The decision-log entry in Step 7 and the roadmap Progress line of Step 6 have one field filled in when this task runs: <date>, the date the stage is closed (ISO format, YYYY-MM-DD). The entry is decided by the controller (unsupervised run, D-084) and Provisional. The commit body of Step 8 uses the values written down in Task 16. The close-out uses its own branch, as the S1–S8.2 close-outs do.
- [x] Step 1: Create the branch and run the gate
git switch -c chore/repo-s8.3-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0.
- [x] Step 2: Bump the version in
Directory.Build.props
Replace
<Version>0.8.1</Version>
with
<Version>0.8.2</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.2</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.2"
- [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`), and S8.3 (tag `v0.8.2`, D-084). The pipeline runs end to end for nine plan generators: program presets → a plan generator (one `PlanGenerator` subclass per *High-Density Housing* typology family with typed parameter and preset records, [ADR-006](docs/decisions/ADR-006-plan-generation-mechanism.md): `LinearPlanGenerator`, the double-sided internal corridor bar `HDHM-TYP-009`; the bar families `StairBayBarGenerator` 001, `StairPairGenerator` 008, `GalleryBarGenerator` 002 with its gallery not modelled, and `TerraceRowGenerator` 006 with one dwelling zone per plot and storey, D-085; the non-rectangular families `PointPlateGenerator` 007, a square plate whose core has no outdoor wall, `WingedBandGenerator` 003, a comb, and `OpenCourtGenerator` 004, a U; and `EnclosedCourtGenerator` 005, four wings around a court closed on all four sides, the first footprint with a hole; 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). 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, and the S4.2, S5, S8.1, S8.2, and S8.3 plans were accepted by the controller and await the owner's reading (D-075, D-080, D-084). In Grasshopper every component has an icon drawn by `scripts/make_icons.py` (D-063), and the generator, *Transform Plan*, simplifier, and aggregator components have a *Preview Location* input that moves only their preview (D-062). Until the owner says otherwise, changes need not keep backward compatibility, but component GUIDs never change (D-064).
Not implemented yet: `Lod.Export` and Convert2IDF (S6), 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`), and S8.3 (tag `v0.8.2`). In Rhino 8 the plugin runs the whole pipeline for nine plan generators: program presets with conditioning (a default preset component per space type); the linear plan generator and the eight other typologies of the *High-Density Housing* precedent study (ADR-006), the bars *Stair Bay Bar*, *Stair Pair*, *Gallery Bar*, and *Terrace Row*, the non-rectangular *Point Plate*, *Winged Band*, and *Open Court*, and *Enclosed Court*, whose footprint has the court as a hole, 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: 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, and the S8.2 non-rectangular families; the enclosed court in S8.3)
with
Lod.Generators/ plan generators, one per typology family (the linear plan, the S8.1 bars, the S8.2 non-rectangular families, and the S8.3 enclosed court)
- [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.3 Progress line below the S8.2 Progress line (the last > **Progress:** line), with a > separator line between them; the second adds the S8.3 row of the detailed-plans table.
- Replace
> **Progress:** 2026-10-02 · S8.2 Non-rectangular typologies without holes complete (tag `v0.8.1`, local until the owner pushes it, D-084); next: S6 and S8.3. New generators and components *Point Plate* (007, a square plate whose core has no outdoor wall), *Winged Band* (003, a comb), and *Open Court* (004, a U), their wings on the shared stair-bay layout; *Semantic Merge* unites its groups as tiles, the surface builder tests vertices on edges at their original positions, and unions of tiles keep the tiles' own vertices, which repairs rotated plans released up to `v0.8.0` (D-088); *Perimeter Core* states its width precondition in `PlanTooNarrow` (D-087); every non-rectangular family validates with every simplifier and aggregator, also rotated and moved; existing snapshots byte-identical, three new; 154 core, 159 generator, and 474 integration tests pass on `net8.0`.
with
> **Progress:** 2026-10-02 · S8.2 Non-rectangular typologies without holes complete (tag `v0.8.1`, local until the owner pushes it, D-084); next: S6 and S8.3. New generators and components *Point Plate* (007, a square plate whose core has no outdoor wall), *Winged Band* (003, a comb), and *Open Court* (004, a U), their wings on the shared stair-bay layout; *Semantic Merge* unites its groups as tiles, the surface builder tests vertices on edges at their original positions, and unions of tiles keep the tiles' own vertices, which repairs rotated plans released up to `v0.8.0` (D-088); *Perimeter Core* states its width precondition in `PlanTooNarrow` (D-087); every non-rectangular family validates with every simplifier and aggregator, also rotated and moved; existing snapshots byte-identical, three new; 154 core, 159 generator, and 474 integration tests pass on `net8.0`.
>
> **Progress:** <date> · S8.3 Enclosed courtyard and footprints with holes complete (tag `v0.8.2`, local until the owner pushes it, D-084); next: S6 and S9. New generator and component *Enclosed Court* (005, four wings of stair bays around a court closed on all four sides), the first footprint with a hole; the surface builder requires every footprint edge, court edges included, to be covered by outdoor zone edges; *Perimeter Core* zones the court façades in their own zones `P-Court-<Bin>` around a ring core, with its width precondition on every wing (D-087); *No Simplification*, *Semantic Merge*, *Single Zone per Floor*, the four floor aggregators, and validation needed no change; every simplifier and aggregator validates the enclosed court, also rotated and moved; zone solids are outward-oriented and surfaces and windows face out of their zones in *Convert2BEM*, which repairs every plan released up to `v0.8.1` (D-091); existing snapshots byte-identical, one new; 158 core, 181 generator, and 601 integration tests pass on `net8.0`.
- Replace
| S8.2 | [2026-10-02-s8.2-non-rectangular-typologies.md](2026-10-02-s8.2-non-rectangular-typologies.md) | 154 core, 159 generators, 474 integration |
with
| S8.2 | [2026-10-02-s8.2-non-rectangular-typologies.md](2026-10-02-s8.2-non-rectangular-typologies.md) | 154 core, 159 generators, 474 integration |
| S8.3 | [2026-10-02-s8.3-enclosed-courtyard.md](2026-10-02-s8.3-enclosed-courtyard.md) | 158 core, 181 generators, 601 integration |
The S8.3 stage edits of the plan commit (the S8.3 bullet and the risk row) stay as they are; §2 (target repository layout) already names EnclosedCourt/.
- [x] Step 7: Append the decision-log entry (template; fill
<date>now)
Append at the end of docs/decisions/decision-log.md, after a blank line. The log ends with D-091 when this plan is approved, so the entry is D-092; if the log ends with another number, use the next free number and say so in the commit body.
### D-092 — S8.3 complete
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-084) · **Status:** Provisional
- Sub-stage S8.3 (D-084) is complete and tagged `v0.8.2` locally, and with it S8: a plan generator exists for each of the nine *High-Density Housing* families (D-083). Pushing `main` and the tags `v0.8.0`, `v0.8.1`, and `v0.8.2` waits for the owner (D-084). `EnclosedCourtGenerator` (`HDHM-TYP-005`) is implemented as ADR-006 specifies it, with no record, default, or rule of ADR-006 changed: four wings of stair bays on the shared `StairBayLayout` around one court closed on all four sides (zones `S-…` and `N-…` west to east, then `W-…` and `E-…` south to north; by default 1728 m² and 44 windows, 16 of them on the court façades); the footprint is the outer rectangle with the court as its one hole. New component in *2 Generate*, with icon: *Enclosed Court* (ECourt, `b9765cd0-a62b-483d-9470-86d5fda287e8`). ADR-006 records the details settled in S8.3 and the pipeline consequences.
- Every step supports footprints with holes. The court is outside the building: its edges are outdoor walls facing into it, and it has no zone, floor, ceiling, ground slab, or roof. The surface builder requires every footprint edge, court edges included, to be covered by outdoor zone edges over its full length (`UnmatchedEdge`), which edge matching alone missed for a zone filling a court ([ADR-002](ADR-002-geometry.md)). *Perimeter Core* offsets every ring by the bisector rule, gives the court façades their own zones `P-Court-<Bin>` after the outer zones, and leaves a core that is a ring around the court; a wing between the outer and a court façade that is not wider than twice the depth fails with `PlanTooNarrow` (D-087, [ADR-008](ADR-008-perimeter-core-corners.md)). *Perimeter Core* no longer emits `NotSupported`, and the test of that behaviour is removed. The other simplifiers, the four floor aggregators, and validation needed no change ([ADR-013](ADR-013-vertical-aggregation-methods.md)). No tolerance or validation check was relaxed.
- D-091 is implemented in `RhinoGeometry` for every plan generator: zone solids outward-oriented, walls and windows facing their wall's outward side (court walls into the court), floors down, ceilings and roofs up, interzone surfaces included; this repairs the orientation of the *Convert2BEM* Breps and previews released up to `v0.8.1` and is documented in `docs/architecture/convert2bem.md`. The limit of D-090 (zones about 1 m wide on rotated plans) is unchanged.
- The fifteen existing snapshots are byte-identical; one canonical snapshot is new. 158 core, 181 generator, and 601 integration tests pass on `net8.0`, including a ring footprint and the enclosed court with every simplifier and aggregator on the canonical plan and on the plan rotated by 30° and moved by (5.3, −2.1) m and rotated by 131.4° and moved by (15.08, −153.9) m.
- The S8.3 smoke test is recorded in the close-out commit; items it did not perform stay open for a person (D-058).
- S6 (Convert2IDF) and then S9 (Grasshopper UX and release, which depends on S6 and S8.3) 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 8: Tick this plan and commit the records
Tick every completed checkbox of this plan and set its status line to > **Status:** Done (controller, D-084; pending the owner's reading) · … (D-051). Leave unticked the steps that run after this commit (Steps 9 and 10 below) and every step of Task 18, and name them in the commit body. Tick only the plan's own step checkboxes: the - [ ] items inside the checklist block of Task 15 are file content of grasshopper-smoke-test.md and stay unticked, so the block keeps matching the file. The second -m records the smoke test as docs/development/grasshopper-smoke-test.md prescribes ("Recording the result"); fill <tester and method>, <date>, <version>, <sha>, <version text>, and <items> with the values written down in Task 16.
git add AGENTS.md README.md docs/plans/2026-09-30-implementation-roadmap.md docs/decisions/decision-log.md docs/plans/2026-10-02-s8.3-enclosed-courtyard.md
git commit -m "docs(repo): record s8.3 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S8.3 checklist) by <tester and method> on <date>: Rhino <version>, .NET Core runtime, build <sha>, Version output '<version text>', all performed items passed. Not performed, pending a person (D-058): <items>."
- [ ] Step 9: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s8.3-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s8.3-close-out
git branch -d chore/repo-s8.3-close-out
Expected: VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0.
- [ ] Step 10: Tag (local)
git tag -a v0.8.2 -m "S8.3 enclosed courtyard and footprints with holes complete"
git describe --tags
Expected: v0.8.2. Nothing is pushed (D-084).
Task 18: Fresh-clone gate and push (owner; not performed in this run, D-084)¶
Files: none
Not performed in this run. D-084 leaves pushing to the owner: the controller does not run this task, and its steps stay unticked when the stage is closed. They are listed so that the owner, or the controller once the owner authorises it, pushes the stage the way S5 was pushed (D-080); never a force push. The S8.1 and S8.2 pushes (S8.1 plan, Task 18; S8.2 plan, Task 16) were not performed in their runs, so this task pushes main with the three sub-stage tags v0.8.0, v0.8.1, and v0.8.2; a tag the remote already has is left as it is.
- [ ] Step 1: Check the state to push
git status --short
git fetch origin
git rev-parse main v0.8.2^{commit}
git merge-base --is-ancestor origin/main main && echo fast-forward
git merge-base --is-ancestor v0.8.0 v0.8.1 && git merge-base --is-ancestor v0.8.1 v0.8.2 && echo tags-in-history
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, unless later commits were added on purpose); fast-forward (local main contains origin/main); tags-in-history; the tags v0.8.0, v0.8.1, and v0.8.2; ls-remote shows which of them the remote has. If origin/main has commits that local main does not, or a remote v0.8.* tag points at another commit than the local one, stop and report: nothing is pushed, and no rebase or force push is attempted.
- [ ] Step 2: Verify a fresh clone of the tag
git clone --branch v0.8.2 . ../BEMGen-v0.8.2-clone
powershell -NoProfile -ExecutionPolicy Bypass -File ../BEMGen-v0.8.2-clone/scripts/verify.ps1
git -C ../BEMGen-v0.8.2-clone describe --tags
Expected: the clone reports a detached HEAD at v0.8.2; verify builds the clone with 0 Warning(s) and 0 Error(s) and ends with VERIFY PASSED, with 158 core, 181 generators, and 601 integration tests passed on net8.0; describe prints v0.8.2. Then delete the clone (rm -rf ../BEMGen-v0.8.2-clone in Git Bash). If verify fails, stop: nothing is pushed.
- [ ] Step 3: Push (no force)
git push origin main
git push origin v0.8.0 v0.8.1 v0.8.2
git ls-remote --tags origin "v0.8.*"
git status -sb
Leave out of the second command any tag that Step 1 showed on the remote already. Expected: each push succeeds without --force (main as a fast-forward, the tags as [new tag]); ls-remote lists the three tags (each with its peeled ^{} line); git status -sb shows ## main...origin/main with nothing ahead or behind. A rejected push is not retried with force: stop and report.
Exit criteria¶
- The surface builder requires every footprint edge, outer ring and holes, to be covered by outdoor zone edges over its full length (
UnmatchedEdge), tested byLayoutSurfaceBuilderTestswith a test that failed on the code before it; ADR-002 describes the rule and footprints with holes. - Perimeter Core zones footprints with holes: every ring offset by the bisector rule, the court zones
P-Court-<Bin>after the outer zones, a ring core, andPlanTooNarrowfor a wing between the outer and a court façade that is not wider than twice the depth (D-087); tested byRingFootprintTestswith tests that failed on the code before it and by the crossing-offset case; ADR-008 describes it.FootprintsWithHolesAreNotSupportedYetis removed, and no other test. - No Simplification, Semantic Merge, Single Zone per Floor, the four floor aggregators, and validation handle the ring footprint, canonical and at two rotated and moved placements, with the court as a hole and no ground slab or roof under it (
RingFootprintTests, 76 cases); ADR-013 records it. EnclosedCourtGenerator(005, four wings of stair bays around a court closed on all four sides, the court the footprint's one hole) implements ADR-006 with its defaults, zone IDs, and validation; it has tiling, court façade, stair, window, invalid-parameter, determinism, provenance, and snapshot tests.- The enclosed court validates with every plan simplifier and floor aggregator, on its canonical plan and at two rotated and moved placements (
EnclosedCourtTypologyTests, 52 cases). - Snapshots: the fifteen existing snapshots byte-identical; one new canonical snapshot.
- One new component in 2 Generate with the GUID of this plan, its ADR-006 defaults, required preset inputs, Preview Location last, and an icon (36 icons); no existing component, input, or GUID changed.
RhinoGeometryfollows D-091 for every plan generator: zone solids outward-oriented, walls and windows facing their wall's outward side, floors down, ceilings up;convert2bem.mdstates it; the smoke test observed it.- ADR-006 records 005 as implemented and the pipeline consequences of S8.3;
pipeline.md,validation.md, and the precedent page describe S8.3; the S8.3 smoke-test checklist is in the guide. scripts/verify.ps1printsVERIFY PASSED; tests: 158 core, 181 generators, 601 integration onnet8.0; 0 warnings.- The S8.3 smoke test is recorded in the close-out commit, with the items left for a person named.
- Version 0.8.2, local tag
v0.8.2; AGENTS.md, README, the roadmap Progress line and detailed-plans row, and the decision-log entry D-092 are written; this plan is ticked and marked Done. - Nothing is pushed in this run (D-084); Task 18 is left for the owner.
Notes for the reviewer¶
- Source. Every code block, snapshot, and documentation replacement was extracted programmatically from a verified reference implementation built on the S8.2 reference (
09c0ac3), 14 commits ending at754707f, each passingscripts/verify.ps1with 0 warnings. Localmainis87bd272: S8.2 executed (v0.8.1;src/,tests/, andscripts/identical to the S8.2 reference,Directory.Build.propsat 0.8.1) plus the follow-up commitsf27f7e2(the S8.2 plan's contact-sheet text),34deeaa(a corrected sentence in ADR-006's Pipeline consequences of S8.2),1566f85(D-090), and87bd272(D-091). Every file the reference touches is identical onmainand in the S8.2 reference except ADR-006: Task 9's replacements are taken from the reference and checked againstmain's ADR-006, none of them touches the sentence of34deeaa, and the result equals the reference's ADR-006 withmain's sentence. The reference's commit order is kept with three exceptions, each of which moves a commit across commits that touch none of its files, so every file content in this plan is the content of the reference commit it comes from: the test of a court offset crossing the outer offset (338f543, written after the component in the reference) is Task 6, with the other ring tests; the ADR commit (24400ff) is Task 9, at the end of Slice A, so that the rules of Tasks 1 and 3 and the ADRs that state them are merged together (GLOBAL.md scientific rule 7; note 9); and the component commit (58b5f6a) is split into Task 11 (the component) and Task 12 (the icon andmake_icons.py), as S8.1 and S8.2 did. The reference has no roadmap commit; the roadmap edits are the plan commit's and the close-out's. Every task was then replayed on a clone of87bd272, taking each file content and each replacement from this plan's text: the failing tests and messages of Tasks 1 and 3, the red errors of Task 7 (with line and column numbers and in the compiler's order), its single failing snapshot test and the received snapshot (byte-identical to the expected content), the failure ofmake_icons.pybefore Task 12 and the 36 hashes after it (all 36 PNGs byte-identical to the reference's), the counts after every task, a 0-warning build after every task, every documentation replacement (each "Replace" text occurs exactly once when it is applied, and the result equals the reference, ADR-006 withmain's sentence andprogram-presets.mdwith the anchor of note 10), the close-out edits, andVERIFY PASSEDon a fresh clone of the replayedv0.8.2(158 core, 181 generators, 601 integration) are observed results. The replayed end state equals the reference apart from line endings, the version (the reference keeps 0.5.0), and the files the reference holds in their S8.2 form or not at all (the decision log, the roadmap, AGENTS.md, README.md, the plans, the.gitignorelinesmaingained in S8.1, ADR-006's sentence of34deeaa, and the anchor inprogram-presets.md). - Slices and branches. The roadmap names no slices for S8.3; this plan uses four slices and a close-out, named after D-004:
feature/footprints-with-holes(which also holds thefix(core)commit of Task 1 and the ADRs of Task 9),feature/grasshopper-enclosed-court,fix/grasshopper-orientation(D-091, typefix, its own slice because it changes the output of every plan generator, not only the new one),docs/architecture-enclosed-court, andchore/repo-s8.3-close-out. Merges rebase onto localmain. Like S8.1 and S8.2, this run ends without a push (D-084); Task 18 is written out for the owner, pushesv0.8.0,v0.8.1, andv0.8.2(any the remote lacks), and stays unticked. On the machine this plan was written on, the remote-trackingorigin/mainis at274fff9(S8.2 Task 4), recorded in the reflog as "update by push", although the S8.2 records say nothing was pushed in that run; localmaincontains it, so a push stays a fast-forward. Task 18, Step 1 checks this and the remote's tags before anything is pushed. - The removed test.
SimplifierTests.FootprintsWithHolesAreNotSupportedYet(S3) asserted that Perimeter Core rejects a ring footprint withNotSupported, the placeholder that D-043 and ADR-008 announced "until S8 (courtyard plans)". Task 3 replaces that behaviour, as D-084 assigns it to S8.3, so the test is removed in the same commit; it is the only test S8.3 removes or changes. This does not weaken a check: the replacing tests require more (the zones, their areas, the court façades and glazing, validation at three placements), and the narrow rings it might have covered now fail withPlanTooNarrow, also tested. The constantDiagnosticCodes.NotSupportedstays (removing a public constant is not needed and would touch an S3 file for nothing); no code emits it after Task 3, andpipeline.mdandvalidation.mddrop it from the simplifier preconditions (Task 14). P-Court-<Bin>naming. The court façade zones are separate from the outer façade zones of the same orientation bin, and are named by the direction they face: the south wing's court façade faces north and belongs toP-Court-North. They are listed after the four outer zones and beforeCORE, with the S3 suffix rule (-1,-2) for several separate runs in one bin. The reasons are in ADR-008 (Task 9): a court façade faces a court, not the street, and its band never touches the outer band of the same bin (the core lies between them), so uniting them would always give two polygons numberedP-North-1andP-North-2in an order that says nothing about which is which. This is a zoning rule chosen by the controller (unsupervised run, D-084) and recorded in ADR-008 and in the completion entry D-092, without a decision-log entry of its own; the owner may revisit it at the reading of ADR-008. Perimeter Core remains a Z2 strategy; the court zones change no equivalence rule, since every zone keeps its areas, loads, and glazing by the existing rules.- The boundary coverage rule (Task 1). Edge matching splits zone edges at other zones' vertices, pairs reversed edges as interzone walls, and marks edges on the footprint boundary as outdoors; any other edge is an error. It cannot see a zone lying outside the footprint whose every edge is shared with another zone, such as a zone filling a court: the court's edges become interzone walls and nothing is left unmatched (
AZoneFillingTheCourtIsAnErrorfailed withAssert.False() Failurebefore the fix). The new check requires the outdoor segments on each edge of every footprint ring, outer ring and holes, to add up to the edge's length. The lengths are compared withAreaEquals, the relative tolerance with which façade coverage is compared (D-041); no tolerance is added, and the segment test is the S8.2IsOnSegmenton original positions, so rotated plans pass (ARotatedRingKeepsItsCourtWalls). Three of the four new tests pass before the fix: the surface builder has treated hole edges as plan boundary since S2; they document that behaviour. Every existing plan covers its footprint edges, so every snapshot is unchanged; the check can only reject layouts that were wrong. - The orientation fix applies to every family and to released versions (D-091, Task 13). D-091 was logged on
mainafter the reference's first headless check found every Convert2BEM zone solid inward-oriented (negative volume) and every floor facing up, on every plan ofv0.8.1and earlier, not only on courts:PlanarBrepsgave each face the upward normal of its counter-clockwise outer ring, and the extrusion of an upward floor is an inward solid. The fix changes orientation only (faces built unreversed with the facing they need, the solid extruded from the downward floor, and theOutwardandFacingguards), never geometry, and it changes the output of every plan generator and of the merged previews, so it is a slice of its own (typefix). Grasshopper code has no unit tests (RhinoCommon needs Rhino); the evidence is the second headless session on the reference's last commit (Task 16, item 7: positive volumes; walls, floors, ceilings, and windows facing out of their zones on the linear plan, the enclosed court, and a rotated Single Zone Building). The state before the fix is described by D-091 and was not re-observed for this plan. The Convert2BEM output layout and every GUID are unchanged; tools that read the Breps see only corrected orientation (D-064). The reference'sconvert2bem.mdandRhinoGeometryremarks state the rule without citing D-091, which was logged after them; the plan keeps the reference text, and the roadmap edits, AGENTS.md, README.md, and D-092 cite it. - The D-090 limit. S8.3 does not change the small-zone limit of D-090. The reference build reported that the enclosed court with extreme parameters fails
SourceCoverageon rotated plans after a merging simplifier in the same way as the open court: with Court Depth 4 m each side wing holds one bay of 0.5 m dwellings. This was not replayed. No tolerance is widened (AGENTS.md rule 12); every default plan validates at every tested placement (Task 8, Task 16). - Tests that were not test-first, and why. Tasks 2, 4, and 5 add 66 tests that passed at once in the reference and in the replay: No Simplification, Semantic Merge (tiles since S8.2), Single Zone per Floor, the floor aggregators (designation on the stack since S4.2, outlines as tiles since S8.2), and validation already handled a hole, so no code change was needed and no red phase is possible; the tests record that the brief's question "does this step need work for holes?" was answered by observation, and each task says to stop if a case fails. Task 6 adds one test that covers a branch of Task 3's code that no case of Task 3 reaches: a probe on the replayed end state (not committed) showed that the three precondition cases of Task 3 and the 9 m enclosed court are all rejected by the earlier check, with the message
… leaves no valid core for this footprint: …, while the off-centre court of Task 6 gives… between the outer and the court façades: …. The reference wrote this test after the Grasshopper component, as a review follow-up. Task 8 is the combination matrix (no red phase, as in S8.1 and S8.2). Tasks 10 to 13 are Grasshopper code. The build-error red phase of Task 7 lists only declaration-level errors (four): the compiler stops before binding method bodies. - ADRs in Slice A. S8.2 put each ADR change in the task of its code; the reference of S8.3 wrote all ADR changes in one commit after the code. This plan keeps that commit whole but moves it into Slice A (Task 9), because the ADR texts cite the enclosed court of Task 7 and the matrix of Task 8 (ADR-008's numbers for the default court, ADR-013's "every combination … validates"), so they cannot land with Tasks 1 or 3; Slice A is merged only after Task 9, so
mainnever holds the rules without their ADRs. - A link the reference missed. The reference renamed the heading
## Grasshopper components through S8.2ofpipeline.mdto S8.3 but left the link indocs/research/program-presets.mdat#grasshopper-components-through-s82, which would no longer resolve (the replay's link check found it). Task 14, Step 5 changes the anchor, as S8.2 did for the same link; it is the only documentation content of this plan not taken from the reference. - ADR-006's corrected sentence.
main's ADR-006 carries the wording of34deeaa("found three faults on rotated plans; the second (UnmatchedEdgein Semantic Merge) also affected the linear plan and the gallery bar at some placements in released versions up tov0.8.0(D-088);"), which the reference lacks. Task 9 keeps it: no replacement contains it, and the replay compared the result with the reference plus that sentence. - D-087 stays a simplifier precondition. The generator validates only its own parameters (positive lengths, stair bays that leave a dwelling width); it does not reject wings narrower than twice a perimeter depth, which is a property of Perimeter Core's depth input, not of the plan. The default 12 m wings pass with the 4.57 m default depth; 9 m wings fail Perimeter Core with
PlanTooNarrow(Task 8 and the smoke test), and the other simplifiers accept them. - Semantic Merge on the court. Merging the default enclosed court gives four L-shaped dwelling zones of 360 m², one around each corner of the court, and the eight stairs; no merged zone has a hole, because the stairs of the west and east wings and the middle stairs of the south and north wings separate the dwellings. A merged zone with a hole around the court is tested on the ring footprint of Task 2, whose four wings are all dwellings.
- Icons. The 35 existing PNGs are byte-identical after Task 12 (Pillow 12.3.0); the new one equals the reference's bytes. The contact-sheet description is from the replay's sheet.
- Smoke-test values. The expected values of Task 16 were observed in two scripted headless Rhino 8.25 sessions (D-056) on the reference's last commit, which reported the informational version
0.5.0+754707f…: registration and inputs, the default plan (bounding box, the footprint line of Inspect, Boundaries, Windows, Zones, and the zone counts of three simplifiers), the Breps with holes (faces, inner loops, validity, volumes, boxes) of three combinations, the 16 canonical and 32 rotated combinations (states and Validate Passed), the orientation checks, and three parameter scenarios. They were not re-observed on the replay. Inspect texts beyond the footprint line, the zone IDs, the Validate glazing values, Convert2BEM on most combinations, and the viewport were not observed. - Clean working tree. The
git statusexpectations assume a working tree with no untracked files outside.gitignorebefore Task 1. 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.2.