S4.2 — Vertical aggregation methods, plan transform, and heat-transfer options Implementation Plan¶
Status: Done (controller, D-075; pending the owner's reading) · Date: 2026-10-01 · Roadmap: S4.2 (revision stage, D-073) · Decisions: D-068, D-069, D-070, D-071, D-072, D-073, D-074, D-075, D-076, D-077; ADR-013 (Task 1)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the S4.1 review decisions before S5 (D-073): every floor aggregator designates floors and ceilings on the assembled stack (D-068) and warns about a storey the storey below does not carry (D-074); the floor aggregators become four methods, Stack Floors, Stacked Floor Zone Multiplier (the former Floor Area Multiplier), Single Zone per Floor Type (the former Single Zone Merged component, with one zone per floor entry), and the new Single Zone Building (D-071); a new Transform Plan rotates and moves a plan (D-070), and every plan simplifier and floor aggregator works on rotated and moved plans (D-076); heat transfer between zones becomes an export option of Convert2BEM, off by default (D-069). Every existing building stays numerically identical.
Architecture: Lod.Core gains Storeys.Assemble and AssembledStack (every storey of the building with its floors and ceilings designated once, plus the UnsupportedStorey warning); StackedFloorZoneMultiplier takes its representative storeys' pieces from that stack and turns pieces between storeys adiabatic; SingleZoneBuilding and SingleZonePerFloorType share one internal merge, SingleZoneMerge, over groups of stacked storeys. PlanMotion and PlanTransform move a plan rigidly (Polygon2.Transform, Zone.Transform, abstract Surface.Transform). Two rules that held only for axis-aligned plans are generalised without new tolerances: façade lines are matched over their overlap (FacadeAttribution.Interval), and storey outlines are unions of tiles (PolygonOps.UnionTiles with a VertexGrid shared with LayoutSurfaceBuilder). Lod.Core.Conversion.HeatTransferOptions gives the boundary a converter writes. In Lod.Grasshopper the aggregator components are renamed and one is added, Transform Plan decomposes a Grasshopper transform (PlanMotionDecomposition), Convert2BEM gets two Boolean inputs, the building preview draws unmodelled storeys in transparent grey and merges multi-part zones into one volume (VerticalRuns), and scripts/make_icons.py draws 28 icons.
Tech Stack: C# 12 (LangVersion 12.0); .NET SDK ≥ 8 (global.json pins 8.0.100 with rollForward: latestMajor); netstandard2.0 core libraries; Clipper2 2.0.0; RhinoCommon and Grasshopper 8.19.25132.1001 (plugin targets net7.0); xUnit 2.9.3, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 17.14.1 (tests target net8.0); Windows PowerShell 5.1 for scripts/verify.ps1; Python 3.10 or later with Pillow for scripts/make_icons.py (the icons in this plan were drawn with Pillow 12.3.0).
Global Constraints¶
- Gate: D-068 to D-076 are recorded on
main(b8bba9f), and this plan is committed tomaintogether with the roadmap's S4.2 rows before Task 1 starts. ADR-013 (Task 1) is written and accepted before any code task (D-073); in this run the controller accepts it under D-075, provisional pending the owner's reading. S5 starts only after S4.2 is tagged (roadmap: S5 depends on S4.2). - 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 icon script is deterministic for a given Pillow version.
- Numerical tolerances come only from
ToleranceSettings(ADR-004); S4.2 adds none and changes none. The rotated-plan rules (D-076) make two predicates exact for rotated geometry; they relax no check. - Existing buildings are numerically unchanged. For every input that S4.1 accepts, zones, surfaces, boundary areas, multipliers, surface IDs, windows, loads, internal mass, and validation reports are unchanged. Snapshots:
linear-plan-canonical,stack-two-storeys,simplifier-SemanticMerge,simplifier-PerimeterCore, andsimplifier-SingleZonePerFloorstay byte-identical;aggregator-FloorAreaMultiplier.txtandaggregator-SingleZoneMerged.txtare renamed toaggregator-StackedFloorZoneMultiplier.txtandaggregator-SingleZoneBuilding.txtwith byte-identical content (the report holds no operation name);aggregator-SingleZonePerFloorType.txtis new. Provenance changes only in the operation namesStackedFloorZoneMultiplierandSingleZoneBuilding, and buildings whose storeys overhang now carry theUnsupportedStoreywarning. - No backward compatibility (D-064). Types, components, inputs, and outputs may be renamed or changed without migration; saved definitions may change meaning. Component GUIDs never change once merged, and a deleted component's GUID is never reused: Stacked Floor Zone Multiplier keeps
e4b44821-c9ed-49b9-9c4e-7c08355254dd, Single Zone per Floor Type keeps506d5037-de93-4aee-a6c4-47dcc97509e1, Single Zone Building is new with50965154-e31e-4730-b61c-15a6f40bee6f, Transform Plan is new with57c17b9c-04b4-4595-9150-bb923877d95e. - Floor multipliers stay whole numbers from 1 (D-072).
- Grasshopper components are thin adaptors (AGENTS.md): designation, transforms, unmodelled storeys, and exported boundaries are computed in
Lod.Core; the components and Goo only read inputs, call core code, convert geometry, and report diagnostics. - The PNG icons are generated by
scripts/make_icons.pyand never edited by hand. - 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. - Nothing is pushed in this run (D-075). The merge steps below end on local
main, the stage is taggedv0.4.2locally, and pushingmainand the tag waits for the owner. .ghexample definitions are made and saved by a person in Rhino 8; agents never write or check them (D-058). S4.2 adds none.- S4.2 changes these earlier code files:
Storeys.cs(S2, last changed in S4),Stack.cs(S2),DiagnosticCodes.cs(S1, last changed in S4.1:UnsupportedStoreyappended),FloorAreaMultiplier.csandSingleZoneMerged.cs(S4: renamed or replaced, then deleted),Zone.cs,Surfaces.cs,Polygon2.cs,PolygonOps.cs,LayoutSurfaceBuilder.cs(S2),FacadeAttribution.cs(S3),AggregatorTests.cs(S4),Pipeline.cs(S2, last changed in S4.1),GeometryTests.cs(S2),MappingTests.cs(S3), the snapshotsaggregator-FloorAreaMultiplier.txtandaggregator-SingleZoneMerged.txt(S4: renamed),AggregatorComponents.cs(S4),PipelineComponents.cs,BemGoo.cs,Convert2BemComponent.cs,RhinoGeometry.cs(S2, last changed in S4.1),scripts/make_icons.pyand two icons (S4.1: renamed).
Files¶
| Path | Change | Responsibility |
|---|---|---|
docs/decisions/ADR-013-vertical-aggregation-methods.md |
Create | The four methods, designation on the assembled stack, unsupported storeys, heat transfer as a conversion rule, moved and rotated plans (D-068 to D-076) |
docs/decisions/ADR-009-vertical-aggregation.md |
Modify (status line) | "superseded in part by ADR-013" |
src/Lod.Core/Buildings/Storeys.cs |
Modify (S2 file, last changed in S4) | AssembledStack; Storeys.Assemble (stack, designation, UnsupportedStorey); ResolveStacked per storey; ResolveRepresentative deleted |
src/Lod.Core/Common/DiagnosticCodes.cs |
Modify (S1 file, last changed in S4.1) | Append UnsupportedStorey (warning, D-074) |
src/Lod.Core/Buildings/Stack.cs |
Modify (S2 file) | Built from Storeys.Assemble; passes its warnings on |
src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs |
Create (rename of FloorAreaMultiplier.cs, S4) |
The zone-multiplier method on the assembled stack; UnmodelledStoreys for the preview |
src/Lod.Core/Buildings/SingleZoneMerge.cs |
Create | Internal merge shared by the single-zone methods: one zone per group of stacked storeys |
src/Lod.Core/Buildings/SingleZoneBuilding.cs |
Create (replaces SingleZoneMerged.cs, S4) |
The whole building as zone BUILDING |
src/Lod.Core/Buildings/SingleZonePerFloorType.cs |
Create | One zone E{i} per floor entry |
src/Lod.Core/Model/Zone.cs |
Modify (S2 file) | Doc comment names the single-zone methods; Zone.Transform |
src/Lod.Core/Geometry/PlanMotion.cs |
Create | Rotation about the origin and x-y translation; exact quarter turns |
src/Lod.Core/Geometry/Polygon2.cs |
Modify (S2 file) | Polygon2.Transform |
src/Lod.Core/Model/Surfaces.cs |
Modify (S2 file) | Abstract Surface.Transform; wall and horizontal surface overrides |
src/Lod.Core/Plans/PlanTransform.cs |
Create | PlanTransform.Apply with provenance TransformPlan and InvalidParameter for non-finite values |
src/Lod.Core/Simplification/FacadeAttribution.cs |
Modify (S3 file) | Same façade line: separation measured over the overlap (D-076) |
src/Lod.Core/Geometry/VertexGrid.cs |
Create | Distance grid and T-junction tests, extracted from LayoutSurfaceBuilder |
src/Lod.Core/Geometry/PolygonOps.cs |
Modify (S2 file) | UnionTiles (D-076) |
src/Lod.Core/Layout/LayoutSurfaceBuilder.cs |
Modify (S2 file) | Uses VertexGrid; behaviour unchanged |
src/Lod.Core/Conversion/HeatTransferOptions.cs |
Create | HeatTransferOptions, ExportedBoundary, Exported(surface), Describe() (D-069) |
tests/Lod.Integration.Tests/AggregatorTests.cs |
Modify (S4 file) | Stack designation, unsupported storeys, renames, single zone per floor type, unmodelled storeys |
tests/Lod.Integration.Tests/Snapshots/aggregator-StackedFloorZoneMultiplier.txt |
Rename (S4 file, content unchanged) | Golden snapshot of the zone-multiplier method |
tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneBuilding.txt |
Rename (S4 file, content unchanged) | Golden snapshot of the single-zone building |
tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.txt |
Create | Golden snapshot of one zone per floor entry |
tests/Lod.Integration.Tests/Pipeline.cs |
Modify (S2 file, last changed in S4.1) | Pipeline.Simplifier(name) and Pipeline.Aggregator(name) |
tests/Lod.Integration.Tests/PlanTransformTests.cs |
Create | Transform invariants, facing, provenance, invalid input, unsupported storeys of moved plans, the 4 × 4 × 4 rotated matrix, rotated setback |
tests/Lod.Core.Tests/Geometry/PlanMotionTests.cs |
Create | Rotation direction, exact quarter turns, polygon transform |
tests/Lod.Core.Tests/Simplification/MappingTests.cs |
Modify (S3 file) | Façade lines over the overlap |
tests/Lod.Core.Tests/Geometry/GeometryTests.cs |
Modify (S2 file) | Union of rotated tiles with T-junctions |
tests/Lod.Core.Tests/Conversion/HeatTransferOptionsTests.cs |
Create | Defaults, the four option combinations, the surface unchanged, Describe |
src/Lod.Grasshopper/Components/AggregatorComponents.cs |
Modify (S4 file) | Stacked Floor Zone Multiplier (ZMult), Single Zone per Floor Type (1ZType), Single Zone Building (1ZBldg); unmodelled parts for the preview |
src/Lod.Grasshopper/Components/TransformPlanComponent.cs |
Create | Transform Plan (TPlan) |
src/Lod.Grasshopper/Convert/PlanMotionDecomposition.cs |
Create | Rotation about z and x-y translation of a Rhino transform; what else it holds |
src/Lod.Grasshopper/Components/Convert2BemComponent.cs |
Modify (S2 file, last changed in S4.1) | Inputs EnableInternalWallHeatTransfer and EnableFloorHeatTransfer; Boundaries through HeatTransferOptions; OPTIONS: line |
src/Lod.Grasshopper/Components/PipelineComponents.cs |
Modify (S2 file, last changed in S4.1) | FloorAggregatorComponent.UnmodelledParts |
src/Lod.Grasshopper/Goo/BemGoo.cs |
Modify (S2 file, last changed in S4.1) | Grey unmodelled parts; merged volumes for multi-part zones |
src/Lod.Grasshopper/Convert/RhinoGeometry.cs |
Modify (S2 file, last changed in S4.1) | MergedZoneBreps, PartBreps |
src/Lod.Grasshopper/Convert/VerticalRuns.cs |
Create | Preview-only grouping of stacked parts of one outline |
scripts/make_icons.py |
Modify (S4.1 file) | Motifs for Transform Plan and the renamed and new aggregators |
src/Lod.Grasshopper/Icons/*.png |
Rename 2, create 2 (generated) | 28 icons |
docs/architecture/pipeline.md, docs/architecture/convert2bem.md, docs/architecture/validation.md |
Modify | Methods, plan transform, heat-transfer options, component table through S4.2 |
docs/development/grasshopper-smoke-test.md |
Modify | Append the S4.2 checklist |
Directory.Build.props |
Modify | Version 0.4.2 |
AGENTS.md, README.md |
Modify | "Project state" and "Status" after S4.2 |
docs/plans/2026-09-30-implementation-roadmap.md |
Modify | Progress line, pipeline block, repository layout (the S4.2 stage rows are committed with this plan) |
docs/decisions/decision-log.md |
Modify | S4.2 completion entry D-078 |
Test counts on net8.0 at the start of S4.2: 120 core, 19 generators, 63 integration. After Task 2: 120 / 19 / 70. After Tasks 3 and 4: 120 / 19 / 70. After Task 5: 120 / 19 / 81. After Task 6: 120 / 19 / 83. After Task 7: 126 / 19 / 98. After Task 8: 128 / 19 / 98. After Task 9: 129 / 19 / 162. After Task 10: 129 / 19 / 163. After Task 11 and at the end: 136 core, 19 generators, 163 integration (core: +6 PlanMotionTests, +2 MappingTests, +1 GeometryTests, +7 HeatTransferOptionsTests; integration: +7 and +11 and +2 AggregatorTests, +15, +64, and +1 PlanTransformTests). Grasshopper code has no unit tests; Tasks 12–16 are checked by a 0-warning build and the smoke test of Task 19.
Roadmap edits¶
The roadmap edits that describe S4.2 as a planned stage are committed together with this plan, before Task 1: the revision note v6 in the header, the S4.2 row of the stage table (depends on S4.1, tag v0.4.2) with S5 now depending on S4.2, the S4.2 branch in the stage diagram, the sentence on S4.2 below the diagram, and the S4.2 row of the detailed stage plans table. The close-out (Task 20) adds the Progress line and updates the pipeline block and the repository layout; the stage table has no status column, so the S4.2 row is not changed again.
Task assignment (D-048, execution handoff §6)¶
| Task | Agent | Why |
|---|---|---|
| 1 ADR-013 | bemgen-bounded |
Document fully given by the plan; accepted by the controller under D-075 |
| 2 Designation on the assembled stack, unsupported storeys | bemgen-complex |
Changes the resolution every aggregator uses; every building must stay numerically identical |
3 Rename to StackedFloorZoneMultiplier |
bemgen-complex |
Floor aggregator; renames a snapshot that must stay byte-identical |
4 SingleZoneBuilding on a shared merge |
bemgen-complex |
Floor aggregator refactored onto a new shared merge; snapshot byte-identical |
5 SingleZonePerFloorType |
bemgen-complex |
New floor aggregator, conservation, new snapshot |
| 6 Unmodelled storeys | bemgen-complex |
Floor aggregator grouping reused for the preview |
| 7 Plan transform | bemgen-complex |
Geometry; invariants under rotation |
| 8 Façade lines over the overlap | bemgen-complex |
Robustness fix of a validation predicate (D-076) |
| 9 Union of tiles | bemgen-complex |
Robustness fix of polygon geometry shared with the layout builder |
| 10 Supported area | bemgen-complex |
Robustness fix of the unsupported-storey rule |
| 11 Heat-transfer options | bemgen-bounded |
A model type fully given by the plan |
| 12 Aggregator components | bemgen-bounded |
Thin Grasshopper components |
| 13 Transform Plan | bemgen-complex |
Decomposes a Rhino transform with tolerances and model units |
| 14 Convert2BEM options | bemgen-bounded |
Thin component change |
| 15 Previews | bemgen-complex |
Preview geometry, Brep union with fallback, model units |
| 16 Icons | bemgen-complex |
Generated binaries, renamed resources, a visual check |
| 17 Architecture documents | bemgen-bounded |
Documents |
| 18 S4.2 smoke-test checklist | bemgen-bounded |
Documents |
| 19 Smoke test in Rhino 8 | Manual (person), or the controller headless (D-056) | Needs Rhino 8 |
| 20 Close-out | bemgen-bounded |
Bookkeeping |
The end-of-stage review is done by bemgen-complex. A bounded task escalates to bemgen-complex after two failures or any deviation from this plan's expected output.
Slice A — docs/decisions-adr-013¶
Task 1: ADR-013 Vertical aggregation methods¶
Files:
- Create: docs/decisions/ADR-013-vertical-aggregation-methods.md
- Modify: docs/decisions/ADR-009-vertical-aggregation.md (status line)
Interfaces:
- Consumes: decisions D-068 to D-076 and the rules of ADR-009 (D-031, D-032, D-034, D-038, D-039, D-041, D-045, D-046, D-047); S2–S4 types Storeys, FloorEntry, Stack, FloorAreaMultiplier, SingleZoneMerged, BuildingValidator, FacadeAttribution, LayoutSurfaceBuilder.
- Produces: the accepted rules that Tasks 2–16 implement: Storeys.Assemble and AssembledStack; UnsupportedStorey; the names StackedFloorZoneMultiplier, SingleZonePerFloorType (zones E{i}, "Floor type i"), SingleZoneBuilding (zone BUILDING); StackedFloorZoneMultiplier.UnmodelledStoreys; SingleZoneMerge; PlanTransform.Apply and the provenance operation TransformPlan; HeatTransferOptions.Exported; FacadeAttribution.Interval over the overlap; PolygonOps.UnionTiles.
- [x] Step 1: Create the branch
git switch -c docs/decisions-adr-013 main
- [x] Step 2: Write
docs/decisions/ADR-013-vertical-aggregation-methods.mdwith exactly this content
# ADR-013: Vertical aggregation methods
Status: Accepted (by the controller under D-075; provisional pending the owner's reading)
Date: 2026-10-01
Decisions: D-068, D-069, D-070, D-071, D-072, D-074, D-075; supersedes [ADR-009](ADR-009-vertical-aggregation.md) where it differs; applies D-031, D-034, D-038, D-039, D-041, D-045, D-046, D-047
## Context
[ADR-009](ADR-009-vertical-aggregation.md) (S4) fixed the geometry of the three floor aggregators of Checkpoint 1: `Stack`, `FloorAreaMultiplier`, and `SingleZoneMerged`. The review of S4.1 changed four things about vertical aggregation:
- **Where exposure is decided (D-068).** `FloorAreaMultiplier` resolved the floors and ceilings of its representative storeys against the representative storey below and above (`Storeys.ResolveRepresentative`), and decided which storeys to split off (D-046) by comparing neighbouring floor types. Whether a floor touches the ground, a ceiling is a roof, or a piece is exposed at a setback or overhang is a property of the whole building, not of the storeys a method chooses to model.
- **Which methods exist (D-071).** `FloorAreaMultiplier` is renamed `StackedFloorZoneMultiplier`. `SingleZoneMerged` (the whole building as one zone, D-021) becomes `SingleZoneBuilding`, and a new method `SingleZonePerFloorType` makes one zone per floor entry. In Grasshopper the *Single Zone Merged* component becomes *Single Zone per Floor Type* and keeps its GUID (D-064); *Single Zone Building* is a new component with a new GUID.
- **Heat transfer between zones (D-069).** Whether walls between zones and floors and ceilings between storeys exchange heat becomes a choice of the converter, not of the building model.
- **Moved and rotated plans (D-070, D-074).** *Transform Plan* moves and rotates a plan's geometry before it is simplified and stacked, so storeys can overhang the storey below without being a setback of the same footprint, and plans can be rotated by angles other than multiples of 90°.
Multipliers stay whole numbers (D-072). Validation keeps the rules of ADR-009: every building is compared with the fully stacked reference built by `Stack` from the same floor entries.
## Options considered
### Where floors and ceilings are designated (D-068)
1. **Per method, against the modelled neighbours (ADR-009).** Each method resolves the storeys it models against each other. A method that leaves storeys out (the zone multiplier) resolves against storeys that are not physically adjacent, and needs its own exposure test for the D-046 split.
2. **Once, on the assembled stack.** Every aggregator first assembles every storey of the building (each entry repeated by its multiplier, at its true elevation) and designates every floor and ceiling piece there: the lowest storey's floors face the ground, the uncovered parts of a floor or ceiling face outdoors, and the overlaps with the storey below or above lie between storeys. Each method then takes the designation of the storeys it models from that one resolution.
Option 1 gives the same numbers as option 2 for every building ADR-009 allows, because the representative storey below always has the floor of the storey physically below. Option 2 states the rule once, makes the D-046 split and the D-074 warning read the same designation, and does not depend on which storeys a method models.
### Pieces facing a represented but unmodelled storey (zone multiplier)
a. **Always adiabatic (ADR-009, D-075 default).** The modelled storey exchanges no heat with the storeys it stands for or with the storeys between representative storeys.
b. **Follow *EnableFloorHeatTransfer*.** Pair the piece with the representative zone of the storey it faces. That zone is not the physical neighbour, and it is multiplied differently, so the pairing has no physical counterpart.
### Zones of a single-zone method
i. **One zone for the building (`SingleZoneMerged`, D-021).**
ii. **One zone per floor entry (D-071).** Each entry (one floor type and the number of storeys it stands for) becomes one zone spanning those storeys; neighbouring entries stay separate zones.
iii. **One zone per floor type with area-based quantities multiplied by the number of storeys** (the "single-zone proxy" of the review). It is not a stack of real storeys: geometry, ground, and roof would no longer correspond to the building, and the quantities would be scaled outside the conserving aggregator. Rejected in D-071.
### A storey that the storey below does not carry (D-074)
- **Error.** Rejects every overhang, including the overhangs of plans that differ in length, which S4 accepts and tests.
- **Silent.** The uncovered part faces outdoors and nothing is said.
- **Warning.** The building is produced, the uncovered part faces outdoors as before, and the user is told which storey overhangs by how much.
### Heat transfer between zones (D-069)
- **In the building model.** Each aggregator would turn interzone surfaces into adiabatic ones on request, so the building and its validation would depend on a converter's choice.
- **As an export option.** The building keeps its interzone surfaces; each converter writes them as adiabatic or paired, by one shared rule.
### Multipliers
- **Whole numbers (D-072).** As since S2; the EnergyPlus `Zone` multiplier takes whole numbers only.
- **Fractional.** Rejected in D-072.
## Decision
### One designation on the assembled stack (D-068)
`Storeys.Assemble` stacks every entry `Multiplier` times, bottom to top, at the floor-to-floor heights, with the storey prefix `L{k}/` on zone and surface IDs (`k` the storey index in the fully stacked building, as since S2). It designates every horizontal piece as `Stack` did since S2 (`Storeys.ResolveStacked`): each zone's floor is split by overlap with the zones of the storey below and its ceiling by overlap with the zones of the storey above (`…/F{n}`, `…/C{n}`); overlapping pieces are `Interzone` with the zone on the other side; uncovered pieces face `Outdoors`; the floors of storey 0 face the `Ground`; the ceilings of the highest storey are roofs (`Outdoors`). Plans and floors keep their floors and ceilings `Unresolved` (since S2). Every aggregator starts from this assembly; the four methods differ only in what they do with it.
### Unsupported storeys (D-074)
A storey `k > 0` is unsupported when the part of its floor covered by storey `k − 1` (its floor area minus its floor pieces facing outdoors) differs from its floor area by more than the relative area tolerance (`ToleranceSettings.AreaEquals(floor area, covered area)`, as every area check compares). Every aggregator reports one warning `UnsupportedStorey` per such storey, naming the storey and the uncovered floor area in m², and still produces the building; the uncovered part faces outdoors. Storey 0 stands on the ground and is never unsupported. The overhang of a longer plan on a shorter one is an unsupported storey too. The comparison is relative to the storey's floor area, not with zero, because the floors of rotated plans can leave slivers of the order of 1e-5 m² uncovered where rounding on the distance grid moves a shared façade line by less than the distance tolerance; such a sliver is not an overhang. The D-046 split below keeps comparing the exposed area with zero, so that not even a sliver is multiplied.
### The four methods (D-071)
| Method | Zones and IDs | Multiplier | Floors and ceilings | Walls, windows, slabs |
| --- | --- | --- | --- | --- |
| `Stack` (*Stack Floors*) | every storey of the assembly, `L{k}/…` | 1 | as designated on the stack | as in the floors |
| `StackedFloorZoneMultiplier` (*Stacked Floor Zone Multiplier*) | one representative storey per group, `L{k}/…` (D-045) | `Zone.Multiplier = n`, the group's storey count | the representative storeys' pieces from the stack: ground, roof, and exposed pieces kept; every piece between two storeys `Adiabatic` | as in the floors |
| `SingleZonePerFloorType` (*Single Zone per Floor Type*) | one zone `E{i}` per floor entry `i` (from 0, bottom to top), name "Floor type i" | 1 | ground, roof, and exposed pieces kept; pieces between two entries `Interzone` between `E{i}` and `E{i+1}`; slabs within an entry become internal mass | rebuilt per storey outline, windows re-hosted, partitions discarded |
| `SingleZoneBuilding` (*Single Zone Building*) | one zone `BUILDING`, name "Building" | 1 | ground, roof, and exposed pieces kept; every slab between storeys becomes internal mass | rebuilt per storey outline, windows re-hosted, partitions discarded |
**`StackedFloorZoneMultiplier`** is `FloorAreaMultiplier` renamed; it models what it modelled before. Floor type `i` stands for its Nᵢ storeys of the assembly. A storey of a multiplied type is split off as its own × 1 storey (D-046, info `FloorTypeSplit`) when the type's bottom storey's floor, or its top storey's ceiling, has pieces on the stack that are not between storeys (ground, roof, or exposed) with an area that is not zero within `AreaEquals` (as in ADR-009); the remaining storeys form one group. A group of n storeys from storey `first` is represented by storey `first + ⌊n/2⌋` at its true elevation (D-045), with the IDs of that storey of the assembly. Its floor and ceiling pieces are the stacked pieces of that storey, with every piece between two storeys turned `Adiabatic` (option a), whether the storey on the other side is modelled or not, and whatever *EnableFloorHeatTransfer* says (D-075). Ground, roof, and exposed pieces are kept and always lie on × 1 storeys, so exposure is never multiplied. Provenance: operation `StackedFloorZoneMultiplier`, parameters `Multipliers` and `Storeys` (e.g. `L0x1,L5x8,L9x1`). The storeys that are represented but not modelled are given by `StackedFloorZoneMultiplier.UnmodelledStoreys` (the assembly's storeys outside the representative ones) for the viewport preview, which draws them transparent grey (D-071, D-075).
**`SingleZonePerFloorType`** and **`SingleZoneBuilding`** share one merge (`SingleZoneMerge`) over groups of consecutive storeys of the assembly: one group per floor entry, or one group of every storey. For each group:
- **Zone:** one zone with one `ZonePart` per storey outline (the union of the storey's zone footprints; a storey whose zones form several polygons gets one part per polygon). The union treats the footprints as tiles (`PolygonOps.UnionTiles`): vertices in one cell of the distance grid are unified and every vertex lying on another footprint's edge (a T-junction) is inserted into that edge before clipping, as `LayoutSurfaceBuilder` splits walls, so shared edges cancel exactly. A plain union of rotated footprints leaves slits or splits the outline where rounding on the clipping grid moves a T-junction off its edge. Its space type is the sources' shared space type, or `Mixed`. Its source zones are the stacked zones of the group, storey by storey.
- **Walls:** rebuilt from each outline (`LayoutSurfaceBuilder` with the outline as the only zone), all `Outdoors`, IDs `S{p}/<zone>/W{n}` with `p` the part index within the zone. The rebuilt walls must cover every outdoor wall of the assembly over its full length (D-041, `FacadeNotCovered`); every window is re-hosted unchanged on the rebuilt wall that contains it (D-039, `WindowNotHosted`); walls between merged zones are discarded (D-034).
- **Slabs:** for every elevation at which the group has interzone floor pieces between two of its own storeys, one `InternalMass` with the slab area (one face), `ExposedFaces = 2`, the elevation, and the replaced floor and ceiling pieces as source surfaces (D-031).
- **Floors and ceilings:** every other piece of the group's storeys keeps its stacked ID, polygon, elevation, and boundary and is assigned to the group's zone; a piece between two groups stays `Interzone`, with the other group's zone as its adjacent zone, so the pieces between `E{i}` and `E{i+1}` come in matching pairs.
- **Program:** `EquivalentPropertyAggregator` over the group's stacked zones with area fraction 1 and exterior-wall fraction 1; the target measures are the parts' floor area and volume and the rebuilt walls' area. Every invariant is conserved, and the zone is conditioned if any source is (D-038).
`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.
### Heat transfer is a conversion rule (D-069)
`HeatTransferOptions(EnableInternalWallHeatTransfer, EnableFloorHeatTransfer)`, both `false` by default, lives in `Lod.Core` so that *Convert2BEM* (S4.2) and *Convert2IDF* (S6) apply the same rule. `HeatTransferOptions.Exported(surface)` gives the boundary a converter writes: an `Interzone` wall becomes `Adiabatic` unless `EnableInternalWallHeatTransfer`; an `Interzone` floor or ceiling becomes `Adiabatic` unless `EnableFloorHeatTransfer`; every other surface keeps its boundary. The building is not changed, its validation does not depend on the options, and each converter records the values it used.
### Moved and rotated plans (D-070)
`PlanTransform.Apply` rotates a plan about the world origin by an angle about +z (counter-clockwise seen from above, degrees) and then translates it in x and y (metres). Footprint, zone parts, walls, and floors and ceilings move rigidly; windows keep their offsets, sills, and sizes, and zone and surface IDs stay. Wall facing follows the rotated geometry because it is computed from the wall's end points; the plan's orientation (true north) is unchanged. Provenance: operation `TransformPlan`, parameters `RotationDegrees`, `TranslationX`, `TranslationY`, the source plan's provenance as input. Non-finite values are `InvalidParameter`. Whole quarter turns use exact cosines and sines, so an axis-aligned plan stays axis-aligned. Every plan simplifier and floor aggregator works on transformed plans; storeys from differently transformed plans stack, and where a storey overhangs the storey below it is reported as unsupported (D-074).
Two rules make the downstream steps independent of the rotation; neither changes a tolerance:
- **Same façade line.** Two walls lie on the same façade line (`FacadeAttribution.Interval`, used for façade coverage, D-041, and window re-hosting, D-039) when they face the same way within `Angle`, share elevation and height within `Distance`, overlap, and are within `Distance` of each other over their overlap, measured at its two ends. Through S4.1 the end points of the second wall had to lie within `Distance` of the first wall's infinite line, which extrapolates a short wall's direction: a 4 m wall whose end carries 0.4 µm of grid rounding leaves a 24 m wall's line by 2.4 µm at the far end, although the two walls are 0.4 µm apart where they overlap. For axis-aligned plans both rules give the same result.
- **Storey outlines** of the single-zone methods are unions of tiles (above).
### Validation
Unchanged in principle (ADR-009): `BuildingValidator` validates every distinct source floor against its plan and compares the building with the fully stacked reference `Stack` of the same entries, counting zone multipliers. Enforced: floor area, volume, exterior wall area, glazing in total and per orientation, installed and hourly scheduled magnitude per load type, ground, roof, and exposed floor area, building height, and façade coverage per source floor. Reported only: conditioned floor area. Interzone pieces between `E{i}` and `E{i+1}` are neither ground, roof, nor exposed, like the stack's pieces between storeys. Internal mass is not compared.
## Consequences
- Every existing building is numerically unchanged: areas, boundary areas, multipliers, surface IDs, and validation reports. The snapshots of the renamed methods only change their file names (`aggregator-StackedFloorZoneMultiplier.txt`, `aggregator-SingleZoneBuilding.txt`); the report text holds no operation name.
- `Storeys.ResolveRepresentative` is deleted; `Storeys.Assemble` and `AssembledStack` replace the per-method resolution. `SingleZoneMerged` is renamed `SingleZoneBuilding` and keeps the zone ID `BUILDING`.
- One new warning code, `UnsupportedStorey`, appended to `DiagnosticCodes`. The overhang tests of S4 now expect it.
- Plans rotated by any angle and moved anywhere go through every plan simplifier and every floor aggregator and validate; the test suite checks all 16 combinations at four placements, a setback and an overhang of rotated plans, and storeys from differently moved plans.
- Heat exchange between storeys in `StackedFloorZoneMultiplier` stays removed, as in ADR-009. In `SingleZonePerFloorType` it is kept in the model between entries and written by the converters as *EnableFloorHeatTransfer* says; within an entry the slabs are internal mass.
- The thermal mass of partitions is still not represented at Z1–Z3 or in the single-zone methods (D-034); the slabs within a single-zone group are internal mass (D-031).
- Changing the designation rule, the pieces facing unmodelled storeys, the single-zone grouping, the unsupported-storey rule, or the heat-transfer rule needs a decision-log entry and an update of this ADR.
- [x] Step 3: Mark ADR-009 as superseded in part
In docs/decisions/ADR-009-vertical-aggregation.md make this replacement; the "Replace" text occurs exactly once in the file.
- Replace
Status: Accepted
with
Status: Accepted; superseded in part by [ADR-013](ADR-013-vertical-aggregation-methods.md)
- [x] Step 4: Commit
git add docs/decisions/ADR-013-vertical-aggregation-methods.md docs/decisions/ADR-009-vertical-aggregation.md
git commit -m "docs(decisions): add adr-013 on vertical aggregation methods"
- [x] Step 5: Gate — acceptance (controller under D-075)
D-073 requires ADR-013 to be accepted before any S4.2 code. In this run the controller accepts it under D-075, after checking it against D-068 to D-076 and ADR-009; its status line already says so ("Accepted (by the controller under D-075; provisional pending the owner's reading)"). The owner's reading stays open; requested changes are made in a later change, with code changes if the ADR's rules change. If the check finds a contradiction with a decision, stop and report it instead of merging. Slice B starts only after this branch is merged.
- [x] Step 6: Merge Slice A (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/decisions-adr-013
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/decisions-adr-013
git branch -d docs/decisions-adr-013
Expected: verify ends with VERIFY PASSED; its test lines show 120 core, 19 generators, and 63 integration tests passed on net8.0 (this slice changes no code). git pull --ff-only reports Already up to date. or fast-forwards; it fails only if origin/main has commits that local main does not, and then the controller stops and reports. Do not push (D-075).
Slice B — feature/aggregators-assembled-stack¶
Task 2: Designation on the assembled stack and unsupported storeys¶
Files:
- Modify: src/Lod.Core/Buildings/Storeys.cs (S2 file, last changed in S4), src/Lod.Core/Common/DiagnosticCodes.cs (S1 file, last changed in S4.1), src/Lod.Core/Buildings/Stack.cs (S2 file), src/Lod.Core/Buildings/FloorAreaMultiplier.cs (S4 file), src/Lod.Core/Buildings/SingleZoneMerged.cs (S4 file)
- Test: modify tests/Lod.Integration.Tests/AggregatorTests.cs (S4 file)
Interfaces:
- Consumes (S2–S4): Storeys.Validate(IReadOnlyList<FloorEntry>, ToleranceSettings) : IReadOnlyList<Diagnostic>; Storeys.Place(IEnumerable<(IFloor Floor, int ZoneMultiplier)>) : IReadOnlyList<PlacedStorey>; Storeys.PlaceAt(IFloor floor, int index, double elevation, int zoneMultiplier) : PlacedStorey; PlacedStorey(int Index, double Elevation, double Height, IReadOnlyList<Zone> Zones, IReadOnlyList<WallSurface> Walls); PolygonOps.Intersect, PolygonOps.Difference; HorizontalSurface(SurfaceId, ZoneId, HorizontalKind, Polygon2, double elevation, BoundaryCondition, ZoneId? adjacent); ToleranceSettings.AreaEquals(double expected, double actual); TextReport.F(double); Diagnostic.Warning(string code, string message, string? subject); BuildingValidator; test support Pipeline.DetailedFloor(...), Pipeline.Canonical, Pipeline.Tolerances.
- Produces:
- public sealed class AssembledStack with Storeys (every storey bottom to top, multiplier 1, prefix L{k}/), Entries (the floor entry of each storey), Horizontal (the floors and ceilings of each storey), and ExposedArea(int storey, HorizontalKind kind) : double (pieces that do not lie between two storeys).
- public static Result<AssembledStack> Storeys.Assemble(IReadOnlyList<FloorEntry> floors, ToleranceSettings tolerances): the errors of Validate, or the stack with one warning UnsupportedStorey per storey k > 0 whose floor pieces facing outdoors are not zero within AreaEquals (Task 10 refines the comparison), message The floor of storey {k} is not fully covered by storey {k - 1}: {TextReport.F(uncovered)} m² of it faces outdoors (D-074)., subject L{k}.
- public static IReadOnlyList<HorizontalSurface> Storeys.ResolveStacked(IReadOnlyList<PlacedStorey> storeys, int index, PolygonOps ops) (one storey; was all storeys); Storeys.ResolveRepresentative deleted.
- DiagnosticCodes.UnsupportedStorey = "UnsupportedStorey".
- Stack, FloorAreaMultiplier, and SingleZoneMerged build on Storeys.Assemble and pass its warnings on; FloorAreaMultiplier decides the D-046 split from ExposedArea on the stack and takes the floors and ceilings of its representative storeys from the stack, with every piece between two storeys turned Adiabatic (no adjacent zone).
- [x] Step 1: Create the branch
git switch -c feature/aggregators-assembled-stack main
- [x] Step 2: Write the failing tests — replace
tests/Lod.Integration.Tests/AggregatorTests.cs(full file)
Changes: VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem gains a fourth argument, the storeys expected to be reported as unsupported (none for the setback, L2 for the overhang); MultiplierTakesTheDesignationsOfItsStoreysFromTheStack checks that every floor and ceiling piece of the multiplier's building is the stacked piece with the same ID, with pieces between storeys adiabatic (D-068); AnOverhangingStoreyIsReportedAsUnsupported (three cases) expects one warning for a 24 m floor on two 14 m floors, 180 m² uncovered, and a building that validates; SetbacksAndIdenticalStoreysAreSupported (three cases) expects none for a setback. The new tests use the private helper Aggregator(name) that the matrix test already has.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void FloorAreaMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new FloorAreaMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new FloorAreaMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new FloorAreaMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new FloorAreaMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new FloorAreaMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("FloorAreaMultiplier")]
[InlineData("SingleZoneMerged")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("FloorAreaMultiplier")]
[InlineData("SingleZoneMerged")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new FloorAreaMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZoneMergedHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneMergedTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneMergedKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "FloorAreaMultiplier", "SingleZoneMerged" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Simplifier("PerimeterCore"));
IGeneratedBuilding building = new FloorAreaMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-FloorAreaMultiplier");
}
[Fact]
public void SingleZoneMergedSnapshot()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneMerged");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Pipeline.Tolerances),
"FloorAreaMultiplier" => new FloorAreaMultiplier(Pipeline.Tolerances),
"SingleZoneMerged" => new SingleZoneMerged(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
- [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected: the build fails because the code does not exist yet, each error reported once, for the net8.0 target:
tests\Lod.Integration.Tests\AggregatorTests.cs(83,108): error CS0117: 'DiagnosticCodes' does not contain a definition for 'UnsupportedStorey'
tests\Lod.Integration.Tests\AggregatorTests.cs(130,95): error CS0117: 'DiagnosticCodes' does not contain a definition for 'UnsupportedStorey'
tests\Lod.Integration.Tests\AggregatorTests.cs(151,82): error CS0117: 'DiagnosticCodes' does not contain a definition for 'UnsupportedStorey'
- [x] Step 4: Append the code — replace
src/Lod.Core/Common/DiagnosticCodes.cs(full file)
Only UnsupportedStorey is appended at the end.
namespace Lod.Core.Common;
/// <summary>
/// Stable diagnostic codes. Add new codes here; never reuse or rename a published code.
/// </summary>
public static class DiagnosticCodes
{
/// <summary>A required name is empty.</summary>
public const string EmptyName = "EmptyName";
/// <summary>A schedule does not have exactly <c>Schedule.HoursPerYear</c> values.</summary>
public const string ScheduleLength = "ScheduleLength";
/// <summary>A schedule value is not finite or outside the range allowed by its kind.</summary>
public const string ScheduleValue = "ScheduleValue";
/// <summary>A schedule has the wrong kind for its use (fraction vs temperature).</summary>
public const string ScheduleKindMismatch = "ScheduleKindMismatch";
/// <summary>A load value is negative or not finite.</summary>
public const string LoadValue = "LoadValue";
/// <summary>A load uses a basis that is not allowed for its type.</summary>
public const string LoadBasisNotAllowed = "LoadBasisNotAllowed";
/// <summary>A program contains the same load type in the same basis twice.</summary>
public const string DuplicateLoadType = "DuplicateLoadType";
/// <summary>A per-person load is defined without an occupancy load.</summary>
public const string PerPersonWithoutOccupancy = "PerPersonWithoutOccupancy";
/// <summary>A window-to-wall ratio is outside [0, 1).</summary>
public const string WindowToWallRatio = "WindowToWallRatio";
/// <summary>An aggregation received no source zones.</summary>
public const string NoSources = "NoSources";
/// <summary>A transfer fraction is outside [0, 1].</summary>
public const string FractionOutOfRange = "FractionOutOfRange";
/// <summary>A positive load cannot be expressed because the target's basis quantity is zero.</summary>
public const string ZeroBasisQuantity = "ZeroBasisQuantity";
/// <summary>An aggregated load is zero; its schedule is set to a constant zero.</summary>
public const string ZeroLoadSchedule = "ZeroLoadSchedule";
/// <summary>Setpoint weights of the conditioned sources sum to zero.</summary>
public const string ZeroSetpointWeight = "ZeroSetpointWeight";
/// <summary>Zone measures are negative or not finite.</summary>
public const string InvalidMeasures = "InvalidMeasures";
/// <summary>A polygon ring has fewer than three distinct points or no area.</summary>
public const string DegeneratePolygon = "DegeneratePolygon";
/// <summary>Two zones overlap.</summary>
public const string OverlappingZones = "OverlappingZones";
/// <summary>A zone edge is neither shared with another zone nor on the plan boundary.</summary>
public const string UnmatchedEdge = "UnmatchedEdge";
/// <summary>A generator, simplifier, or aggregator parameter is invalid.</summary>
public const string InvalidParameter = "InvalidParameter";
/// <summary>No program preset is given for a preset input, or for a space type the generator's layout places.</summary>
public const string MissingPreset = "MissingPreset";
/// <summary>Floors with different orientations cannot form one building.</summary>
public const string OrientationMismatch = "OrientationMismatch";
/// <summary>A floor aggregator received no floors.</summary>
public const string NoFloors = "NoFloors";
/// <summary>Zones grouped for merging do not form one connected polygon.</summary>
public const string DisconnectedGroup = "DisconnectedGroup";
/// <summary>The perimeter depth leaves no valid core.</summary>
public const string PlanTooNarrow = "PlanTooNarrow";
/// <summary>The input is valid but not supported by this implementation yet.</summary>
public const string NotSupported = "NotSupported";
/// <summary>Target outdoor walls do not cover a source outdoor wall over its full length (D-041).</summary>
public const string FacadeNotCovered = "FacadeNotCovered";
/// <summary>No single target wall contains a source window (D-039).</summary>
public const string WindowNotHosted = "WindowNotHosted";
/// <summary>Validation failed and conversion was blocked.</summary>
public const string ValidationFailed = "ValidationFailed";
/// <summary>Validation failed and conversion continued because an override was given.</summary>
public const string ValidationOverridden = "ValidationOverridden";
/// <summary>A multiplied floor type had exposed floors or ceilings; its exposed storeys were split off as x1 storeys (D-046).</summary>
public const string FloorTypeSplit = "FloorTypeSplit";
/// <summary>A preset given to a generator's preset input has a different space type than the input feeds (D-061).</summary>
public const string PresetSpaceTypeMismatch = "PresetSpaceTypeMismatch";
/// <summary>Part of a storey's floor is not covered by the storey below; the part faces outdoors (D-074).</summary>
public const string UnsupportedStorey = "UnsupportedStorey";
}
- [x] Step 5: Run the tests and see the four unsupported-storey checks fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected on net8.0 (25 tests from S4 plus 7 new; MultiplierTakesTheDesignationsOfItsStoreysFromTheStack and the three setback cases already pass, because designation on the stack gives the same pieces as before):
Failed! - Failed: 4, Passed: 28, Skipped: 0, Total: 32, Duration: … - Lod.Integration.Tests.dll (net8.0)
with the failing tests and their messages, in any order:
Failed Lod.Integration.Tests.AggregatorTests.AnOverhangingStoreyIsReportedAsUnsupported(aggregator: "Stack")
Assert.Single() Failure: The collection did not contain any matching items
Expected: (predicate expression)
Collection: []
Failed Lod.Integration.Tests.AggregatorTests.AnOverhangingStoreyIsReportedAsUnsupported(aggregator: "SingleZoneMerged")
Assert.Single() Failure: The collection did not contain any matching items
Expected: (predicate expression)
Collection: []
Failed Lod.Integration.Tests.AggregatorTests.AnOverhangingStoreyIsReportedAsUnsupported(aggregator: "FloorAreaMultiplier")
Assert.Single() Failure: The collection did not contain any matching items
Expected: (predicate expression)
Collection: [Info FloorTypeSplit: Floor type 0 (x2) has an exposed floor; split into x1 + x1 so exposure is not multiplied (D-046).]
Failed Lod.Integration.Tests.AggregatorTests.VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(name: "overhang", types: "narrow x2, wide x3", storeys: "L0x1,L1x1,L2x1,L3x1,L4x1", unsupported: "L2")
Assert.Equal() Failure: Strings differ
↓ (pos 0)
Expected: "L2"
Actual: ""
- [x] Step 6: Assemble and designate the stack — replace
src/Lod.Core/Buildings/Storeys.cs(full file)
AssembledStack and Storeys.Assemble are new; ResolveStacked resolves one storey of the physically stacked storeys; ResolveRepresentative and the overlap parameter of Split are deleted (every overlap is Interzone with the zone on the other side).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>A floor placed at its elevation with storey-prefixed zone and surface IDs.</summary>
/// <param name="Index">Index of the storey in the building, from 0 at the bottom.</param>
/// <param name="Elevation">Floor elevation, m.</param>
/// <param name="Height">Floor-to-floor height, m.</param>
/// <param name="Zones">Relocated zones.</param>
/// <param name="Walls">Relocated walls.</param>
public sealed record PlacedStorey(int Index, double Elevation, double Height, IReadOnlyList<Zone> Zones, IReadOnlyList<WallSurface> Walls);
/// <summary>
/// Every storey of a building with every floor and ceiling designated on the whole stack (D-068): the input of every floor aggregator.
/// </summary>
public sealed class AssembledStack
{
/// <summary>Creates the stack.</summary>
/// <param name="storeys">Every storey, bottom to top, with multiplier 1.</param>
/// <param name="entries">Index of the floor entry each storey belongs to.</param>
/// <param name="horizontal">Floors and ceilings of each storey.</param>
public AssembledStack(IReadOnlyList<PlacedStorey> storeys, IReadOnlyList<int> entries, IReadOnlyList<IReadOnlyList<HorizontalSurface>> horizontal)
{
Storeys = storeys;
Entries = entries;
Horizontal = horizontal;
}
/// <summary>Every storey, bottom to top; storey <c>k</c> has the ID prefix <c>L{k}/</c>.</summary>
public IReadOnlyList<PlacedStorey> Storeys { get; }
/// <summary>Index of the floor entry each storey belongs to.</summary>
public IReadOnlyList<int> Entries { get; }
/// <summary>Floors and ceilings of each storey: ground, roof (outdoors), exposed (outdoors), or interzone between two storeys.</summary>
public IReadOnlyList<IReadOnlyList<HorizontalSurface>> Horizontal { get; }
/// <summary>Area of a storey's floor or ceiling pieces that do not lie between two storeys (ground, roof, or exposed), m².</summary>
/// <param name="storey">Storey index.</param>
/// <param name="kind">Floor or ceiling.</param>
/// <returns>The area.</returns>
public double ExposedArea(int storey, HorizontalKind kind) =>
Horizontal[storey].Where(h => h.Kind == kind && h.Boundary != BoundaryCondition.Interzone).Sum(h => h.Area);
}
/// <summary>Shared steps of the floor aggregators: input validation, vertical placement, and assembling and designating the stack.</summary>
public static class Storeys
{
/// <summary>Checks that there is at least one floor, multipliers are positive, and all floors share one orientation.</summary>
/// <param name="floors">Floor entries.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>Errors, if any.</returns>
public static IReadOnlyList<Diagnostic> Validate(IReadOnlyList<FloorEntry> floors, ToleranceSettings tolerances)
{
var diagnostics = new List<Diagnostic>();
if (floors.Count == 0)
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.NoFloors, "A building needs at least one floor."));
return diagnostics;
}
foreach (FloorEntry entry in floors.Where(e => e.Multiplier < 1))
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"Multipliers start at 1, got {entry.Multiplier}."));
}
double orientation = floors[0].Floor.OrientationDegrees;
double toleranceDegrees = tolerances.Angle * 180.0 / Math.PI;
if (floors.Any(e => Math.Abs(e.Floor.OrientationDegrees - orientation) > toleranceDegrees))
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.OrientationMismatch, "All floors of a building must have the same orientation."));
}
return diagnostics;
}
/// <summary>Stacks floors bottom to top, one storey each, prefixing IDs with <c>L{index}/</c>.</summary>
/// <param name="storeys">Floors in vertical order with the zone multiplier to apply.</param>
/// <returns>The placed storeys.</returns>
public static IReadOnlyList<PlacedStorey> Place(IEnumerable<(IFloor Floor, int ZoneMultiplier)> storeys)
{
var placed = new List<PlacedStorey>();
double elevation = 0.0;
foreach ((IFloor floor, int multiplier) in storeys)
{
placed.Add(PlaceAt(floor, placed.Count, elevation, multiplier));
elevation += floor.FloorHeight;
}
return placed;
}
/// <summary>Places a floor as storey <paramref name="index"/> of the building, prefixing IDs with <c>L{index}/</c>.</summary>
/// <param name="floor">The floor.</param>
/// <param name="index">Storey index in the building, from 0 at the bottom.</param>
/// <param name="elevation">Elevation of the storey, m.</param>
/// <param name="zoneMultiplier">Zone multiplier to apply.</param>
/// <returns>The placed storey.</returns>
public static PlacedStorey PlaceAt(IFloor floor, int index, double elevation, int zoneMultiplier)
{
string prefix = $"L{index}/";
ZoneId Rename(ZoneId id) => new(prefix + id.Value);
Zone[] zones = floor.Zones.Select(z => z.Relocate(Rename(z.Id), elevation, zoneMultiplier)).ToArray();
WallSurface[] walls = floor.Surfaces.OfType<WallSurface>().Select(w => w.Relocate(Rename, prefix, elevation)).ToArray();
return new PlacedStorey(index, elevation, floor.FloorHeight, zones, walls);
}
/// <summary>
/// Assembles every storey of a building (D-068): each entry repeated by its multiplier, bottom to top, at its floor-to-floor height, with every
/// floor and ceiling designated on the whole stack. Reports a <see cref="DiagnosticCodes.UnsupportedStorey"/> warning for every storey whose
/// floor is not covered by the storey below (D-074).
/// </summary>
/// <param name="floors">Floor entries, bottom to top.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The assembled stack, or the errors of <see cref="Validate"/>.</returns>
public static Result<AssembledStack> Assemble(IReadOnlyList<FloorEntry> floors, ToleranceSettings tolerances)
{
IReadOnlyList<Diagnostic> errors = Validate(floors, tolerances);
if (errors.Count > 0)
{
return Result.Failure<AssembledStack>(errors);
}
int[] entries = floors.SelectMany((e, i) => Enumerable.Repeat(i, e.Multiplier)).ToArray();
IReadOnlyList<PlacedStorey> storeys = Place(entries.Select(i => (floors[i].Floor, 1)));
var ops = new PolygonOps(tolerances);
IReadOnlyList<HorizontalSurface>[] horizontal = Enumerable.Range(0, storeys.Count).Select(k => ResolveStacked(storeys, k, ops)).ToArray();
var stack = new AssembledStack(storeys, entries, horizontal);
var warnings = new List<Diagnostic>();
for (int k = 1; k < storeys.Count; k++)
{
double uncovered = stack.ExposedArea(k, HorizontalKind.Floor);
if (!tolerances.AreaEquals(0.0, uncovered))
{
warnings.Add(Diagnostic.Warning(
DiagnosticCodes.UnsupportedStorey,
$"The floor of storey {k} is not fully covered by storey {k - 1}: {TextReport.F(uncovered)} m² of it faces outdoors (D-074).",
$"L{k}"));
}
}
return Result.Success(stack, warnings);
}
/// <summary>
/// Floors and ceilings of storey <paramref name="index"/> of physically stacked storeys: split by overlap with the zones below and above into
/// interzone pieces; uncovered parts face outdoors; the lowest floors face the ground and the highest ceilings are roofs.
/// </summary>
/// <param name="storeys">Placed storeys, bottom to top.</param>
/// <param name="index">Index of the storey to resolve.</param>
/// <param name="ops">Polygon operations.</param>
/// <returns>The storey's horizontal surfaces, zone by zone, floor pieces before ceiling pieces.</returns>
public static IReadOnlyList<HorizontalSurface> ResolveStacked(IReadOnlyList<PlacedStorey> storeys, int index, PolygonOps ops)
{
var surfaces = new List<HorizontalSurface>();
PlacedStorey storey = storeys[index];
PlacedStorey? below = index > 0 ? storeys[index - 1] : null;
PlacedStorey? above = index < storeys.Count - 1 ? storeys[index + 1] : null;
foreach (Zone zone in storey.Zones)
{
Polygon2 footprint = zone.Parts[0].Footprint;
surfaces.AddRange(Split(zone.Id, HorizontalKind.Floor, footprint, storey.Elevation, below, BoundaryCondition.Ground, ops));
surfaces.AddRange(Split(zone.Id, HorizontalKind.Ceiling, footprint, storey.Elevation + storey.Height, above, BoundaryCondition.Outdoors, ops));
}
return surfaces;
}
private static IEnumerable<HorizontalSurface> Split(
ZoneId zone,
HorizontalKind kind,
Polygon2 footprint,
double elevation,
PlacedStorey? neighbour,
BoundaryCondition exposed,
PolygonOps ops)
{
string tag = kind == HorizontalKind.Floor ? "F" : "C";
if (neighbour is null)
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}1"), zone, kind, footprint, elevation, exposed, null);
yield break;
}
int number = 1;
foreach (Zone other in neighbour.Zones)
{
foreach (Polygon2 piece in ops.Intersect(footprint, other.Parts[0].Footprint))
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}{number++}"), zone, kind, piece, elevation, BoundaryCondition.Interzone, other.Id);
}
}
foreach (Polygon2 piece in ops.Difference(footprint, neighbour.Zones.Select(z => z.Parts[0].Footprint)))
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}{number++}"), zone, kind, piece, elevation, BoundaryCondition.Outdoors, null);
}
}
}
- [x] Step 7: Build
Stackon the stack — replacesrc/Lod.Core/Buildings/Stack.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>Turns floors into a building (D-018).</summary>
public interface IFloorAggregator
{
/// <summary>Aggregates floors, listed bottom to top, into a building.</summary>
/// <param name="floors">Floor entries; the multiplier is the number of storeys an entry stands for.</param>
/// <returns>The building, or errors.</returns>
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors);
}
/// <summary>
/// All storeys explicit: each entry is repeated <see cref="FloorEntry.Multiplier"/> times and stacked at its floor-to-floor height.
/// Inter-storey floors and ceilings become interzone surfaces; the bottom faces the ground and the top is the roof.
/// </summary>
public sealed class Stack : IFloorAggregator
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public Stack(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, _tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
IEnumerable<Surface> surfaces = stack.Storeys.SelectMany(s => s.Walls).Cast<Surface>().Concat(stack.Horizontal.SelectMany(h => h));
var provenance = Provenance.Of(
nameof(Stack),
new[] { new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))) },
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(
floors[0].Floor.OrientationDegrees,
stack.Storeys.SelectMany(s => s.Zones),
surfaces,
Enumerable.Empty<InternalMass>(),
floors,
provenance);
return Result.Success(building, assembled.Diagnostics);
}
}
- [x] Step 8: Take the multiplier's designations from the stack — replace
src/Lod.Core/Buildings/FloorAreaMultiplier.cs(full file)
The D-046 split reads ExposedArea of the type's bottom storey's floor and top storey's ceiling on the stack (the neighbour-type test IsExposed is deleted); storeys are placed at the stack's elevations (StoreyElevations is deleted); the floors and ceilings of the representative storeys are the stack's pieces of those storeys, with Interzone pieces turned Adiabatic without adjacent zone.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>
/// Representative storeys with zone multipliers (D-032). Floor types are listed bottom to top; type <c>i</c> stands for <c>Nᵢ</c> storeys.
/// <list type="bullet">
/// <item>Exposure is never multiplied (D-046): a storey of a multiplied type whose floor or ceiling is exposed
/// on the assembled stack (ground, roof, setback, overhang) is split off as its own ×1 storey, reported with an info diagnostic.</item>
/// <item>The remaining storeys of a type become one storey with their count as zone multiplier, placed at the whole storey nearest the middle
/// of the storeys it stands for (D-045). Every storey sits at its true elevation and the building keeps its true height.</item>
/// <item>Floors and ceilings are designated on the assembled stack (D-068): each representative storey keeps its ground, roof, and exposed pieces,
/// and every piece between two storeys is adiabatic, whether the storey on the other side is modelled or not (D-075).</item>
/// </list>
/// </summary>
public sealed class FloorAreaMultiplier : IFloorAggregator
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public FloorAreaMultiplier(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, _tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
var diagnostics = new List<Diagnostic>(assembled.Diagnostics);
var groups = new List<(IFloor Floor, int First, int Count)>();
int next = 0;
for (int i = 0; i < floors.Count; i++)
{
FloorEntry entry = floors[i];
bool floorExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next, HorizontalKind.Floor));
bool ceilingExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next + entry.Multiplier - 1, HorizontalKind.Ceiling));
int bottom = floorExposed && entry.Multiplier > 1 ? 1 : 0;
int top = ceilingExposed && entry.Multiplier - bottom > 1 ? 1 : 0;
int middle = entry.Multiplier - bottom - top;
if (bottom + top > 0)
{
string parts = string.Join(" + ", new[] { bottom, middle, top }.Where(n => n > 0).Select(n => $"x{n}"));
diagnostics.Add(Diagnostic.Info(
DiagnosticCodes.FloorTypeSplit,
$"Floor type {i} (x{entry.Multiplier}) has an exposed {(bottom > 0 ? "floor" : "ceiling")}{(bottom > 0 && top > 0 ? " and ceiling" : string.Empty)}; split into {parts} so exposure is not multiplied (D-046)."));
}
if (bottom > 0)
{
groups.Add((entry.Floor, next, 1));
}
groups.Add((entry.Floor, next + bottom, middle));
if (top > 0)
{
groups.Add((entry.Floor, next + bottom + middle, 1));
}
next += entry.Multiplier;
}
int[] representative = groups.Select(g => g.First + (g.Count / 2)).ToArray();
PlacedStorey[] storeys = groups
.Select((g, k) => Storeys.PlaceAt(g.Floor, representative[k], stack.Storeys[representative[k]].Elevation, g.Count))
.ToArray();
IEnumerable<HorizontalSurface> horizontal = representative
.SelectMany(k => stack.Horizontal[k])
.Select(h => h.Boundary == BoundaryCondition.Interzone
? new HorizontalSurface(h.Id, h.Zone, h.Kind, h.Polygon, h.Elevation, BoundaryCondition.Adiabatic, null)
: h);
IEnumerable<Surface> surfaces = storeys.SelectMany(s => s.Walls).Cast<Surface>().Concat(horizontal);
var provenance = Provenance.Of(
nameof(FloorAreaMultiplier),
new[]
{
new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))),
new KeyValuePair<string, string>("Storeys", string.Join(",", groups.Select((g, k) => $"L{representative[k]}x{g.Count}"))),
},
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(
floors[0].Floor.OrientationDegrees,
storeys.SelectMany(s => s.Zones),
surfaces,
Enumerable.Empty<InternalMass>(),
floors,
provenance);
return Result.Success(building, diagnostics);
}
}
- [x] Step 9: Pass the warnings on in
SingleZoneMerged— replacesrc/Lod.Core/Buildings/SingleZoneMerged.cs(full file)
Only the diagnostics list changes: it starts with the stacked building's diagnostics, so the UnsupportedStorey warning reaches the merged building. Task 4 replaces this class.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Aggregation;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
namespace Lod.Core.Buildings;
/// <summary>
/// The whole building as one zone (D-021), built from the fully stacked building: one zone part per storey; outdoor walls rebuilt per storey,
/// required to cover every source façade (D-041), keeping every source window in place (D-039); walls between merged zones discarded (D-034); slabs between storeys turned
/// into internal mass with both faces exposed, one entry per slab level (D-031, ADR-009); ground, roof, and exposed floors kept.
/// </summary>
public sealed class SingleZoneMerged : IFloorAggregator
{
/// <summary>ID of the single zone.</summary>
public static readonly ZoneId BuildingZone = new("BUILDING");
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public SingleZoneMerged(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors)
{
Result<IGeneratedBuilding> stacked = new Stack(_tolerances).Aggregate(floors);
if (!stacked.IsSuccess)
{
return stacked;
}
IGeneratedBuilding source = stacked.Value;
var ops = new PolygonOps(_tolerances);
var builder = new LayoutSurfaceBuilder(_tolerances);
var parts = new List<ZonePart>();
var walls = new List<Surface>();
foreach (IGrouping<double, Zone> storey in source.Zones.GroupBy(z => z.Parts[0].Elevation).OrderBy(g => g.Key))
{
double height = storey.First().Parts[0].Height;
IReadOnlyList<Polygon2> outlines = ops.Union(storey.Select(z => z.Parts[0].Footprint));
for (int k = 0; k < outlines.Count; k++)
{
parts.Add(new ZonePart(outlines[k], storey.Key, height));
Result<IReadOnlyList<Surface>> built = builder.Build(outlines[k], new[] { new LayoutZone(BuildingZone, outlines[k]) }, height);
if (!built.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(built.Diagnostics);
}
string prefix = $"S{parts.Count - 1}/";
walls.AddRange(built.Value.OfType<WallSurface>().Select(w => w.Relocate(id => id, prefix, storey.Key)));
}
}
FacadeAttribution facade = FacadeAttribution.Compute(source.Surfaces, walls, _tolerances);
Diagnostic[] gaps = facade.CoverageGaps()
.Select(g => Diagnostic.Error(DiagnosticCodes.FacadeNotCovered, $"Rebuilt façades cover {g.CoveredLength:0.######} m of this {g.Wall.Length:0.######} m wall (D-041).", g.Wall.Id.Value))
.ToArray();
if (gaps.Length > 0)
{
return Result.Failure<IGeneratedBuilding>(gaps);
}
Result<IReadOnlyList<Surface>> rehosted = WindowRehosting.Rehost(source.Surfaces, walls, _tolerances);
if (!rehosted.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(rehosted.Diagnostics);
}
IReadOnlyList<Surface> glazedWalls = rehosted.Value;
var diagnostics = new List<Diagnostic>(stacked.Diagnostics);
HorizontalSurface[] horizontal = source.Surfaces.OfType<HorizontalSurface>().ToArray();
InternalMass[] masses = horizontal
.Where(h => h.Kind == HorizontalKind.Floor && h.Boundary == BoundaryCondition.Interzone)
.GroupBy(h => h.Elevation)
.OrderBy(g => g.Key)
.Select(level => new InternalMass(
BuildingZone,
level.Sum(h => h.Area),
ExposedFaces: 2,
level.Key,
level.Select(h => h.Id).Concat(horizontal.Where(c => c.Kind == HorizontalKind.Ceiling && c.Boundary == BoundaryCondition.Interzone && c.Elevation == level.Key).Select(c => c.Id)).ToArray()))
.ToArray();
IEnumerable<Surface> envelope = horizontal
.Where(h => h.Boundary != BoundaryCondition.Interzone)
.Select(h => new HorizontalSurface(h.Id, BuildingZone, h.Kind, h.Polygon, h.Elevation, h.Boundary, null));
var measures = new ZoneMeasures(parts.Sum(p => p.Footprint.Area), parts.Sum(p => p.Volume), glazedWalls.Sum(w => w.Area));
SourceZoneContribution[] contributions = source.Zones
.Select(z => new SourceZoneContribution(z.Id, z.Program, LayoutMeasures.Of(z, source.Surfaces), 1.0, 1.0))
.ToArray();
Result<ZoneProgram> program = new EquivalentPropertyAggregator(_tolerances).Aggregate(BuildingZone, measures, contributions);
diagnostics.AddRange(program.Diagnostics);
if (!program.IsSuccess || diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
{
return Result.Failure<IGeneratedBuilding>(diagnostics);
}
SpaceType[] types = source.Zones.Select(z => z.SpaceType).Distinct().ToArray();
var zone = new Zone(BuildingZone, "Building", types.Length == 1 ? types[0] : SpaceType.Mixed, parts, program.Value, source.Zones.Select(z => z.Id));
var provenance = Provenance.Of(
nameof(SingleZoneMerged),
new[] { new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))) },
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(source.OrientationDegrees, new[] { zone }, glazedWalls.Concat(envelope), masses, floors, provenance);
return Result.Success(building, diagnostics);
}
}
- [x] Step 10: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected:
Passed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32, Duration: … - Lod.Integration.Tests.dll (net8.0)
Then the solution:
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short
Expected: Build succeeded., 0 Warning(s), 0 Error(s); on net8.0 (the order of the lines may differ):
Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 70, Skipped: 0, Total: 70, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short lists exactly the six files of this task, and no *.received.txt: every snapshot is unchanged.
- [x] Step 11: Commit
git add src/Lod.Core/Buildings/Storeys.cs src/Lod.Core/Common/DiagnosticCodes.cs src/Lod.Core/Buildings/Stack.cs src/Lod.Core/Buildings/FloorAreaMultiplier.cs src/Lod.Core/Buildings/SingleZoneMerged.cs tests/Lod.Integration.Tests/AggregatorTests.cs
git commit -m "feature(aggregators): designate floors and ceilings on the assembled stack and warn about unsupported storeys"
Task 3: Rename the floor area multiplier to StackedFloorZoneMultiplier¶
Files:
- Rename and modify: src/Lod.Core/Buildings/FloorAreaMultiplier.cs (Task 2) → src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs
- Rename: tests/Lod.Integration.Tests/Snapshots/aggregator-FloorAreaMultiplier.txt (S4 file) → tests/Lod.Integration.Tests/Snapshots/aggregator-StackedFloorZoneMultiplier.txt (content unchanged)
- Modify: tests/Lod.Integration.Tests/AggregatorTests.cs (Task 2), src/Lod.Grasshopper/Components/AggregatorComponents.cs (S4 file), so that the solution still builds
Interfaces:
- Consumes: FloorAreaMultiplier (Task 2).
- Produces: public sealed class StackedFloorZoneMultiplier : IFloorAggregator (D-071), the same behaviour; provenance operation StackedFloorZoneMultiplier. The Floor Area Multiplier component (renamed in Task 12) creates a StackedFloorZoneMultiplier.
- [x] Step 1: Rename the files
git mv src/Lod.Core/Buildings/FloorAreaMultiplier.cs src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs
git mv tests/Lod.Integration.Tests/Snapshots/aggregator-FloorAreaMultiplier.txt tests/Lod.Integration.Tests/Snapshots/aggregator-StackedFloorZoneMultiplier.txt
- [x] Step 2: Rename the class — replace
src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs(full file)
Only the class name, its constructor, the summary's first sentence, and the provenance operation (nameof) change.
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>
/// Stacked floor zone multiplier (D-071): representative storeys with zone multipliers (D-032). Floor types are listed bottom to top; type <c>i</c> stands for <c>Nᵢ</c> storeys.
/// <list type="bullet">
/// <item>Exposure is never multiplied (D-046): a storey of a multiplied type whose floor or ceiling is exposed
/// on the assembled stack (ground, roof, setback, overhang) is split off as its own ×1 storey, reported with an info diagnostic.</item>
/// <item>The remaining storeys of a type become one storey with their count as zone multiplier, placed at the whole storey nearest the middle
/// of the storeys it stands for (D-045). Every storey sits at its true elevation and the building keeps its true height.</item>
/// <item>Floors and ceilings are designated on the assembled stack (D-068): each representative storey keeps its ground, roof, and exposed pieces,
/// and every piece between two storeys is adiabatic, whether the storey on the other side is modelled or not (D-075).</item>
/// </list>
/// </summary>
public sealed class StackedFloorZoneMultiplier : IFloorAggregator
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public StackedFloorZoneMultiplier(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, _tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
var diagnostics = new List<Diagnostic>(assembled.Diagnostics);
var groups = new List<(IFloor Floor, int First, int Count)>();
int next = 0;
for (int i = 0; i < floors.Count; i++)
{
FloorEntry entry = floors[i];
bool floorExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next, HorizontalKind.Floor));
bool ceilingExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next + entry.Multiplier - 1, HorizontalKind.Ceiling));
int bottom = floorExposed && entry.Multiplier > 1 ? 1 : 0;
int top = ceilingExposed && entry.Multiplier - bottom > 1 ? 1 : 0;
int middle = entry.Multiplier - bottom - top;
if (bottom + top > 0)
{
string parts = string.Join(" + ", new[] { bottom, middle, top }.Where(n => n > 0).Select(n => $"x{n}"));
diagnostics.Add(Diagnostic.Info(
DiagnosticCodes.FloorTypeSplit,
$"Floor type {i} (x{entry.Multiplier}) has an exposed {(bottom > 0 ? "floor" : "ceiling")}{(bottom > 0 && top > 0 ? " and ceiling" : string.Empty)}; split into {parts} so exposure is not multiplied (D-046)."));
}
if (bottom > 0)
{
groups.Add((entry.Floor, next, 1));
}
groups.Add((entry.Floor, next + bottom, middle));
if (top > 0)
{
groups.Add((entry.Floor, next + bottom + middle, 1));
}
next += entry.Multiplier;
}
int[] representative = groups.Select(g => g.First + (g.Count / 2)).ToArray();
PlacedStorey[] storeys = groups
.Select((g, k) => Storeys.PlaceAt(g.Floor, representative[k], stack.Storeys[representative[k]].Elevation, g.Count))
.ToArray();
IEnumerable<HorizontalSurface> horizontal = representative
.SelectMany(k => stack.Horizontal[k])
.Select(h => h.Boundary == BoundaryCondition.Interzone
? new HorizontalSurface(h.Id, h.Zone, h.Kind, h.Polygon, h.Elevation, BoundaryCondition.Adiabatic, null)
: h);
IEnumerable<Surface> surfaces = storeys.SelectMany(s => s.Walls).Cast<Surface>().Concat(horizontal);
var provenance = Provenance.Of(
nameof(StackedFloorZoneMultiplier),
new[]
{
new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))),
new KeyValuePair<string, string>("Storeys", string.Join(",", groups.Select((g, k) => $"L{representative[k]}x{g.Count}"))),
},
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(
floors[0].Floor.OrientationDegrees,
storeys.SelectMany(s => s.Zones),
surfaces,
Enumerable.Empty<InternalMass>(),
floors,
provenance);
return Result.Success(building, diagnostics);
}
}
- [x] Step 3: Rename in the tests — replace
tests/Lod.Integration.Tests/AggregatorTests.cs(full file)
Every FloorAreaMultiplier becomes StackedFloorZoneMultiplier: the constructor calls, the theory data, the matrix, the Aggregator(name) helper, and the snapshot name aggregator-StackedFloorZoneMultiplier; the test FloorAreaMultiplierCarriesMultipliersAndOrderedBoundaries becomes ZoneMultiplierCarriesMultipliersAndOrderedBoundaries.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZoneMerged")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZoneMerged")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZoneMergedHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneMergedTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneMergedKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZoneMerged" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Simplifier("PerimeterCore"));
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
}
[Fact]
public void SingleZoneMergedSnapshot()
{
IGeneratedBuilding building = new SingleZoneMerged(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneMerged");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Pipeline.Tolerances),
"StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(Pipeline.Tolerances),
"SingleZoneMerged" => new SingleZoneMerged(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
- [x] Step 4: Keep the plugin building — replace
src/Lod.Grasshopper/Components/AggregatorComponents.cs(full file)
Only CreateAggregator() of the Floor Area Multiplier component changes; Task 12 renames the components.
using System;
using Lod.Core.Buildings;
using Lod.Core.Common;
namespace Lod.Grasshopper.Components;
public sealed class FloorAreaMultiplierComponent : FloorAggregatorComponent
{
public FloorAreaMultiplierComponent()
: base(
"Floor Area Multiplier",
"Mult",
"Representative storeys with zone multipliers (D-032). Each floor type stands for N storeys; its storeys with exposed floors or ceilings (ground, roof, setbacks) are split off as x1 storeys so exposure is never multiplied (D-046). Storeys keep their true elevations (D-045); overlap between storeys is adiabatic.")
{
}
public override Guid ComponentGuid => new("e4b44821-c9ed-49b9-9c4e-7c08355254dd");
protected override string MultiplierDescription => "How many storeys each floor type stands for. Default 1.";
protected override IFloorAggregator CreateAggregator() => new StackedFloorZoneMultiplier(ToleranceSettings.Default);
}
public sealed class SingleZoneMergedComponent : FloorAggregatorComponent
{
public SingleZoneMergedComponent()
: base(
"Single Zone Merged",
"1Zone",
"The whole building as one zone (D-021): slabs become internal mass (D-031), walls between merged zones are discarded (D-034).")
{
}
public override Guid ComponentGuid => new("506d5037-de93-4aee-a6c4-47dcc97509e1");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZoneMerged(ToleranceSettings.Default);
}
- [x] Step 5: Check the renamed snapshot
tests/Lod.Integration.Tests/Snapshots/aggregator-StackedFloorZoneMultiplier.txt keeps the S4 content byte for byte:
building orientation=0.000000 sources=1,2,1
zone L0/P-North Mixed "Perimeter North" area=88.795100 volume=266.385300 parts=1 multiplier=1 sources=[ST,UN1,UN2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L0/P-East Mixed "Perimeter East" area=61.375100 volume=184.125300 parts=1 multiplier=1 sources=[CO,US2,UN2]
load Occupancy PerFloorArea value=0.025532 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=3541.009233
load ElectricEquipment PerFloorArea value=4.255398 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L0/P-South Mixed "Perimeter South" area=88.795100 volume=266.385300 parts=1 multiplier=1 sources=[ST,US1,US2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L0/P-West Mixed "Perimeter West" area=61.375100 volume=184.125300 parts=1 multiplier=1 sources=[ST,CO,US1,UN1]
load Occupancy PerFloorArea value=0.002070 annual=6391.400000
load Lighting PerFloorArea value=3.175156 annual=8093.664821
load ElectricEquipment PerFloorArea value=0.345018 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L0/CORE Mixed "Core" area=131.659600 volume=394.978800 parts=1 multiplier=1 sources=[CO,US1,US2,UN1,UN2]
load Occupancy PerFloorArea value=0.023228 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=4012.043792
load ElectricEquipment PerFloorArea value=3.871332 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L2/P-North Mixed "Perimeter North" area=88.795100 volume=266.385300 parts=1 multiplier=2 sources=[ST,UN1,UN2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L2/P-East Mixed "Perimeter East" area=61.375100 volume=184.125300 parts=1 multiplier=2 sources=[CO,US2,UN2]
load Occupancy PerFloorArea value=0.025532 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=3541.009233
load ElectricEquipment PerFloorArea value=4.255398 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L2/P-South Mixed "Perimeter South" area=88.795100 volume=266.385300 parts=1 multiplier=2 sources=[ST,US1,US2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L2/P-West Mixed "Perimeter West" area=61.375100 volume=184.125300 parts=1 multiplier=2 sources=[ST,CO,US1,UN1]
load Occupancy PerFloorArea value=0.002070 annual=6391.400000
load Lighting PerFloorArea value=3.175156 annual=8093.664821
load ElectricEquipment PerFloorArea value=0.345018 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L2/CORE Mixed "Core" area=131.659600 volume=394.978800 parts=1 multiplier=2 sources=[CO,US1,US2,UN1,UN2]
load Occupancy PerFloorArea value=0.023228 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=4012.043792
load ElectricEquipment PerFloorArea value=3.871332 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L3/P-North Mixed "Perimeter North" area=88.795100 volume=266.385300 parts=1 multiplier=1 sources=[ST,UN1,UN2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L3/P-East Mixed "Perimeter East" area=61.375100 volume=184.125300 parts=1 multiplier=1 sources=[CO,US2,UN2]
load Occupancy PerFloorArea value=0.025532 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=3541.009233
load ElectricEquipment PerFloorArea value=4.255398 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L3/P-South Mixed "Perimeter South" area=88.795100 volume=266.385300 parts=1 multiplier=1 sources=[ST,US1,US2]
load Occupancy PerFloorArea value=0.027297 annual=6391.400000
load Lighting PerFloorArea value=4.819810 annual=2971.681367
load ElectricEquipment PerFloorArea value=4.549525 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L3/P-West Mixed "Perimeter West" area=61.375100 volume=184.125300 parts=1 multiplier=1 sources=[ST,CO,US1,UN1]
load Occupancy PerFloorArea value=0.002070 annual=6391.400000
load Lighting PerFloorArea value=3.175156 annual=8093.664821
load ElectricEquipment PerFloorArea value=0.345018 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone L3/CORE Mixed "Core" area=131.659600 volume=394.978800 parts=1 multiplier=1 sources=[CO,US1,US2,UN1,UN2]
load Occupancy PerFloorArea value=0.023228 annual=6391.400000
load Lighting PerFloorArea value=5.000000 annual=4012.043792
load ElectricEquipment PerFloorArea value=3.871332 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
surface L0/P-North/W1 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=0.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface L0/P-North/W2 Interzone adjacent=L0/P-West area=19.388868 wall (0.000000,18.000000)-(4.570000,13.430000) z=0.000000 facing=West glazing=0.000000
surface L0/P-North/W3 Interzone adjacent=L0/CORE area=44.580000 wall (4.570000,13.430000)-(19.430000,13.430000) z=0.000000 facing=South glazing=0.000000
surface L0/P-North/W4 Interzone adjacent=L0/P-East area=19.388868 wall (19.430000,13.430000)-(24.000000,18.000000) z=0.000000 facing=South glazing=0.000000
surface L0/P-East/W1 Interzone adjacent=L0/P-North area=19.388868 wall (24.000000,18.000000)-(19.430000,13.430000) z=0.000000 facing=North glazing=0.000000
surface L0/P-East/W2 Interzone adjacent=L0/CORE area=26.580000 wall (19.430000,13.430000)-(19.430000,4.570000) z=0.000000 facing=West glazing=0.000000
surface L0/P-East/W3 Interzone adjacent=L0/P-South area=19.388868 wall (19.430000,4.570000)-(24.000000,0.000000) z=0.000000 facing=West glazing=0.000000
surface L0/P-East/W4 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=0.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface L0/P-South/W1 Interzone adjacent=L0/CORE area=44.580000 wall (19.430000,4.570000)-(4.570000,4.570000) z=0.000000 facing=North glazing=0.000000
surface L0/P-South/W2 Interzone adjacent=L0/P-West area=19.388868 wall (4.570000,4.570000)-(0.000000,0.000000) z=0.000000 facing=North glazing=0.000000
surface L0/P-South/W3 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=0.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface L0/P-South/W4 Interzone adjacent=L0/P-East area=19.388868 wall (24.000000,0.000000)-(19.430000,4.570000) z=0.000000 facing=North glazing=0.000000
surface L0/P-West/W1 Interzone adjacent=L0/CORE area=26.580000 wall (4.570000,4.570000)-(4.570000,13.430000) z=0.000000 facing=East glazing=0.000000
surface L0/P-West/W2 Interzone adjacent=L0/P-North area=19.388868 wall (4.570000,13.430000)-(0.000000,18.000000) z=0.000000 facing=East glazing=0.000000
surface L0/P-West/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface L0/P-West/W4 Interzone adjacent=L0/P-South area=19.388868 wall (0.000000,0.000000)-(4.570000,4.570000) z=0.000000 facing=South glazing=0.000000
surface L0/CORE/W1 Interzone adjacent=L0/P-South area=44.580000 wall (4.570000,4.570000)-(19.430000,4.570000) z=0.000000 facing=South glazing=0.000000
surface L0/CORE/W2 Interzone adjacent=L0/P-East area=26.580000 wall (19.430000,4.570000)-(19.430000,13.430000) z=0.000000 facing=East glazing=0.000000
surface L0/CORE/W3 Interzone adjacent=L0/P-North area=44.580000 wall (19.430000,13.430000)-(4.570000,13.430000) z=0.000000 facing=North glazing=0.000000
surface L0/CORE/W4 Interzone adjacent=L0/P-West area=26.580000 wall (4.570000,13.430000)-(4.570000,4.570000) z=0.000000 facing=West glazing=0.000000
surface L2/P-North/W1 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=6.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface L2/P-North/W2 Interzone adjacent=L2/P-West area=19.388868 wall (0.000000,18.000000)-(4.570000,13.430000) z=6.000000 facing=West glazing=0.000000
surface L2/P-North/W3 Interzone adjacent=L2/CORE area=44.580000 wall (4.570000,13.430000)-(19.430000,13.430000) z=6.000000 facing=South glazing=0.000000
surface L2/P-North/W4 Interzone adjacent=L2/P-East area=19.388868 wall (19.430000,13.430000)-(24.000000,18.000000) z=6.000000 facing=South glazing=0.000000
surface L2/P-East/W1 Interzone adjacent=L2/P-North area=19.388868 wall (24.000000,18.000000)-(19.430000,13.430000) z=6.000000 facing=North glazing=0.000000
surface L2/P-East/W2 Interzone adjacent=L2/CORE area=26.580000 wall (19.430000,13.430000)-(19.430000,4.570000) z=6.000000 facing=West glazing=0.000000
surface L2/P-East/W3 Interzone adjacent=L2/P-South area=19.388868 wall (19.430000,4.570000)-(24.000000,0.000000) z=6.000000 facing=West glazing=0.000000
surface L2/P-East/W4 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=6.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface L2/P-South/W1 Interzone adjacent=L2/CORE area=44.580000 wall (19.430000,4.570000)-(4.570000,4.570000) z=6.000000 facing=North glazing=0.000000
surface L2/P-South/W2 Interzone adjacent=L2/P-West area=19.388868 wall (4.570000,4.570000)-(0.000000,0.000000) z=6.000000 facing=North glazing=0.000000
surface L2/P-South/W3 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=6.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface L2/P-South/W4 Interzone adjacent=L2/P-East area=19.388868 wall (24.000000,0.000000)-(19.430000,4.570000) z=6.000000 facing=North glazing=0.000000
surface L2/P-West/W1 Interzone adjacent=L2/CORE area=26.580000 wall (4.570000,4.570000)-(4.570000,13.430000) z=6.000000 facing=East glazing=0.000000
surface L2/P-West/W2 Interzone adjacent=L2/P-North area=19.388868 wall (4.570000,13.430000)-(0.000000,18.000000) z=6.000000 facing=East glazing=0.000000
surface L2/P-West/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=6.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface L2/P-West/W4 Interzone adjacent=L2/P-South area=19.388868 wall (0.000000,0.000000)-(4.570000,4.570000) z=6.000000 facing=South glazing=0.000000
surface L2/CORE/W1 Interzone adjacent=L2/P-South area=44.580000 wall (4.570000,4.570000)-(19.430000,4.570000) z=6.000000 facing=South glazing=0.000000
surface L2/CORE/W2 Interzone adjacent=L2/P-East area=26.580000 wall (19.430000,4.570000)-(19.430000,13.430000) z=6.000000 facing=East glazing=0.000000
surface L2/CORE/W3 Interzone adjacent=L2/P-North area=44.580000 wall (19.430000,13.430000)-(4.570000,13.430000) z=6.000000 facing=North glazing=0.000000
surface L2/CORE/W4 Interzone adjacent=L2/P-West area=26.580000 wall (4.570000,13.430000)-(4.570000,4.570000) z=6.000000 facing=West glazing=0.000000
surface L3/P-North/W1 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=9.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface L3/P-North/W2 Interzone adjacent=L3/P-West area=19.388868 wall (0.000000,18.000000)-(4.570000,13.430000) z=9.000000 facing=West glazing=0.000000
surface L3/P-North/W3 Interzone adjacent=L3/CORE area=44.580000 wall (4.570000,13.430000)-(19.430000,13.430000) z=9.000000 facing=South glazing=0.000000
surface L3/P-North/W4 Interzone adjacent=L3/P-East area=19.388868 wall (19.430000,13.430000)-(24.000000,18.000000) z=9.000000 facing=South glazing=0.000000
surface L3/P-East/W1 Interzone adjacent=L3/P-North area=19.388868 wall (24.000000,18.000000)-(19.430000,13.430000) z=9.000000 facing=North glazing=0.000000
surface L3/P-East/W2 Interzone adjacent=L3/CORE area=26.580000 wall (19.430000,13.430000)-(19.430000,4.570000) z=9.000000 facing=West glazing=0.000000
surface L3/P-East/W3 Interzone adjacent=L3/P-South area=19.388868 wall (19.430000,4.570000)-(24.000000,0.000000) z=9.000000 facing=West glazing=0.000000
surface L3/P-East/W4 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=9.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface L3/P-South/W1 Interzone adjacent=L3/CORE area=44.580000 wall (19.430000,4.570000)-(4.570000,4.570000) z=9.000000 facing=North glazing=0.000000
surface L3/P-South/W2 Interzone adjacent=L3/P-West area=19.388868 wall (4.570000,4.570000)-(0.000000,0.000000) z=9.000000 facing=North glazing=0.000000
surface L3/P-South/W3 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=9.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface L3/P-South/W4 Interzone adjacent=L3/P-East area=19.388868 wall (24.000000,0.000000)-(19.430000,4.570000) z=9.000000 facing=North glazing=0.000000
surface L3/P-West/W1 Interzone adjacent=L3/CORE area=26.580000 wall (4.570000,4.570000)-(4.570000,13.430000) z=9.000000 facing=East glazing=0.000000
surface L3/P-West/W2 Interzone adjacent=L3/P-North area=19.388868 wall (4.570000,13.430000)-(0.000000,18.000000) z=9.000000 facing=East glazing=0.000000
surface L3/P-West/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=9.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface L3/P-West/W4 Interzone adjacent=L3/P-South area=19.388868 wall (0.000000,0.000000)-(4.570000,4.570000) z=9.000000 facing=South glazing=0.000000
surface L3/CORE/W1 Interzone adjacent=L3/P-South area=44.580000 wall (4.570000,4.570000)-(19.430000,4.570000) z=9.000000 facing=South glazing=0.000000
surface L3/CORE/W2 Interzone adjacent=L3/P-East area=26.580000 wall (19.430000,4.570000)-(19.430000,13.430000) z=9.000000 facing=East glazing=0.000000
surface L3/CORE/W3 Interzone adjacent=L3/P-North area=44.580000 wall (19.430000,13.430000)-(4.570000,13.430000) z=9.000000 facing=North glazing=0.000000
surface L3/CORE/W4 Interzone adjacent=L3/P-West area=26.580000 wall (4.570000,13.430000)-(4.570000,4.570000) z=9.000000 facing=West glazing=0.000000
surface L0/P-North/F1 Ground area=88.795100 Floor z=0.000000
surface L0/P-North/C1 Adiabatic area=88.795100 Ceiling z=3.000000
surface L0/P-East/F1 Ground area=61.375100 Floor z=0.000000
surface L0/P-East/C1 Adiabatic area=61.375100 Ceiling z=3.000000
surface L0/P-South/F1 Ground area=88.795100 Floor z=0.000000
surface L0/P-South/C1 Adiabatic area=88.795100 Ceiling z=3.000000
surface L0/P-West/F1 Ground area=61.375100 Floor z=0.000000
surface L0/P-West/C1 Adiabatic area=61.375100 Ceiling z=3.000000
surface L0/CORE/F1 Ground area=131.659600 Floor z=0.000000
surface L0/CORE/C1 Adiabatic area=131.659600 Ceiling z=3.000000
surface L2/P-North/F1 Adiabatic area=88.795100 Floor z=6.000000
surface L2/P-North/C1 Adiabatic area=88.795100 Ceiling z=9.000000
surface L2/P-East/F1 Adiabatic area=61.375100 Floor z=6.000000
surface L2/P-East/C1 Adiabatic area=61.375100 Ceiling z=9.000000
surface L2/P-South/F1 Adiabatic area=88.795100 Floor z=6.000000
surface L2/P-South/C1 Adiabatic area=88.795100 Ceiling z=9.000000
surface L2/P-West/F1 Adiabatic area=61.375100 Floor z=6.000000
surface L2/P-West/C1 Adiabatic area=61.375100 Ceiling z=9.000000
surface L2/CORE/F1 Adiabatic area=131.659600 Floor z=6.000000
surface L2/CORE/C1 Adiabatic area=131.659600 Ceiling z=9.000000
surface L3/P-North/F1 Adiabatic area=88.795100 Floor z=9.000000
surface L3/P-North/C1 Outdoors area=88.795100 Ceiling z=12.000000
surface L3/P-East/F1 Adiabatic area=61.375100 Floor z=9.000000
surface L3/P-East/C1 Outdoors area=61.375100 Ceiling z=12.000000
surface L3/P-South/F1 Adiabatic area=88.795100 Floor z=9.000000
surface L3/P-South/C1 Outdoors area=88.795100 Ceiling z=12.000000
surface L3/P-West/F1 Adiabatic area=61.375100 Floor z=9.000000
surface L3/P-West/C1 Outdoors area=61.375100 Ceiling z=12.000000
surface L3/CORE/F1 Adiabatic area=131.659600 Floor z=9.000000
surface L3/CORE/C1 Outdoors area=131.659600 Ceiling z=12.000000
- [x] Step 6: Build and test
dotnet build BEMGen.sln -c Release
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
dotnet test BEMGen.sln -c Release
git status --short
git grep -n "FloorAreaMultiplier" -- src tests
Expected: Build succeeded., 0 Warning(s), 0 Error(s); AggregatorTests:
Passed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32, Duration: … - Lod.Integration.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 70, Skipped: 0, Total: 70, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short shows the two renames (the class file as RM, the snapshot as R) and the two modified files; git grep prints only the two lines of the Floor Area Multiplier component class in src/Lod.Grasshopper/Components/AggregatorComponents.cs (public sealed class FloorAreaMultiplierComponent and its constructor), which Task 12 renames.
- [x] Step 7: Commit
git add src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs tests/Lod.Integration.Tests/AggregatorTests.cs src/Lod.Grasshopper/Components/AggregatorComponents.cs tests/Lod.Integration.Tests/Snapshots/aggregator-StackedFloorZoneMultiplier.txt
git commit -m "refactor(aggregators): rename the floor area multiplier to stacked floor zone multiplier"
Task 4: SingleZoneBuilding on a shared merge¶
Files:
- Create: src/Lod.Core/Buildings/SingleZoneMerge.cs, src/Lod.Core/Buildings/SingleZoneBuilding.cs
- Delete: src/Lod.Core/Buildings/SingleZoneMerged.cs (Task 2)
- Rename: tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneMerged.txt (S4 file) → tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneBuilding.txt (content unchanged)
- Modify: src/Lod.Core/Model/Zone.cs (S2 file, doc comment), tests/Lod.Integration.Tests/AggregatorTests.cs (Task 3), src/Lod.Grasshopper/Components/AggregatorComponents.cs (Task 3)
Interfaces:
- Consumes: Storeys.Assemble, AssembledStack (Task 2); PolygonOps.Union; LayoutSurfaceBuilder.Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height); WallSurface.Relocate(Func<ZoneId, ZoneId>, string idPrefix, double dz); FacadeAttribution.Compute(...).CoverageGaps(); WindowRehosting.Rehost(...); EquivalentPropertyAggregator.Aggregate(ZoneId, ZoneMeasures, IReadOnlyList<SourceZoneContribution>); LayoutMeasures.Of(Zone, IEnumerable<WallSurface>); codes FacadeNotCovered, WindowNotHosted.
- Produces:
- internal sealed record MergedZone(ZoneId Id, string Name) and internal static class SingleZoneMerge with Merge(string operation, IReadOnlyList<FloorEntry> floors, Func<int, MergedZone> zoneOfEntry, ToleranceSettings tolerances) : Result<IGeneratedBuilding>: the storeys of each floor entry merge into the entry's zone (equal zones merge together); one part per storey outline, walls rebuilt per outline (S{p}/<zone>/W{n}), façade coverage and window re-hosting, one internal mass per slab level between two storeys of the same zone, every other floor and ceiling piece kept with its stacked designation and the zones renamed to the merged zones, programs aggregated per zone.
- public sealed class SingleZoneBuilding : IFloorAggregator with public static readonly ZoneId BuildingZone (BUILDING): SingleZoneMerge.Merge(nameof(SingleZoneBuilding), floors, _ => new MergedZone(BuildingZone, "Building"), tolerances); the same building as SingleZoneMerged, provenance operation SingleZoneBuilding.
- SingleZoneMerged deleted. The Single Zone Merged component (renamed in Task 12) creates a SingleZoneBuilding.
- [x] Step 1: Rename the snapshot and delete the old class
git mv tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneMerged.txt tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneBuilding.txt
git rm src/Lod.Core/Buildings/SingleZoneMerged.cs
- [x] Step 2: Create
src/Lod.Core/Buildings/SingleZoneMerge.cs(full file)
The body of SingleZoneMerged.Aggregate, generalised from one zone to one zone per group of storeys: it starts from Storeys.Assemble instead of a Stack building, a slab is internal mass when both of its sides belong to the same merged zone (Within), and a piece between two merged zones stays Interzone with the merged zone on the other side.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Aggregation;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
namespace Lod.Core.Buildings;
/// <summary>A zone that a single-zone method merges storeys into.</summary>
/// <param name="Id">Zone ID.</param>
/// <param name="Name">Display name.</param>
internal sealed record MergedZone(ZoneId Id, string Name);
/// <summary>
/// The merge shared by the single-zone methods (ADR-013): the storeys of the assembled stack are grouped by floor entry into zones. Each zone gets one
/// part per storey outline; its outdoor walls are rebuilt per outline, must cover every source façade (D-041), and keep every source window in
/// place (D-039); walls between merged zones are discarded (D-034); slabs between two storeys of the same zone become internal mass with both faces
/// exposed, one entry per slab level (D-031); every other floor and ceiling piece keeps its stacked designation (D-068), and a piece between two
/// zones stays interzone. Programs are aggregated from the stacked zones with fractions 1, so every invariant is conserved (ADR-005, ADR-007).
/// </summary>
internal static class SingleZoneMerge
{
/// <summary>Merges the stacked storeys of <paramref name="floors"/> into the zones given per floor entry.</summary>
/// <param name="operation">Operation name recorded in provenance.</param>
/// <param name="floors">Floor entries, bottom to top.</param>
/// <param name="zoneOfEntry">The zone each floor entry's storeys merge into; equal zones are merged together.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The building, or errors.</returns>
public static Result<IGeneratedBuilding> Merge(string operation, IReadOnlyList<FloorEntry> floors, Func<int, MergedZone> zoneOfEntry, ToleranceSettings tolerances)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
MergedZone[] zoneOfStorey = stack.Entries.Select(zoneOfEntry).ToArray();
MergedZone[] targets = zoneOfStorey.Distinct().ToArray();
Dictionary<ZoneId, ZoneId> targetOf = stack.Storeys
.SelectMany((s, k) => s.Zones.Select(z => (Source: z.Id, Target: zoneOfStorey[k].Id)))
.ToDictionary(p => p.Source, p => p.Target);
var ops = new PolygonOps(tolerances);
var builder = new LayoutSurfaceBuilder(tolerances);
Dictionary<ZoneId, List<ZonePart>> parts = targets.ToDictionary(t => t.Id, _ => new List<ZonePart>());
var walls = new List<Surface>();
for (int k = 0; k < stack.Storeys.Count; k++)
{
PlacedStorey storey = stack.Storeys[k];
ZoneId target = zoneOfStorey[k].Id;
foreach (Polygon2 outline in ops.Union(storey.Zones.Select(z => z.Parts[0].Footprint)))
{
parts[target].Add(new ZonePart(outline, storey.Elevation, storey.Height));
Result<IReadOnlyList<Surface>> built = builder.Build(outline, new[] { new LayoutZone(target, outline) }, storey.Height);
if (!built.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(built.Diagnostics);
}
string prefix = $"S{parts[target].Count - 1}/";
walls.AddRange(built.Value.OfType<WallSurface>().Select(w => w.Relocate(id => id, prefix, storey.Elevation)));
}
}
WallSurface[] sourceWalls = stack.Storeys.SelectMany(s => s.Walls).ToArray();
FacadeAttribution facade = FacadeAttribution.Compute(sourceWalls, walls, tolerances);
Diagnostic[] gaps = facade.CoverageGaps()
.Select(g => Diagnostic.Error(DiagnosticCodes.FacadeNotCovered, $"Rebuilt façades cover {g.CoveredLength:0.######} m of this {g.Wall.Length:0.######} m wall (D-041).", g.Wall.Id.Value))
.ToArray();
if (gaps.Length > 0)
{
return Result.Failure<IGeneratedBuilding>(gaps);
}
Result<IReadOnlyList<Surface>> rehosted = WindowRehosting.Rehost(sourceWalls, walls, tolerances);
if (!rehosted.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(rehosted.Diagnostics);
}
IReadOnlyList<Surface> glazedWalls = rehosted.Value;
HorizontalSurface[] horizontal = stack.Horizontal.SelectMany(h => h).ToArray();
bool Within(HorizontalSurface h) => h.Boundary == BoundaryCondition.Interzone && targetOf[h.Zone] == targetOf[h.AdjacentZone!.Value];
InternalMass[] masses = targets
.SelectMany(t => horizontal
.Where(h => h.Kind == HorizontalKind.Floor && Within(h) && targetOf[h.Zone] == t.Id)
.GroupBy(h => h.Elevation)
.OrderBy(g => g.Key)
.Select(level => new InternalMass(
t.Id,
level.Sum(h => h.Area),
ExposedFaces: 2,
level.Key,
level.Select(h => h.Id)
.Concat(horizontal.Where(c => c.Kind == HorizontalKind.Ceiling && Within(c) && targetOf[c.Zone] == t.Id && c.Elevation == level.Key).Select(c => c.Id))
.ToArray())))
.ToArray();
IEnumerable<Surface> envelope = horizontal
.Where(h => !Within(h))
.Select(h => new HorizontalSurface(h.Id, targetOf[h.Zone], h.Kind, h.Polygon, h.Elevation, h.Boundary, h.AdjacentZone is { } adjacent ? targetOf[adjacent] : null));
var diagnostics = new List<Diagnostic>(assembled.Diagnostics);
var aggregator = new EquivalentPropertyAggregator(tolerances);
var zones = new List<Zone>();
foreach (MergedZone target in targets)
{
Zone[] sources = stack.Storeys.Where((_, k) => zoneOfStorey[k] == target).SelectMany(s => s.Zones).ToArray();
List<ZonePart> zoneParts = parts[target.Id];
var measures = new ZoneMeasures(
zoneParts.Sum(p => p.Footprint.Area),
zoneParts.Sum(p => p.Volume),
glazedWalls.Where(w => w.Zone == target.Id).Sum(w => w.Area));
SourceZoneContribution[] contributions = sources
.Select(z => new SourceZoneContribution(z.Id, z.Program, LayoutMeasures.Of(z, sourceWalls), 1.0, 1.0))
.ToArray();
Result<ZoneProgram> program = aggregator.Aggregate(target.Id, measures, contributions);
diagnostics.AddRange(program.Diagnostics);
if (program.IsSuccess)
{
SpaceType[] types = sources.Select(z => z.SpaceType).Distinct().ToArray();
zones.Add(new Zone(target.Id, target.Name, types.Length == 1 ? types[0] : SpaceType.Mixed, zoneParts, program.Value, sources.Select(z => z.Id)));
}
}
if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
{
return Result.Failure<IGeneratedBuilding>(diagnostics);
}
var provenance = Provenance.Of(
operation,
new[] { new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))) },
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(floors[0].Floor.OrientationDegrees, zones, glazedWalls.Concat(envelope), masses, floors, provenance);
return Result.Success(building, diagnostics);
}
}
- [x] Step 3: Create
src/Lod.Core/Buildings/SingleZoneBuilding.cs(full file)
using System.Collections.Generic;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>
/// The whole building as one zone (D-021, D-071), built from the assembled stack: one zone part per storey outline; outdoor walls rebuilt per
/// storey, required to cover every source façade (D-041), keeping every source window in place (D-039); walls between merged zones discarded
/// (D-034); slabs between storeys turned into internal mass with both faces exposed, one entry per slab level (D-031); ground, roof, and exposed
/// floors kept (D-068). Formerly <c>SingleZoneMerged</c>.
/// </summary>
public sealed class SingleZoneBuilding : IFloorAggregator
{
/// <summary>ID of the single zone.</summary>
public static readonly ZoneId BuildingZone = new("BUILDING");
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public SingleZoneBuilding(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors) =>
SingleZoneMerge.Merge(nameof(SingleZoneBuilding), floors, _ => new MergedZone(BuildingZone, "Building"), _tolerances);
}
- [x] Step 4: Update the zone doc comment — replace
src/Lod.Core/Model/Zone.cs(full file)
Only the summary of Zone changes; it names the two single-zone methods.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Programs;
namespace Lod.Core.Model;
/// <summary>One prism of a zone: a floor polygon extruded by a height.</summary>
/// <param name="Footprint">Floor polygon.</param>
/// <param name="Elevation">Floor elevation, m.</param>
/// <param name="Height">Floor-to-floor height, m.</param>
public sealed record ZonePart(Polygon2 Footprint, double Elevation, double Height)
{
/// <summary>Air volume, m³.</summary>
public double Volume => Footprint.Area * Height;
}
/// <summary>
/// A thermal zone. Most zones have one part; a zone spanning storeys (<c>SingleZonePerFloorType</c>, <c>SingleZoneBuilding</c>) has one part per storey outline.
/// </summary>
public sealed class Zone
{
/// <summary>Creates a zone.</summary>
/// <param name="id">Zone ID.</param>
/// <param name="name">Display name.</param>
/// <param name="spaceType">Space type; <see cref="SpaceType.Mixed"/> for zones combining several types.</param>
/// <param name="parts">One or more prisms.</param>
/// <param name="program">Loads and setpoints.</param>
/// <param name="sourceZones">Zones this zone was derived from; empty for generated zones.</param>
/// <param name="multiplier">Zone multiplier, at least 1.</param>
public Zone(ZoneId id, string name, SpaceType spaceType, IEnumerable<ZonePart> parts, ZoneProgram program, IEnumerable<ZoneId> sourceZones, int multiplier = 1)
{
ZonePart[] partArray = parts.ToArray();
if (partArray.Length == 0)
{
throw new ArgumentException("A zone needs at least one part.", nameof(parts));
}
if (multiplier < 1)
{
throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, "Multipliers start at 1.");
}
Id = id;
Name = name;
SpaceType = spaceType;
Parts = Array.AsReadOnly(partArray);
Program = program;
SourceZones = Array.AsReadOnly(sourceZones.ToArray());
Multiplier = multiplier;
}
/// <summary>Zone ID.</summary>
public ZoneId Id { get; }
/// <summary>Display name.</summary>
public string Name { get; }
/// <summary>Space type.</summary>
public SpaceType SpaceType { get; }
/// <summary>Prisms making up the zone.</summary>
public IReadOnlyList<ZonePart> Parts { get; }
/// <summary>Loads and setpoints.</summary>
public ZoneProgram Program { get; }
/// <summary>Zones this zone was derived from.</summary>
public IReadOnlyList<ZoneId> SourceZones { get; }
/// <summary>Zone multiplier.</summary>
public int Multiplier { get; }
/// <summary>Floor area of one instance, m².</summary>
public double FloorArea => Parts.Sum(p => p.Footprint.Area);
/// <summary>Volume of one instance, m³.</summary>
public double Volume => Parts.Sum(p => p.Volume);
/// <summary>A copy with a new ID, translated vertically, with another multiplier.</summary>
/// <param name="id">New ID.</param>
/// <param name="dz">Elevation offset, m.</param>
/// <param name="multiplier">New multiplier.</param>
/// <returns>The copy.</returns>
public Zone Relocate(ZoneId id, double dz, int multiplier) =>
new(id, Name, SpaceType, Parts.Select(p => p with { Elevation = p.Elevation + dz }), Program, SourceZones, multiplier);
}
- [x] Step 5: Rename in the tests — replace
tests/Lod.Integration.Tests/AggregatorTests.cs(full file)
Every SingleZoneMerged becomes SingleZoneBuilding: test names, theory data, the Aggregator(name) helper, and the snapshot name aggregator-SingleZoneBuilding.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZoneBuilding")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZoneBuilding")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZoneBuildingHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneBuildingTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneBuildingKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZoneBuilding" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Simplifier("PerimeterCore"));
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
}
[Fact]
public void SingleZoneBuildingSnapshot()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneBuilding");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Pipeline.Tolerances),
"StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(Pipeline.Tolerances),
"SingleZoneBuilding" => new SingleZoneBuilding(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
- [x] Step 6: Keep the plugin building — replace
src/Lod.Grasshopper/Components/AggregatorComponents.cs(full file)
Only CreateAggregator() of the Single Zone Merged component changes.
using System;
using Lod.Core.Buildings;
using Lod.Core.Common;
namespace Lod.Grasshopper.Components;
public sealed class FloorAreaMultiplierComponent : FloorAggregatorComponent
{
public FloorAreaMultiplierComponent()
: base(
"Floor Area Multiplier",
"Mult",
"Representative storeys with zone multipliers (D-032). Each floor type stands for N storeys; its storeys with exposed floors or ceilings (ground, roof, setbacks) are split off as x1 storeys so exposure is never multiplied (D-046). Storeys keep their true elevations (D-045); overlap between storeys is adiabatic.")
{
}
public override Guid ComponentGuid => new("e4b44821-c9ed-49b9-9c4e-7c08355254dd");
protected override string MultiplierDescription => "How many storeys each floor type stands for. Default 1.";
protected override IFloorAggregator CreateAggregator() => new StackedFloorZoneMultiplier(ToleranceSettings.Default);
}
public sealed class SingleZoneMergedComponent : FloorAggregatorComponent
{
public SingleZoneMergedComponent()
: base(
"Single Zone Merged",
"1Zone",
"The whole building as one zone (D-021): slabs become internal mass (D-031), walls between merged zones are discarded (D-034).")
{
}
public override Guid ComponentGuid => new("506d5037-de93-4aee-a6c4-47dcc97509e1");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZoneBuilding(ToleranceSettings.Default);
}
- [x] Step 7: Check the renamed snapshot
tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneBuilding.txt keeps the S4 content byte for byte:
building orientation=0.000000 sources=2
zone BUILDING Mixed "Building" area=864.000000 volume=2592.000000 parts=2 multiplier=1 sources=[L0/ST,L0/CO,L0/US1,L0/US2,L0/UN1,L0/UN2,L1/ST,L1/CO,L1/US1,L1/US2,L1/UN1,L1/UN2]
load Occupancy PerFloorArea value=0.022222 annual=6391.400000
load Lighting PerFloorArea value=4.666667 annual=3893.174603
load ElectricEquipment PerFloorArea value=3.703704 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
surface S0/BUILDING/W1 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=0.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface S0/BUILDING/W2 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=0.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface S0/BUILDING/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface S0/BUILDING/W4 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=0.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface S1/BUILDING/W1 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=3.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface S1/BUILDING/W2 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=3.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface S1/BUILDING/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=3.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface S1/BUILDING/W4 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=3.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface L0/ST/F1 Ground area=72.000000 Floor z=0.000000
surface L0/CO/F1 Ground area=40.000000 Floor z=0.000000
surface L0/US1/F1 Ground area=80.000000 Floor z=0.000000
surface L0/US2/F1 Ground area=80.000000 Floor z=0.000000
surface L0/UN1/F1 Ground area=80.000000 Floor z=0.000000
surface L0/UN2/F1 Ground area=80.000000 Floor z=0.000000
surface L1/ST/C1 Outdoors area=72.000000 Ceiling z=6.000000
surface L1/CO/C1 Outdoors area=40.000000 Ceiling z=6.000000
surface L1/US1/C1 Outdoors area=80.000000 Ceiling z=6.000000
surface L1/US2/C1 Outdoors area=80.000000 Ceiling z=6.000000
surface L1/UN1/C1 Outdoors area=80.000000 Ceiling z=6.000000
surface L1/UN2/C1 Outdoors area=80.000000 Ceiling z=6.000000
mass BUILDING area=432.000000 faces=2 z=3.000000
- [x] Step 8: Build and test
dotnet build BEMGen.sln -c Release
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
dotnet test BEMGen.sln -c Release
git status --short
git grep -n --untracked "SingleZoneMerged" -- src tests
Expected: Build succeeded., 0 Warning(s), 0 Error(s); AggregatorTests:
Passed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32, Duration: … - Lod.Integration.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 70, Skipped: 0, Total: 70, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short shows the snapshot rename and the deletion (both staged by Step 1), the two new files, and the three modified files; git grep prints the remark Formerly <c>SingleZoneMerged</c>. in SingleZoneBuilding.cs and the two lines of the Single Zone Merged component class in AggregatorComponents.cs, which Task 12 renames.
- [x] Step 9: Commit
git add src/Lod.Core/Buildings/SingleZoneMerge.cs src/Lod.Core/Buildings/SingleZoneBuilding.cs src/Lod.Core/Model/Zone.cs tests/Lod.Integration.Tests/AggregatorTests.cs src/Lod.Grasshopper/Components/AggregatorComponents.cs tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZoneBuilding.txt
git commit -m "refactor(aggregators): rename single zone merged to single zone building on a shared merge"
Task 5: SingleZonePerFloorType¶
Files:
- Create: src/Lod.Core/Buildings/SingleZonePerFloorType.cs, tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.txt
- Test: modify tests/Lod.Integration.Tests/AggregatorTests.cs (Task 4)
Interfaces:
- Consumes: SingleZoneMerge.Merge, MergedZone (Task 4); BuildingValidator; TextReport.Describe; Snapshot.Match (S2 test support).
- Produces: public sealed class SingleZonePerFloorType : IFloorAggregator (D-071) with public static ZoneId ZoneOf(int entry) (E{entry}): SingleZoneMerge.Merge(nameof(SingleZonePerFloorType), floors, i => new MergedZone(ZoneOf(i), $"Floor type {i}"), tolerances); multiplier 1; provenance operation SingleZonePerFloorType, parameter Multipliers.
- [x] Step 1: Write the failing tests — replace
tests/Lod.Integration.Tests/AggregatorTests.cs(full file)
New: SingleZonePerFloorTypeHasOneZonePerFloorEntry (entries × 1 and × 3 give zones E0 and E1 with 1 and 3 parts, 6 and 18 source zones, multiplier 1); SingleZonePerFloorTypeKeepsTheSlabsBetweenEntriesAsInterzonePairs (internal mass in E1 at 6 m and 9 m, the slab at 3 m an Interzone pair of 432 m² between E0 and E1, every wall outdoors); SingleZonePerFloorTypeConservesEveryInvariant (a wide × 2 and a narrow × 3 setback validates and keeps every window); OneFloorEntryGivesTheSingleZoneBuildingUnderAnotherName; SingleZonePerFloorTypeSnapshot (the detailed floor × 1 and × 2); and the method added to the overhang, setback, and matrix theories and to Aggregator(name).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZonePerFloorTypeHasOneZonePerFloorEntry()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { "E0", "E1" }, building.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { "Floor type 0", "Floor type 1" }, building.Zones.Select(z => z.Name));
Assert.Equal(new[] { 1, 3 }, building.Zones.Select(z => z.Parts.Count));
Assert.Equal(new[] { 6, 18 }, building.Zones.Select(z => z.SourceZones.Count));
Assert.All(building.Zones, z => Assert.Equal(1, z.Multiplier));
Assert.Equal(3 * 432.0 * 3.0, building.Zones[1].Volume, 6);
Assert.Equal(new[] { 0.0, 3.0, 6.0, 9.0 }, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation));
}
[Fact]
public void SingleZonePerFloorTypeKeepsTheSlabsBetweenEntriesAsInterzonePairs()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
// Within E1 the slabs at 6 m and 9 m become internal mass (D-031); the slab at 3 m lies between E0 and E1 (D-068, D-071).
Assert.Equal(new[] { ("E1", 6.0), ("E1", 9.0) }, building.InternalMasses.Select(m => (m.Zone.Value, m.Elevation)));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
Assert.All(interzone, h => Assert.Equal(3.0, h.Elevation));
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 6);
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E0").Sum(h => h.Area), 6);
Assert.Equal(interzone.Length, interzone.Count(h => h.Kind == HorizontalKind.Ceiling) * 2);
Assert.DoesNotContain(building.Surfaces.OfType<WallSurface>(), w => w.Boundary != BoundaryCondition.Outdoors);
}
[Fact]
public void SingleZonePerFloorTypeConservesEveryInvariant()
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(wide, 2), new FloorEntry(narrow, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Equal(2 * wide.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count) + (3 * narrow.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count)), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
}
[Fact]
public void OneFloorEntryGivesTheSingleZoneBuildingUnderAnotherName()
{
FloorEntry[] entries = { new(Pipeline.DetailedFloor(), 3) };
string perType = TextReport.Describe(new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(entries).Value);
string building = TextReport.Describe(new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(entries).Value);
Assert.Equal(building.Replace("BUILDING", "E0").Replace("\"Building\"", "\"Floor type 0\""), perType);
}
[Fact]
public void SingleZoneBuildingHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneBuildingTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneBuildingKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Simplifier("PerimeterCore"));
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
}
[Fact]
public void SingleZoneBuildingSnapshot()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneBuilding");
}
[Fact]
public void SingleZonePerFloorTypeSnapshot()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZonePerFloorType");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Pipeline.Tolerances),
"StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(Pipeline.Tolerances),
"SingleZonePerFloorType" => new SingleZonePerFloorType(Pipeline.Tolerances),
"SingleZoneBuilding" => new SingleZoneBuilding(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected: the build fails, each error reported once, for the net8.0 target:
tests\Lod.Integration.Tests\AggregatorTests.cs(172,43): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(187,43): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(207,43): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(220,50): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(307,43): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(325,41): error CS0246: The type or namespace name 'SingleZonePerFloorType' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — create
src/Lod.Core/Buildings/SingleZonePerFloorType.cs(full file)
using System.Collections.Generic;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>
/// One zone per floor entry (D-071): the Nᵢ storeys of entry <c>i</c>, stacked as in <see cref="Stack"/>, merge into one zone
/// <c>E{i}</c> with one part per storey outline. Outdoor walls are rebuilt per storey outline, required to cover every source façade (D-041),
/// keeping every source window in place (D-039); walls between merged zones are discarded (D-034); slabs between storeys of the same entry become
/// internal mass (D-031); the floors and ceilings between two entries stay interzone between their zones, and ground, roof, and exposed pieces are
/// kept as designated on the assembled stack (D-068).
/// </summary>
public sealed class SingleZonePerFloorType : IFloorAggregator
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public SingleZonePerFloorType(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <summary>ID of the zone of floor entry <paramref name="entry"/>.</summary>
/// <param name="entry">Index of the floor entry, from 0 at the bottom.</param>
/// <returns>The zone ID, <c>E{entry}</c>.</returns>
public static ZoneId ZoneOf(int entry) => new($"E{entry}");
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors) =>
SingleZoneMerge.Merge(nameof(SingleZonePerFloorType), floors, i => new MergedZone(ZoneOf(i), $"Floor type {i}"), _tolerances);
}
- [x] Step 4: Run the tests and see the snapshot fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 42, Skipped: 0, Total: 43, Duration: … - Lod.Integration.Tests.dll (net8.0)
with the failing test and its messages:
Failed Lod.Integration.Tests.AggregatorTests.SingleZonePerFloorTypeSnapshot
Snapshot 'aggregator-SingleZonePerFloorType' is missing or differs. Review Snapshots/aggregator-SingleZonePerFloorType.received.txt and rename it to aggregator-SingleZonePerFloorType.txt to accept.
and the file tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.received.txt (git-ignored by *.received.*).
- [x] Step 5: Review and accept the received file
Review the received file against Step 6; it must be identical. What to look for: the sources line 1,2; two zones, E0 ("Floor type 0", parts=1, the six zones of L0/) and E1 ("Floor type 1", parts=2, area 864, the twelve zones of L1/ and L2/), both Mixed with the aggregated loads and setpoints of the detailed floor; four rebuilt outdoor walls per storey (S0/E0/… at z = 0, S0/E1/… at z = 3, S1/E1/… at z = 6) carrying the detailed floor's windows at the same offsets; six ground floors L0/…/F1; the six ceilings L0/…/C1 at 3 m Interzone adjacent=E1 and the six floors L1/…/F1 at 3 m Interzone adjacent=E0; six roofs L2/…/C1 at 9 m; and one mass E1 area=432.000000 faces=2 z=6.000000. Then accept it:
Move-Item tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.received.txt tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.txt
- [x] Step 6: Expected content of
tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.txt
building orientation=0.000000 sources=1,2
zone E0 Mixed "Floor type 0" area=432.000000 volume=1296.000000 parts=1 multiplier=1 sources=[L0/ST,L0/CO,L0/US1,L0/US2,L0/UN1,L0/UN2]
load Occupancy PerFloorArea value=0.022222 annual=6391.400000
load Lighting PerFloorArea value=4.666667 annual=3893.174603
load ElectricEquipment PerFloorArea value=3.703704 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
zone E1 Mixed "Floor type 1" area=864.000000 volume=2592.000000 parts=2 multiplier=1 sources=[L1/ST,L1/CO,L1/US1,L1/US2,L1/UN1,L1/UN2,L2/ST,L2/CO,L2/US1,L2/US2,L2/UN1,L2/UN2]
load Occupancy PerFloorArea value=0.022222 annual=6391.400000
load Lighting PerFloorArea value=4.666667 annual=3893.174603
load ElectricEquipment PerFloorArea value=3.703704 annual=4380.000000
load Infiltration AirChangesPerHour value=0.300000 annual=8760.000000
setpoints heatingMean=21.000000 coolingMean=24.000000
surface S0/E0/W1 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=0.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface S0/E0/W2 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=0.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface S0/E0/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=0.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface S0/E0/W4 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=0.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface S0/E1/W1 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=3.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface S0/E1/W2 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=3.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface S0/E1/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=3.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface S0/E1/W4 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=3.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface S1/E1/W1 Outdoors area=54.000000 wall (24.000000,0.000000)-(24.000000,18.000000) z=6.000000 facing=East glazing=15.600000 window(offset=1.809110 sill=0.678416 4.381780x1.643168) window(offset=8.552786 sill=0.829180 0.894427x1.341641) window(offset=11.809110 sill=0.678416 4.381780x1.643168)
surface S1/E1/W2 Outdoors area=72.000000 wall (24.000000,18.000000)-(0.000000,18.000000) z=6.000000 facing=North glazing=19.200000 window(offset=2.261387 sill=0.678416 5.477226x1.643168) window(offset=12.261387 sill=0.678416 5.477226x1.643168) window(offset=21.367544 sill=1.025658 1.264911x0.948683)
surface S1/E1/W3 Outdoors area=54.000000 wall (0.000000,18.000000)-(0.000000,0.000000) z=6.000000 facing=West glazing=5.400000 window(offset=6.153950 sill=1.025658 5.692100x0.948683)
surface S1/E1/W4 Outdoors area=72.000000 wall (0.000000,0.000000)-(24.000000,0.000000) z=6.000000 facing=South glazing=19.200000 window(offset=1.367544 sill=1.025658 1.264911x0.948683) window(offset=6.261387 sill=0.678416 5.477226x1.643168) window(offset=16.261387 sill=0.678416 5.477226x1.643168)
surface L0/ST/F1 Ground area=72.000000 Floor z=0.000000
surface L0/ST/C1 Interzone adjacent=E1 area=72.000000 Ceiling z=3.000000
surface L0/CO/F1 Ground area=40.000000 Floor z=0.000000
surface L0/CO/C1 Interzone adjacent=E1 area=40.000000 Ceiling z=3.000000
surface L0/US1/F1 Ground area=80.000000 Floor z=0.000000
surface L0/US1/C1 Interzone adjacent=E1 area=80.000000 Ceiling z=3.000000
surface L0/US2/F1 Ground area=80.000000 Floor z=0.000000
surface L0/US2/C1 Interzone adjacent=E1 area=80.000000 Ceiling z=3.000000
surface L0/UN1/F1 Ground area=80.000000 Floor z=0.000000
surface L0/UN1/C1 Interzone adjacent=E1 area=80.000000 Ceiling z=3.000000
surface L0/UN2/F1 Ground area=80.000000 Floor z=0.000000
surface L0/UN2/C1 Interzone adjacent=E1 area=80.000000 Ceiling z=3.000000
surface L1/ST/F1 Interzone adjacent=E0 area=72.000000 Floor z=3.000000
surface L1/CO/F1 Interzone adjacent=E0 area=40.000000 Floor z=3.000000
surface L1/US1/F1 Interzone adjacent=E0 area=80.000000 Floor z=3.000000
surface L1/US2/F1 Interzone adjacent=E0 area=80.000000 Floor z=3.000000
surface L1/UN1/F1 Interzone adjacent=E0 area=80.000000 Floor z=3.000000
surface L1/UN2/F1 Interzone adjacent=E0 area=80.000000 Floor z=3.000000
surface L2/ST/C1 Outdoors area=72.000000 Ceiling z=9.000000
surface L2/CO/C1 Outdoors area=40.000000 Ceiling z=9.000000
surface L2/US1/C1 Outdoors area=80.000000 Ceiling z=9.000000
surface L2/US2/C1 Outdoors area=80.000000 Ceiling z=9.000000
surface L2/UN1/C1 Outdoors area=80.000000 Ceiling z=9.000000
surface L2/UN2/C1 Outdoors area=80.000000 Ceiling z=9.000000
mass E1 area=432.000000 faces=2 z=6.000000
- [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
dotnet test BEMGen.sln -c Release
git status --short
Expected:
Passed! - Failed: 0, Passed: 43, Skipped: 0, Total: 43, Duration: … - Lod.Integration.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 81, Skipped: 0, Total: 81, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short lists the new class, the new snapshot, and AggregatorTests.cs; no *.received.txt.
- [x] Step 8: Commit
git add src/Lod.Core/Buildings/SingleZonePerFloorType.cs tests/Lod.Integration.Tests/AggregatorTests.cs tests/Lod.Integration.Tests/Snapshots/aggregator-SingleZonePerFloorType.txt
git commit -m "feature(aggregators): add the single zone per floor type aggregator"
Task 6: Unmodelled storeys of the zone multiplier¶
Files:
- Modify: src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs (Task 3)
- Test: modify tests/Lod.Integration.Tests/AggregatorTests.cs (Task 5)
Interfaces:
- Consumes: Storeys.Assemble, AssembledStack, PlacedStorey (Task 2).
- Produces: public Result<IReadOnlyList<PlacedStorey>> StackedFloorZoneMultiplier.UnmodelledStoreys(IReadOnlyList<FloorEntry> floors): the storeys of the assembled stack that are represented by a multiplied storey but not modelled, bottom to top, at their true elevations with multiplier 1, or the input errors (NoFloors, …). The grouping (D-046 split and representative storey, D-045) moves into the private methods Groups and Representative, shared with Aggregate; the building is unchanged.
- [x] Step 1: Write the failing tests — replace
tests/Lod.Integration.Tests/AggregatorTests.cs(full file)
New: ZoneMultiplierListsTheStoreysItRepresentsButDoesNotModel (1, 8, 1: storeys 1–4 and 6–8 at 3 … 24 m, each with the floor's zones and multiplier 1) and ZoneMultiplierWithoutMultipliedStoreysModelsEveryStorey (2, 1: none; no floors: NoFloors).
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Fact]
public void ZoneMultiplierListsTheStoreysItRepresentsButDoesNotModel()
{
IFloor floor = Pipeline.DetailedFloor();
FloorEntry[] entries = { new(floor, 1), new(floor, 8), new(floor, 1) };
IReadOnlyList<PlacedStorey> unmodelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(entries).Value;
// Storey 5 represents storeys 1-8 (D-045); the other seven are represented but not modelled, at their true elevations.
Assert.Equal(new[] { 1, 2, 3, 4, 6, 7, 8 }, unmodelled.Select(s => s.Index));
Assert.Equal(new[] { 3.0, 6.0, 9.0, 12.0, 18.0, 21.0, 24.0 }, unmodelled.Select(s => s.Elevation));
Assert.All(unmodelled, s => Assert.Equal(floor.Zones.Select(z => z.FloorArea), s.Zones.Select(z => z.FloorArea)));
Assert.All(unmodelled.SelectMany(s => s.Zones), z => Assert.Equal(1, z.Multiplier));
}
[Fact]
public void ZoneMultiplierWithoutMultipliedStoreysModelsEveryStorey()
{
IFloor floor = Pipeline.DetailedFloor();
Assert.Empty(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new[] { new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value);
Assert.Contains(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new FloorEntry[0]).Diagnostics, d => d.Code == DiagnosticCodes.NoFloors);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZonePerFloorTypeHasOneZonePerFloorEntry()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { "E0", "E1" }, building.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { "Floor type 0", "Floor type 1" }, building.Zones.Select(z => z.Name));
Assert.Equal(new[] { 1, 3 }, building.Zones.Select(z => z.Parts.Count));
Assert.Equal(new[] { 6, 18 }, building.Zones.Select(z => z.SourceZones.Count));
Assert.All(building.Zones, z => Assert.Equal(1, z.Multiplier));
Assert.Equal(3 * 432.0 * 3.0, building.Zones[1].Volume, 6);
Assert.Equal(new[] { 0.0, 3.0, 6.0, 9.0 }, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation));
}
[Fact]
public void SingleZonePerFloorTypeKeepsTheSlabsBetweenEntriesAsInterzonePairs()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
// Within E1 the slabs at 6 m and 9 m become internal mass (D-031); the slab at 3 m lies between E0 and E1 (D-068, D-071).
Assert.Equal(new[] { ("E1", 6.0), ("E1", 9.0) }, building.InternalMasses.Select(m => (m.Zone.Value, m.Elevation)));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
Assert.All(interzone, h => Assert.Equal(3.0, h.Elevation));
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 6);
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E0").Sum(h => h.Area), 6);
Assert.Equal(interzone.Length, interzone.Count(h => h.Kind == HorizontalKind.Ceiling) * 2);
Assert.DoesNotContain(building.Surfaces.OfType<WallSurface>(), w => w.Boundary != BoundaryCondition.Outdoors);
}
[Fact]
public void SingleZonePerFloorTypeConservesEveryInvariant()
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(wide, 2), new FloorEntry(narrow, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Equal(2 * wide.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count) + (3 * narrow.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count)), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
}
[Fact]
public void OneFloorEntryGivesTheSingleZoneBuildingUnderAnotherName()
{
FloorEntry[] entries = { new(Pipeline.DetailedFloor(), 3) };
string perType = TextReport.Describe(new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(entries).Value);
string building = TextReport.Describe(new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(entries).Value);
Assert.Equal(building.Replace("BUILDING", "E0").Replace("\"Building\"", "\"Floor type 0\""), perType);
}
[Fact]
public void SingleZoneBuildingHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneBuildingTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneBuildingKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Simplifier("PerimeterCore"));
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
}
[Fact]
public void SingleZoneBuildingSnapshot()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneBuilding");
}
[Fact]
public void SingleZonePerFloorTypeSnapshot()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZonePerFloorType");
}
private static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Pipeline.Tolerances),
"PerimeterCore" => new PerimeterCore(Pipeline.Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
private static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Pipeline.Tolerances),
"StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(Pipeline.Tolerances),
"SingleZonePerFloorType" => new SingleZonePerFloorType(Pipeline.Tolerances),
"SingleZoneBuilding" => new SingleZoneBuilding(Pipeline.Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
Expected: the build fails, each error reported once, for the net8.0 target:
tests\Lod.Integration.Tests\AggregatorTests.cs(55,102): error CS1061: 'StackedFloorZoneMultiplier' does not contain a definition for 'UnmodelledStoreys' and no accessible extension method 'UnmodelledStoreys' accepting a first argument of type 'StackedFloorZoneMultiplier' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(69,74): error CS1061: 'StackedFloorZoneMultiplier' does not contain a definition for 'UnmodelledStoreys' and no accessible extension method 'UnmodelledStoreys' accepting a first argument of type 'StackedFloorZoneMultiplier' could be found (are you missing a using directive or an assembly reference?)
tests\Lod.Integration.Tests\AggregatorTests.cs(70,77): error CS1061: 'StackedFloorZoneMultiplier' does not contain a definition for 'UnmodelledStoreys' and no accessible extension method 'UnmodelledStoreys' accepting a first argument of type 'StackedFloorZoneMultiplier' could be found (are you missing a using directive or an assembly reference?)
- [x] Step 3: Implement — replace
src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>
/// Stacked floor zone multiplier (D-071): representative storeys with zone multipliers (D-032). Floor types are listed bottom to top; type <c>i</c> stands for <c>Nᵢ</c> storeys.
/// <list type="bullet">
/// <item>Exposure is never multiplied (D-046): a storey of a multiplied type whose floor or ceiling is exposed
/// on the assembled stack (ground, roof, setback, overhang) is split off as its own ×1 storey, reported with an info diagnostic.</item>
/// <item>The remaining storeys of a type become one storey with their count as zone multiplier, placed at the whole storey nearest the middle
/// of the storeys it stands for (D-045). Every storey sits at its true elevation and the building keeps its true height.</item>
/// <item>Floors and ceilings are designated on the assembled stack (D-068): each representative storey keeps its ground, roof, and exposed pieces,
/// and every piece between two storeys is adiabatic, whether the storey on the other side is modelled or not (D-075).</item>
/// </list>
/// </summary>
public sealed class StackedFloorZoneMultiplier : IFloorAggregator
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the aggregator.</summary>
/// <param name="tolerances">Tolerances.</param>
public StackedFloorZoneMultiplier(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <inheritdoc />
public Result<IGeneratedBuilding> Aggregate(IReadOnlyList<FloorEntry> floors)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, _tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
var diagnostics = new List<Diagnostic>(assembled.Diagnostics);
IReadOnlyList<(IFloor Floor, int First, int Count)> groups = Groups(floors, stack, diagnostics);
int[] representative = groups.Select(Representative).ToArray();
PlacedStorey[] storeys = groups
.Select((g, k) => Storeys.PlaceAt(g.Floor, representative[k], stack.Storeys[representative[k]].Elevation, g.Count))
.ToArray();
IEnumerable<HorizontalSurface> horizontal = representative
.SelectMany(k => stack.Horizontal[k])
.Select(h => h.Boundary == BoundaryCondition.Interzone
? new HorizontalSurface(h.Id, h.Zone, h.Kind, h.Polygon, h.Elevation, BoundaryCondition.Adiabatic, null)
: h);
IEnumerable<Surface> surfaces = storeys.SelectMany(s => s.Walls).Cast<Surface>().Concat(horizontal);
var provenance = Provenance.Of(
nameof(StackedFloorZoneMultiplier),
new[]
{
new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))),
new KeyValuePair<string, string>("Storeys", string.Join(",", groups.Select((g, k) => $"L{representative[k]}x{g.Count}"))),
},
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(
floors[0].Floor.OrientationDegrees,
storeys.SelectMany(s => s.Zones),
surfaces,
Enumerable.Empty<InternalMass>(),
floors,
provenance);
return Result.Success(building, diagnostics);
}
/// <summary>
/// The storeys of the assembled stack that <see cref="Aggregate"/> represents by a multiplied storey but does not model (D-071), at their true
/// elevations with multiplier 1; used to preview the whole stacked building.
/// </summary>
/// <param name="floors">Floor entries, bottom to top, as given to <see cref="Aggregate"/>.</param>
/// <returns>The unmodelled storeys, bottom to top, or the input errors.</returns>
public Result<IReadOnlyList<PlacedStorey>> UnmodelledStoreys(IReadOnlyList<FloorEntry> floors)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, _tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IReadOnlyList<PlacedStorey>>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
var modelled = new HashSet<int>(Groups(floors, stack, new List<Diagnostic>()).Select(Representative));
return Result.Success<IReadOnlyList<PlacedStorey>>(stack.Storeys.Where(s => !modelled.Contains(s.Index)).ToArray());
}
/// <summary>The whole storey nearest the middle of a group (D-045).</summary>
private static int Representative((IFloor Floor, int First, int Count) group) => group.First + (group.Count / 2);
/// <summary>
/// Groups of storeys with the same floor type, bottom to top: the exposed storeys of a multiplied type are split off as ×1 groups (D-046), each
/// split reported as <see cref="DiagnosticCodes.FloorTypeSplit"/>.
/// </summary>
private IReadOnlyList<(IFloor Floor, int First, int Count)> Groups(IReadOnlyList<FloorEntry> floors, AssembledStack stack, List<Diagnostic> diagnostics)
{
var groups = new List<(IFloor Floor, int First, int Count)>();
int next = 0;
for (int i = 0; i < floors.Count; i++)
{
FloorEntry entry = floors[i];
bool floorExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next, HorizontalKind.Floor));
bool ceilingExposed = !_tolerances.AreaEquals(0.0, stack.ExposedArea(next + entry.Multiplier - 1, HorizontalKind.Ceiling));
int bottom = floorExposed && entry.Multiplier > 1 ? 1 : 0;
int top = ceilingExposed && entry.Multiplier - bottom > 1 ? 1 : 0;
int middle = entry.Multiplier - bottom - top;
if (bottom + top > 0)
{
string parts = string.Join(" + ", new[] { bottom, middle, top }.Where(n => n > 0).Select(n => $"x{n}"));
diagnostics.Add(Diagnostic.Info(
DiagnosticCodes.FloorTypeSplit,
$"Floor type {i} (x{entry.Multiplier}) has an exposed {(bottom > 0 ? "floor" : "ceiling")}{(bottom > 0 && top > 0 ? " and ceiling" : string.Empty)}; split into {parts} so exposure is not multiplied (D-046)."));
}
if (bottom > 0)
{
groups.Add((entry.Floor, next, 1));
}
groups.Add((entry.Floor, next + bottom, middle));
if (top > 0)
{
groups.Add((entry.Floor, next + bottom + middle, 1));
}
next += entry.Multiplier;
}
return groups;
}
}
- [x] Step 4: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.AggregatorTests"
dotnet test BEMGen.sln -c Release
Expected:
Passed! - Failed: 0, Passed: 45, Skipped: 0, Total: 45, Duration: … - Lod.Integration.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 83, Skipped: 0, Total: 83, Duration: … - Lod.Integration.Tests.dll (net8.0)
- [x] Step 5: Commit
git add src/Lod.Core/Buildings/StackedFloorZoneMultiplier.cs tests/Lod.Integration.Tests/AggregatorTests.cs
git commit -m "feature(aggregators): list the storeys the zone multiplier represents but does not model"
- [x] Step 6: Merge Slice B (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/aggregators-assembled-stack
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/aggregators-assembled-stack
git branch -d feature/aggregators-assembled-stack
Expected: VERIFY PASSED, with 120 core, 19 generators, and 83 integration tests passed on net8.0. Do not push (D-075).
Slice C — feature/plans-transform¶
Task 7: Plan transform¶
Files:
- Create: src/Lod.Core/Geometry/PlanMotion.cs, src/Lod.Core/Plans/PlanTransform.cs
- Modify: src/Lod.Core/Geometry/Polygon2.cs (S2 file), src/Lod.Core/Model/Surfaces.cs (S2 file), src/Lod.Core/Model/Zone.cs (Task 4)
- Test: create tests/Lod.Core.Tests/Geometry/PlanMotionTests.cs, tests/Lod.Integration.Tests/PlanTransformTests.cs; modify tests/Lod.Integration.Tests/Pipeline.cs (S2 file, last changed in S4.1), tests/Lod.Integration.Tests/AggregatorTests.cs (Task 6)
Interfaces:
- Consumes (S2): Point2(double X, double Y); Polygon2 (Outer, Holes, Rings, Area, SignedArea, Rectangle); WallSurface(SurfaceId, ZoneId, Point2 start, Point2 end, double elevation, double height, BoundaryCondition, ZoneId? adjacent, IEnumerable<Window>) and its PlanAzimuth (computed from the end points); HorizontalSurface; Zone(ZoneId, string, SpaceType, IEnumerable<ZonePart>, ZoneProgram, IEnumerable<ZoneId>, int); GeneratedPlan(Polygon2 footprint, double floorHeight, double orientationDegrees, IEnumerable<Zone>, IEnumerable<Surface>, Provenance); Provenance.Of, Provenance.Format(double); Totals.Of(zones, surfaces, orientation); Orientation.Normalise, Orientation.Bin; test support Pipeline.Plan(...).
- Produces:
- public sealed class PlanMotion(double rotationDegrees, double translationX, double translationY) in Lod.Core.Geometry with RotationDegrees, TranslationX, TranslationY, and Apply(Point2) : Point2: rotation about the origin, counter-clockwise seen from above, then translation; whole quarter turns use exact cosines and sines.
- Polygon2.Transform(PlanMotion) : Polygon2; Zone.Transform(PlanMotion) : Zone (parts moved, everything else kept); public abstract Surface Surface.Transform(PlanMotion) with overrides for walls (end points moved, windows kept with their offsets, sills, and sizes) and horizontal surfaces (polygon moved).
- public static class PlanTransform in Lod.Core.Plans with const string Operation = "TransformPlan" and Apply(IGeneratedPlan plan, double rotationDegrees, double translationX, double translationY) : Result<IGeneratedPlan>: footprint, zones, and surfaces moved; IDs, floor height, and orientation (true north) kept; provenance parameters RotationDegrees, TranslationX, TranslationY (Provenance.Format) with the plan's provenance as input; one InvalidParameter per value that is not finite.
- Test support: Pipeline.Simplifier(string name) : IPlanSimplifier and Pipeline.Aggregator(string name) : IFloorAggregator (moved from AggregatorTests, which now calls them).
- [x] Step 1: Create the branch
git switch -c feature/plans-transform main
- [x] Step 2: Write the failing core tests — create
tests/Lod.Core.Tests/Geometry/PlanMotionTests.cs(full file)
using System;
using System.Linq;
using Lod.Core.Geometry;
using Xunit;
namespace Lod.Core.Tests.Geometry;
public sealed class PlanMotionTests
{
[Fact]
public void RotatesCounterClockwiseAboutTheOriginThenTranslates()
{
var motion = new PlanMotion(30.0, 5.3, -2.1);
Point2 moved = motion.Apply(new Point2(2.0, 0.0));
Assert.Equal((2.0 * Math.Cos(Math.PI / 6.0)) + 5.3, moved.X, 12);
Assert.Equal((2.0 * Math.Sin(Math.PI / 6.0)) - 2.1, moved.Y, 12);
}
[Theory]
[InlineData(90.0, 0.0, 1.0)]
[InlineData(180.0, -1.0, 0.0)]
[InlineData(-90.0, 0.0, -1.0)]
[InlineData(450.0, 0.0, 1.0)]
public void QuarterTurnsAreExact(double degrees, double x, double y)
{
Point2 moved = new PlanMotion(degrees, 0.0, 0.0).Apply(new Point2(1.0, 0.0));
Assert.Equal(new Point2(x, y), moved);
}
[Fact]
public void TransformedPolygonKeepsItsAreaAndOrientation()
{
Polygon2 rectangle = Polygon2.Rectangle(0.0, 0.0, 24.0, 18.0);
Polygon2 moved = rectangle.Transform(new PlanMotion(30.0, 5.3, -2.1));
Assert.Equal(rectangle.Area, moved.Area, 9);
Assert.True(Polygon2.SignedArea(moved.Outer) > 0);
Assert.Equal(rectangle.Outer.Count, moved.Outer.Count);
Assert.Equal(new Point2(5.3, -2.1), moved.Outer.First());
}
}
- [x] Step 3: Write the failing integration tests
Replace tests/Lod.Integration.Tests/Pipeline.cs with the following full file. The simplifier and aggregator switches move here from AggregatorTests so that the new PlanTransformTests can use them too.
using System;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Generators.Linear;
namespace Lod.Integration.Tests;
/// <summary>Shared pipeline steps for integration tests.</summary>
internal static class Pipeline
{
/// <summary>24 m × 18 m, 4 m stair, two 10 m units per row, 3 m storeys.</summary>
public static readonly LinearPlanParameters Canonical = new(24.0, 8.0, 2.0, 10.0, 4.0, 3.0, 0.0);
/// <summary>The example presets, one per slot of the linear plan.</summary>
public static readonly LinearPlanPresets Presets = new(ExampleResidentialPresets.DwellingUnit, ExampleResidentialPresets.Corridor, ExampleResidentialPresets.Stair);
public static ToleranceSettings Tolerances => ToleranceSettings.Default;
public static IGeneratedPlan Plan(LinearPlanParameters? parameters = null) =>
new LinearPlanGenerator(Tolerances).Generate(parameters ?? Canonical, Presets).Value;
public static IFloor Floor(IPlanSimplifier simplifier, LinearPlanParameters? parameters = null) =>
simplifier.Simplify(Plan(parameters)).Value;
public static IFloor DetailedFloor(LinearPlanParameters? parameters = null) => Floor(new NoSimplification(), parameters);
/// <summary>A plan simplifier by name; Perimeter Core with the 4.57 m default depth.</summary>
public static IPlanSimplifier Simplifier(string name) => name switch
{
"NoSimplification" => new NoSimplification(),
"SemanticMerge" => new SemanticMerge(Tolerances),
"PerimeterCore" => new PerimeterCore(Tolerances, 4.57),
"SingleZonePerFloor" => new SingleZonePerFloor(Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown simplifier."),
};
/// <summary>A floor aggregator by name.</summary>
public static IFloorAggregator Aggregator(string name) => name switch
{
"Stack" => new Stack(Tolerances),
"StackedFloorZoneMultiplier" => new StackedFloorZoneMultiplier(Tolerances),
"SingleZonePerFloorType" => new SingleZonePerFloorType(Tolerances),
"SingleZoneBuilding" => new SingleZoneBuilding(Tolerances),
_ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown aggregator."),
};
}
Replace tests/Lod.Integration.Tests/AggregatorTests.cs with the following full file. Only the private Simplifier and Aggregator helpers are deleted and their callers use Pipeline.Simplifier and Pipeline.Aggregator.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Validation;
using Lod.Tests.Shared;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class AggregatorTests
{
[Fact]
public void ZoneMultiplierCarriesMultipliersAndOrderedBoundaries()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
Assert.Equal(new[] { 1, 8, 1 }, building.Zones.GroupBy(z => z.Id.Value.Substring(0, 2)).Select(g => g.First().Multiplier));
HorizontalSurface[] horizontal = building.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L0/") && h.Kind == HorizontalKind.Floor), h => Assert.Equal(BoundaryCondition.Ground, h.Boundary));
Assert.All(horizontal.Where(h => h.Zone.Value.StartsWith("L9/") && h.Kind == HorizontalKind.Ceiling), h => Assert.Equal(BoundaryCondition.Outdoors, h.Boundary));
Assert.Equal(
horizontal.Length - 12,
horizontal.Count(h => h.Boundary == BoundaryCondition.Adiabatic));
Assert.Equal(10 * floor.Zones.Sum(z => z.FloorArea), building.Zones.Sum(z => z.Multiplier * z.FloorArea), 6);
}
[Fact]
public void RepresentativeStoreysKeepTheirTrueElevations()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
// Storeys 1-8 are represented by storey 5, the whole storey nearest their middle (D-045); the top storey stays storey 9.
Assert.Equal(new[] { "L0/", "L5/", "L9/" }, building.Zones.Select(z => z.Id.Value.Substring(0, 3)).Distinct());
Assert.Equal(new[] { 0.0, 15.0, 27.0 }, building.Zones.Select(z => z.Parts[0].Elevation).Distinct());
Assert.Equal(30.0, Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height, 9);
Assert.Equal("L0x1,L5x8,L9x1", building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
}
[Fact]
public void ZoneMultiplierListsTheStoreysItRepresentsButDoesNotModel()
{
IFloor floor = Pipeline.DetailedFloor();
FloorEntry[] entries = { new(floor, 1), new(floor, 8), new(floor, 1) };
IReadOnlyList<PlacedStorey> unmodelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(entries).Value;
// Storey 5 represents storeys 1-8 (D-045); the other seven are represented but not modelled, at their true elevations.
Assert.Equal(new[] { 1, 2, 3, 4, 6, 7, 8 }, unmodelled.Select(s => s.Index));
Assert.Equal(new[] { 3.0, 6.0, 9.0, 12.0, 18.0, 21.0, 24.0 }, unmodelled.Select(s => s.Elevation));
Assert.All(unmodelled, s => Assert.Equal(floor.Zones.Select(z => z.FloorArea), s.Zones.Select(z => z.FloorArea)));
Assert.All(unmodelled.SelectMany(s => s.Zones), z => Assert.Equal(1, z.Multiplier));
}
[Fact]
public void ZoneMultiplierWithoutMultipliedStoreysModelsEveryStorey()
{
IFloor floor = Pipeline.DetailedFloor();
Assert.Empty(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new[] { new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value);
Assert.Contains(new StackedFloorZoneMultiplier(Pipeline.Tolerances).UnmodelledStoreys(new FloorEntry[0]).Diagnostics, d => d.Code == DiagnosticCodes.NoFloors);
}
[Theory]
[InlineData(new[] { 5 }, "L0x1,L2x3,L4x1")]
[InlineData(new[] { 2, 1 }, "L0x1,L1x1,L2x1")]
[InlineData(new[] { 1, 8, 2 }, "L0x1,L5x8,L9x1,L10x1")]
public void ExposedStoreysOfMultipliedTypesAreSplitOff(int[] multipliers, string storeys)
{
IFloor floor = Pipeline.DetailedFloor();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(multipliers.Select(n => new FloorEntry(floor, n)).ToArray());
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.Equal(storeys, result.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Contains(result.Diagnostics, d => d.Code == DiagnosticCodes.FloorTypeSplit && d.Severity == DiagnosticSeverity.Info);
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Theory]
[InlineData("setback", "wide x1, wide x4, narrow x3", "L0x1,L2x3,L4x1,L6x2,L7x1", "")]
[InlineData("overhang", "narrow x2, wide x3", "L0x1,L1x1,L2x1,L3x1,L4x1", "L2")]
public void VaryingFootprintsKeepExposedAreasWithoutMultiplyingThem(string name, string types, string storeys, string unsupported)
{
// Wide: 24 m x 18 m, two units per row. Narrow: 14 m x 18 m from the same west end, one unit per row. Either way 180 m² is exposed.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = types.Split(',')
.Select(t => t.Trim().Split(new[] { " x" }, StringSplitOptions.None))
.Select(t => new FloorEntry(t[0] == "wide" ? wide : narrow, int.Parse(t[1], System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();
Result<IGeneratedBuilding> result = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
IGeneratedBuilding building = result.Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.Equal(storeys, building.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
Assert.Equal(unsupported, string.Join(",", result.Diagnostics.Where(d => d.Code == DiagnosticCodes.UnsupportedStorey).Select(d => d.Subject)));
Assert.All(report.Checks, c => Assert.True(c.Passed, $"{name}: {c.Check}"));
Dictionary<ZoneId, int> multiplier = building.Zones.ToDictionary(z => z.Id, z => z.Multiplier);
HorizontalSurface[] exposed = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary != BoundaryCondition.Adiabatic).ToArray();
Assert.All(exposed, h => Assert.Equal(1, multiplier[h.Zone]));
double roof = Totals.Of(building.Zones, building.Surfaces, building.OrientationDegrees).Height;
Assert.Equal(180.0, exposed.Where(h => h.Boundary == BoundaryCondition.Outdoors && h.Elevation < roof - 1.0).Sum(h => h.Area), 6);
}
[Fact]
public void MultiplierTakesTheDesignationsOfItsStoreysFromTheStack()
{
// D-068: every floor and ceiling piece of a modelled storey is the stacked piece with the same ID; pieces between storeys become adiabatic.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
FloorEntry[] entries = { new(wide, 1), new(wide, 4), new(narrow, 3), new(wide, 2) };
Dictionary<SurfaceId, HorizontalSurface> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToDictionary(h => h.Id);
HorizontalSurface[] modelled = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries).Value
.Surfaces.OfType<HorizontalSurface>().ToArray();
Assert.NotEmpty(modelled);
Assert.All(modelled, h =>
{
HorizontalSurface source = stacked[h.Id];
Assert.Equal(source.Boundary == BoundaryCondition.Interzone ? BoundaryCondition.Adiabatic : source.Boundary, h.Boundary);
Assert.Null(h.AdjacentZone);
Assert.Equal(source.Zone, h.Zone);
Assert.Equal(source.Kind, h.Kind);
Assert.Equal(source.Elevation, h.Elevation);
Assert.Equal(source.Area, h.Area, 9);
});
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AnOverhangingStoreyIsReportedAsUnsupported(string aggregator)
{
// Narrow (14 m) x2 below wide (24 m) x1: storey 2 overhangs storey 1 by 10 m x 18 m (D-074). The building is still produced and validates.
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(narrow, 2), new FloorEntry(wide, 1) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("storey 2", warning.Message);
Assert.Contains("180.000000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void SetbacksAndIdenticalStoreysAreSupported(string aggregator)
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(wide, 3), new FloorEntry(narrow, 2) });
Assert.True(result.IsSuccess);
Assert.DoesNotContain(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
}
[Fact]
public void BottomMiddleTopMultiplierMatchesTheStackExactly()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 8), new FloorEntry(floor, 1) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks, c => Assert.True(c.Passed, c.Check));
}
[Fact]
public void SingleZonePerFloorTypeHasOneZonePerFloorEntry()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { "E0", "E1" }, building.Zones.Select(z => z.Id.Value));
Assert.Equal(new[] { "Floor type 0", "Floor type 1" }, building.Zones.Select(z => z.Name));
Assert.Equal(new[] { 1, 3 }, building.Zones.Select(z => z.Parts.Count));
Assert.Equal(new[] { 6, 18 }, building.Zones.Select(z => z.SourceZones.Count));
Assert.All(building.Zones, z => Assert.Equal(1, z.Multiplier));
Assert.Equal(3 * 432.0 * 3.0, building.Zones[1].Volume, 6);
Assert.Equal(new[] { 0.0, 3.0, 6.0, 9.0 }, building.Zones.SelectMany(z => z.Parts).Select(p => p.Elevation));
}
[Fact]
public void SingleZonePerFloorTypeKeepsTheSlabsBetweenEntriesAsInterzonePairs()
{
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 1), new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
// Within E1 the slabs at 6 m and 9 m become internal mass (D-031); the slab at 3 m lies between E0 and E1 (D-068, D-071).
Assert.Equal(new[] { ("E1", 6.0), ("E1", 9.0) }, building.InternalMasses.Select(m => (m.Zone.Value, m.Elevation)));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
HorizontalSurface[] interzone = building.Surfaces.OfType<HorizontalSurface>().Where(h => h.Boundary == BoundaryCondition.Interzone).ToArray();
Assert.All(interzone, h => Assert.Equal(3.0, h.Elevation));
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E0" && h.Kind == HorizontalKind.Ceiling && h.AdjacentZone!.Value.Value == "E1").Sum(h => h.Area), 6);
Assert.Equal(432.0, interzone.Where(h => h.Zone.Value == "E1" && h.Kind == HorizontalKind.Floor && h.AdjacentZone!.Value.Value == "E0").Sum(h => h.Area), 6);
Assert.Equal(interzone.Length, interzone.Count(h => h.Kind == HorizontalKind.Ceiling) * 2);
Assert.DoesNotContain(building.Surfaces.OfType<WallSurface>(), w => w.Boundary != BoundaryCondition.Outdoors);
}
[Fact]
public void SingleZonePerFloorTypeConservesEveryInvariant()
{
IFloor wide = Pipeline.DetailedFloor();
IFloor narrow = Pipeline.DetailedFloor(Pipeline.Canonical with { Length = 14.0 });
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(wide, 2), new FloorEntry(narrow, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Equal(2 * wide.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count) + (3 * narrow.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count)), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
}
[Fact]
public void OneFloorEntryGivesTheSingleZoneBuildingUnderAnotherName()
{
FloorEntry[] entries = { new(Pipeline.DetailedFloor(), 3) };
string perType = TextReport.Describe(new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(entries).Value);
string building = TextReport.Describe(new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(entries).Value);
Assert.Equal(building.Replace("BUILDING", "E0").Replace("\"Building\"", "\"Floor type 0\""), perType);
}
[Fact]
public void SingleZoneBuildingHasOneZoneWithOnePartPerStorey()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Zone zone = Assert.Single(building.Zones);
Assert.Equal(3, zone.Parts.Count);
Assert.Equal(3 * 432.0 * 3.0, zone.Volume, 6);
Assert.Equal(18, zone.SourceZones.Count);
Assert.DoesNotContain(building.Surfaces, s => s.Boundary == BoundaryCondition.Interzone);
}
[Fact]
public void SingleZoneBuildingTurnsSlabsIntoInternalMass()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 3) }).Value;
Assert.Equal(new[] { 3.0, 6.0 }, building.InternalMasses.Select(m => m.Elevation));
Assert.All(building.InternalMasses, m => Assert.Equal(432.0, m.SlabArea, 6));
Assert.All(building.InternalMasses, m => Assert.Equal(2, m.ExposedFaces));
Assert.All(building.InternalMasses, m => Assert.Equal(12, m.SourceSurfaces.Count));
}
[Fact]
public void SingleZoneBuildingKeepsGroundRoofAndGlazing()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 3) }).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
Assert.All(report.Checks.Where(c => c.Enforced), c => Assert.True(c.Passed, c.Check));
Assert.Contains(report.Checks, c => c.Check == "Building.GroundArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.RoofArea" && c.Passed && c.Enforced);
Assert.Contains(report.Checks, c => c.Check == "Building.Height" && c.Passed && c.Enforced);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), building.Surfaces.OfType<WallSurface>().Sum(w => w.GlazedArea), 6);
Assert.Equal(3 * floor.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count), building.Surfaces.OfType<WallSurface>().Sum(w => w.Windows.Count));
Assert.Equal(12, building.Surfaces.OfType<WallSurface>().Count());
}
public static IEnumerable<object[]> Matrix() =>
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
select new object[] { simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EverySimplifierAndAggregatorCombinationValidates(string simplifier, string aggregator)
{
IFloor floor = Pipeline.Floor(Pipeline.Simplifier(simplifier));
FloorEntry[] entries = { new(floor, 1), new(floor, 3), new(floor, 1) };
IGeneratedBuilding building = Pipeline.Aggregator(aggregator).Aggregate(entries).Value;
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(building);
Assert.True(report.Passed, report.Describe());
}
[Fact]
public void MultiplierSnapshot()
{
IFloor floor = Pipeline.Floor(Pipeline.Simplifier("PerimeterCore"));
IGeneratedBuilding building = new StackedFloorZoneMultiplier(Pipeline.Tolerances)
.Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2), new FloorEntry(floor, 1) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-StackedFloorZoneMultiplier");
}
[Fact]
public void SingleZoneBuildingSnapshot()
{
IGeneratedBuilding building = new SingleZoneBuilding(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(Pipeline.DetailedFloor(), 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZoneBuilding");
}
[Fact]
public void SingleZonePerFloorTypeSnapshot()
{
IFloor floor = Pipeline.DetailedFloor();
IGeneratedBuilding building = new SingleZonePerFloorType(Pipeline.Tolerances).Aggregate(new[] { new FloorEntry(floor, 1), new FloorEntry(floor, 2) }).Value;
Snapshot.Match(TextReport.Describe(building), "aggregator-SingleZonePerFloorType");
}
}
Create tests/Lod.Integration.Tests/PlanTransformTests.cs. The tests check, on the canonical plan: area, volume, exterior wall area, glazing, IDs, orientation, and floor height after rotations of 0°, 90°, and 30° with a translation; glazing per orientation after 90° (north to west, east to north, …) and 30° (every façade stays in its bin); the facing of every wall after 90°, recomputed from the geometry, with the windows unchanged; a translation that moves every point; determinism and the provenance record; one InvalidParameter per value that is not finite; and, for each of the four aggregators, a floor from a plan moved by (5.3, −2.1) stacked on the canonical floor: one UnsupportedStorey warning on L2 with 134.67 m² (the plans overlap on 18.7 m × 15.9 m), a building that validates, 134.67 m² of exposed floor, 566.67 m² of roof, and 432 m² of ground.
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.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class PlanTransformTests
{
[Theory]
[InlineData(0.0)]
[InlineData(90.0)]
[InlineData(30.0)]
public void RotationKeepsAreaVolumeWallAreaAndGlazing(double degrees)
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, degrees, 5.3, -2.1).Value;
Totals before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees);
Totals after = Totals.Of(moved.Zones, moved.Surfaces, moved.OrientationDegrees);
Assert.True(Pipeline.Tolerances.AreaEquals(plan.Footprint.Area, moved.Footprint.Area));
Assert.True(Pipeline.Tolerances.AreaEquals(before.FloorArea, after.FloorArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Volume, after.Volume));
Assert.True(Pipeline.Tolerances.AreaEquals(before.ExteriorWallArea, after.ExteriorWallArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Glazing, after.Glazing));
Assert.Equal(plan.Zones.Select(z => z.Id), moved.Zones.Select(z => z.Id));
Assert.Equal(plan.Surfaces.Select(s => s.Id), moved.Surfaces.Select(s => s.Id));
Assert.Equal(plan.OrientationDegrees, moved.OrientationDegrees);
Assert.Equal(plan.FloorHeight, moved.FloorHeight);
}
[Fact]
public void GlazingPerOrientationFollowsTheRotatedFacades()
{
IGeneratedPlan plan = Pipeline.Plan();
IReadOnlyDictionary<OrientationBin, double> before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees).GlazingByOrientation;
IGeneratedPlan quarter = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
IGeneratedPlan thirty = PlanTransform.Apply(plan, 30.0, 0.0, 0.0).Value;
IReadOnlyDictionary<OrientationBin, double> afterQuarter = Totals.Of(quarter.Zones, quarter.Surfaces, quarter.OrientationDegrees).GlazingByOrientation;
IReadOnlyDictionary<OrientationBin, double> afterThirty = Totals.Of(thirty.Zones, thirty.Surfaces, thirty.OrientationDegrees).GlazingByOrientation;
// A quarter turn counter-clockwise turns a north façade west, east north, south east, and west south.
Assert.Equal(before[OrientationBin.North], afterQuarter[OrientationBin.West], 9);
Assert.Equal(before[OrientationBin.East], afterQuarter[OrientationBin.North], 9);
Assert.Equal(before[OrientationBin.South], afterQuarter[OrientationBin.East], 9);
Assert.Equal(before[OrientationBin.West], afterQuarter[OrientationBin.South], 9);
// 30° keeps every façade azimuth inside its 90° bin (0° → 330°, 90° → 60°, ...).
Assert.All(before.Keys, bin => Assert.Equal(before[bin], afterThirty[bin], 9));
}
[Fact]
public void WallFacingIsRecomputedFromTheRotatedGeometry()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
WallSurface[] original = plan.Surfaces.OfType<WallSurface>().ToArray();
WallSurface[] rotated = moved.Surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(original.Length, rotated.Length);
for (int i = 0; i < original.Length; i++)
{
Assert.Equal(Orientation.Normalise(original[i].PlanAzimuth - 90.0), rotated[i].PlanAzimuth, 9);
Assert.Equal(original[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)), rotated[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)));
}
WallSurface northFacing = original.First(w => w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.North);
Assert.Equal(OrientationBin.West, Orientation.Bin(rotated[Array.IndexOf(original, northFacing)].PlanAzimuth));
}
[Fact]
public void TranslationOnlyMovesEveryPoint()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 0.0, 5.3, -2.1).Value;
Assert.Equal(plan.Footprint.Outer.Select(p => new Point2(p.X + 5.3, p.Y - 2.1)), moved.Footprint.Outer);
Assert.Equal(
plan.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer));
Assert.Equal(
plan.Surfaces.OfType<WallSurface>().Select(w => (new Point2(w.Start.X + 5.3, w.Start.Y - 2.1), new Point2(w.End.X + 5.3, w.End.Y - 2.1), w.Elevation, w.Height)),
moved.Surfaces.OfType<WallSurface>().Select(w => (w.Start, w.End, w.Elevation, w.Height)));
Assert.Equal(
plan.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer));
Assert.All(moved.Surfaces.OfType<HorizontalSurface>(), h => Assert.Equal(BoundaryCondition.Unresolved, h.Boundary));
}
[Fact]
public void TransformIsDeterministicAndRecordedInProvenance()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan first = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
IGeneratedPlan second = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
Assert.Equal(TextReport.Describe(first), TextReport.Describe(second));
Assert.Equal("TransformPlan", first.Provenance.Operation);
Assert.Equal(
new[] { "RotationDegrees=30", "TranslationX=5.3", "TranslationY=-2.1" },
first.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
Assert.Same(plan.Provenance, Assert.Single(first.Provenance.Inputs));
}
[Theory]
[InlineData(double.NaN, 0.0, 0.0)]
[InlineData(0.0, double.PositiveInfinity, 0.0)]
[InlineData(0.0, 0.0, double.NegativeInfinity)]
[InlineData(double.PositiveInfinity, double.NaN, double.NaN)]
public void NonFiniteInputsAreInvalidParameters(double degrees, double x, double y)
{
Result<IGeneratedPlan> result = PlanTransform.Apply(Pipeline.Plan(), degrees, x, y);
Assert.False(result.IsSuccess);
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.InvalidParameter, d.Code));
Assert.Equal(new[] { degrees, x, y }.Count(v => double.IsNaN(v) || double.IsInfinity(v)), result.Diagnostics.Count);
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AStoreyMovedOffTheStoreyBelowIsUnsupported(string aggregator)
{
// The upper plan is moved by (5.3, -2.1): it overlaps the lower 24 m x 18 m plan on 18.7 m x 15.9 m = 297.33 m², so 134.67 m² overhang.
IFloor lower = new NoSimplification().Simplify(Pipeline.Plan()).Value;
IFloor upper = new NoSimplification().Simplify(PlanTransform.Apply(Pipeline.Plan(), 0.0, 5.3, -2.1).Value).Value;
Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(lower, 2), new FloorEntry(upper, 2) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("134.670000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
Totals totals = Totals.Of(result.Value.Zones, result.Value.Surfaces, result.Value.OrientationDegrees);
Assert.Equal(134.67, totals.ExposedFloorArea, 6);
Assert.Equal(432.0 + 134.67, totals.RoofArea, 6);
Assert.Equal(432.0, totals.GroundArea, 6);
}
}
- [x] Step 4: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.PlanMotionTests"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
Expected: both builds fail, each error reported once, for the net8.0 target. Core:
tests\Lod.Core.Tests\Geometry\PlanMotionTests.cs(13,26): error CS0246: The type or namespace name 'PlanMotion' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Geometry\PlanMotionTests.cs(28,28): error CS0246: The type or namespace name 'PlanMotion' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Geometry\PlanMotionTests.cs(38,50): error CS0246: The type or namespace name 'PlanMotion' could not be found (are you missing a using directive or an assembly reference?)
tests\Lod.Core.Tests\Geometry\PlanMotionTests.cs(38,36): error CS1061: 'Polygon2' does not contain a definition for 'Transform' and no accessible extension method 'Transform' accepting a first argument of type 'Polygon2' could be found (are you missing a using directive or an assembly reference?)
Integration:
tests\Lod.Integration.Tests\PlanTransformTests.cs(25,32): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(46,34): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(47,33): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(66,32): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(86,32): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(106,32): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(107,33): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(124,41): error CS0103: The name 'PlanTransform' does not exist in the current context
tests\Lod.Integration.Tests\PlanTransformTests.cs(140,56): error CS0103: The name 'PlanTransform' does not exist in the current context
- [x] Step 5: Implement the motion — create
src/Lod.Core/Geometry/PlanMotion.cs(full file)
using System;
namespace Lod.Core.Geometry;
/// <summary>
/// A rigid motion of the plan frame (D-070): a rotation about the origin by <see cref="RotationDegrees"/>, counter-clockwise seen from above
/// (about +z), followed by a translation by (<see cref="TranslationX"/>, <see cref="TranslationY"/>) metres. Whole quarter turns use exact
/// cosines and sines, so axis-aligned geometry stays axis-aligned.
/// </summary>
public sealed class PlanMotion
{
private readonly double _cos;
private readonly double _sin;
/// <summary>Creates the motion.</summary>
/// <param name="rotationDegrees">Rotation about the origin, degrees, counter-clockwise seen from above.</param>
/// <param name="translationX">Translation along x after the rotation, m.</param>
/// <param name="translationY">Translation along y after the rotation, m.</param>
public PlanMotion(double rotationDegrees, double translationX, double translationY)
{
RotationDegrees = rotationDegrees;
TranslationX = translationX;
TranslationY = translationY;
double quarterTurns = rotationDegrees / 90.0;
if (quarterTurns == Math.Floor(quarterTurns) && !double.IsInfinity(quarterTurns))
{
int quarter = (((int)(quarterTurns % 4.0)) + 4) % 4;
_cos = quarter == 0 ? 1.0 : quarter == 2 ? -1.0 : 0.0;
_sin = quarter == 1 ? 1.0 : quarter == 3 ? -1.0 : 0.0;
}
else
{
double radians = rotationDegrees * Math.PI / 180.0;
_cos = Math.Cos(radians);
_sin = Math.Sin(radians);
}
}
/// <summary>Rotation about the origin, degrees, counter-clockwise seen from above.</summary>
public double RotationDegrees { get; }
/// <summary>Translation along x, m.</summary>
public double TranslationX { get; }
/// <summary>Translation along y, m.</summary>
public double TranslationY { get; }
/// <summary>Moves a point.</summary>
/// <param name="point">The point.</param>
/// <returns>The rotated and translated point.</returns>
public Point2 Apply(Point2 point) =>
new((_cos * point.X) - (_sin * point.Y) + TranslationX, (_sin * point.X) + (_cos * point.Y) + TranslationY);
}
- [x] Step 6: Move polygons — replace
src/Lod.Core/Geometry/Polygon2.cs(full file)
Only Transform is added. A rotation and a translation keep the orientation of every ring, so the rings are not normalised again.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
namespace Lod.Core.Geometry;
/// <summary>A point in the building-local plan frame, metres. +Y is plan north before the plan's orientation is applied.</summary>
/// <param name="X">X coordinate.</param>
/// <param name="Y">Y coordinate.</param>
public readonly record struct Point2(double X, double Y)
{
/// <summary>Euclidean distance to another point.</summary>
/// <param name="other">The other point.</param>
/// <returns>The distance.</returns>
public double DistanceTo(Point2 other)
{
double dx = other.X - X;
double dy = other.Y - Y;
return Math.Sqrt((dx * dx) + (dy * dy));
}
}
/// <summary>
/// An immutable planar polygon with holes (ADR-002). The outer ring is counter-clockwise, holes are clockwise, and rings are stored open (the first point is not repeated).
/// </summary>
public sealed class Polygon2
{
private Polygon2(Point2[] outer, Point2[][] holes)
{
Outer = Array.AsReadOnly(outer);
Holes = Array.AsReadOnly(holes.Select(h => (IReadOnlyList<Point2>)Array.AsReadOnly(h)).ToArray());
Area = SignedArea(outer) + holes.Sum(SignedArea);
}
/// <summary>Outer ring, counter-clockwise.</summary>
public IReadOnlyList<Point2> Outer { get; }
/// <summary>Hole rings, clockwise.</summary>
public IReadOnlyList<IReadOnlyList<Point2>> Holes { get; }
/// <summary>Area of the outer ring minus the holes, m².</summary>
public double Area { get; }
/// <summary>All rings: the outer ring first, then the holes.</summary>
public IEnumerable<IReadOnlyList<Point2>> Rings => new[] { Outer }.Concat(Holes);
/// <summary>Creates a polygon, normalising ring orientation and dropping repeated points.</summary>
/// <param name="outer">Outer ring.</param>
/// <param name="holes">Optional hole rings.</param>
/// <returns>The polygon, or an error for rings with fewer than three distinct points or zero area.</returns>
public static Result<Polygon2> Create(IEnumerable<Point2> outer, IEnumerable<IEnumerable<Point2>>? holes = null)
{
Point2[]? outerRing = Normalise(outer, counterClockwise: true);
Point2[]?[] holeRings = (holes ?? Enumerable.Empty<IEnumerable<Point2>>()).Select(h => Normalise(h, counterClockwise: false)).ToArray();
if (outerRing is null || holeRings.Any(h => h is null))
{
return Result.Failure<Polygon2>(Diagnostic.Error(DiagnosticCodes.DegeneratePolygon, "Polygon rings need at least three distinct points and a non-zero area."));
}
var polygon = new Polygon2(outerRing, holeRings.Select(h => h!).ToArray());
return polygon.Area > 0
? Result.Success(polygon)
: Result.Failure<Polygon2>(Diagnostic.Error(DiagnosticCodes.DegeneratePolygon, "Holes cover the whole outer ring."));
}
/// <summary>An axis-aligned rectangle.</summary>
/// <param name="minX">Minimum X.</param>
/// <param name="minY">Minimum Y.</param>
/// <param name="maxX">Maximum X, greater than <paramref name="minX"/>.</param>
/// <param name="maxY">Maximum Y, greater than <paramref name="minY"/>.</param>
/// <returns>The rectangle.</returns>
public static Polygon2 Rectangle(double minX, double minY, double maxX, double maxY) =>
Create(new[] { new Point2(minX, minY), new Point2(maxX, minY), new Point2(maxX, maxY), new Point2(minX, maxY) }).Value;
/// <summary>The polygon moved by an offset.</summary>
/// <param name="dx">X offset.</param>
/// <param name="dy">Y offset.</param>
/// <returns>The translated polygon.</returns>
public Polygon2 Translate(double dx, double dy) =>
new(Outer.Select(p => new Point2(p.X + dx, p.Y + dy)).ToArray(), Holes.Select(h => h.Select(p => new Point2(p.X + dx, p.Y + dy)).ToArray()).ToArray());
/// <summary>The polygon moved by a rigid motion; a rotation and translation keep ring orientation, so the rings are not re-normalised.</summary>
/// <param name="motion">The motion.</param>
/// <returns>The moved polygon.</returns>
public Polygon2 Transform(PlanMotion motion) =>
new(Outer.Select(motion.Apply).ToArray(), Holes.Select(h => h.Select(motion.Apply).ToArray()).ToArray());
/// <summary>Signed area of a ring; positive when counter-clockwise.</summary>
/// <param name="ring">An open ring.</param>
/// <returns>The signed area.</returns>
public static double SignedArea(IReadOnlyList<Point2> ring)
{
double sum = 0.0;
for (int i = 0; i < ring.Count; i++)
{
Point2 a = ring[i];
Point2 b = ring[(i + 1) % ring.Count];
sum += (a.X * b.Y) - (b.X * a.Y);
}
return sum / 2.0;
}
private static Point2[]? Normalise(IEnumerable<Point2> ring, bool counterClockwise)
{
var points = new List<Point2>();
foreach (Point2 p in ring)
{
if (points.Count == 0 || points[points.Count - 1] != p)
{
points.Add(p);
}
}
if (points.Count > 1 && points[0] == points[points.Count - 1])
{
points.RemoveAt(points.Count - 1);
}
if (points.Count < 3)
{
return null;
}
double area = SignedArea(points);
if (area == 0)
{
return null;
}
if ((area > 0) != counterClockwise)
{
points.Reverse();
}
return points.ToArray();
}
}
- [x] Step 7: Move surfaces — replace
src/Lod.Core/Model/Surfaces.cs(full file)
Surface gets the abstract Transform; WallSurface moves its end points and keeps its windows, so its facing (PlanAzimuth, computed from the end points) follows the rotation; HorizontalSurface moves its polygon.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
namespace Lod.Core.Model;
/// <summary>What is on the other side of a surface.</summary>
public enum BoundaryCondition
{
/// <summary>Outdoor air.</summary>
Outdoors,
/// <summary>Ground contact.</summary>
Ground,
/// <summary>No heat transfer.</summary>
Adiabatic,
/// <summary>Another zone, given by <see cref="Surface.AdjacentZone"/>.</summary>
Interzone,
/// <summary>Floor or ceiling of a plan or floor that has not been stacked yet.</summary>
Unresolved,
}
/// <summary>Identifies a surface.</summary>
/// <param name="Value">The identifier text.</param>
public readonly record struct SurfaceId(string Value)
{
/// <inheritdoc />
public override string ToString() => Value;
}
/// <summary>A planar surface bounding a zone.</summary>
public abstract class Surface
{
/// <summary>Initialises the common properties.</summary>
/// <param name="id">Surface ID.</param>
/// <param name="zone">Zone the surface belongs to.</param>
/// <param name="boundary">Boundary condition.</param>
/// <param name="adjacentZone">Zone on the other side; required for <see cref="BoundaryCondition.Interzone"/>, otherwise <c>null</c>.</param>
protected Surface(SurfaceId id, ZoneId zone, BoundaryCondition boundary, ZoneId? adjacentZone)
{
if ((boundary == BoundaryCondition.Interzone) != adjacentZone.HasValue)
{
throw new ArgumentException("An adjacent zone is required for, and only for, interzone surfaces.", nameof(adjacentZone));
}
Id = id;
Zone = zone;
Boundary = boundary;
AdjacentZone = adjacentZone;
}
/// <summary>Surface ID.</summary>
public SurfaceId Id { get; }
/// <summary>Zone the surface belongs to.</summary>
public ZoneId Zone { get; }
/// <summary>Boundary condition.</summary>
public BoundaryCondition Boundary { get; }
/// <summary>Zone on the other side of an interzone surface.</summary>
public ZoneId? AdjacentZone { get; }
/// <summary>Gross area, m².</summary>
public abstract double Area { get; }
/// <summary>A copy moved in plan by a rigid motion (D-070), with the same ID, zone, boundary, and windows.</summary>
/// <param name="motion">The motion.</param>
/// <returns>The copy.</returns>
public abstract Surface Transform(PlanMotion motion);
}
/// <summary>A vertical rectangular wall from <see cref="Start"/> to <see cref="End"/>; the zone lies to the left of that direction.</summary>
public sealed class WallSurface : Surface
{
/// <summary>Creates a wall.</summary>
/// <param name="id">Surface ID.</param>
/// <param name="zone">Owning zone.</param>
/// <param name="start">Start point in plan.</param>
/// <param name="end">End point in plan.</param>
/// <param name="elevation">Bottom edge elevation, m.</param>
/// <param name="height">Wall height, m.</param>
/// <param name="boundary">Boundary condition.</param>
/// <param name="adjacentZone">Zone on the other side of an interzone wall.</param>
/// <param name="windows">Explicit windows in this wall, ordered by offset (D-039).</param>
public WallSurface(SurfaceId id, ZoneId zone, Point2 start, Point2 end, double elevation, double height, BoundaryCondition boundary, ZoneId? adjacentZone, IEnumerable<Window> windows)
: base(id, zone, boundary, adjacentZone)
{
Start = start;
End = end;
Elevation = elevation;
Height = height;
Windows = Array.AsReadOnly(windows.OrderBy(w => w.Offset).ToArray());
}
/// <summary>Start point in plan.</summary>
public Point2 Start { get; }
/// <summary>End point in plan.</summary>
public Point2 End { get; }
/// <summary>Bottom edge elevation, m.</summary>
public double Elevation { get; }
/// <summary>Wall height, m.</summary>
public double Height { get; }
/// <summary>Explicit windows, ordered by offset.</summary>
public IReadOnlyList<Window> Windows { get; }
/// <summary>Wall length, m.</summary>
public double Length => Start.DistanceTo(End);
/// <inheritdoc />
public override double Area => Length * Height;
/// <summary>Glazed area of all windows, m².</summary>
public double GlazedArea => Windows.Sum(w => w.Area);
/// <summary>Plan-frame azimuth of the outward normal, degrees.</summary>
public double PlanAzimuth => Orientation.OutwardAzimuth(Start, End);
/// <inheritdoc />
/// <remarks>Windows keep their offsets from the wall's start point, sills, and sizes, so they move rigidly with the wall.</remarks>
public override Surface Transform(PlanMotion motion) =>
new WallSurface(Id, Zone, motion.Apply(Start), motion.Apply(End), Elevation, Height, Boundary, AdjacentZone, Windows);
/// <summary>A copy with different windows.</summary>
/// <param name="windows">The new windows.</param>
/// <returns>The copy.</returns>
public WallSurface WithWindows(IEnumerable<Window> windows) => new(Id, Zone, Start, End, Elevation, Height, Boundary, AdjacentZone, windows);
/// <summary>A copy placed at another elevation with re-keyed IDs.</summary>
/// <param name="rename">Maps old zone IDs to new ones.</param>
/// <param name="idPrefix">Prefix for the surface ID.</param>
/// <param name="dz">Elevation offset, m.</param>
/// <returns>The copy.</returns>
public WallSurface Relocate(Func<ZoneId, ZoneId> rename, string idPrefix, double dz) =>
new(new SurfaceId(idPrefix + Id.Value), rename(Zone), Start, End, Elevation + dz, Height, Boundary, AdjacentZone.HasValue ? rename(AdjacentZone.Value) : null, Windows);
}
/// <summary>Whether a horizontal surface is a zone's floor or ceiling.</summary>
public enum HorizontalKind
{
/// <summary>Floor; faces down.</summary>
Floor,
/// <summary>Ceiling or roof; faces up.</summary>
Ceiling,
}
/// <summary>A horizontal floor or ceiling polygon.</summary>
public sealed class HorizontalSurface : Surface
{
/// <summary>Creates a horizontal surface.</summary>
/// <param name="id">Surface ID.</param>
/// <param name="zone">Owning zone.</param>
/// <param name="kind">Floor or ceiling.</param>
/// <param name="polygon">Plan polygon.</param>
/// <param name="elevation">Elevation, m.</param>
/// <param name="boundary">Boundary condition.</param>
/// <param name="adjacentZone">Zone on the other side of an interzone surface.</param>
public HorizontalSurface(SurfaceId id, ZoneId zone, HorizontalKind kind, Polygon2 polygon, double elevation, BoundaryCondition boundary, ZoneId? adjacentZone)
: base(id, zone, boundary, adjacentZone)
{
Kind = kind;
Polygon = polygon;
Elevation = elevation;
}
/// <summary>Floor or ceiling.</summary>
public HorizontalKind Kind { get; }
/// <summary>Plan polygon.</summary>
public Polygon2 Polygon { get; }
/// <summary>Elevation, m.</summary>
public double Elevation { get; }
/// <inheritdoc />
public override double Area => Polygon.Area;
/// <inheritdoc />
public override Surface Transform(PlanMotion motion) =>
new HorizontalSurface(Id, Zone, Kind, Polygon.Transform(motion), Elevation, Boundary, AdjacentZone);
}
- [x] Step 8: Move zones — replace
src/Lod.Core/Model/Zone.cs(full file)
Only Transform is added.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Programs;
namespace Lod.Core.Model;
/// <summary>One prism of a zone: a floor polygon extruded by a height.</summary>
/// <param name="Footprint">Floor polygon.</param>
/// <param name="Elevation">Floor elevation, m.</param>
/// <param name="Height">Floor-to-floor height, m.</param>
public sealed record ZonePart(Polygon2 Footprint, double Elevation, double Height)
{
/// <summary>Air volume, m³.</summary>
public double Volume => Footprint.Area * Height;
}
/// <summary>
/// A thermal zone. Most zones have one part; a zone spanning storeys (<c>SingleZonePerFloorType</c>, <c>SingleZoneBuilding</c>) has one part per storey outline.
/// </summary>
public sealed class Zone
{
/// <summary>Creates a zone.</summary>
/// <param name="id">Zone ID.</param>
/// <param name="name">Display name.</param>
/// <param name="spaceType">Space type; <see cref="SpaceType.Mixed"/> for zones combining several types.</param>
/// <param name="parts">One or more prisms.</param>
/// <param name="program">Loads and setpoints.</param>
/// <param name="sourceZones">Zones this zone was derived from; empty for generated zones.</param>
/// <param name="multiplier">Zone multiplier, at least 1.</param>
public Zone(ZoneId id, string name, SpaceType spaceType, IEnumerable<ZonePart> parts, ZoneProgram program, IEnumerable<ZoneId> sourceZones, int multiplier = 1)
{
ZonePart[] partArray = parts.ToArray();
if (partArray.Length == 0)
{
throw new ArgumentException("A zone needs at least one part.", nameof(parts));
}
if (multiplier < 1)
{
throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, "Multipliers start at 1.");
}
Id = id;
Name = name;
SpaceType = spaceType;
Parts = Array.AsReadOnly(partArray);
Program = program;
SourceZones = Array.AsReadOnly(sourceZones.ToArray());
Multiplier = multiplier;
}
/// <summary>Zone ID.</summary>
public ZoneId Id { get; }
/// <summary>Display name.</summary>
public string Name { get; }
/// <summary>Space type.</summary>
public SpaceType SpaceType { get; }
/// <summary>Prisms making up the zone.</summary>
public IReadOnlyList<ZonePart> Parts { get; }
/// <summary>Loads and setpoints.</summary>
public ZoneProgram Program { get; }
/// <summary>Zones this zone was derived from.</summary>
public IReadOnlyList<ZoneId> SourceZones { get; }
/// <summary>Zone multiplier.</summary>
public int Multiplier { get; }
/// <summary>Floor area of one instance, m².</summary>
public double FloorArea => Parts.Sum(p => p.Footprint.Area);
/// <summary>Volume of one instance, m³.</summary>
public double Volume => Parts.Sum(p => p.Volume);
/// <summary>A copy whose parts are moved in plan by a rigid motion (D-070); everything else is unchanged.</summary>
/// <param name="motion">The motion.</param>
/// <returns>The copy.</returns>
public Zone Transform(PlanMotion motion) =>
new(Id, Name, SpaceType, Parts.Select(p => p with { Footprint = p.Footprint.Transform(motion) }), Program, SourceZones, Multiplier);
/// <summary>A copy with a new ID, translated vertically, with another multiplier.</summary>
/// <param name="id">New ID.</param>
/// <param name="dz">Elevation offset, m.</param>
/// <param name="multiplier">New multiplier.</param>
/// <returns>The copy.</returns>
public Zone Relocate(ZoneId id, double dz, int multiplier) =>
new(id, Name, SpaceType, Parts.Select(p => p with { Elevation = p.Elevation + dz }), Program, SourceZones, multiplier);
}
- [x] Step 9: Transform plans — create
src/Lod.Core/Plans/PlanTransform.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Core.Plans;
/// <summary>
/// Moves and rotates a plan's geometry (D-070): a rotation about the world origin by an angle about +z (counter-clockwise seen from above),
/// then a translation in x and y. Footprint, zone parts, walls, and floors and ceilings move rigidly; windows keep their offsets, sills, and sizes;
/// zone and surface IDs are kept. Wall facing follows the rotated geometry, because it is computed from the wall's end points; the plan's
/// orientation (true north) is unchanged. Area, volume, wall area, and window area are unchanged.
/// </summary>
public static class PlanTransform
{
/// <summary>Operation name recorded in provenance.</summary>
public const string Operation = "TransformPlan";
/// <summary>Rotates and translates a plan.</summary>
/// <param name="plan">The plan.</param>
/// <param name="rotationDegrees">Rotation about the world origin, degrees, counter-clockwise seen from above.</param>
/// <param name="translationX">Translation along x after the rotation, m.</param>
/// <param name="translationY">Translation along y after the rotation, m.</param>
/// <returns>The moved plan, or <see cref="DiagnosticCodes.InvalidParameter"/> errors for values that are not finite.</returns>
public static Result<IGeneratedPlan> Apply(IGeneratedPlan plan, double rotationDegrees, double translationX, double translationY)
{
var parameters = new[]
{
new KeyValuePair<string, double>("RotationDegrees", rotationDegrees),
new KeyValuePair<string, double>("TranslationX", translationX),
new KeyValuePair<string, double>("TranslationY", translationY),
};
Diagnostic[] errors = parameters
.Where(p => double.IsNaN(p.Value) || double.IsInfinity(p.Value))
.Select(p => Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"{p.Key} must be finite, got {Provenance.Format(p.Value)}."))
.ToArray();
if (errors.Length > 0)
{
return Result.Failure<IGeneratedPlan>(errors);
}
var motion = new PlanMotion(rotationDegrees, translationX, translationY);
var provenance = Provenance.Of(
Operation,
parameters.Select(p => new KeyValuePair<string, string>(p.Key, Provenance.Format(p.Value))),
plan.Provenance);
IGeneratedPlan moved = new GeneratedPlan(
plan.Footprint.Transform(motion),
plan.FloorHeight,
plan.OrientationDegrees,
plan.Zones.Select(z => z.Transform(motion)),
plan.Surfaces.Select(s => s.Transform(motion)),
provenance);
return Result.Success(moved);
}
}
- [x] Step 10: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.PlanMotionTests"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
Expected:
Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 15, Skipped: 0, Total: 15, Duration: … - Lod.Integration.Tests.dll (net8.0)
Build succeeded., 0 Warning(s), 0 Error(s), and on net8.0:
Passed! - Failed: 0, Passed: 126, Skipped: 0, Total: 126, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 98, Skipped: 0, Total: 98, Duration: … - Lod.Integration.Tests.dll (net8.0)
- [x] Step 11: Commit
git add src/Lod.Core/Geometry/PlanMotion.cs src/Lod.Core/Geometry/Polygon2.cs src/Lod.Core/Model/Surfaces.cs src/Lod.Core/Model/Zone.cs src/Lod.Core/Plans/PlanTransform.cs tests/Lod.Core.Tests/Geometry/PlanMotionTests.cs tests/Lod.Integration.Tests/AggregatorTests.cs tests/Lod.Integration.Tests/Pipeline.cs tests/Lod.Integration.Tests/PlanTransformTests.cs
git commit -m "feature(plans): add a plan transform that rotates and moves a plan"
Task 8: Façade lines matched over their overlap¶
Files:
- Modify: src/Lod.Core/Simplification/FacadeAttribution.cs (S3 file)
- Test: modify tests/Lod.Core.Tests/Simplification/MappingTests.cs (S3 file)
Interfaces:
- Consumes (S3): FacadeAttribution.Compute, CoverageGaps(), OverlapLength(WallSurface, WallSurface, ToleranceSettings), Interval(WallSurface a, WallSurface b, ToleranceSettings) : (double Start, double End)?; test support TestZones.Wall(id, zone, x0, y0, x1, y1).
- Produces: FacadeAttribution.Interval (signature unchanged) returns null unless b overlaps a along a and the separation of the two walls, measured at both ends of their overlap, is within ToleranceSettings.Distance (D-076); before, both end points of b had to lie within the distance of a's infinite line. The facing, elevation, and height conditions are unchanged. It is used by façade coverage (D-041) and window re-hosting (D-039).
- [x] Step 1: Write the failing tests — replace
tests/Lod.Core.Tests/Simplification/MappingTests.cs(full file)
Two facts are added at the end. AShortSourceWallIsMatchedWhereItOverlapsALongTarget: a 4 m wall whose end lies 0.4 µm off the line of a 24 m wall, as grid rounding leaves it in a rotated plan, is covered over its full length; extended over 24 m its line would leave the long wall by 2.4 µm. WallsFartherApartThanTheDistanceToleranceOverTheirOverlapDoNotCover: a wall 2 µm off over the overlap is not covered, and two collinear walls that do not overlap are not on the same façade line.
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Lod.Core.Simplification;
using Lod.Core.Tests.TestSupport;
using Xunit;
namespace Lod.Core.Tests.Simplification;
public sealed class MappingTests
{
[Fact]
public void OverlapsAreRecordedWithBothFractions()
{
Zone source = TestZones.Rectangle("A", 0, 0, 10, 10);
var targets = new[] { (new ZoneId("L"), Polygon2.Rectangle(0, 0, 4, 10)), (new ZoneId("R"), Polygon2.Rectangle(4, 0, 20, 10)) };
ZoneMapping[] rows = new OverlapMapper(ToleranceSettings.Default).Map(new[] { source }, targets).ToArray();
Assert.Equal(2, rows.Length);
Assert.Equal(40.0, rows[0].OverlapArea, 9);
Assert.Equal(0.4, rows[0].SourceFraction, 9);
Assert.Equal(1.0, rows[0].TargetFraction, 9);
Assert.Equal(0.6, rows[1].SourceFraction, 9);
Assert.Equal(60.0 / 160.0, rows[1].TargetFraction, 9);
}
[Fact]
public void TouchingZonesAreNotMapped()
{
Zone source = TestZones.Rectangle("A", 0, 0, 10, 10);
Assert.Empty(new OverlapMapper(ToleranceSettings.Default).Map(new[] { source }, new[] { (new ZoneId("T"), Polygon2.Rectangle(10, 0, 20, 10)) }));
}
[Fact]
public void TransferFractionsAreNormalisedPerSource()
{
TransferMatrix matrix = TransferMatrix.FromMapping(new[]
{
new ZoneMapping(new ZoneId("A"), new ZoneId("X"), 30.0, 0.3, 1.0),
new ZoneMapping(new ZoneId("A"), new ZoneId("Y"), 69.9, 0.699, 1.0),
});
Assert.Equal(1.0, matrix.AreaFraction(new ZoneId("A"), new ZoneId("X")) + matrix.AreaFraction(new ZoneId("A"), new ZoneId("Y")), 15);
Assert.Equal(0.0, matrix.AreaFraction(new ZoneId("A"), new ZoneId("Z")));
Assert.Equal(new[] { new ZoneId("A") }, matrix.SourcesOf(new ZoneId("X")));
}
[Fact]
public void OneTargetWallSpanningTwoSourceWallsCoversBoth()
{
WallSurface left = TestZones.Wall("A/W1", "A", 0, 0, 10, 0);
WallSurface right = TestZones.Wall("B/W1", "B", 10, 0, 20, 0);
WallSurface merged = TestZones.Wall("M/W1", "M", 0, 0, 20, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { left, right }, new Surface[] { merged }, ToleranceSettings.Default);
Assert.Empty(facade.CoverageGaps());
Assert.Equal(1.0, facade.ExteriorWallFraction(new ZoneId("A"), new ZoneId("M")), 12);
}
[Fact]
public void PartialCoverageIsReportedBeforeAnyNormalisation()
{
// The target covers only 16 m of a 20 m source wall: without the coverage check, normalisation would hide the missing 4 m (D-041).
WallSurface source = TestZones.Wall("A/W1", "A", 0, 0, 20, 0);
WallSurface target = TestZones.Wall("P/W1", "P", 0, 0, 16, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { source }, new Surface[] { target }, ToleranceSettings.Default);
(WallSurface wall, double covered) = Assert.Single(facade.CoverageGaps());
Assert.Equal(source.Id, wall.Id);
Assert.Equal(16.0, covered, 9);
}
[Fact]
public void SourceWallSplitAcrossTargetsSharesItsAreaByLength()
{
WallSurface source = TestZones.Wall("A/W1", "A", 0, 0, 20, 0);
WallSurface first = TestZones.Wall("P/W1", "P", 0, 0, 5, 0);
WallSurface second = TestZones.Wall("Q/W1", "Q", 5, 0, 20, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { source }, new Surface[] { first, second }, ToleranceSettings.Default);
Assert.Empty(facade.CoverageGaps());
Assert.Equal(0.25, facade.ExteriorWallFraction(new ZoneId("A"), new ZoneId("P")), 12);
Assert.Equal(0.75, facade.ExteriorWallFraction(new ZoneId("A"), new ZoneId("Q")), 12);
}
[Fact]
public void WallsOnParallelLinesOrFacingAwayDoNotCover()
{
WallSurface source = TestZones.Wall("A/W1", "A", 0, 0, 10, 0);
WallSurface parallel = TestZones.Wall("B/W1", "B", 0, 1, 10, 1);
WallSurface reversed = TestZones.Wall("C/W1", "C", 10, 0, 0, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { source }, new Surface[] { parallel, reversed }, ToleranceSettings.Default);
Assert.Equal(0.0, Assert.Single(facade.CoverageGaps()).CoveredLength);
}
[Fact]
public void AShortSourceWallIsMatchedWhereItOverlapsALongTarget()
{
// The 4 m source ends 0.4 µm off the 24 m target's line, as rounding on the 1e-6 grid leaves it in a rotated plan. Extended over 24 m its
// line would leave the target by 2.4 µm, more than the distance tolerance; over their overlap the walls are at most 0.4 µm apart (ADR-013).
WallSurface source = TestZones.Wall("A/W1", "A", 0, 0, 4, 4e-7);
WallSurface target = TestZones.Wall("M/W1", "M", 0, 0, 24, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { source }, new Surface[] { target }, ToleranceSettings.Default);
Assert.Empty(facade.CoverageGaps());
Assert.Equal(source.Length, FacadeAttribution.OverlapLength(source, target, ToleranceSettings.Default), 9);
}
[Fact]
public void WallsFartherApartThanTheDistanceToleranceOverTheirOverlapDoNotCover()
{
WallSurface source = TestZones.Wall("A/W1", "A", 0, 0, 4, 2e-6);
WallSurface target = TestZones.Wall("M/W1", "M", 0, 0, 24, 0);
WallSurface beyond = TestZones.Wall("N/W1", "N", 30, 0, 40, 0);
FacadeAttribution facade = FacadeAttribution.Compute(new Surface[] { source }, new Surface[] { target, beyond }, ToleranceSettings.Default);
Assert.Equal(0.0, Assert.Single(facade.CoverageGaps()).CoveredLength);
Assert.Null(FacadeAttribution.Interval(target, beyond, ToleranceSettings.Default));
}
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Simplification.MappingTests"
Expected on net8.0:
Failed! - Failed: 2, Passed: 7, Skipped: 0, Total: 9, Duration: … - Lod.Core.Tests.dll (net8.0)
with the failing tests and their messages, in any order:
Failed Lod.Core.Tests.Simplification.MappingTests.AShortSourceWallIsMatchedWhereItOverlapsALongTarget
Assert.Empty() Failure: Collection was not empty
Collection: [Tuple (WallSurface { AdjacentZone = null, Area = 12.000000000000059, Boundary = Outdoors, Elevation = 0, End = Point2 { X = 4, Y = 4E-07 }, ··· }, 0)]
Failed Lod.Core.Tests.Simplification.MappingTests.WallsFartherApartThanTheDistanceToleranceOverTheirOverlapDoNotCover
Assert.Null() Failure: Value of type 'Nullable<ValueTuple<double, double>>' has a value
Expected: null
Actual: Tuple (30, 40)
- [x] Step 3: Implement — replace
src/Lod.Core/Simplification/FacadeAttribution.cs(full file)
Only Interval and its summary change.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Simplification;
/// <summary>
/// Matches source and target outdoor walls lying on the same façade line, by projected overlap length. Coverage is checked first (D-041):
/// every source wall must be covered over its full length; only then are the overlaps normalised per source wall to transfer exterior-wall-based
/// loads exactly.
/// </summary>
public sealed class FacadeAttribution
{
private readonly Dictionary<(SurfaceId Source, SurfaceId Target), double> _overlaps = new();
private readonly Dictionary<SurfaceId, WallSurface> _sources;
private readonly Dictionary<SurfaceId, WallSurface> _targets;
private readonly ToleranceSettings _tolerances;
private FacadeAttribution(Dictionary<SurfaceId, WallSurface> sources, Dictionary<SurfaceId, WallSurface> targets, ToleranceSettings tolerances)
{
_sources = sources;
_targets = targets;
_tolerances = tolerances;
}
/// <summary>Matches outdoor source walls with outdoor target walls.</summary>
/// <param name="sourceSurfaces">Source surfaces; only outdoor walls are used.</param>
/// <param name="targetSurfaces">Target surfaces; only outdoor walls are used.</param>
/// <param name="tolerances">Tolerances; walls are on the same line within <see cref="ToleranceSettings.Distance"/>.</param>
/// <returns>The attribution.</returns>
public static FacadeAttribution Compute(IEnumerable<Surface> sourceSurfaces, IEnumerable<Surface> targetSurfaces, ToleranceSettings tolerances)
{
Dictionary<SurfaceId, WallSurface> sources = Outdoor(sourceSurfaces).ToDictionary(w => w.Id);
Dictionary<SurfaceId, WallSurface> targets = Outdoor(targetSurfaces).ToDictionary(w => w.Id);
var attribution = new FacadeAttribution(sources, targets, tolerances);
foreach (WallSurface source in sources.Values)
{
foreach (WallSurface target in targets.Values)
{
double length = OverlapLength(source, target, tolerances);
if (length > tolerances.Distance)
{
attribution._overlaps[(source.Id, target.Id)] = length;
}
}
}
return attribution;
}
/// <summary>
/// Source outdoor walls whose length is not fully covered by target outdoor walls: <c>Σⱼ Lᵢⱼ ≠ Lᵢ</c> within the relative geometric
/// tolerance (ADR-004). Must be empty before the attribution is used (D-041).
/// </summary>
/// <returns>The uncovered or partly covered source walls with their covered length.</returns>
public IReadOnlyList<(WallSurface Wall, double CoveredLength)> CoverageGaps() =>
_sources.Values
.Select(w => (Wall: w, CoveredLength: _overlaps.Where(o => o.Key.Source == w.Id).Sum(o => o.Value)))
.Where(c => !_tolerances.AreaEquals(c.Wall.Length, c.CoveredLength))
.ToArray();
/// <summary>
/// Share of a source zone's exterior wall area that becomes a target zone's exterior wall area. Each source wall's overlaps are normalised to
/// its covered length, which equals its length once <see cref="CoverageGaps"/> is empty.
/// </summary>
/// <param name="sourceZone">Source zone.</param>
/// <param name="targetZone">Target zone.</param>
/// <returns>Fraction in [0, 1].</returns>
public double ExteriorWallFraction(ZoneId sourceZone, ZoneId targetZone)
{
double total = 0.0;
double toTarget = 0.0;
foreach (WallSurface wall in _sources.Values.Where(w => w.Zone == sourceZone))
{
KeyValuePair<(SurfaceId Source, SurfaceId Target), double>[] overlaps = _overlaps.Where(o => o.Key.Source == wall.Id).ToArray();
double covered = overlaps.Sum(o => o.Value);
if (covered <= 0.0)
{
continue;
}
total += wall.Area;
toTarget += wall.Area * overlaps.Where(o => _targets[o.Key.Target].Zone == targetZone).Sum(o => o.Value) / covered;
}
return total > 0.0 ? toTarget / total : 0.0;
}
/// <summary>Overlap length of <paramref name="b"/> on <paramref name="a"/> when both lie on one façade line, face the same way, and share an elevation.</summary>
/// <param name="a">Reference wall.</param>
/// <param name="b">Other wall.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>Overlap length along <paramref name="a"/>, m; 0 when they are not on the same façade line.</returns>
public static double OverlapLength(WallSurface a, WallSurface b, ToleranceSettings tolerances)
{
(double start, double end)? interval = Interval(a, b, tolerances);
return interval is { } i ? Math.Max(0.0, Math.Min(a.Length, i.end) - Math.Max(0.0, i.start)) : 0.0;
}
/// <summary>
/// Where <paramref name="b"/> lies along <paramref name="a"/>: the start and end of <paramref name="b"/> measured from <paramref name="a"/>'s start,
/// or <c>null</c> when the walls are not on the same façade line, facing the same way, at the same elevation and height. Two walls are on the
/// same façade line when they overlap along <paramref name="a"/> and are within <see cref="ToleranceSettings.Distance"/> of each other over
/// that overlap; the separation is measured at the ends of the overlap, not at the ends of the longer wall, so that a short wall whose end
/// points carry rounding on the distance grid is not extrapolated beyond itself (ADR-013).
/// </summary>
/// <param name="a">Reference wall.</param>
/// <param name="b">Other wall.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The interval along <paramref name="a"/>, m, or <c>null</c>.</returns>
public static (double Start, double End)? Interval(WallSurface a, WallSurface b, ToleranceSettings tolerances)
{
double length = a.Length;
double ux = (a.End.X - a.Start.X) / length;
double uy = (a.End.Y - a.Start.Y) / length;
double bx = (b.End.X - b.Start.X) / b.Length;
double by = (b.End.Y - b.Start.Y) / b.Length;
if ((ux * bx) + (uy * by) < Math.Cos(tolerances.Angle)
|| Math.Abs(a.Elevation - b.Elevation) > tolerances.Distance
|| Math.Abs(a.Height - b.Height) > tolerances.Distance)
{
return null;
}
double Along(double x, double y) => (ux * (x - a.Start.X)) + (uy * (y - a.Start.Y));
double Offset(double x, double y) => (ux * (y - a.Start.Y)) - (uy * (x - a.Start.X));
double start = Along(b.Start.X, b.Start.Y);
double end = Along(b.End.X, b.End.Y);
double from = Math.Max(0.0, start);
double to = Math.Min(length, end);
if (to < from)
{
return null;
}
double startOffset = Offset(b.Start.X, b.Start.Y);
double endOffset = Offset(b.End.X, b.End.Y);
double OffsetAt(double along) => startOffset + ((endOffset - startOffset) * (along - start) / (end - start));
if (Math.Abs(OffsetAt(from)) > tolerances.Distance || Math.Abs(OffsetAt(to)) > tolerances.Distance)
{
return null;
}
return (start, end);
}
private static IEnumerable<WallSurface> Outdoor(IEnumerable<Surface> surfaces) =>
surfaces.OfType<WallSurface>().Where(w => w.Boundary == BoundaryCondition.Outdoors);
}
- [x] Step 4: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Simplification.MappingTests"
dotnet test BEMGen.sln -c Release
git status --short
Expected:
Passed! - Failed: 0, Passed: 9, Skipped: 0, Total: 9, Duration: … - Lod.Core.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 128, Skipped: 0, Total: 128, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 98, Skipped: 0, Total: 98, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short lists the two files of this task and no *.received.txt: for axis-aligned walls both rules give the same result, and every snapshot is unchanged.
- [x] Step 5: Commit
git add src/Lod.Core/Simplification/FacadeAttribution.cs tests/Lod.Core.Tests/Simplification/MappingTests.cs
git commit -m "fix(simplifiers): match facade walls by their separation over the overlap"
Task 9: Storey outlines unioned as tiles¶
Files:
- Create: src/Lod.Core/Geometry/VertexGrid.cs
- Modify: src/Lod.Core/Geometry/PolygonOps.cs (S2 file), src/Lod.Core/Layout/LayoutSurfaceBuilder.cs (S2 file), src/Lod.Core/Buildings/SingleZoneMerge.cs (Task 4)
- Test: modify tests/Lod.Core.Tests/Geometry/GeometryTests.cs (S2 file), tests/Lod.Integration.Tests/PlanTransformTests.cs (Task 7)
Interfaces:
- Consumes: PlanMotion, Polygon2.Transform, PlanTransform.Apply (Task 7); PolygonOps.Union; Polygon2.Create(IEnumerable<Point2>, IEnumerable<IEnumerable<Point2>>?) : Result<Polygon2>; Pipeline.Simplifier, Pipeline.Aggregator (Task 7).
- Produces:
- internal sealed class VertexGrid and internal readonly record struct GridPoint(long X, long Y) in Lod.Core.Geometry: Key(Point2), Register(Point2), Original(GridPoint), Keys, and the static tests Parameter, IsStrictlyInside, IsOnSegment, moved unchanged from the private Grid of LayoutSurfaceBuilder.
- public IReadOnlyList<Polygon2> PolygonOps.UnionTiles(IReadOnlyList<Polygon2> tiles) (D-076): vertices in one cell of the distance grid are unified and every vertex lying on another tile's edge (a T-junction) is inserted into that edge before Union; a tile whose rings collapse on the grid is clipped as given.
- LayoutSurfaceBuilder uses VertexGrid; its output is unchanged. SingleZoneMerge builds each storey outline with UnionTiles.
- [x] Step 1: Write the failing tests
Replace tests/Lod.Core.Tests/Geometry/GeometryTests.cs with the following full file. The fact UnionOfRotatedTilesWithTJunctionsIsOneOutline is added at the end: a 4 m × 18 m strip and two 20 m × 9 m rectangles beside it, rotated by 7.3°, give one outline of 432 m² without holes whose vertices all lie on the rotated 24 m × 18 m rectangle.
using System;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Xunit;
namespace Lod.Core.Tests.Geometry;
public sealed class GeometryTests
{
private readonly PolygonOps _ops = new(ToleranceSettings.Default);
[Fact]
public void PolygonNormalisesOrientationAndComputesAreaWithHoles()
{
Polygon2 polygon = Polygon2.Create(
new[] { new Point2(0, 0), new Point2(0, 10), new Point2(10, 10), new Point2(10, 0) },
new[] { new[] { new Point2(2, 2), new Point2(4, 2), new Point2(4, 4), new Point2(2, 4) } }).Value;
Assert.True(Polygon2.SignedArea(polygon.Outer) > 0);
Assert.True(Polygon2.SignedArea(polygon.Holes[0]) < 0);
Assert.Equal(96.0, polygon.Area, 12);
}
[Fact]
public void PolygonDropsClosingAndRepeatedPoints()
{
Polygon2 polygon = Polygon2.Create(new[] { new Point2(0, 0), new Point2(1, 0), new Point2(1, 0), new Point2(1, 1), new Point2(0, 0) }).Value;
Assert.Equal(3, polygon.Outer.Count);
}
[Fact]
public void PolygonRejectsDegenerateRings()
{
Assert.Contains(
Polygon2.Create(new[] { new Point2(0, 0), new Point2(1, 0), new Point2(2, 0) }).Diagnostics,
d => d.Code == DiagnosticCodes.DegeneratePolygon);
}
[Fact]
public void IntersectionOfOverlappingRectangles()
{
double area = _ops.IntersectionArea(Polygon2.Rectangle(0, 0, 10, 10), Polygon2.Rectangle(5, 5, 15, 15));
Assert.Equal(25.0, area, 9);
}
[Fact]
public void TouchingRectanglesDoNotIntersect()
{
Assert.Empty(_ops.Intersect(Polygon2.Rectangle(0, 0, 10, 10), Polygon2.Rectangle(10, 0, 20, 10)));
}
[Fact]
public void UnionOfAdjacentRectanglesIsOnePolygon()
{
Polygon2 union = Assert.Single(_ops.Union(new[] { Polygon2.Rectangle(0, 0, 10, 5), Polygon2.Rectangle(10, 0, 20, 5) }));
Assert.Equal(100.0, union.Area, 9);
}
[Fact]
public void DifferenceKeepsHoles()
{
Polygon2 ring = Assert.Single(_ops.Difference(Polygon2.Rectangle(0, 0, 10, 10), new[] { Polygon2.Rectangle(3, 3, 7, 7) }));
Assert.Single(ring.Holes);
Assert.Equal(84.0, ring.Area, 9);
}
[Theory]
[InlineData(0, 0, 10, 0, 180.0)]
[InlineData(10, 0, 10, 10, 90.0)]
[InlineData(10, 10, 0, 10, 0.0)]
[InlineData(0, 10, 0, 0, 270.0)]
public void OutwardAzimuthOfCounterClockwiseRectangleEdges(double x1, double y1, double x2, double y2, double azimuth)
{
Assert.Equal(azimuth, Orientation.OutwardAzimuth(new Point2(x1, y1), new Point2(x2, y2)), 9);
}
[Theory]
[InlineData(0.0, OrientationBin.North)]
[InlineData(44.9, OrientationBin.North)]
[InlineData(45.0, OrientationBin.East)]
[InlineData(180.0, OrientationBin.South)]
[InlineData(270.0, OrientationBin.West)]
[InlineData(315.0, OrientationBin.North)]
[InlineData(-90.0, OrientationBin.West)]
public void OrientationBins(double azimuth, OrientationBin bin)
{
Assert.Equal(bin, Orientation.Bin(azimuth));
}
[Fact]
public void CenteredWindowScalesTheWallRectangle()
{
Window window = Window.Centered(10.0, 3.0, 0.25 * 30.0)!;
Assert.Equal(5.0, window.Width, 12);
Assert.Equal(1.5, window.Height, 12);
Assert.Equal(0.75, window.SillHeight, 12);
Assert.Equal(7.5, window.Area, 12);
}
[Fact]
public void ZeroGlazingHasNoWindow()
{
Assert.Null(Window.Centered(10.0, 3.0, 0.0));
}
[Fact]
public void GlazingMustBeSmallerThanTheWall()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Window.Centered(10.0, 3.0, 30.0));
}
[Fact]
public void TranslateMovesEveryRing()
{
Polygon2 moved = Polygon2.Rectangle(0, 0, 1, 1).Translate(2, 3);
Assert.Equal(2.0, moved.Outer.Min(p => p.X));
Assert.Equal(3.0, moved.Outer.Min(p => p.Y));
}
[Fact]
public void UnionOfRotatedTilesWithTJunctionsIsOneOutline()
{
// A 4 m x 18 m strip and two 20 m x 9 m rectangles beside it: the rectangles' corners at (4, 9) lie on the strip's edge (a T-junction).
// Rotated by 7.3°, rounding on the clipping grid moves that corner off the edge, which a plain union turns into a slit or a split.
var motion = new PlanMotion(7.3, 0.0, 0.0);
Polygon2[] tiles =
{
Polygon2.Rectangle(0, 0, 4, 18).Transform(motion),
Polygon2.Rectangle(4, 0, 24, 9).Transform(motion),
Polygon2.Rectangle(4, 9, 24, 18).Transform(motion),
};
Polygon2 outline = Assert.Single(_ops.UnionTiles(tiles));
Assert.Empty(outline.Holes);
Assert.Equal(432.0, outline.Area, 5);
var back = new PlanMotion(-7.3, 0.0, 0.0);
Assert.All(outline.Outer.Select(back.Apply), p => Assert.True(
Math.Abs(p.X) < 1e-5 || Math.Abs(p.X - 24) < 1e-5 || Math.Abs(p.Y) < 1e-5 || Math.Abs(p.Y - 18) < 1e-5,
$"({p.X}, {p.Y}) is not on the outline"));
}
}
Replace tests/Lod.Integration.Tests/PlanTransformTests.cs with the following full file. The theory EveryCombinationValidatesOnARotatedAndTranslatedPlan is added: every plan simplifier with every floor aggregator, Multipliers 1, 3, 1, on the canonical plan rotated and moved by four placements (30° and (5.3, −2.1), 7.3° in place, 131.4° and (15.08, −153.9), 248.2° and (42.62, −245.2)), 64 cases, each simplifying and aggregating without error and without warning and validating.
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.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class PlanTransformTests
{
[Theory]
[InlineData(0.0)]
[InlineData(90.0)]
[InlineData(30.0)]
public void RotationKeepsAreaVolumeWallAreaAndGlazing(double degrees)
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, degrees, 5.3, -2.1).Value;
Totals before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees);
Totals after = Totals.Of(moved.Zones, moved.Surfaces, moved.OrientationDegrees);
Assert.True(Pipeline.Tolerances.AreaEquals(plan.Footprint.Area, moved.Footprint.Area));
Assert.True(Pipeline.Tolerances.AreaEquals(before.FloorArea, after.FloorArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Volume, after.Volume));
Assert.True(Pipeline.Tolerances.AreaEquals(before.ExteriorWallArea, after.ExteriorWallArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Glazing, after.Glazing));
Assert.Equal(plan.Zones.Select(z => z.Id), moved.Zones.Select(z => z.Id));
Assert.Equal(plan.Surfaces.Select(s => s.Id), moved.Surfaces.Select(s => s.Id));
Assert.Equal(plan.OrientationDegrees, moved.OrientationDegrees);
Assert.Equal(plan.FloorHeight, moved.FloorHeight);
}
[Fact]
public void GlazingPerOrientationFollowsTheRotatedFacades()
{
IGeneratedPlan plan = Pipeline.Plan();
IReadOnlyDictionary<OrientationBin, double> before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees).GlazingByOrientation;
IGeneratedPlan quarter = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
IGeneratedPlan thirty = PlanTransform.Apply(plan, 30.0, 0.0, 0.0).Value;
IReadOnlyDictionary<OrientationBin, double> afterQuarter = Totals.Of(quarter.Zones, quarter.Surfaces, quarter.OrientationDegrees).GlazingByOrientation;
IReadOnlyDictionary<OrientationBin, double> afterThirty = Totals.Of(thirty.Zones, thirty.Surfaces, thirty.OrientationDegrees).GlazingByOrientation;
// A quarter turn counter-clockwise turns a north façade west, east north, south east, and west south.
Assert.Equal(before[OrientationBin.North], afterQuarter[OrientationBin.West], 9);
Assert.Equal(before[OrientationBin.East], afterQuarter[OrientationBin.North], 9);
Assert.Equal(before[OrientationBin.South], afterQuarter[OrientationBin.East], 9);
Assert.Equal(before[OrientationBin.West], afterQuarter[OrientationBin.South], 9);
// 30° keeps every façade azimuth inside its 90° bin (0° → 330°, 90° → 60°, ...).
Assert.All(before.Keys, bin => Assert.Equal(before[bin], afterThirty[bin], 9));
}
[Fact]
public void WallFacingIsRecomputedFromTheRotatedGeometry()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
WallSurface[] original = plan.Surfaces.OfType<WallSurface>().ToArray();
WallSurface[] rotated = moved.Surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(original.Length, rotated.Length);
for (int i = 0; i < original.Length; i++)
{
Assert.Equal(Orientation.Normalise(original[i].PlanAzimuth - 90.0), rotated[i].PlanAzimuth, 9);
Assert.Equal(original[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)), rotated[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)));
}
WallSurface northFacing = original.First(w => w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.North);
Assert.Equal(OrientationBin.West, Orientation.Bin(rotated[Array.IndexOf(original, northFacing)].PlanAzimuth));
}
[Fact]
public void TranslationOnlyMovesEveryPoint()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 0.0, 5.3, -2.1).Value;
Assert.Equal(plan.Footprint.Outer.Select(p => new Point2(p.X + 5.3, p.Y - 2.1)), moved.Footprint.Outer);
Assert.Equal(
plan.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer));
Assert.Equal(
plan.Surfaces.OfType<WallSurface>().Select(w => (new Point2(w.Start.X + 5.3, w.Start.Y - 2.1), new Point2(w.End.X + 5.3, w.End.Y - 2.1), w.Elevation, w.Height)),
moved.Surfaces.OfType<WallSurface>().Select(w => (w.Start, w.End, w.Elevation, w.Height)));
Assert.Equal(
plan.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer));
Assert.All(moved.Surfaces.OfType<HorizontalSurface>(), h => Assert.Equal(BoundaryCondition.Unresolved, h.Boundary));
}
[Fact]
public void TransformIsDeterministicAndRecordedInProvenance()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan first = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
IGeneratedPlan second = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
Assert.Equal(TextReport.Describe(first), TextReport.Describe(second));
Assert.Equal("TransformPlan", first.Provenance.Operation);
Assert.Equal(
new[] { "RotationDegrees=30", "TranslationX=5.3", "TranslationY=-2.1" },
first.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
Assert.Same(plan.Provenance, Assert.Single(first.Provenance.Inputs));
}
[Theory]
[InlineData(double.NaN, 0.0, 0.0)]
[InlineData(0.0, double.PositiveInfinity, 0.0)]
[InlineData(0.0, 0.0, double.NegativeInfinity)]
[InlineData(double.PositiveInfinity, double.NaN, double.NaN)]
public void NonFiniteInputsAreInvalidParameters(double degrees, double x, double y)
{
Result<IGeneratedPlan> result = PlanTransform.Apply(Pipeline.Plan(), degrees, x, y);
Assert.False(result.IsSuccess);
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.InvalidParameter, d.Code));
Assert.Equal(new[] { degrees, x, y }.Count(v => double.IsNaN(v) || double.IsInfinity(v)), result.Diagnostics.Count);
}
public static IEnumerable<object[]> Matrix() =>
from placement in new[] { (30.0, 5.3, -2.1), (7.3, 0.0, 0.0), (131.4, 15.08, -153.9), (248.2, 42.62, -245.2) }
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
select new object[] { placement.Item1, placement.Item2, placement.Item3, simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(double degrees, double x, double y, string simplifier, string aggregator)
{
IGeneratedPlan plan = PlanTransform.Apply(Pipeline.Plan(), degrees, x, y).Value;
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);
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AStoreyMovedOffTheStoreyBelowIsUnsupported(string aggregator)
{
// The upper plan is moved by (5.3, -2.1): it overlaps the lower 24 m x 18 m plan on 18.7 m x 15.9 m = 297.33 m², so 134.67 m² overhang.
IFloor lower = new NoSimplification().Simplify(Pipeline.Plan()).Value;
IFloor upper = new NoSimplification().Simplify(PlanTransform.Apply(Pipeline.Plan(), 0.0, 5.3, -2.1).Value).Value;
Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(lower, 2), new FloorEntry(upper, 2) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("134.670000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
Totals totals = Totals.Of(result.Value.Zones, result.Value.Surfaces, result.Value.OrientationDegrees);
Assert.Equal(134.67, totals.ExposedFloorArea, 6);
Assert.Equal(432.0 + 134.67, totals.RoofArea, 6);
Assert.Equal(432.0, totals.GroundArea, 6);
}
}
- [x] Step 2: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.GeometryTests"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
Expected: the core build fails, once, for the net8.0 target:
tests\Lod.Core.Tests\Geometry\GeometryTests.cs(140,47): error CS1061: 'PolygonOps' does not contain a definition for 'UnionTiles' and no accessible extension method 'UnionTiles' accepting a first argument of type 'PolygonOps' could be found (are you missing a using directive or an assembly reference?)
The integration tests build and 10 of the 64 new cases fail, all with a single-zone method; the failing check of each validation report is listed (the order may differ):
Failed! - Failed: 10, Passed: 69, Skipped: 0, Total: 79, Duration: … - Lod.Integration.Tests.dll (net8.0)
with the failing tests and their messages, in any order:
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 248.19999999999999, x: 42.619999999999997, y: -245.19999999999999, simplifier: "SemanticMerge", aggregator: "SingleZoneBuilding")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 1800.000009
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 131.40000000000001, x: 15.08, y: -153.90000000000001, simplifier: "NoSimplification", aggregator: "SingleZonePerFloorType")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000011
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 131.40000000000001, x: 15.08, y: -153.90000000000001, simplifier: "SemanticMerge", aggregator: "SingleZoneBuilding")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000011
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 7.2999999999999998, x: 0, y: 0, simplifier: "NoSimplification", aggregator: "SingleZonePerFloorType")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000002
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 131.40000000000001, x: 15.08, y: -153.90000000000001, simplifier: "NoSimplification", aggregator: "SingleZoneBuilding")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000011
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 131.40000000000001, x: 15.08, y: -153.90000000000001, simplifier: "SemanticMerge", aggregator: "SingleZonePerFloorType")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000011
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 7.2999999999999998, x: 0, y: 0, simplifier: "NoSimplification", aggregator: "SingleZoneBuilding")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2100.000002
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 248.19999999999999, x: 42.619999999999997, y: -245.19999999999999, simplifier: "SemanticMerge", aggregator: "SingleZonePerFloorType")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 1800.000009
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 248.19999999999999, x: 42.619999999999997, y: -245.19999999999999, simplifier: "NoSimplification", aggregator: "SingleZoneBuilding")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2400.000014
Failed Lod.Integration.Tests.PlanTransformTests.EveryCombinationValidatesOnARotatedAndTranslatedPlan(degrees: 248.19999999999999, x: 42.619999999999997, y: -245.19999999999999, simplifier: "NoSimplification", aggregator: "SingleZonePerFloorType")
FAIL Building.ExteriorWallArea: reference 1260.000000, actual 2400.000014
The plain union of rotated zone footprints leaves slits or splits the outline where grid rounding moves a T-junction off its edge, and the rebuilt walls along those slits add exterior wall area that the stack does not have.
- [x] Step 3: Extract the vertex grid — create
src/Lod.Core/Geometry/VertexGrid.cs(full file)
using System;
using System.Collections.Generic;
namespace Lod.Core.Geometry;
/// <summary>A cell of the <see cref="VertexGrid"/>, in grid units.</summary>
/// <param name="X">X index.</param>
/// <param name="Y">Y index.</param>
internal readonly record struct GridPoint(long X, long Y);
/// <summary>
/// Snaps points to the distance grid, remembers the first original point of each grid cell, and tests whether a grid point lies on a segment
/// within one grid unit. Shared by the layout surface builder and the union of tiles, so both recognise the same vertices and T-junctions.
/// </summary>
internal sealed class VertexGrid
{
private readonly double _cell;
private readonly Dictionary<GridPoint, Point2> _originals = new();
/// <summary>Creates an empty grid.</summary>
/// <param name="cell">Grid spacing, m: the distance tolerance.</param>
public VertexGrid(double cell)
{
_cell = cell;
}
/// <summary>Registered cells, in registration order.</summary>
public IEnumerable<GridPoint> Keys => _originals.Keys;
/// <summary>The cell of a point.</summary>
/// <param name="p">The point.</param>
/// <returns>The cell.</returns>
public GridPoint Key(Point2 p) => new((long)Math.Round(p.X / _cell, MidpointRounding.AwayFromZero), (long)Math.Round(p.Y / _cell, MidpointRounding.AwayFromZero));
/// <summary>Registers a point; the first point registered in a cell stands for the cell.</summary>
/// <param name="p">The point.</param>
public void Register(Point2 p)
{
GridPoint key = Key(p);
if (!_originals.ContainsKey(key))
{
_originals.Add(key, p);
}
}
/// <summary>The first point registered in a cell.</summary>
/// <param name="key">The cell.</param>
/// <returns>The point.</returns>
public Point2 Original(GridPoint key) => _originals[key];
/// <summary>Position of <paramref name="p"/> along <paramref name="a"/>–<paramref name="b"/>, 0 at <paramref name="a"/> and 1 at <paramref name="b"/>.</summary>
/// <param name="a">Segment start.</param>
/// <param name="b">Segment end.</param>
/// <param name="p">The point.</param>
/// <returns>The parameter.</returns>
public static double Parameter(GridPoint a, GridPoint b, GridPoint p)
{
double dx = b.X - a.X;
double dy = b.Y - a.Y;
return (((p.X - a.X) * dx) + ((p.Y - a.Y) * dy)) / ((dx * dx) + (dy * dy));
}
/// <summary>Within one grid unit of the line and strictly between the end points (by more than half a grid unit).</summary>
/// <param name="a">Segment start.</param>
/// <param name="b">Segment end.</param>
/// <param name="p">The point.</param>
/// <returns>Whether <paramref name="p"/> splits the segment.</returns>
public static bool IsStrictlyInside(GridPoint a, GridPoint b, GridPoint p)
{
if (DistanceToLine(a, b, p) > 1.0)
{
return false;
}
double length = Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2));
double along = Parameter(a, b, p) * length;
return along > 0.5 && along < length - 0.5;
}
/// <summary>Within one grid unit of the segment, end points included.</summary>
/// <param name="a">Segment start.</param>
/// <param name="b">Segment end.</param>
/// <param name="p">The point.</param>
/// <returns>Whether <paramref name="p"/> lies on the segment.</returns>
public static bool IsOnSegment(GridPoint a, GridPoint b, GridPoint p)
{
if (p == a || p == b)
{
return true;
}
double length = Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2));
double along = Parameter(a, b, p) * length;
return DistanceToLine(a, b, p) <= 1.0 && along >= -1.0 && along <= length + 1.0;
}
private static double DistanceToLine(GridPoint a, GridPoint b, GridPoint p)
{
double dx = b.X - a.X;
double dy = b.Y - a.Y;
return Math.Abs((dx * (p.Y - a.Y)) - (dy * (p.X - a.X))) / Math.Sqrt((dx * dx) + (dy * dy));
}
}
- [x] Step 4: Use it in the layout builder — replace
src/Lod.Core/Layout/LayoutSurfaceBuilder.cs(full file)
The private Grid, GridPoint, Parameter, DistanceToLine, IsStrictlyInside, and IsOnSegment are deleted and their callers use VertexGrid; 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.
/// </summary>
public sealed class LayoutSurfaceBuilder
{
private readonly ToleranceSettings _tolerances;
/// <summary>Creates the builder.</summary>
/// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.Distance"/> is the vertex matching grid.</param>
public LayoutSurfaceBuilder(ToleranceSettings tolerances)
{
_tolerances = tolerances;
}
/// <summary>Builds walls (without windows), floors, and ceilings.</summary>
/// <param name="footprint">Plan boundary; walls on it are outdoors.</param>
/// <param name="zones">Zones tiling the footprint.</param>
/// <param name="height">Floor-to-floor height, m.</param>
/// <returns>Surfaces grouped by zone (walls, floor, ceiling), or errors for gaps and overlaps.</returns>
public Result<IReadOnlyList<Surface>> Build(Polygon2 footprint, IReadOnlyList<LayoutZone> zones, double height)
{
var grid = new VertexGrid(_tolerances.Distance);
foreach (Point2 p in footprint.Rings.SelectMany(r => r).Concat(zones.SelectMany(z => z.Footprint.Rings.SelectMany(r => r))))
{
grid.Register(p);
}
GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
var segments = new List<Segment>();
for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
{
int ringIndex = 0;
foreach (IReadOnlyList<Point2> ring in zones[zoneIndex].Footprint.Rings)
{
AddRingSegments(grid, vertices, zoneIndex, ringIndex++, ring, segments);
}
}
var diagnostics = new List<Diagnostic>();
Classify(grid, footprint, zones, segments, diagnostics);
if (diagnostics.Count > 0)
{
return Result.Failure<IReadOnlyList<Surface>>(diagnostics);
}
var surfaces = new List<Surface>();
for (int zoneIndex = 0; zoneIndex < zones.Count; zoneIndex++)
{
LayoutZone zone = zones[zoneIndex];
int wallNumber = 1;
foreach (IGrouping<int, Segment> ring in segments.Where(s => s.Zone == zoneIndex).GroupBy(s => s.Ring))
{
foreach (List<Segment> run in MergeRuns(ring.ToList()))
{
Segment first = run[0];
surfaces.Add(new WallSurface(
new SurfaceId($"{zone.Id.Value}/W{wallNumber++}"),
zone.Id,
grid.Original(first.A),
grid.Original(run[run.Count - 1].B),
elevation: 0.0,
height,
first.Boundary,
first.Adjacent is int adjacent ? zones[adjacent].Id : null,
Array.Empty<Window>()));
}
}
surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/F"), zone.Id, HorizontalKind.Floor, zone.Footprint, 0.0, BoundaryCondition.Unresolved, null));
surfaces.Add(new HorizontalSurface(new SurfaceId($"{zone.Id.Value}/C"), zone.Id, HorizontalKind.Ceiling, zone.Footprint, height, BoundaryCondition.Unresolved, null));
}
return Result.Success<IReadOnlyList<Surface>>(surfaces);
}
private static void AddRingSegments(VertexGrid grid, GridPoint[] vertices, int zone, int ring, IReadOnlyList<Point2> points, List<Segment> segments)
{
for (int i = 0; i < points.Count; i++)
{
GridPoint a = grid.Key(points[i]);
GridPoint b = grid.Key(points[(i + 1) % points.Count]);
if (a == b)
{
continue;
}
GridPoint[] cuts = vertices
.Where(v => v != a && v != b && VertexGrid.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(ring, segment.A, segment.B)))
{
segment.Boundary = BoundaryCondition.Outdoors;
}
else
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.UnmatchedEdge, $"Edge {Describe(grid, segment)} is neither shared with another zone nor on the plan boundary (gap or overlap).", subject));
}
}
}
private IEnumerable<List<Segment>> MergeRuns(List<Segment> ring)
{
int start = Enumerable.Range(0, ring.Count).FirstOrDefault(i => !CanMerge(ring[(i + ring.Count - 1) % ring.Count], ring[i]));
List<Segment>? run = null;
for (int k = 0; k < ring.Count; k++)
{
Segment segment = ring[(start + k) % ring.Count];
if (run is not null && CanMerge(run[run.Count - 1], segment))
{
run.Add(segment);
continue;
}
if (run is not null)
{
yield return run;
}
run = new List<Segment> { segment };
}
if (run is not null)
{
yield return run;
}
}
private bool CanMerge(Segment previous, Segment next)
{
if (previous.B != next.A || previous.Boundary != next.Boundary || previous.Adjacent != next.Adjacent)
{
return false;
}
double ax = previous.B.X - previous.A.X;
double ay = previous.B.Y - previous.A.Y;
double bx = next.B.X - next.A.X;
double by = next.B.Y - next.A.Y;
double lengths = Math.Sqrt((ax * ax) + (ay * ay)) * Math.Sqrt((bx * bx) + (by * by));
double sine = ((ax * by) - (ay * bx)) / lengths;
double cosine = ((ax * bx) + (ay * by)) / lengths;
return cosine > 0 && Math.Abs(sine) <= Math.Sin(_tolerances.Angle);
}
private static bool OnRing(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 (VertexGrid.IsOnSegment(p, q, a) && VertexGrid.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: Union tiles — replace
src/Lod.Core/Geometry/PolygonOps.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using Lod.Core.Common;
namespace Lod.Core.Geometry;
/// <summary>
/// Polygon boolean operations on Clipper2 (ADR-002). Results are snapped to the <see cref="ToleranceSettings.Distance"/> grid.
/// </summary>
public sealed class PolygonOps
{
private readonly int _precision;
private readonly double _distance;
/// <summary>Creates the operations with the clipping precision of <paramref name="tolerances"/>.</summary>
/// <param name="tolerances">Tolerances; <see cref="ToleranceSettings.DecimalPrecision"/> sets the snapping grid.</param>
public PolygonOps(ToleranceSettings tolerances)
{
_precision = tolerances.DecimalPrecision;
_distance = tolerances.Distance;
}
/// <summary>Intersection of two polygons.</summary>
/// <param name="a">First polygon.</param>
/// <param name="b">Second polygon.</param>
/// <returns>Zero or more polygons.</returns>
public IReadOnlyList<Polygon2> Intersect(Polygon2 a, Polygon2 b) => Execute(ClipType.Intersection, new[] { a }, new[] { b });
/// <summary>Union of polygons.</summary>
/// <param name="polygons">Polygons to merge.</param>
/// <returns>Disjoint polygons covering the union.</returns>
public IReadOnlyList<Polygon2> Union(IEnumerable<Polygon2> polygons) => Execute(ClipType.Union, polygons, Enumerable.Empty<Polygon2>());
/// <summary>
/// Union of polygons that tile a region and share their edges up to the distance tolerance, such as the zones of a storey (ADR-013). Before
/// clipping, vertices in the same cell of the distance grid are unified, and every vertex lying on another tile's edge within one grid unit
/// (a T-junction) is inserted into that edge, as <see cref="Layout.LayoutSurfaceBuilder"/> splits walls. Shared edges then cancel exactly;
/// a plain <see cref="Union"/> of tiles that are not axis-aligned can leave slits or splits where rounding on the clipping grid moves a
/// T-junction off its edge. Inserted vertices lie on their edges within the distance tolerance, so the outline is not moved.
/// </summary>
/// <param name="tiles">Polygons tiling a region.</param>
/// <returns>Disjoint polygons covering the union.</returns>
public IReadOnlyList<Polygon2> UnionTiles(IReadOnlyList<Polygon2> tiles)
{
var grid = new VertexGrid(_distance);
foreach (Point2 p in tiles.SelectMany(t => t.Rings).SelectMany(r => r))
{
grid.Register(p);
}
GridPoint[] vertices = grid.Keys.OrderBy(k => k.X).ThenBy(k => k.Y).ToArray();
IEnumerable<Point2> Split(IReadOnlyList<Point2> ring)
{
for (int i = 0; i < ring.Count; i++)
{
GridPoint a = grid.Key(ring[i]);
GridPoint b = grid.Key(ring[(i + 1) % ring.Count]);
yield return grid.Original(a);
if (a == b)
{
continue;
}
foreach (GridPoint cut in vertices.Where(v => v != a && v != b && VertexGrid.IsStrictlyInside(a, b, v)).OrderBy(v => VertexGrid.Parameter(a, b, v)))
{
yield return grid.Original(cut);
}
}
}
// A tile whose rings collapse on the grid cannot be split; it is clipped as given.
Polygon2[] prepared = tiles
.Select(t => Polygon2.Create(Split(t.Outer).ToArray(), t.Holes.Select(h => Split(h).ToArray()).ToArray()) is { IsSuccess: true } split ? split.Value : t)
.ToArray();
return Union(prepared);
}
/// <summary>Part of <paramref name="subject"/> not covered by <paramref name="clips"/>.</summary>
/// <param name="subject">Polygon to cut.</param>
/// <param name="clips">Polygons to remove.</param>
/// <returns>Zero or more polygons.</returns>
public IReadOnlyList<Polygon2> Difference(Polygon2 subject, IEnumerable<Polygon2> clips) => Execute(ClipType.Difference, new[] { subject }, clips);
/// <summary>Area of the intersection of two polygons.</summary>
/// <param name="a">First polygon.</param>
/// <param name="b">Second polygon.</param>
/// <returns>Overlap area, m².</returns>
public double IntersectionArea(Polygon2 a, Polygon2 b) => Intersect(a, b).Sum(p => p.Area);
private IReadOnlyList<Polygon2> Execute(ClipType clipType, IEnumerable<Polygon2> subjects, IEnumerable<Polygon2> clips)
{
var clipper = new ClipperD(_precision);
clipper.AddSubject(ToPaths(subjects));
clipper.AddClip(ToPaths(clips));
var tree = new PolyTreeD();
clipper.Execute(clipType, FillRule.NonZero, tree);
var result = new List<Polygon2>();
Collect(tree, result);
return result;
}
private static PathsD ToPaths(IEnumerable<Polygon2> polygons)
{
var paths = new PathsD();
foreach (IReadOnlyList<Point2> ring in polygons.SelectMany(p => p.Rings))
{
var path = new PathD(ring.Count);
path.AddRange(ring.Select(p => new PointD(p.X, p.Y)));
paths.Add(path);
}
return paths;
}
private static void Collect(PolyPathD parent, List<Polygon2> result)
{
for (int i = 0; i < parent.Count; i++)
{
PolyPathD outer = parent[i];
var holes = new List<IEnumerable<Point2>>();
for (int j = 0; j < outer.Count; j++)
{
PolyPathD hole = outer[j];
holes.Add(ToPoints(hole.Polygon!));
Collect(hole, result);
}
Result<Polygon2> polygon = Polygon2.Create(ToPoints(outer.Polygon!), holes);
if (polygon.IsSuccess)
{
result.Add(polygon.Value);
}
}
}
private static IEnumerable<Point2> ToPoints(PathD path) => path.Select(p => new Point2(p.x, p.y));
}
- [x] Step 6: Merge storeys as tiles — replace
src/Lod.Core/Buildings/SingleZoneMerge.cs(full file)
Only the outline call (ops.UnionTiles(…) instead of ops.Union(…)) and the summary change.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Aggregation;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Layout;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
namespace Lod.Core.Buildings;
/// <summary>A zone that a single-zone method merges storeys into.</summary>
/// <param name="Id">Zone ID.</param>
/// <param name="Name">Display name.</param>
internal sealed record MergedZone(ZoneId Id, string Name);
/// <summary>
/// The merge shared by the single-zone methods (ADR-013): the storeys of the assembled stack are grouped by floor entry into zones. Each zone gets one
/// part per storey outline (the union of the storey's zone footprints as tiles, <see cref="PolygonOps.UnionTiles"/>); its outdoor walls are rebuilt per outline, must cover every source façade (D-041), and keep every source window in
/// place (D-039); walls between merged zones are discarded (D-034); slabs between two storeys of the same zone become internal mass with both faces
/// exposed, one entry per slab level (D-031); every other floor and ceiling piece keeps its stacked designation (D-068), and a piece between two
/// zones stays interzone. Programs are aggregated from the stacked zones with fractions 1, so every invariant is conserved (ADR-005, ADR-007).
/// </summary>
internal static class SingleZoneMerge
{
/// <summary>Merges the stacked storeys of <paramref name="floors"/> into the zones given per floor entry.</summary>
/// <param name="operation">Operation name recorded in provenance.</param>
/// <param name="floors">Floor entries, bottom to top.</param>
/// <param name="zoneOfEntry">The zone each floor entry's storeys merge into; equal zones are merged together.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The building, or errors.</returns>
public static Result<IGeneratedBuilding> Merge(string operation, IReadOnlyList<FloorEntry> floors, Func<int, MergedZone> zoneOfEntry, ToleranceSettings tolerances)
{
Result<AssembledStack> assembled = Storeys.Assemble(floors, tolerances);
if (!assembled.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(assembled.Diagnostics);
}
AssembledStack stack = assembled.Value;
MergedZone[] zoneOfStorey = stack.Entries.Select(zoneOfEntry).ToArray();
MergedZone[] targets = zoneOfStorey.Distinct().ToArray();
Dictionary<ZoneId, ZoneId> targetOf = stack.Storeys
.SelectMany((s, k) => s.Zones.Select(z => (Source: z.Id, Target: zoneOfStorey[k].Id)))
.ToDictionary(p => p.Source, p => p.Target);
var ops = new PolygonOps(tolerances);
var builder = new LayoutSurfaceBuilder(tolerances);
Dictionary<ZoneId, List<ZonePart>> parts = targets.ToDictionary(t => t.Id, _ => new List<ZonePart>());
var walls = new List<Surface>();
for (int k = 0; k < stack.Storeys.Count; k++)
{
PlacedStorey storey = stack.Storeys[k];
ZoneId target = zoneOfStorey[k].Id;
foreach (Polygon2 outline in ops.UnionTiles(storey.Zones.Select(z => z.Parts[0].Footprint).ToArray()))
{
parts[target].Add(new ZonePart(outline, storey.Elevation, storey.Height));
Result<IReadOnlyList<Surface>> built = builder.Build(outline, new[] { new LayoutZone(target, outline) }, storey.Height);
if (!built.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(built.Diagnostics);
}
string prefix = $"S{parts[target].Count - 1}/";
walls.AddRange(built.Value.OfType<WallSurface>().Select(w => w.Relocate(id => id, prefix, storey.Elevation)));
}
}
WallSurface[] sourceWalls = stack.Storeys.SelectMany(s => s.Walls).ToArray();
FacadeAttribution facade = FacadeAttribution.Compute(sourceWalls, walls, tolerances);
Diagnostic[] gaps = facade.CoverageGaps()
.Select(g => Diagnostic.Error(DiagnosticCodes.FacadeNotCovered, $"Rebuilt façades cover {g.CoveredLength:0.######} m of this {g.Wall.Length:0.######} m wall (D-041).", g.Wall.Id.Value))
.ToArray();
if (gaps.Length > 0)
{
return Result.Failure<IGeneratedBuilding>(gaps);
}
Result<IReadOnlyList<Surface>> rehosted = WindowRehosting.Rehost(sourceWalls, walls, tolerances);
if (!rehosted.IsSuccess)
{
return Result.Failure<IGeneratedBuilding>(rehosted.Diagnostics);
}
IReadOnlyList<Surface> glazedWalls = rehosted.Value;
HorizontalSurface[] horizontal = stack.Horizontal.SelectMany(h => h).ToArray();
bool Within(HorizontalSurface h) => h.Boundary == BoundaryCondition.Interzone && targetOf[h.Zone] == targetOf[h.AdjacentZone!.Value];
InternalMass[] masses = targets
.SelectMany(t => horizontal
.Where(h => h.Kind == HorizontalKind.Floor && Within(h) && targetOf[h.Zone] == t.Id)
.GroupBy(h => h.Elevation)
.OrderBy(g => g.Key)
.Select(level => new InternalMass(
t.Id,
level.Sum(h => h.Area),
ExposedFaces: 2,
level.Key,
level.Select(h => h.Id)
.Concat(horizontal.Where(c => c.Kind == HorizontalKind.Ceiling && Within(c) && targetOf[c.Zone] == t.Id && c.Elevation == level.Key).Select(c => c.Id))
.ToArray())))
.ToArray();
IEnumerable<Surface> envelope = horizontal
.Where(h => !Within(h))
.Select(h => new HorizontalSurface(h.Id, targetOf[h.Zone], h.Kind, h.Polygon, h.Elevation, h.Boundary, h.AdjacentZone is { } adjacent ? targetOf[adjacent] : null));
var diagnostics = new List<Diagnostic>(assembled.Diagnostics);
var aggregator = new EquivalentPropertyAggregator(tolerances);
var zones = new List<Zone>();
foreach (MergedZone target in targets)
{
Zone[] sources = stack.Storeys.Where((_, k) => zoneOfStorey[k] == target).SelectMany(s => s.Zones).ToArray();
List<ZonePart> zoneParts = parts[target.Id];
var measures = new ZoneMeasures(
zoneParts.Sum(p => p.Footprint.Area),
zoneParts.Sum(p => p.Volume),
glazedWalls.Where(w => w.Zone == target.Id).Sum(w => w.Area));
SourceZoneContribution[] contributions = sources
.Select(z => new SourceZoneContribution(z.Id, z.Program, LayoutMeasures.Of(z, sourceWalls), 1.0, 1.0))
.ToArray();
Result<ZoneProgram> program = aggregator.Aggregate(target.Id, measures, contributions);
diagnostics.AddRange(program.Diagnostics);
if (program.IsSuccess)
{
SpaceType[] types = sources.Select(z => z.SpaceType).Distinct().ToArray();
zones.Add(new Zone(target.Id, target.Name, types.Length == 1 ? types[0] : SpaceType.Mixed, zoneParts, program.Value, sources.Select(z => z.Id)));
}
}
if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
{
return Result.Failure<IGeneratedBuilding>(diagnostics);
}
var provenance = Provenance.Of(
operation,
new[] { new KeyValuePair<string, string>("Multipliers", string.Join(",", floors.Select(e => e.Multiplier))) },
floors.Select(e => e.Floor.Provenance).ToArray());
IGeneratedBuilding building = new GeneratedBuilding(floors[0].Floor.OrientationDegrees, zones, glazedWalls.Concat(envelope), masses, floors, provenance);
return Result.Success(building, diagnostics);
}
}
- [x] Step 7: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Geometry.GeometryTests"
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
dotnet build BEMGen.sln -c Release
dotnet test BEMGen.sln -c Release
git status --short
Expected:
Passed! - Failed: 0, Passed: 23, Skipped: 0, Total: 23, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 79, Skipped: 0, Total: 79, Duration: … - Lod.Integration.Tests.dll (net8.0)
Build succeeded., 0 Warning(s), 0 Error(s), and on net8.0:
Passed! - Failed: 0, Passed: 129, Skipped: 0, Total: 129, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 162, Skipped: 0, Total: 162, Duration: … - Lod.Integration.Tests.dll (net8.0)
git status --short lists the six files of this task and no *.received.txt: the layout builder's output and every snapshot are unchanged.
- [x] Step 8: Commit
git add src/Lod.Core/Geometry/VertexGrid.cs src/Lod.Core/Geometry/PolygonOps.cs src/Lod.Core/Layout/LayoutSurfaceBuilder.cs src/Lod.Core/Buildings/SingleZoneMerge.cs tests/Lod.Core.Tests/Geometry/GeometryTests.cs tests/Lod.Integration.Tests/PlanTransformTests.cs
git commit -m "fix(aggregators): merge rotated storeys into one outline per storey"
Task 10: Supported area compared with the floor area¶
Files:
- Modify: src/Lod.Core/Buildings/Storeys.cs (Task 2)
- Test: modify tests/Lod.Integration.Tests/PlanTransformTests.cs (Task 9)
Interfaces:
- Consumes: Storeys.Assemble (Task 2); PerimeterCore(ToleranceSettings, double depth) (S3); Totals.Of(...).ExposedFloorArea (S3).
- Produces: Storeys.Assemble reports UnsupportedStorey for storey k > 0 when !tolerances.AreaEquals(floorArea, floorArea − uncovered), the storey's floor area against the area covered by storey k − 1 (D-076); before, the uncovered area was compared with zero. The message and subject are unchanged. The D-046 split in StackedFloorZoneMultiplier still compares the exposed area with zero.
- [x] Step 1: Write the failing test — replace
tests/Lod.Integration.Tests/PlanTransformTests.cs(full file)
The fact RoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported is added at the end: Perimeter Core floors of the 24 m plan × 3 and the 14 m plan × 4, both rotated by 55.27° and moved by (3.1, −7.7), leave between 1e-6 and 1e-4 m² of the setback storey's floor uncovered (grid rounding along shared façades). No storey is reported as unsupported; the zone multiplier still splits the setback storey off (L0x1,L1x1,L2x1,L3x1,L5x2,L6x1), and its building validates.
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.Simplification;
using Lod.Core.Validation;
using Xunit;
namespace Lod.Integration.Tests;
public sealed class PlanTransformTests
{
[Theory]
[InlineData(0.0)]
[InlineData(90.0)]
[InlineData(30.0)]
public void RotationKeepsAreaVolumeWallAreaAndGlazing(double degrees)
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, degrees, 5.3, -2.1).Value;
Totals before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees);
Totals after = Totals.Of(moved.Zones, moved.Surfaces, moved.OrientationDegrees);
Assert.True(Pipeline.Tolerances.AreaEquals(plan.Footprint.Area, moved.Footprint.Area));
Assert.True(Pipeline.Tolerances.AreaEquals(before.FloorArea, after.FloorArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Volume, after.Volume));
Assert.True(Pipeline.Tolerances.AreaEquals(before.ExteriorWallArea, after.ExteriorWallArea));
Assert.True(Pipeline.Tolerances.AreaEquals(before.Glazing, after.Glazing));
Assert.Equal(plan.Zones.Select(z => z.Id), moved.Zones.Select(z => z.Id));
Assert.Equal(plan.Surfaces.Select(s => s.Id), moved.Surfaces.Select(s => s.Id));
Assert.Equal(plan.OrientationDegrees, moved.OrientationDegrees);
Assert.Equal(plan.FloorHeight, moved.FloorHeight);
}
[Fact]
public void GlazingPerOrientationFollowsTheRotatedFacades()
{
IGeneratedPlan plan = Pipeline.Plan();
IReadOnlyDictionary<OrientationBin, double> before = Totals.Of(plan.Zones, plan.Surfaces, plan.OrientationDegrees).GlazingByOrientation;
IGeneratedPlan quarter = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
IGeneratedPlan thirty = PlanTransform.Apply(plan, 30.0, 0.0, 0.0).Value;
IReadOnlyDictionary<OrientationBin, double> afterQuarter = Totals.Of(quarter.Zones, quarter.Surfaces, quarter.OrientationDegrees).GlazingByOrientation;
IReadOnlyDictionary<OrientationBin, double> afterThirty = Totals.Of(thirty.Zones, thirty.Surfaces, thirty.OrientationDegrees).GlazingByOrientation;
// A quarter turn counter-clockwise turns a north façade west, east north, south east, and west south.
Assert.Equal(before[OrientationBin.North], afterQuarter[OrientationBin.West], 9);
Assert.Equal(before[OrientationBin.East], afterQuarter[OrientationBin.North], 9);
Assert.Equal(before[OrientationBin.South], afterQuarter[OrientationBin.East], 9);
Assert.Equal(before[OrientationBin.West], afterQuarter[OrientationBin.South], 9);
// 30° keeps every façade azimuth inside its 90° bin (0° → 330°, 90° → 60°, ...).
Assert.All(before.Keys, bin => Assert.Equal(before[bin], afterThirty[bin], 9));
}
[Fact]
public void WallFacingIsRecomputedFromTheRotatedGeometry()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 90.0, 0.0, 0.0).Value;
WallSurface[] original = plan.Surfaces.OfType<WallSurface>().ToArray();
WallSurface[] rotated = moved.Surfaces.OfType<WallSurface>().ToArray();
Assert.Equal(original.Length, rotated.Length);
for (int i = 0; i < original.Length; i++)
{
Assert.Equal(Orientation.Normalise(original[i].PlanAzimuth - 90.0), rotated[i].PlanAzimuth, 9);
Assert.Equal(original[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)), rotated[i].Windows.Select(w => (w.Offset, w.SillHeight, w.Width, w.Height)));
}
WallSurface northFacing = original.First(w => w.Boundary == BoundaryCondition.Outdoors && Orientation.Bin(w.PlanAzimuth) == OrientationBin.North);
Assert.Equal(OrientationBin.West, Orientation.Bin(rotated[Array.IndexOf(original, northFacing)].PlanAzimuth));
}
[Fact]
public void TranslationOnlyMovesEveryPoint()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan moved = PlanTransform.Apply(plan, 0.0, 5.3, -2.1).Value;
Assert.Equal(plan.Footprint.Outer.Select(p => new Point2(p.X + 5.3, p.Y - 2.1)), moved.Footprint.Outer);
Assert.Equal(
plan.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Zones.SelectMany(z => z.Parts).SelectMany(p => p.Footprint.Outer));
Assert.Equal(
plan.Surfaces.OfType<WallSurface>().Select(w => (new Point2(w.Start.X + 5.3, w.Start.Y - 2.1), new Point2(w.End.X + 5.3, w.End.Y - 2.1), w.Elevation, w.Height)),
moved.Surfaces.OfType<WallSurface>().Select(w => (w.Start, w.End, w.Elevation, w.Height)));
Assert.Equal(
plan.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer).Select(p => new Point2(p.X + 5.3, p.Y - 2.1)),
moved.Surfaces.OfType<HorizontalSurface>().SelectMany(h => h.Polygon.Outer));
Assert.All(moved.Surfaces.OfType<HorizontalSurface>(), h => Assert.Equal(BoundaryCondition.Unresolved, h.Boundary));
}
[Fact]
public void TransformIsDeterministicAndRecordedInProvenance()
{
IGeneratedPlan plan = Pipeline.Plan();
IGeneratedPlan first = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
IGeneratedPlan second = PlanTransform.Apply(plan, 30.0, 5.3, -2.1).Value;
Assert.Equal(TextReport.Describe(first), TextReport.Describe(second));
Assert.Equal("TransformPlan", first.Provenance.Operation);
Assert.Equal(
new[] { "RotationDegrees=30", "TranslationX=5.3", "TranslationY=-2.1" },
first.Provenance.Parameters.Select(p => $"{p.Key}={p.Value}"));
Assert.Same(plan.Provenance, Assert.Single(first.Provenance.Inputs));
}
[Theory]
[InlineData(double.NaN, 0.0, 0.0)]
[InlineData(0.0, double.PositiveInfinity, 0.0)]
[InlineData(0.0, 0.0, double.NegativeInfinity)]
[InlineData(double.PositiveInfinity, double.NaN, double.NaN)]
public void NonFiniteInputsAreInvalidParameters(double degrees, double x, double y)
{
Result<IGeneratedPlan> result = PlanTransform.Apply(Pipeline.Plan(), degrees, x, y);
Assert.False(result.IsSuccess);
Assert.All(result.Diagnostics, d => Assert.Equal(DiagnosticCodes.InvalidParameter, d.Code));
Assert.Equal(new[] { degrees, x, y }.Count(v => double.IsNaN(v) || double.IsInfinity(v)), result.Diagnostics.Count);
}
public static IEnumerable<object[]> Matrix() =>
from placement in new[] { (30.0, 5.3, -2.1), (7.3, 0.0, 0.0), (131.4, 15.08, -153.9), (248.2, 42.62, -245.2) }
from simplifier in new[] { "NoSimplification", "SemanticMerge", "PerimeterCore", "SingleZonePerFloor" }
from aggregator in new[] { "Stack", "StackedFloorZoneMultiplier", "SingleZonePerFloorType", "SingleZoneBuilding" }
select new object[] { placement.Item1, placement.Item2, placement.Item3, simplifier, aggregator };
[Theory]
[MemberData(nameof(Matrix))]
public void EveryCombinationValidatesOnARotatedAndTranslatedPlan(double degrees, double x, double y, string simplifier, string aggregator)
{
IGeneratedPlan plan = PlanTransform.Apply(Pipeline.Plan(), degrees, x, y).Value;
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);
}
[Fact]
public void RoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported()
{
// Rotated by 55.27°, the perimeter zones of the 14 m plan leave about 1.4e-5 m² of its floor uncovered by the 24 m plan below, from rounding
// on the distance grid along their shared façades. Compared with its 252 m² floor that is within the area tolerance: the setback storey is
// supported (D-074). The zone multiplier still splits it off, so even the sliver is not multiplied (D-046), and the building validates.
IFloor wide = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(PlanTransform.Apply(Pipeline.Plan(), 55.27, 3.1, -7.7).Value).Value;
IFloor narrow = new PerimeterCore(Pipeline.Tolerances, 4.57).Simplify(PlanTransform.Apply(Pipeline.Plan(Pipeline.Canonical with { Length = 14.0 }), 55.27, 3.1, -7.7).Value).Value;
FloorEntry[] entries = { new(wide, 3), new(narrow, 4) };
Result<IGeneratedBuilding> stacked = new Stack(Pipeline.Tolerances).Aggregate(entries);
Result<IGeneratedBuilding> multiplied = new StackedFloorZoneMultiplier(Pipeline.Tolerances).Aggregate(entries);
Assert.InRange(Totals.Of(stacked.Value.Zones, stacked.Value.Surfaces, stacked.Value.OrientationDegrees).ExposedFloorArea, 1e-6, 1e-4);
Assert.DoesNotContain(stacked.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.DoesNotContain(multiplied.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal("L0x1,L1x1,L2x1,L3x1,L5x2,L6x1", multiplied.Value.Provenance.Parameters.Single(p => p.Key == "Storeys").Value);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(multiplied.Value);
Assert.True(report.Passed, report.Describe());
}
[Theory]
[InlineData("Stack")]
[InlineData("StackedFloorZoneMultiplier")]
[InlineData("SingleZonePerFloorType")]
[InlineData("SingleZoneBuilding")]
public void AStoreyMovedOffTheStoreyBelowIsUnsupported(string aggregator)
{
// The upper plan is moved by (5.3, -2.1): it overlaps the lower 24 m x 18 m plan on 18.7 m x 15.9 m = 297.33 m², so 134.67 m² overhang.
IFloor lower = new NoSimplification().Simplify(Pipeline.Plan()).Value;
IFloor upper = new NoSimplification().Simplify(PlanTransform.Apply(Pipeline.Plan(), 0.0, 5.3, -2.1).Value).Value;
Result<IGeneratedBuilding> result = Pipeline.Aggregator(aggregator).Aggregate(new[] { new FloorEntry(lower, 2), new FloorEntry(upper, 2) });
Diagnostic warning = Assert.Single(result.Diagnostics, d => d.Code == DiagnosticCodes.UnsupportedStorey);
Assert.Equal(DiagnosticSeverity.Warning, warning.Severity);
Assert.Equal("L2", warning.Subject);
Assert.Contains("134.670000 m²", warning.Message);
ValidationReport report = new BuildingValidator(Pipeline.Tolerances).Validate(result.Value);
Assert.True(report.Passed, report.Describe());
Totals totals = Totals.Of(result.Value.Zones, result.Value.Surfaces, result.Value.OrientationDegrees);
Assert.Equal(134.67, totals.ExposedFloorArea, 6);
Assert.Equal(432.0 + 134.67, totals.RoofArea, 6);
Assert.Equal(432.0, totals.GroundArea, 6);
}
}
- [x] Step 2: Run the tests and see the new one fail
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
Expected on net8.0:
Failed! - Failed: 1, Passed: 79, Skipped: 0, Total: 80, Duration: … - Lod.Integration.Tests.dll (net8.0)
with the failing test and its messages:
Failed Lod.Integration.Tests.PlanTransformTests.RoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported
Assert.DoesNotContain() Failure: Filter matched in collection
↓ (pos 0)
Collection: [Warning UnsupportedStorey [L3]: The floor of storey 3 is not fully covered by storey 2: 0.000014 m² of it faces outdoors (D-074).]
- [x] Step 3: Implement — replace
src/Lod.Core/Buildings/Storeys.cs(full file)
Only the comparison in Assemble and its summary change.
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Core.Buildings;
/// <summary>A floor placed at its elevation with storey-prefixed zone and surface IDs.</summary>
/// <param name="Index">Index of the storey in the building, from 0 at the bottom.</param>
/// <param name="Elevation">Floor elevation, m.</param>
/// <param name="Height">Floor-to-floor height, m.</param>
/// <param name="Zones">Relocated zones.</param>
/// <param name="Walls">Relocated walls.</param>
public sealed record PlacedStorey(int Index, double Elevation, double Height, IReadOnlyList<Zone> Zones, IReadOnlyList<WallSurface> Walls);
/// <summary>
/// Every storey of a building with every floor and ceiling designated on the whole stack (D-068): the input of every floor aggregator.
/// </summary>
public sealed class AssembledStack
{
/// <summary>Creates the stack.</summary>
/// <param name="storeys">Every storey, bottom to top, with multiplier 1.</param>
/// <param name="entries">Index of the floor entry each storey belongs to.</param>
/// <param name="horizontal">Floors and ceilings of each storey.</param>
public AssembledStack(IReadOnlyList<PlacedStorey> storeys, IReadOnlyList<int> entries, IReadOnlyList<IReadOnlyList<HorizontalSurface>> horizontal)
{
Storeys = storeys;
Entries = entries;
Horizontal = horizontal;
}
/// <summary>Every storey, bottom to top; storey <c>k</c> has the ID prefix <c>L{k}/</c>.</summary>
public IReadOnlyList<PlacedStorey> Storeys { get; }
/// <summary>Index of the floor entry each storey belongs to.</summary>
public IReadOnlyList<int> Entries { get; }
/// <summary>Floors and ceilings of each storey: ground, roof (outdoors), exposed (outdoors), or interzone between two storeys.</summary>
public IReadOnlyList<IReadOnlyList<HorizontalSurface>> Horizontal { get; }
/// <summary>Area of a storey's floor or ceiling pieces that do not lie between two storeys (ground, roof, or exposed), m².</summary>
/// <param name="storey">Storey index.</param>
/// <param name="kind">Floor or ceiling.</param>
/// <returns>The area.</returns>
public double ExposedArea(int storey, HorizontalKind kind) =>
Horizontal[storey].Where(h => h.Kind == kind && h.Boundary != BoundaryCondition.Interzone).Sum(h => h.Area);
}
/// <summary>Shared steps of the floor aggregators: input validation, vertical placement, and assembling and designating the stack.</summary>
public static class Storeys
{
/// <summary>Checks that there is at least one floor, multipliers are positive, and all floors share one orientation.</summary>
/// <param name="floors">Floor entries.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>Errors, if any.</returns>
public static IReadOnlyList<Diagnostic> Validate(IReadOnlyList<FloorEntry> floors, ToleranceSettings tolerances)
{
var diagnostics = new List<Diagnostic>();
if (floors.Count == 0)
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.NoFloors, "A building needs at least one floor."));
return diagnostics;
}
foreach (FloorEntry entry in floors.Where(e => e.Multiplier < 1))
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.InvalidParameter, $"Multipliers start at 1, got {entry.Multiplier}."));
}
double orientation = floors[0].Floor.OrientationDegrees;
double toleranceDegrees = tolerances.Angle * 180.0 / Math.PI;
if (floors.Any(e => Math.Abs(e.Floor.OrientationDegrees - orientation) > toleranceDegrees))
{
diagnostics.Add(Diagnostic.Error(DiagnosticCodes.OrientationMismatch, "All floors of a building must have the same orientation."));
}
return diagnostics;
}
/// <summary>Stacks floors bottom to top, one storey each, prefixing IDs with <c>L{index}/</c>.</summary>
/// <param name="storeys">Floors in vertical order with the zone multiplier to apply.</param>
/// <returns>The placed storeys.</returns>
public static IReadOnlyList<PlacedStorey> Place(IEnumerable<(IFloor Floor, int ZoneMultiplier)> storeys)
{
var placed = new List<PlacedStorey>();
double elevation = 0.0;
foreach ((IFloor floor, int multiplier) in storeys)
{
placed.Add(PlaceAt(floor, placed.Count, elevation, multiplier));
elevation += floor.FloorHeight;
}
return placed;
}
/// <summary>Places a floor as storey <paramref name="index"/> of the building, prefixing IDs with <c>L{index}/</c>.</summary>
/// <param name="floor">The floor.</param>
/// <param name="index">Storey index in the building, from 0 at the bottom.</param>
/// <param name="elevation">Elevation of the storey, m.</param>
/// <param name="zoneMultiplier">Zone multiplier to apply.</param>
/// <returns>The placed storey.</returns>
public static PlacedStorey PlaceAt(IFloor floor, int index, double elevation, int zoneMultiplier)
{
string prefix = $"L{index}/";
ZoneId Rename(ZoneId id) => new(prefix + id.Value);
Zone[] zones = floor.Zones.Select(z => z.Relocate(Rename(z.Id), elevation, zoneMultiplier)).ToArray();
WallSurface[] walls = floor.Surfaces.OfType<WallSurface>().Select(w => w.Relocate(Rename, prefix, elevation)).ToArray();
return new PlacedStorey(index, elevation, floor.FloorHeight, zones, walls);
}
/// <summary>
/// Assembles every storey of a building (D-068): each entry repeated by its multiplier, bottom to top, at its floor-to-floor height, with every
/// floor and ceiling designated on the whole stack. Reports a <see cref="DiagnosticCodes.UnsupportedStorey"/> warning for every storey whose
/// floor area covered by the storey below differs from its floor area by more than the relative area tolerance (D-074).
/// </summary>
/// <param name="floors">Floor entries, bottom to top.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The assembled stack, or the errors of <see cref="Validate"/>.</returns>
public static Result<AssembledStack> Assemble(IReadOnlyList<FloorEntry> floors, ToleranceSettings tolerances)
{
IReadOnlyList<Diagnostic> errors = Validate(floors, tolerances);
if (errors.Count > 0)
{
return Result.Failure<AssembledStack>(errors);
}
int[] entries = floors.SelectMany((e, i) => Enumerable.Repeat(i, e.Multiplier)).ToArray();
IReadOnlyList<PlacedStorey> storeys = Place(entries.Select(i => (floors[i].Floor, 1)));
var ops = new PolygonOps(tolerances);
IReadOnlyList<HorizontalSurface>[] horizontal = Enumerable.Range(0, storeys.Count).Select(k => ResolveStacked(storeys, k, ops)).ToArray();
var stack = new AssembledStack(storeys, entries, horizontal);
var warnings = new List<Diagnostic>();
for (int k = 1; k < storeys.Count; k++)
{
double floorArea = storeys[k].Zones.Sum(z => z.FloorArea);
double uncovered = stack.ExposedArea(k, HorizontalKind.Floor);
if (!tolerances.AreaEquals(floorArea, floorArea - uncovered))
{
warnings.Add(Diagnostic.Warning(
DiagnosticCodes.UnsupportedStorey,
$"The floor of storey {k} is not fully covered by storey {k - 1}: {TextReport.F(uncovered)} m² of it faces outdoors (D-074).",
$"L{k}"));
}
}
return Result.Success(stack, warnings);
}
/// <summary>
/// Floors and ceilings of storey <paramref name="index"/> of physically stacked storeys: split by overlap with the zones below and above into
/// interzone pieces; uncovered parts face outdoors; the lowest floors face the ground and the highest ceilings are roofs.
/// </summary>
/// <param name="storeys">Placed storeys, bottom to top.</param>
/// <param name="index">Index of the storey to resolve.</param>
/// <param name="ops">Polygon operations.</param>
/// <returns>The storey's horizontal surfaces, zone by zone, floor pieces before ceiling pieces.</returns>
public static IReadOnlyList<HorizontalSurface> ResolveStacked(IReadOnlyList<PlacedStorey> storeys, int index, PolygonOps ops)
{
var surfaces = new List<HorizontalSurface>();
PlacedStorey storey = storeys[index];
PlacedStorey? below = index > 0 ? storeys[index - 1] : null;
PlacedStorey? above = index < storeys.Count - 1 ? storeys[index + 1] : null;
foreach (Zone zone in storey.Zones)
{
Polygon2 footprint = zone.Parts[0].Footprint;
surfaces.AddRange(Split(zone.Id, HorizontalKind.Floor, footprint, storey.Elevation, below, BoundaryCondition.Ground, ops));
surfaces.AddRange(Split(zone.Id, HorizontalKind.Ceiling, footprint, storey.Elevation + storey.Height, above, BoundaryCondition.Outdoors, ops));
}
return surfaces;
}
private static IEnumerable<HorizontalSurface> Split(
ZoneId zone,
HorizontalKind kind,
Polygon2 footprint,
double elevation,
PlacedStorey? neighbour,
BoundaryCondition exposed,
PolygonOps ops)
{
string tag = kind == HorizontalKind.Floor ? "F" : "C";
if (neighbour is null)
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}1"), zone, kind, footprint, elevation, exposed, null);
yield break;
}
int number = 1;
foreach (Zone other in neighbour.Zones)
{
foreach (Polygon2 piece in ops.Intersect(footprint, other.Parts[0].Footprint))
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}{number++}"), zone, kind, piece, elevation, BoundaryCondition.Interzone, other.Id);
}
}
foreach (Polygon2 piece in ops.Difference(footprint, neighbour.Zones.Select(z => z.Parts[0].Footprint)))
{
yield return new HorizontalSurface(new SurfaceId($"{zone.Value}/{tag}{number++}"), zone, kind, piece, elevation, BoundaryCondition.Outdoors, null);
}
}
}
- [x] Step 4: Run the tests and see them pass
dotnet test tests/Lod.Integration.Tests -c Release --filter "FullyQualifiedName~Lod.Integration.Tests.PlanTransformTests"
dotnet test BEMGen.sln -c Release
Expected:
Passed! - Failed: 0, Passed: 80, Skipped: 0, Total: 80, Duration: … - Lod.Integration.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 129, Skipped: 0, Total: 129, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 163, Skipped: 0, Total: 163, Duration: … - Lod.Integration.Tests.dll (net8.0)
The overhang tests of Tasks 2 and 7 still report their 180 m² and 134.67 m².
- [x] Step 5: Commit
git add src/Lod.Core/Buildings/Storeys.cs tests/Lod.Integration.Tests/PlanTransformTests.cs
git commit -m "fix(aggregators): compare the supported area of a storey with its floor area"
- [x] Step 6: Merge Slice C (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/plans-transform
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/plans-transform
git branch -d feature/plans-transform
Expected: VERIFY PASSED, with 129 core, 19 generators, and 163 integration tests passed on net8.0. Do not push (D-075).
Slice D — feature/convert-heat-transfer-options¶
Task 11: Heat-transfer options¶
Files:
- Create: src/Lod.Core/Conversion/HeatTransferOptions.cs
- Test: create tests/Lod.Core.Tests/Conversion/HeatTransferOptionsTests.cs
Interfaces:
- Consumes (S2): Surface (Boundary, AdjacentZone), WallSurface, HorizontalSurface, BoundaryCondition, ZoneId.
- Produces, in the new namespace Lod.Core.Conversion (D-069), for Convert2BEM (Task 14) and Convert2IDF (S6):
- public readonly record struct ExportedBoundary(BoundaryCondition Boundary, ZoneId? AdjacentZone).
- public sealed record HeatTransferOptions(bool EnableInternalWallHeatTransfer = false, bool EnableFloorHeatTransfer = false) with static HeatTransferOptions Default, ExportedBoundary Exported(Surface surface) (an Interzone wall is Adiabatic without adjacent zone unless EnableInternalWallHeatTransfer; an Interzone floor or ceiling the same unless EnableFloorHeatTransfer; every other surface keeps its boundary and adjacent zone), and string Describe() (EnableInternalWallHeatTransfer=<bool> EnableFloorHeatTransfer=<bool>).
- [x] Step 1: Create the branch
git switch -c feature/convert-heat-transfer-options main
- [x] Step 2: Write the failing tests — create
tests/Lod.Core.Tests/Conversion/HeatTransferOptionsTests.cs(full file)
using System;
using Lod.Core.Common;
using Lod.Core.Conversion;
using Lod.Core.Geometry;
using Lod.Core.Model;
using Xunit;
namespace Lod.Core.Tests.Conversion;
public sealed class HeatTransferOptionsTests
{
private static readonly ZoneId A = new("A");
private static readonly ZoneId B = new("B");
private static readonly WallSurface InterzoneWall =
new(new SurfaceId("A/W1"), A, new Point2(0, 0), new Point2(10, 0), 0.0, 3.0, BoundaryCondition.Interzone, B, Array.Empty<Window>());
private static readonly WallSurface OutdoorWall =
new(new SurfaceId("A/W2"), A, new Point2(10, 0), new Point2(10, 5), 0.0, 3.0, BoundaryCondition.Outdoors, null, Array.Empty<Window>());
private static readonly HorizontalSurface InterzoneFloor =
new(new SurfaceId("A/F1"), A, HorizontalKind.Floor, Polygon2.Rectangle(0, 0, 10, 5), 3.0, BoundaryCondition.Interzone, B);
private static readonly HorizontalSurface InterzoneCeiling =
new(new SurfaceId("A/C1"), A, HorizontalKind.Ceiling, Polygon2.Rectangle(0, 0, 10, 5), 6.0, BoundaryCondition.Interzone, B);
private static readonly HorizontalSurface GroundFloor =
new(new SurfaceId("A/F2"), A, HorizontalKind.Floor, Polygon2.Rectangle(0, 0, 10, 5), 0.0, BoundaryCondition.Ground, null);
private static readonly HorizontalSurface AdiabaticCeiling =
new(new SurfaceId("A/C2"), A, HorizontalKind.Ceiling, Polygon2.Rectangle(0, 0, 10, 5), 3.0, BoundaryCondition.Adiabatic, null);
[Fact]
public void BothOptionsAreOffByDefault()
{
Assert.False(HeatTransferOptions.Default.EnableInternalWallHeatTransfer);
Assert.False(HeatTransferOptions.Default.EnableFloorHeatTransfer);
Assert.Equal(HeatTransferOptions.Default, new HeatTransferOptions());
}
[Theory]
[InlineData(false, false, "Adiabatic", "Adiabatic", "Adiabatic")]
[InlineData(true, false, "Interzone:B", "Adiabatic", "Adiabatic")]
[InlineData(false, true, "Adiabatic", "Interzone:B", "Interzone:B")]
[InlineData(true, true, "Interzone:B", "Interzone:B", "Interzone:B")]
public void InterzoneSurfacesAreAdiabaticUnlessTheirOptionIsOn(bool walls, bool floors, string wall, string floor, string ceiling)
{
var options = new HeatTransferOptions(walls, floors);
Assert.Equal(wall, Text(options.Exported(InterzoneWall)));
Assert.Equal(floor, Text(options.Exported(InterzoneFloor)));
Assert.Equal(ceiling, Text(options.Exported(InterzoneCeiling)));
Assert.Equal("Outdoors", Text(options.Exported(OutdoorWall)));
Assert.Equal("Ground", Text(options.Exported(GroundFloor)));
Assert.Equal("Adiabatic", Text(options.Exported(AdiabaticCeiling)));
}
[Fact]
public void TheSurfaceIsNotChanged()
{
new HeatTransferOptions().Exported(InterzoneWall);
Assert.Equal(BoundaryCondition.Interzone, InterzoneWall.Boundary);
Assert.Equal(B, InterzoneWall.AdjacentZone);
}
[Fact]
public void DescribeNamesBothOptions()
{
Assert.Equal("EnableInternalWallHeatTransfer=True EnableFloorHeatTransfer=False", new HeatTransferOptions(true, false).Describe());
}
private static string Text(ExportedBoundary boundary) =>
boundary.AdjacentZone is { } adjacent ? $"{boundary.Boundary}:{adjacent.Value}" : boundary.Boundary.ToString();
}
- [x] Step 3: Run the tests and see them fail
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Conversion.HeatTransferOptionsTests"
Expected: the build fails, each error reported once, for the net8.0 target:
tests\Lod.Core.Tests\Conversion\HeatTransferOptionsTests.cs(3,16): error CS0234: The type or namespace name 'Conversion' does not exist in the namespace 'Lod.Core' (are you missing an assembly reference?)
tests\Lod.Core.Tests\Conversion\HeatTransferOptionsTests.cs(73,32): error CS0246: The type or namespace name 'ExportedBoundary' could not be found (are you missing a using directive or an assembly reference?)
- [x] Step 4: Implement — create
src/Lod.Core/Conversion/HeatTransferOptions.cs(full file)
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Core.Conversion;
/// <summary>The boundary a converter writes for a surface.</summary>
/// <param name="Boundary">Boundary condition.</param>
/// <param name="AdjacentZone">Zone on the other side when <paramref name="Boundary"/> is <see cref="BoundaryCondition.Interzone"/>, otherwise <c>null</c>.</param>
public readonly record struct ExportedBoundary(BoundaryCondition Boundary, ZoneId? AdjacentZone);
/// <summary>
/// Heat transfer between zones as a conversion option (D-069), shared by every converter (<c>Convert2BEM</c>, <c>Convert2IDF</c>). The building keeps
/// its interzone surfaces; the options only decide what a converter writes, and the converter records the values it used.
/// </summary>
/// <param name="EnableInternalWallHeatTransfer">Write walls between two zones paired with the adjacent zone; otherwise adiabatic.</param>
/// <param name="EnableFloorHeatTransfer">Write floors and ceilings between two zones paired with the adjacent zone; otherwise adiabatic.</param>
public sealed record HeatTransferOptions(bool EnableInternalWallHeatTransfer = false, bool EnableFloorHeatTransfer = false)
{
/// <summary>Both options off (D-069).</summary>
public static HeatTransferOptions Default { get; } = new();
/// <summary>
/// The boundary to write for a surface: an interzone wall is adiabatic unless <see cref="EnableInternalWallHeatTransfer"/>, an interzone floor or
/// ceiling is adiabatic unless <see cref="EnableFloorHeatTransfer"/>, and every other surface keeps its boundary.
/// </summary>
/// <param name="surface">A surface of a building.</param>
/// <returns>The exported boundary.</returns>
public ExportedBoundary Exported(Surface surface)
{
bool keep = surface.Boundary != BoundaryCondition.Interzone
|| (surface is WallSurface ? EnableInternalWallHeatTransfer : EnableFloorHeatTransfer);
return keep ? new ExportedBoundary(surface.Boundary, surface.AdjacentZone) : new ExportedBoundary(BoundaryCondition.Adiabatic, null);
}
/// <summary>The options as one line for a converter's provenance record.</summary>
/// <returns>The text.</returns>
public string Describe() =>
$"{nameof(EnableInternalWallHeatTransfer)}={EnableInternalWallHeatTransfer} {nameof(EnableFloorHeatTransfer)}={EnableFloorHeatTransfer}";
}
- [x] Step 5: Run the tests and see them pass
dotnet test tests/Lod.Core.Tests -c Release --filter "FullyQualifiedName~Lod.Core.Tests.Conversion.HeatTransferOptionsTests"
dotnet test BEMGen.sln -c Release
Expected:
Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7, Duration: … - Lod.Core.Tests.dll (net8.0)
and on net8.0:
Passed! - Failed: 0, Passed: 136, Skipped: 0, Total: 136, Duration: … - Lod.Core.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: … - Lod.Generators.Tests.dll (net8.0)
Passed! - Failed: 0, Passed: 163, Skipped: 0, Total: 163, Duration: … - Lod.Integration.Tests.dll (net8.0)
- [x] Step 6: Commit
git add src/Lod.Core/Conversion/HeatTransferOptions.cs tests/Lod.Core.Tests/Conversion/HeatTransferOptionsTests.cs
git commit -m "feature(convert): add heat transfer options for converters"
- [x] Step 7: Merge Slice D (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/convert-heat-transfer-options
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/convert-heat-transfer-options
git branch -d feature/convert-heat-transfer-options
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0. Do not push (D-075).
Slice E — feature/grasshopper-aggregation-transform¶
Grasshopper code cannot be unit-tested without Rhino: Tasks 12–16 are written, built with 0 warnings, committed, and checked in Rhino by the smoke test of Task 19.
Task 12: Aggregator components¶
Files:
- Modify: src/Lod.Grasshopper/Components/AggregatorComponents.cs (Task 4)
Interfaces:
- Consumes: StackedFloorZoneMultiplier (Task 3), SingleZonePerFloorType (Task 5), SingleZoneBuilding (Task 4); FloorAggregatorComponent(string name, string nickname, string description) with MultiplierDescription and CreateAggregator() (S2, S4.1: inputs Floors, Multipliers, Preview Location).
- Produces, all in panel 4 Aggregate with primary exposure (D-071, D-075 nicknames):
| Component | Nickname | Class | GUID | Aggregator |
|---|---|---|---|---|
| Stacked Floor Zone Multiplier (was Floor Area Multiplier, Mult) | ZMult | StackedFloorZoneMultiplierComponent (was FloorAreaMultiplierComponent) |
e4b44821-c9ed-49b9-9c4e-7c08355254dd (kept) |
StackedFloorZoneMultiplier |
| Single Zone per Floor Type (was Single Zone Merged, 1Zone) | 1ZType | SingleZonePerFloorTypeComponent (was SingleZoneMergedComponent) |
506d5037-de93-4aee-a6c4-47dcc97509e1 (kept) |
SingleZonePerFloorType |
| Single Zone Building (new) | 1ZBldg | SingleZoneBuildingComponent |
50965154-e31e-4730-b61c-15a6f40bee6f |
SingleZoneBuilding |
Stack Floors (Stack) is unchanged. Inputs and outputs of every aggregator are unchanged.
- [x] Step 1: Create the branch
git switch -c feature/grasshopper-aggregation-transform main
- [x] Step 2: Replace
src/Lod.Grasshopper/Components/AggregatorComponents.cs(full file)
using System;
using Lod.Core.Buildings;
using Lod.Core.Common;
namespace Lod.Grasshopper.Components;
public sealed class StackedFloorZoneMultiplierComponent : FloorAggregatorComponent
{
public StackedFloorZoneMultiplierComponent()
: base(
"Stacked Floor Zone Multiplier",
"ZMult",
"Representative storeys with zone multipliers (D-071, formerly Floor Area Multiplier). Each floor type stands for N storeys; floors and ceilings are designated on the whole stack (D-068), storeys with exposed floors or ceilings (ground, roof, setbacks, overhangs) are split off as x1 storeys so exposure is never multiplied (D-046), and storeys keep their true elevations (D-045). Pieces between storeys are adiabatic.")
{
}
public override Guid ComponentGuid => new("e4b44821-c9ed-49b9-9c4e-7c08355254dd");
protected override string MultiplierDescription => "How many storeys each floor type stands for. Default 1.";
protected override IFloorAggregator CreateAggregator() => new StackedFloorZoneMultiplier(ToleranceSettings.Default);
}
public sealed class SingleZonePerFloorTypeComponent : FloorAggregatorComponent
{
public SingleZonePerFloorTypeComponent()
: base(
"Single Zone per Floor Type",
"1ZType",
"One zone per floor entry (D-071): the storeys of entry i merge into zone E{i}. Slabs within an entry become internal mass (D-031), walls between merged zones are discarded (D-034), and floors and ceilings between entries stay between two zones (D-068).")
{
}
public override Guid ComponentGuid => new("506d5037-de93-4aee-a6c4-47dcc97509e1");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZonePerFloorType(ToleranceSettings.Default);
}
public sealed class SingleZoneBuildingComponent : FloorAggregatorComponent
{
public SingleZoneBuildingComponent()
: base(
"Single Zone Building",
"1ZBldg",
"The whole building as one zone, BUILDING (D-021, D-071): slabs become internal mass (D-031), walls between merged zones are discarded (D-034).")
{
}
public override Guid ComponentGuid => new("50965154-e31e-4730-b61c-15a6f40bee6f");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZoneBuilding(ToleranceSettings.Default);
}
- [x] Step 3: Build and check the GUIDs and names
dotnet build BEMGen.sln -c Release
git grep -n -E "ComponentGuid => new\(" -- src/Lod.Grasshopper/Components/AggregatorComponents.cs
git grep -n -E "FloorAreaMultiplier|SingleZoneMerged" -- src
Expected: Build succeeded., 0 Warning(s), 0 Error(s); three GUID lines, e4b44821-…, 506d5037-…, and 50965154-e31e-4730-b61c-15a6f40bee6f; the second git grep prints only the remark Formerly <c>SingleZoneMerged</c>. in src/Lod.Core/Buildings/SingleZoneBuilding.cs. The two renamed classes and the new one have no icon yet (Grasshopper's default icon) until Task 16.
- [x] Step 4: Commit
git add src/Lod.Grasshopper/Components/AggregatorComponents.cs
git commit -m "feature(grasshopper): rename the aggregator components and add single zone building"
Task 13: Transform Plan¶
Files:
- Create: src/Lod.Grasshopper/Components/TransformPlanComponent.cs, src/Lod.Grasshopper/Convert/PlanMotionDecomposition.cs
Interfaces:
- Consumes: PlanTransform.Apply (Task 7); PlanGoo(IGeneratedPlan, Vector3d previewOffset), PlanParameter (S2, S4.1); ComponentSupport.Unwrap, ComponentSupport.PreviewLocationParameter(), ComponentSupport.PreviewLocation(component, DA) (S4.1); RhinoGeometry.UnitScale (metres to model units, S2); ToleranceSettings.Default (Distance, Angle); IconLibrary.For(this) (S4.1); RhinoCommon Transform (M00 … M33).
- Produces:
- internal sealed record PlanMotionParts(double RotationDegrees, double TranslationX, double TranslationY, IReadOnlyList<string> Ignored) and internal static class PlanMotionDecomposition with Decompose(Transform transform, double unitScale, ToleranceSettings tolerances) : PlanMotionParts: rotation atan2(M10, M00) in degrees, translation (M03, M13) divided by the unit scale; listed as not applied: a translation along z (|M23| in metres above Distance), scaling, shear, or mirroring in plan (M00, M01, M10, M11 not the rotation within Angle), a rotation about another axis or scaling along z (M02, M12, M20, M21, M22), a projection (M30 … M33).
- Transform Plan (TPlan), TransformPlanComponent, GUID 57c17b9c-04b4-4595-9150-bb923877d95e, panel 2 Generate, primary: inputs Plan (required item), Transform (T, required item, Param_Transform), Preview Location (last, D-062); output Plan. When anything is not applied it shows one warning Not applied (D-070): <parts>. Applied: a rotation of <deg>° about the world z axis and a translation of (<x>, <y>) m in x and y. and still outputs the plan moved by the applied parts; the plan's errors (InvalidParameter) are shown as errors.
- [x] Step 1: Create
src/Lod.Grasshopper/Convert/PlanMotionDecomposition.cs(full file)
using System;
using System.Collections.Generic;
using Lod.Core.Common;
using Rhino.Geometry;
namespace Lod.Grasshopper.Convert;
/// <summary>The parts of a Rhino transform that <c>PlanTransform</c> applies, and what else the transform contains.</summary>
/// <param name="RotationDegrees">Rotation about the world z axis, degrees, counter-clockwise seen from above.</param>
/// <param name="TranslationX">Translation along world x, m.</param>
/// <param name="TranslationY">Translation along world y, m.</param>
/// <param name="Ignored">The parts of the transform that are not applied, empty for a rotation about z followed by an x-y translation.</param>
internal sealed record PlanMotionParts(double RotationDegrees, double TranslationX, double TranslationY, IReadOnlyList<string> Ignored);
/// <summary>
/// Splits a Rhino transform into the rotation about the world z axis and the translation in the world x-y plane (D-070). The rotation is
/// <c>atan2(M10, M00)</c>; the translation is (<c>M03</c>, <c>M13</c>) converted from model units to metres with the same unit scale the preview
/// uses. Whatever else the transform holds is listed, never applied: a translation along z (more than <see cref="ToleranceSettings.Distance"/>),
/// a plan part that is not a pure rotation (scaling, shear, or mirroring; entries compared within <see cref="ToleranceSettings.Angle"/>), a rotation
/// about another axis or scaling along z, and a projection.
/// </summary>
internal static class PlanMotionDecomposition
{
/// <summary>Decomposes a transform given in model units.</summary>
/// <param name="transform">The transform.</param>
/// <param name="unitScale">Metres to model units, <see cref="RhinoGeometry.UnitScale"/>.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>The applied parts and the ignored ones.</returns>
public static PlanMotionParts Decompose(Transform transform, double unitScale, ToleranceSettings tolerances)
{
double angle = Math.Atan2(transform.M10, transform.M00);
double cos = Math.Cos(angle);
double sin = Math.Sin(angle);
bool Near(double actual, double expected) => Math.Abs(actual - expected) <= tolerances.Angle;
var ignored = new List<string>();
if (Math.Abs(transform.M23 / unitScale) > tolerances.Distance)
{
ignored.Add("a translation along z");
}
if (!Near(transform.M00, cos) || !Near(transform.M11, cos) || !Near(transform.M10, sin) || !Near(transform.M01, -sin))
{
ignored.Add("scaling, shear, or mirroring in plan");
}
if (!Near(transform.M02, 0.0) || !Near(transform.M12, 0.0) || !Near(transform.M20, 0.0) || !Near(transform.M21, 0.0) || !Near(transform.M22, 1.0))
{
ignored.Add("a rotation about another axis or scaling along z");
}
if (!Near(transform.M30, 0.0) || !Near(transform.M31, 0.0) || !Near(transform.M32, 0.0) || !Near(transform.M33, 1.0))
{
ignored.Add("a projection");
}
return new PlanMotionParts(angle * 180.0 / Math.PI, transform.M03 / unitScale, transform.M13 / unitScale, ignored);
}
}
- [x] Step 2: Create
src/Lod.Grasshopper/Components/TransformPlanComponent.cs(full file)
using System;
using System.Drawing;
using System.Globalization;
using Grasshopper.Kernel;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Plans;
using Lod.Grasshopper.Convert;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
using Rhino.Geometry;
namespace Lod.Grasshopper.Components;
/// <summary>
/// Moves and rotates a plan (D-070): applies the rotation about the world z axis and the x-y translation of a Grasshopper transform through
/// <see cref="PlanTransform"/>, and warns about anything else the transform contains, which is not applied.
/// </summary>
public sealed class TransformPlanComponent : GH_Component
{
public TransformPlanComponent()
: base(
"Transform Plan",
"TPlan",
"Rotates a plan about the world z axis and moves it in the world x-y plane (D-070). Any other part of the transform (z translation, rotation about another axis, scaling, shear, mirroring, projection) is not applied and gives a warning. Storey elevations come from the floor aggregator.",
ComponentCategories.Category,
ComponentCategories.Generate)
{
}
public override Guid ComponentGuid => new("57c17b9c-04b4-4595-9150-bb923877d95e");
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The plan to move.", GH_ParamAccess.item);
pManager.AddTransformParameter("Transform", "T", "Rotation about the world z axis and translation in x and y, in model units; other parts are not applied.", GH_ParamAccess.item);
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The moved plan.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
PlanGoo? plan = null;
Transform transform = Transform.Identity;
if (!DA.GetData(0, ref plan) || plan is null || !DA.GetData(1, ref transform))
{
return;
}
PlanMotionParts parts = PlanMotionDecomposition.Decompose(transform, RhinoGeometry.UnitScale, ToleranceSettings.Default);
if (parts.Ignored.Count > 0)
{
AddRuntimeMessage(
GH_RuntimeMessageLevel.Warning,
string.Format(
CultureInfo.InvariantCulture,
"Not applied (D-070): {0}. Applied: a rotation of {1:0.######}° about the world z axis and a translation of ({2:0.######}, {3:0.######}) m in x and y.",
string.Join(", ", parts.Ignored),
parts.RotationDegrees,
parts.TranslationX,
parts.TranslationY));
}
IGeneratedPlan? moved = ComponentSupport.Unwrap(this, PlanTransform.Apply(plan.Value, parts.RotationDegrees, parts.TranslationX, parts.TranslationY));
if (moved is not null)
{
DA.SetData(0, new PlanGoo(moved, ComponentSupport.PreviewLocation(this, DA)));
}
}
}
- [x] Step 3: Build
dotnet build BEMGen.sln -c Release
git grep -n -F "57c17b9c-04b4-4595-9150-bb923877d95e" -- src
Expected: Build succeeded., 0 Warning(s), 0 Error(s); one line, in TransformPlanComponent.cs.
- [x] Step 4: Commit
git add src/Lod.Grasshopper/Components/TransformPlanComponent.cs src/Lod.Grasshopper/Convert/PlanMotionDecomposition.cs
git commit -m "feature(grasshopper): add the transform plan component"
Task 14: Heat-transfer options in Convert2BEM¶
Files:
- Modify: src/Lod.Grasshopper/Components/Convert2BemComponent.cs (S2 file, last changed in S4.1)
Interfaces:
- Consumes: HeatTransferOptions, ExportedBoundary (Task 11); BuildingValidator, ValidationReport.Describe(), Provenance.Describe() (S2, S3).
- Produces: Convert2BEM inputs EnableInternalWallHeatTransfer (IWHT) at index 2 and EnableFloorHeatTransfer (FHT) at index 3, Boolean items, default False, after Override (D-069). Boundaries (output 12) is written through HeatTransferOptions.Exported. Provenance (output 15) is the building's provenance, the validation report, the line OPTIONS: EnableInternalWallHeatTransfer=<True|False> EnableFloorHeatTransfer=<True|False>, and then, when used, the override line; the OPTIONS: line is written on blocked solves too. Validation, the other outputs, and the building are unchanged.
- [x] Step 1: Replace
src/Lod.Grasshopper/Components/Convert2BemComponent.cs(full file)
using System;
using System.Drawing;
using System.Linq;
using Grasshopper;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Data;
using Lod.Core.Common;
using Lod.Core.Conversion;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Validation;
using Lod.Grasshopper.Convert;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
using Rhino.Geometry;
using Surface = Lod.Core.Model.Surface;
namespace Lod.Grasshopper.Components;
/// <summary>
/// Converts a building into neutral Grasshopper data for a ClimateStudio definition (D-023, provisional): one tree branch per zone,
/// in building zone order. The output layout is documented in docs/architecture/convert2bem.md. The building is validated first; a failed
/// validation blocks conversion unless Override is set, and the override is recorded in the Provenance output (GLOBAL.md, scientific rule 6).
/// Interzone surfaces are written as adiabatic unless the heat-transfer options say otherwise (D-069, <see cref="HeatTransferOptions"/>); the
/// building itself is not changed, and the options are recorded in the Provenance output.
/// </summary>
public sealed class Convert2BemComponent : GH_Component
{
public Convert2BemComponent()
: base(
"Convert2BEM",
"2BEM",
"Building to zone geometry, programs, and surfaces as data trees (branch {i} = zone i), ready to wire into a ClimateStudio definition. Provisional layout (D-023).",
ComponentCategories.Category,
ComponentCategories.Convert)
{
}
public override Guid ComponentGuid => new("d091d70c-4de3-4f29-a900-ea4c9b777ba3");
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddParameter(new BuildingParameter(), "Building", "Bldg", "The building.", GH_ParamAccess.item);
pManager.AddBooleanParameter("Override", "Ovr", "Convert even if validation fails; the override is recorded.", GH_ParamAccess.item, false);
pManager.AddBooleanParameter(
nameof(HeatTransferOptions.EnableInternalWallHeatTransfer),
"IWHT",
"Write walls between two zones paired with the adjacent zone (Interzone:<zone ID>); when false they are written as Adiabatic (D-069).",
GH_ParamAccess.item,
false);
pManager.AddBooleanParameter(
nameof(HeatTransferOptions.EnableFloorHeatTransfer),
"FHT",
"Write floors and ceilings between two zones paired with the adjacent zone (Interzone:<zone ID>); when false they are written as Adiabatic (D-069).",
GH_ParamAccess.item,
false);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddBrepParameter("Zones", "Z", "{i}: closed Brep of each part of zone i, model units.", GH_ParamAccess.tree);
pManager.AddTextParameter("Names", "N", "{i}: zone ID.", GH_ParamAccess.tree);
pManager.AddTextParameter("Space Types", "ST", "{i}: space type.", GH_ParamAccess.tree);
pManager.AddIntegerParameter("Multipliers", "M", "{i}: zone multiplier.", GH_ParamAccess.tree);
pManager.AddBooleanParameter("Conditioned", "Cd", "{i}: whether zone i is conditioned (D-038).", GH_ParamAccess.tree);
pManager.AddTextParameter("Load Types", "LT", "{i}: load types, aligned with Load Values.", GH_ParamAccess.tree);
pManager.AddTextParameter("Load Bases", "LB", "{i}: load bases.", GH_ParamAccess.tree);
pManager.AddNumberParameter("Load Values", "LV", "{i}: design values in their basis.", GH_ParamAccess.tree);
pManager.AddNumberParameter("Load Schedules", "LS", "{i;k}: 8760 fractions of load k of zone i.", GH_ParamAccess.tree);
pManager.AddNumberParameter("Heating Setpoints", "HS", "{i}: 8760 heating setpoints, °C; empty for unconditioned zones.", GH_ParamAccess.tree);
pManager.AddNumberParameter("Cooling Setpoints", "CS", "{i}: 8760 cooling setpoints, °C; empty for unconditioned zones.", GH_ParamAccess.tree);
pManager.AddBrepParameter("Surfaces", "S", "{i}: walls, floors, and ceilings of zone i.", GH_ParamAccess.tree);
pManager.AddTextParameter("Boundaries", "B", "{i}: boundary written for each surface: Outdoors, Ground, Adiabatic, or Interzone:<zone ID>; interzone surfaces are Adiabatic unless their heat-transfer option is on (D-069).", GH_ParamAccess.tree);
pManager.AddBrepParameter("Windows", "W", "{i}: explicit windows of zone i, at their positions.", GH_ParamAccess.tree);
pManager.AddNumberParameter("Internal Mass", "IM", "{i}: exposed internal-mass area of zone i (slab area × exposed faces), m².", GH_ParamAccess.tree);
pManager.AddTextParameter("Provenance", "P", "Building provenance, validation report, heat-transfer options, and any override.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
BuildingGoo? goo = null;
bool overrideValidation = false;
bool internalWalls = false;
bool floors = false;
if (!DA.GetData(0, ref goo) || goo is null || !DA.GetData(1, ref overrideValidation) || !DA.GetData(2, ref internalWalls) || !DA.GetData(3, ref floors))
{
return;
}
IGeneratedBuilding building = goo.Value;
var options = new HeatTransferOptions(internalWalls, floors);
ValidationReport report = new BuildingValidator(ToleranceSettings.Default).Validate(building);
string record = building.Provenance.Describe() + report.Describe() + "OPTIONS: " + options.Describe() + "\n";
if (!report.Passed && !overrideValidation)
{
ComponentSupport.Report(this, new[] { Diagnostic.Error(DiagnosticCodes.ValidationFailed, "Validation failed; conversion blocked. See Provenance, or set Override.") });
DA.SetData(15, record);
return;
}
if (!report.Passed)
{
ComponentSupport.Report(this, new[] { Diagnostic.Warning(DiagnosticCodes.ValidationOverridden, "Validation failed; converted because Override is set. The override is recorded in Provenance.") });
record += "OVERRIDE: converted despite failed validation\n";
}
var zones = new DataTree<Brep>();
var names = new DataTree<string>();
var spaceTypes = new DataTree<string>();
var multipliers = new DataTree<int>();
var conditioned = new DataTree<bool>();
var loadTypes = new DataTree<string>();
var loadBases = new DataTree<string>();
var loadValues = new DataTree<double>();
var loadSchedules = new DataTree<double>();
var heating = new DataTree<double>();
var cooling = new DataTree<double>();
var surfaces = new DataTree<Brep>();
var boundaries = new DataTree<string>();
var windows = new DataTree<Brep>();
var mass = new DataTree<double>();
for (int i = 0; i < building.Zones.Count; i++)
{
Zone zone = building.Zones[i];
var path = new GH_Path(i);
zones.AddRange(RhinoGeometry.ZoneBreps(zone), path);
names.Add(zone.Id.Value, path);
spaceTypes.Add(zone.SpaceType.ToString(), path);
multipliers.Add(zone.Multiplier, path);
conditioned.Add(zone.Program.IsConditioned, path);
for (int k = 0; k < zone.Program.Loads.Count; k++)
{
LoadDefinition load = zone.Program.Loads[k];
loadTypes.Add(load.Type.ToString(), path);
loadBases.Add(load.Basis.ToString(), path);
loadValues.Add(load.Value, path);
loadSchedules.AddRange(load.Schedule.Values, new GH_Path(i, k));
}
heating.EnsurePath(path);
cooling.EnsurePath(path);
if (zone.Program.Thermostat is { } thermostat)
{
heating.AddRange(thermostat.HeatingSetpoint.Values, path);
cooling.AddRange(thermostat.CoolingSetpoint.Values, path);
}
foreach (Surface surface in building.Surfaces.Where(s => s.Zone == zone.Id))
{
foreach (Brep brep in SurfaceBreps(surface))
{
surfaces.Add(brep, path);
ExportedBoundary boundary = options.Exported(surface);
boundaries.Add(boundary.AdjacentZone is { } adjacent ? $"{boundary.Boundary}:{adjacent.Value}" : boundary.Boundary.ToString(), path);
}
if (surface is WallSurface wall)
{
windows.AddRange(RhinoGeometry.WindowBreps(wall), path);
}
}
mass.Add(building.InternalMasses.Where(m => m.Zone == zone.Id).Sum(m => m.SlabArea * m.ExposedFaces), path);
}
DA.SetDataTree(0, zones);
DA.SetDataTree(1, names);
DA.SetDataTree(2, spaceTypes);
DA.SetDataTree(3, multipliers);
DA.SetDataTree(4, conditioned);
DA.SetDataTree(5, loadTypes);
DA.SetDataTree(6, loadBases);
DA.SetDataTree(7, loadValues);
DA.SetDataTree(8, loadSchedules);
DA.SetDataTree(9, heating);
DA.SetDataTree(10, cooling);
DA.SetDataTree(11, surfaces);
DA.SetDataTree(12, boundaries);
DA.SetDataTree(13, windows);
DA.SetDataTree(14, mass);
DA.SetData(15, record);
}
private static Brep[] SurfaceBreps(Surface surface) => surface switch
{
WallSurface wall => RhinoGeometry.WallBrep(wall) is { } brep ? new[] { brep } : new Brep[0],
HorizontalSurface horizontal => RhinoGeometry.HorizontalBreps(horizontal).ToArray(),
_ => new Brep[0],
};
}
- [x] Step 2: Build
dotnet build BEMGen.sln -c Release
Expected: Build succeeded., 0 Warning(s), 0 Error(s).
- [x] Step 3: Commit
git add src/Lod.Grasshopper/Components/Convert2BemComponent.cs
git commit -m "feature(grasshopper): add heat transfer options to convert2bem"
Task 15: Previews of unmodelled storeys and merged single-zone volumes¶
Files:
- Create: src/Lod.Grasshopper/Convert/VerticalRuns.cs
- Modify: src/Lod.Grasshopper/Components/AggregatorComponents.cs (Task 12), src/Lod.Grasshopper/Components/PipelineComponents.cs (S2 file, last changed in S4.1), src/Lod.Grasshopper/Convert/RhinoGeometry.cs (S2 file, last changed in S4.1), src/Lod.Grasshopper/Goo/BemGoo.cs (S2 file, last changed in S4.1)
Interfaces:
- Consumes: StackedFloorZoneMultiplier.UnmodelledStoreys (Task 6); ZonePart (Footprint, Elevation, Height), Polygon2.Rings, Point2.DistanceTo (S2); ToleranceSettings.Default.Distance; RhinoGeometry.PlanarBreps, UnitScale (S2); PreviewGoo<T> with PreviewOffset (S4.1); RhinoCommon Brep.CreateBooleanUnion, Brep.MergeCoplanarFaces.
- Produces:
- protected virtual IReadOnlyList<ZonePart> FloorAggregatorComponent.UnmodelledParts(IReadOnlyList<FloorEntry> entries) (none by default), passed into the building's Goo; Stacked Floor Zone Multiplier overrides it with the parts of UnmodelledStoreys.
- BuildingGoo(IGeneratedBuilding value, Vector3d previewOffset = default, IReadOnlyList<ZonePart>? unmodelled = null), kept by Duplicate; PreviewGoo<T> draws the unmodelled parts in grey 150, 150, 150 with transparency 0.85, and a zone with several parts as merged volumes. Data, Inspect, Validate, and Convert2BEM are unchanged.
- RhinoGeometry.PartBreps(IEnumerable<ZonePart>) (one closed Brep per part; ZoneBreps uses it) and RhinoGeometry.MergedZoneBreps(Zone): the zone's vertical runs extruded once each; a boolean union of the runs, with coplanar faces merged, only when there are several runs, falling back to the runs when the union fails.
- internal static class VerticalRuns with Of(IEnumerable<ZonePart> parts, ToleranceSettings tolerances) : IReadOnlyList<ZonePart>: consecutive parts, bottom to top, whose outlines are the same polygon (vertices within Distance, rings in the same cyclic order) and that start at the top of the part below merge into one part over their total height. Preview only.
- [x] Step 1: Pass the unmodelled parts — replace
src/Lod.Grasshopper/Components/PipelineComponents.cs(full file)
Only FloorAggregatorComponent changes: SolveInstance passes UnmodelledParts(entries) to the BuildingGoo, and the virtual UnmodelledParts is added.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Simplification;
using Lod.Generators.Linear;
using Lod.Grasshopper.Goo;
using Lod.Grasshopper.Parameters;
namespace Lod.Grasshopper.Components;
public sealed class LinearPlanGeneratorComponent : GH_Component
{
public LinearPlanGeneratorComponent()
: base(
"Linear Plan Generator",
"Linear",
"Detailed linear block plan: west stair, double-loaded corridor, one zone per dwelling unit. All lengths in metres regardless of model units.",
ComponentCategories.Category,
ComponentCategories.Generate)
{
}
public override Guid ComponentGuid => new("a4a9131e-0a0f-49e0-99a4-f26760f6810f");
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddNumberParameter("Length", "L", "Plan length along X, m.", GH_ParamAccess.item, 24.0);
pManager.AddNumberParameter("Unit Depth", "UD", "Depth of each unit row, m.", GH_ParamAccess.item, 8.0);
pManager.AddNumberParameter("Corridor Width", "CW", "Corridor width, m.", GH_ParamAccess.item, 2.0);
pManager.AddNumberParameter("Unit Width", "UW", "Target unit width, m; the row is divided evenly.", GH_ParamAccess.item, 10.0);
pManager.AddNumberParameter("Stair Length", "SL", "Length of the west stair, m.", GH_ParamAccess.item, 4.0);
pManager.AddNumberParameter("Floor Height", "FH", "Floor-to-floor height, m.", GH_ParamAccess.item, 3.0);
pManager.AddNumberParameter("Orientation", "O", "Clockwise rotation of plan north from true north, degrees.", GH_ParamAccess.item, 0.0);
pManager.AddParameter(new ProgramPresetParameter(), "Dwelling Unit", "DU", "Program preset of the dwelling units; space type DwellingUnit.", GH_ParamAccess.item);
pManager.AddParameter(new ProgramPresetParameter(), "Corridor", "Co", "Program preset of the corridor; space type Corridor.", GH_ParamAccess.item);
pManager.AddParameter(new ProgramPresetParameter(), "Stair", "St", "Program preset of the stair; space type Stair.", GH_ParamAccess.item);
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "The detailed plan.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
double[] values = new double[7];
for (int i = 0; i < values.Length; i++)
{
if (!DA.GetData(i, ref values[i]))
{
return;
}
}
ProgramPresetGoo? dwellingUnit = null;
ProgramPresetGoo? corridor = null;
ProgramPresetGoo? stair = null;
if (!DA.GetData(7, ref dwellingUnit) || !DA.GetData(8, ref corridor) || !DA.GetData(9, ref stair)
|| dwellingUnit is null || corridor is null || stair is null)
{
return;
}
var parameters = new LinearPlanParameters(values[0], values[1], values[2], values[3], values[4], values[5], values[6]);
var presets = new LinearPlanPresets(dwellingUnit.Value, corridor.Value, stair.Value);
IGeneratedPlan? plan = ComponentSupport.Unwrap(this, new LinearPlanGenerator(ToleranceSettings.Default).Generate(parameters, presets));
if (plan is not null)
{
DA.SetData(0, new PlanGoo(plan, ComponentSupport.PreviewLocation(this, DA)));
}
}
}
public sealed class NoSimplificationComponent : GH_Component
{
public NoSimplificationComponent()
: base("No Simplification", "Z0", "Keeps the detailed plan's zones and surfaces (Z0).", ComponentCategories.Category, ComponentCategories.Simplify)
{
}
public override Guid ComponentGuid => new("83270abe-a13f-4f4c-a893-79739ad6e006");
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddParameter(new PlanParameter(), "Plan", "Plan", "Detailed plan.", GH_ParamAccess.item);
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddParameter(new FloorParameter(), "Floor", "Floor", "The floor.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
PlanGoo? plan = null;
if (!DA.GetData(0, ref plan) || plan is null)
{
return;
}
IFloor? floor = ComponentSupport.Unwrap(this, new NoSimplification().Simplify(plan.Value));
if (floor is not null)
{
DA.SetData(0, new FloorGoo(floor, ComponentSupport.PreviewLocation(this, DA)));
}
}
}
/// <summary>Shared inputs and solving of the floor aggregator components: floors bottom to top and their multipliers.</summary>
public abstract class FloorAggregatorComponent : GH_Component
{
protected FloorAggregatorComponent(string name, string nickname, string description)
: base(name, nickname, description, ComponentCategories.Category, ComponentCategories.Aggregate)
{
}
protected override Bitmap? Icon => IconLibrary.For(this);
protected abstract string MultiplierDescription { get; }
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddParameter(new FloorParameter(), "Floors", "F", "Floors, bottom to top.", GH_ParamAccess.list);
pManager.AddIntegerParameter("Multipliers", "N", MultiplierDescription, GH_ParamAccess.list);
pManager[1].Optional = true;
pManager.AddParameter(ComponentSupport.PreviewLocationParameter());
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddParameter(new BuildingParameter(), "Building", "Bldg", "The building.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
var floors = new List<FloorGoo>();
var multipliers = new List<int>();
if (!DA.GetDataList(0, floors) || floors.Any(f => f is null))
{
return;
}
DA.GetDataList(1, multipliers);
if (multipliers.Count != 0 && multipliers.Count != floors.Count)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Give one multiplier per floor ({floors.Count}), or none for all 1.");
return;
}
FloorEntry[] entries = floors.Select((f, i) => new FloorEntry(f.Value, multipliers.Count == 0 ? 1 : multipliers[i])).ToArray();
IGeneratedBuilding? building = ComponentSupport.Unwrap(this, CreateAggregator().Aggregate(entries));
if (building is not null)
{
DA.SetData(0, new BuildingGoo(building, ComponentSupport.PreviewLocation(this, DA), UnmodelledParts(entries)));
}
}
protected abstract IFloorAggregator CreateAggregator();
/// <summary>Parts of the storeys the building represents but does not model, for its preview; none unless the method leaves storeys out.</summary>
protected virtual IReadOnlyList<ZonePart> UnmodelledParts(IReadOnlyList<FloorEntry> entries) => new ZonePart[0];
}
public sealed class StackFloorsComponent : FloorAggregatorComponent
{
public StackFloorsComponent()
: base("Stack Floors", "Stack", "All storeys explicit: each floor is repeated by its multiplier and stacked; inter-storey surfaces are interzone.")
{
}
public override Guid ComponentGuid => new("6ce6524d-09b0-49fc-9b88-4c15bdcc8797");
protected override string MultiplierDescription => "How many storeys each floor stands for (repeated explicitly). Default 1.";
protected override IFloorAggregator CreateAggregator() => new Stack(ToleranceSettings.Default);
}
public sealed class InspectComponent : GH_Component
{
public InspectComponent()
: base("Inspect", "Inspect", "Text report and provenance of a plan, floor, or building.", ComponentCategories.Category, ComponentCategories.Inspect)
{
}
public override Guid ComponentGuid => new("1d56a553-7a66-451f-90e2-81e744b43bdd");
protected override Bitmap? Icon => IconLibrary.For(this);
protected override void RegisterInputParams(GH_InputParamManager pManager)
{
pManager.AddGenericParameter("Object", "O", "A plan, floor, or building.", GH_ParamAccess.item);
}
protected override void RegisterOutputParams(GH_OutputParamManager pManager)
{
pManager.AddTextParameter("Report", "R", "Zones, loads, and surfaces.", GH_ParamAccess.item);
pManager.AddTextParameter("Provenance", "P", "How the object was produced, with code version.", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
IGH_Goo? goo = null;
if (!DA.GetData(0, ref goo) || goo is null)
{
return;
}
(string Report, Provenance Provenance)? described = goo switch
{
PlanGoo plan => (TextReport.Describe(plan.Value), plan.Value.Provenance),
FloorGoo floor => (TextReport.Describe(floor.Value), floor.Value.Provenance),
BuildingGoo building => (TextReport.Describe(building.Value), building.Value.Provenance),
_ => null,
};
if (described is null)
{
AddRuntimeMessage(GH_RuntimeMessageLevel.Error, $"Expected a plan, floor, or building, got {goo.TypeName}.");
return;
}
DA.SetData(0, described.Value.Report);
DA.SetData(1, described.Value.Provenance.Describe());
}
}
- [x] Step 2: Compute them in core for the zone multiplier — replace
src/Lod.Grasshopper/Components/AggregatorComponents.cs(full file)
using System;
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Buildings;
using Lod.Core.Common;
using Lod.Core.Model;
namespace Lod.Grasshopper.Components;
public sealed class StackedFloorZoneMultiplierComponent : FloorAggregatorComponent
{
public StackedFloorZoneMultiplierComponent()
: base(
"Stacked Floor Zone Multiplier",
"ZMult",
"Representative storeys with zone multipliers (D-071, formerly Floor Area Multiplier). Each floor type stands for N storeys; floors and ceilings are designated on the whole stack (D-068), storeys with exposed floors or ceilings (ground, roof, setbacks, overhangs) are split off as x1 storeys so exposure is never multiplied (D-046), and storeys keep their true elevations (D-045). Pieces between storeys are adiabatic.")
{
}
public override Guid ComponentGuid => new("e4b44821-c9ed-49b9-9c4e-7c08355254dd");
protected override string MultiplierDescription => "How many storeys each floor type stands for. Default 1.";
protected override IFloorAggregator CreateAggregator() => new StackedFloorZoneMultiplier(ToleranceSettings.Default);
/// <summary>The represented but unmodelled storeys, from <see cref="StackedFloorZoneMultiplier.UnmodelledStoreys"/>, drawn transparent grey.</summary>
protected override IReadOnlyList<ZonePart> UnmodelledParts(IReadOnlyList<FloorEntry> entries)
{
Result<IReadOnlyList<PlacedStorey>> storeys = new StackedFloorZoneMultiplier(ToleranceSettings.Default).UnmodelledStoreys(entries);
return storeys.IsSuccess ? storeys.Value.SelectMany(s => s.Zones).SelectMany(z => z.Parts).ToArray() : new ZonePart[0];
}
}
public sealed class SingleZonePerFloorTypeComponent : FloorAggregatorComponent
{
public SingleZonePerFloorTypeComponent()
: base(
"Single Zone per Floor Type",
"1ZType",
"One zone per floor entry (D-071): the storeys of entry i merge into zone E{i}. Slabs within an entry become internal mass (D-031), walls between merged zones are discarded (D-034), and floors and ceilings between entries stay between two zones (D-068).")
{
}
public override Guid ComponentGuid => new("506d5037-de93-4aee-a6c4-47dcc97509e1");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZonePerFloorType(ToleranceSettings.Default);
}
public sealed class SingleZoneBuildingComponent : FloorAggregatorComponent
{
public SingleZoneBuildingComponent()
: base(
"Single Zone Building",
"1ZBldg",
"The whole building as one zone, BUILDING (D-021, D-071): slabs become internal mass (D-031), walls between merged zones are discarded (D-034).")
{
}
public override Guid ComponentGuid => new("50965154-e31e-4730-b61c-15a6f40bee6f");
protected override string MultiplierDescription => "How many storeys each floor stands for (stacked explicitly before merging). Default 1.";
protected override IFloorAggregator CreateAggregator() => new SingleZoneBuilding(ToleranceSettings.Default);
}
- [x] Step 3: Group stacked parts — create
src/Lod.Grasshopper/Convert/VerticalRuns.cs(full file)
using System.Collections.Generic;
using System.Linq;
using Lod.Core.Common;
using Lod.Core.Geometry;
using Lod.Core.Model;
namespace Lod.Grasshopper.Convert;
/// <summary>
/// Preview-only grouping of a zone's parts into vertical runs (D-071): consecutive parts, bottom to top, whose outlines are the same polygon
/// (every vertex within <see cref="ToleranceSettings.Distance"/>, rings in the same cyclic order) and where each part starts at the top of the one
/// below (within <see cref="ToleranceSettings.Distance"/>) become one part spanning their total height. The zone's data is not changed.
/// </summary>
internal static class VerticalRuns
{
/// <summary>The parts merged into maximal vertical runs of one outline, in the order the runs start.</summary>
/// <param name="parts">A zone's parts.</param>
/// <param name="tolerances">Tolerances.</param>
/// <returns>One part per run.</returns>
public static IReadOnlyList<ZonePart> Of(IEnumerable<ZonePart> parts, ToleranceSettings tolerances)
{
var runs = new List<ZonePart>();
foreach (ZonePart part in parts.OrderBy(p => p.Elevation))
{
int index = runs.FindIndex(r =>
System.Math.Abs(r.Elevation + r.Height - part.Elevation) <= tolerances.Distance && SameOutline(r.Footprint, part.Footprint, tolerances.Distance));
if (index >= 0)
{
runs[index] = runs[index] with { Height = runs[index].Height + part.Height };
}
else
{
runs.Add(part);
}
}
return runs;
}
private static bool SameOutline(Polygon2 a, Polygon2 b, double distance)
{
IReadOnlyList<Point2>[] ringsA = a.Rings.ToArray();
IReadOnlyList<Point2>[] ringsB = b.Rings.ToArray();
return ringsA.Length == ringsB.Length && ringsA.Zip(ringsB, (x, y) => SameRing(x, y, distance)).All(same => same);
}
private static bool SameRing(IReadOnlyList<Point2> a, IReadOnlyList<Point2> b, double distance)
{
if (a.Count != b.Count)
{
return false;
}
for (int shift = 0; shift < b.Count; shift++)
{
if (Enumerable.Range(0, a.Count).All(i => a[i].DistanceTo(b[(i + shift) % b.Count]) <= distance))
{
return true;
}
}
return false;
}
}
- [x] Step 4: Merged volumes — 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>
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.</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)
{
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.</summary>
public static IEnumerable<Brep> HorizontalBreps(HorizontalSurface surface) => PlanarBreps(surface.Polygon, surface.Elevation).Select(Scaled);
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 5: Draw them — replace
src/Lod.Grasshopper/Goo/BemGoo.cs(full file)
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
using Lod.Core.Loads;
using Lod.Core.Model;
using Lod.Core.Programs;
using Lod.Core.Schedules;
using Lod.Grasshopper.Convert;
using Rhino.Display;
using Rhino.Geometry;
using Surface = Lod.Core.Model.Surface;
namespace Lod.Grasshopper.Goo;
/// <summary>Grasshopper wrapper of an immutable pipeline object. Values are never modified, so duplicates share the value.</summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class BemGoo<T> : GH_Goo<T>
where T : class
{
protected BemGoo(T value)
: base(value)
{
}
public override bool IsValid => Value is not null;
public override string TypeDescription => $"BEMGen {TypeName}";
}
public sealed class ScheduleGoo : BemGoo<Schedule>
{
public ScheduleGoo(Schedule value)
: base(value)
{
}
public override string TypeName => "Schedule";
public override IGH_Goo Duplicate() => new ScheduleGoo(Value);
public override string ToString() => $"Schedule {Value.Name} ({Value.Kind})";
}
public sealed class LoadGoo : BemGoo<LoadDefinition>
{
public LoadGoo(LoadDefinition value)
: base(value)
{
}
public override string TypeName => "Load";
public override IGH_Goo Duplicate() => new LoadGoo(Value);
public override string ToString() => $"Load {Value.Type} {Value.Value} {Value.Basis}";
}
public sealed class ProgramPresetGoo : BemGoo<ProgramPreset>
{
public ProgramPresetGoo(ProgramPreset value)
: base(value)
{
}
public override string TypeName => "Program Preset";
public override IGH_Goo Duplicate() => new ProgramPresetGoo(Value);
public override string ToString() => $"Preset {Value.Name} ({Value.SpaceType}, WWR {Value.WindowToWallRatio})";
}
/// <summary>
/// Wrapper with viewport preview: zones coloured by space type (a zone with several parts as one merged volume, D-071), storeys that are represented
/// but not modelled in transparent grey (D-071, D-075), windows in blue. The preview is moved by <see cref="PreviewOffset"/> (D-062); the wrapped
/// value keeps its true coordinates.
/// </summary>
/// <typeparam name="T">Wrapped type.</typeparam>
public abstract class PreviewGoo<T> : BemGoo<T>, IGH_PreviewData
where T : class
{
private static readonly Color WindowColour = Color.FromArgb(70, 140, 220);
private static readonly Color UnmodelledColour = Color.FromArgb(150, 150, 150);
private List<(Brep Brep, DisplayMaterial Material)>? _preview;
protected PreviewGoo(T value, Vector3d previewOffset)
: base(value)
{
PreviewOffset = previewOffset;
}
/// <summary>Translation of the viewport preview only, in model units (D-062).</summary>
public Vector3d PreviewOffset { get; }
public BoundingBox ClippingBox
{
get
{
BoundingBox box = BoundingBox.Empty;
foreach ((Brep brep, DisplayMaterial _) in Preview)
{
box.Union(brep.GetBoundingBox(false));
}
return box;
}
}
private List<(Brep Brep, DisplayMaterial Material)> Preview => _preview ??= BuildPreview();
public void DrawViewportMeshes(GH_PreviewMeshArgs args)
{
foreach ((Brep brep, DisplayMaterial material) in Preview)
{
args.Pipeline.DrawBrepShaded(brep, material);
}
}
public void DrawViewportWires(GH_PreviewWireArgs args)
{
foreach ((Brep brep, DisplayMaterial _) in Preview)
{
args.Pipeline.DrawBrepWires(brep, args.Color);
}
}
protected abstract IEnumerable<Zone> Zones { get; }
protected abstract IEnumerable<Surface> Surfaces { get; }
/// <summary>Parts of storeys that are represented but not modelled, drawn transparent grey; none unless a building says so.</summary>
protected virtual IEnumerable<ZonePart> UnmodelledParts => Enumerable.Empty<ZonePart>();
private List<(Brep, DisplayMaterial)> BuildPreview()
{
var items = new List<(Brep, DisplayMaterial)>();
foreach (Zone zone in Zones)
{
var material = new DisplayMaterial(SpaceTypeColour(zone.SpaceType), 0.6);
IEnumerable<Brep> volumes = zone.Parts.Count > 1 ? RhinoGeometry.MergedZoneBreps(zone) : RhinoGeometry.ZoneBreps(zone);
items.AddRange(volumes.Select(b => (b, material)));
}
var unmodelled = new DisplayMaterial(UnmodelledColour, 0.85);
items.AddRange(RhinoGeometry.PartBreps(UnmodelledParts).Select(b => (b, unmodelled)));
var glass = new DisplayMaterial(WindowColour, 0.2);
foreach (WallSurface wall in Surfaces.OfType<WallSurface>())
{
items.AddRange(RhinoGeometry.WindowBreps(wall).Select(b => (b, glass)));
}
if (!PreviewOffset.IsZero)
{
Transform move = Transform.Translation(PreviewOffset);
foreach ((Brep brep, DisplayMaterial _) in items)
{
brep.Transform(move);
}
}
return items;
}
private static Color SpaceTypeColour(SpaceType type) => type switch
{
SpaceType.DwellingUnit => Color.FromArgb(240, 200, 120),
SpaceType.Corridor => Color.FromArgb(180, 180, 180),
SpaceType.Stair => Color.FromArgb(200, 120, 120),
SpaceType.Core => Color.FromArgb(160, 120, 200),
SpaceType.Mixed => Color.FromArgb(120, 190, 160),
_ => Color.FromArgb(150, 170, 200),
};
}
public sealed class PlanGoo : PreviewGoo<IGeneratedPlan>
{
public PlanGoo(IGeneratedPlan value, Vector3d previewOffset = default)
: base(value, previewOffset)
{
}
public override string TypeName => "Plan";
protected override IEnumerable<Zone> Zones => Value.Zones;
protected override IEnumerable<Surface> Surfaces => Value.Surfaces;
public override IGH_Goo Duplicate() => new PlanGoo(Value, PreviewOffset);
public override string ToString() => $"Plan ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
public sealed class FloorGoo : PreviewGoo<IFloor>
{
public FloorGoo(IFloor value, Vector3d previewOffset = default)
: base(value, previewOffset)
{
}
public override string TypeName => "Floor";
protected override IEnumerable<Zone> Zones => Value.Zones;
protected override IEnumerable<Surface> Surfaces => Value.Surfaces;
public override IGH_Goo Duplicate() => new FloorGoo(Value, PreviewOffset);
public override string ToString() => $"Floor ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
public sealed class BuildingGoo : PreviewGoo<IGeneratedBuilding>
{
private readonly IReadOnlyList<ZonePart> _unmodelled;
/// <param name="value">The building.</param>
/// <param name="previewOffset">Preview Location (D-062).</param>
/// <param name="unmodelled">Parts of the storeys the building represents but does not model, from the aggregator (preview only).</param>
public BuildingGoo(IGeneratedBuilding value, Vector3d previewOffset = default, IReadOnlyList<ZonePart>? unmodelled = null)
: base(value, previewOffset)
{
_unmodelled = unmodelled ?? new ZonePart[0];
}
public override string TypeName => "Building";
protected override IEnumerable<Zone> Zones => Value.Zones;
protected override IEnumerable<Surface> Surfaces => Value.Surfaces;
protected override IEnumerable<ZonePart> UnmodelledParts => _unmodelled;
public override IGH_Goo Duplicate() => new BuildingGoo(Value, PreviewOffset, _unmodelled);
public override string ToString() => $"Building ({Value.Zones.Count} zones, {Value.Provenance.Operation})";
}
- [x] Step 6: Build
dotnet build BEMGen.sln -c Release
git grep -n "UnmodelledParts" -- src/Lod.Grasshopper
Expected: Build succeeded., 0 Warning(s), 0 Error(s); UnmodelledParts in AggregatorComponents.cs (the override), PipelineComponents.cs (the call and the virtual method), and BemGoo.cs (the property, its override, and its use in BuildPreview).
- [x] Step 7: Commit
git add src/Lod.Grasshopper/Convert/VerticalRuns.cs src/Lod.Grasshopper/Components/AggregatorComponents.cs src/Lod.Grasshopper/Components/PipelineComponents.cs src/Lod.Grasshopper/Convert/RhinoGeometry.cs src/Lod.Grasshopper/Goo/BemGoo.cs
git commit -m "feature(grasshopper): preview unmodelled storeys and merged single-zone volumes"
- [x] Step 8: Merge Slice E (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-aggregation-transform
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-aggregation-transform
git branch -d feature/grasshopper-aggregation-transform
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0. Do not push (D-075).
Slice F — feature/grasshopper-s4.2-icons¶
Task 16: Icons for Transform Plan and the renamed and new aggregators¶
Files:
- Modify: scripts/make_icons.py (S4.1 file)
- Rename (generated): src/Lod.Grasshopper/Icons/FloorAreaMultiplierComponent.png → StackedFloorZoneMultiplierComponent.png, src/Lod.Grasshopper/Icons/SingleZoneMergedComponent.png → SingleZoneBuildingComponent.png
- Create (generated): src/Lod.Grasshopper/Icons/SingleZonePerFloorTypeComponent.png, src/Lod.Grasshopper/Icons/TransformPlanComponent.png
Interfaces:
- Consumes: the component classes of Tasks 12 and 13 (21 components, 6 parameters, 1 assembly info); IconLibrary.For and the Icons\*.png embedding of Lod.Grasshopper.csproj (S4.1, unchanged).
- Produces: motifs transform_plan (a footprint outline, a turning arrow, and the turned footprint in the Generate green), single_zone_per_floor_type (a short block below a tall one) and single_zone_building (one tall block, the former single_zone_merged) in the Aggregate purple; the icon table maps TransformPlanComponent, StackedFloorZoneMultiplierComponent (the multiplier motif), SingleZonePerFloorTypeComponent, and SingleZoneBuildingComponent. 28 icons.
- [x] Step 1: Create the branch and check Python
git switch -c feature/grasshopper-s4.2-icons main
python -c "import sys, PIL; print(sys.version_info[:2] >= (3, 10), PIL.__version__)"
Expected: True 12.3.0. With another Pillow version the script still runs, but the PNG bytes may differ from Step 5's hashes; then Step 6 decides.
- [x] Step 2: Replace
scripts/make_icons.py(full file)
"""Draws the BEMGen Grasshopper icons (D-063).
Every icon is drawn at 96 x 96 pixels on a transparent background and downsampled to 24 x 24 with LANCZOS, then written to
src/Lod.Grasshopper/Icons/<Name>.png, where <Name> is the class name of the component, parameter, or assembly info that shows it.
Each icon is a dark glyph plus the accent colour of its toolbar panel; a parameter uses the panel of the component that makes its object.
Shapes only, no fonts, so the output does not depend on installed fonts.
Run from the repository root: python scripts/make_icons.py
Requires Python 3.10+ and Pillow. Generated with Pillow 12.3.0; the same Pillow version gives the same pixels.
The script stops with an error when an icon has no class in src/Lod.Grasshopper or a class has no icon.
"""
from __future__ import annotations
import re
import sys
from collections.abc import Callable
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "src" / "Lod.Grasshopper"
OUTPUT = SOURCE / "Icons"
CANVAS = 96
SIZE = 24
LINE = 8 # 2 px after downsampling
DARK = (38, 38, 38, 255)
WHITE = (255, 255, 255, 255)
# One accent per toolbar panel (ComponentCategories).
INFO = (96, 125, 139, 255)
PROGRAM = (230, 140, 30, 255)
GENERATE = (46, 158, 79, 255)
SIMPLIFY = (47, 123, 216, 255)
AGGREGATE = (138, 79, 200, 255)
CONVERT = (208, 69, 58, 255)
INSPECT = (24, 150, 150, 255)
Colour = tuple[int, int, int, int]
Draw = ImageDraw.ImageDraw
Motif = Callable[[Draw, Colour], None]
Painter = Callable[[Image.Image, Colour], None]
def box(d: Draw, xy: tuple[float, float, float, float], fill=None, outline=DARK, width: int = LINE) -> None:
d.rectangle(xy, fill=fill, outline=outline, width=width)
def line(d: Draw, points: list[tuple[float, float]], width: int = LINE, fill=DARK) -> None:
d.line(points, fill=fill, width=width, joint="curve")
def arrow_head(d: Draw, tip: tuple[float, float], direction: str, size: float, fill=DARK) -> None:
x, y = tip
if direction == "right":
d.polygon([(x, y), (x - size, y - size), (x - size, y + size)], fill=fill)
elif direction == "down":
d.polygon([(x, y), (x - size, y - size), (x + size, y - size)], fill=fill)
# --- motifs: each draws inside the 96 x 96 canvas -------------------------------------------------------------
def info(d: Draw, accent) -> None:
d.ellipse((6, 6, 90, 90), fill=accent, outline=DARK, width=LINE)
d.rectangle((42, 40, 54, 74), fill=WHITE)
d.rectangle((42, 20, 54, 32), fill=WHITE)
def assembly(d: Draw, accent) -> None:
# A stepped block: the building at three levels of detail.
box(d, (8, 52, 88, 88), fill=accent)
box(d, (8, 30, 60, 56), fill=accent)
box(d, (8, 8, 36, 34), fill=accent)
def schedule(d: Draw, accent) -> None:
# A 24-hour profile as bars on a time axis.
heights = [16, 48, 72, 36, 60, 76, 24]
for i, h in enumerate(heights):
x = 12 + i * 12
d.rectangle((x, 84 - h, x + 7, 83), fill=accent)
d.rectangle((4, 84, 92, 91), fill=DARK)
d.rectangle((4, 4, 11, 91), fill=DARK)
def load(d: Draw, accent) -> None:
bolt = [(58, 4), (18, 54), (44, 54), (34, 92), (78, 38), (52, 38), (64, 4)]
d.polygon(bolt, fill=accent, outline=DARK, width=6)
def preset_tag(d: Draw, accent) -> None:
tag = [(8, 30), (30, 8), (88, 8), (88, 88), (30, 88), (8, 66)]
d.polygon(tag, fill=accent, outline=DARK, width=LINE)
d.ellipse((24, 40, 40, 56), fill=WHITE, outline=DARK, width=4)
line(d, [(52, 34), (76, 34)], width=6)
line(d, [(52, 48), (76, 48)], width=6)
line(d, [(52, 62), (76, 62)], width=6)
def house(d: Draw, accent) -> None:
d.polygon([(48, 6), (90, 44), (6, 44)], fill=accent, outline=DARK, width=LINE)
box(d, (16, 42, 80, 90), fill=accent)
d.rectangle((40, 60, 56, 90), fill=DARK)
def corridor(d: Draw, accent) -> None:
box(d, (4, 30, 92, 66), fill=accent)
line(d, [(16, 48), (60, 48)], width=8)
arrow_head(d, (78, 48), "right", 12)
def stair(d: Draw, accent) -> None:
steps = [(6, 90), (6, 70), (28, 70), (28, 50), (50, 50), (50, 30), (72, 30), (72, 10), (90, 10), (90, 90)]
d.polygon(steps, fill=accent, outline=DARK, width=LINE)
def linear_generator(d: Draw, accent) -> None:
# Footprint with a west stair, a corridor band, and dwelling-unit divisions on both sides.
box(d, (4, 12, 91, 83))
d.rectangle((12, 20, 23, 75), fill=DARK)
d.rectangle((28, 40, 87, 55), fill=accent)
for x in (48, 68):
line(d, [(x, 12), (x, 40)])
line(d, [(x, 55), (x, 83)])
def 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)),
"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: Rename the two icons of renamed classes
The script stops when Icons/ holds a PNG of no class, so the old names are moved first; the script then redraws them with the same pixels.
git mv src/Lod.Grasshopper/Icons/FloorAreaMultiplierComponent.png src/Lod.Grasshopper/Icons/StackedFloorZoneMultiplierComponent.png
git mv src/Lod.Grasshopper/Icons/SingleZoneMergedComponent.png src/Lod.Grasshopper/Icons/SingleZoneBuildingComponent.png
- [x] Step 4: Draw the icons
python scripts/make_icons.py
Expected: exit code 0 and the single line
Wrote 28 icons to src\Lod.Grasshopper\Icons
- [x] Step 5: Check the icon files
ls src/Lod.Grasshopper/Icons/*.png | wc -l
sha256sum src/Lod.Grasshopper/Icons/*.png
git status --short -- src/Lod.Grasshopper/Icons
Expected: 28, and with Pillow 12.3.0 exactly these hashes; the 26 icons of S4.1 keep their S4.1 hashes, the two renamed ones included (StackedFloorZoneMultiplierComponent.png has the hash of FloorAreaMultiplierComponent.png, SingleZoneBuildingComponent.png that of SingleZoneMergedComponent.png), and the new SingleZonePerFloorTypeComponent.png and TransformPlanComponent.png are b8bb1b28… and 3ce5e835…:
606a11f2bbd6ee6cb7edede403166ae0f1b39231fcf5882dff360717df990cae *src/Lod.Grasshopper/Icons/BemGenAssemblyInfo.png
e33ae284b7f017ee11851986e79e4b8449e1b5027ffedf41cad68ed2abbfb15d *src/Lod.Grasshopper/Icons/BemGenInfoComponent.png
431104585e8745167567797eefef526bbf664ba6777101e3677281cb6976bfde *src/Lod.Grasshopper/Icons/BuildingParameter.png
edc0ff9b82e6161bf8bdf3b5acebc8f4a43644d42d845827a101593d91d1a110 *src/Lod.Grasshopper/Icons/Convert2BemComponent.png
c8a815ff220dafff9f84af05411eab8c43191b4b6f4be2f0b4750385e1d95489 *src/Lod.Grasshopper/Icons/CorridorPresetComponent.png
755de6f9d4122d567040d0df98ec3232c76f4f1c0af9da934276fe33585dc528 *src/Lod.Grasshopper/Icons/DwellingUnitPresetComponent.png
324ffc55b8f3aed81902fdaea4ac6293654a194e9f58aa97c9aa088547e584be *src/Lod.Grasshopper/Icons/FloorParameter.png
a7191b8f5e30874eaf7d50f0d4555b3565b77157f83ae584393ab9c3b1c3a792 *src/Lod.Grasshopper/Icons/InspectComponent.png
75b4d98c4ad7cffbec53284781d3ad544ccd8ba762c7fd5c33bfd6d6845e42d4 *src/Lod.Grasshopper/Icons/LinearPlanGeneratorComponent.png
acf5163f017f01a1ebb394861f61750e54f9ae8c730a0a6a614f2120f59feadc *src/Lod.Grasshopper/Icons/LoadComponent.png
5045ceb77bc754553aa717110fab061978ef7b51d0c05e0f7f93788a8158f1d5 *src/Lod.Grasshopper/Icons/LoadParameter.png
438a45a6b5f3071e4918cb6c0f7e6112e4d0ab9ca0ad1aacdc189b4e5de9ecb8 *src/Lod.Grasshopper/Icons/MapSourceToTargetComponent.png
4a702c6b6ae3c4773678a3594228ce4d1e06c2623bc9eeb5bf1fdaaee8e50c30 *src/Lod.Grasshopper/Icons/NoSimplificationComponent.png
ac5d421e2cded0be0f7797510b864cdfcdd019dd46caed4147251923602184b8 *src/Lod.Grasshopper/Icons/PerimeterCoreComponent.png
a4bd3109301f4fccec8fa698ceca5dcfbb22e4dc839d850dd5aa4241b5cca95b *src/Lod.Grasshopper/Icons/PlanParameter.png
13741aac4525cf0c0d991bf3b673e55cd22612324fdc3ef3a8aaaaf0e66dec45 *src/Lod.Grasshopper/Icons/ProgramPresetComponent.png
bd0a92bf623dd4b3f6b9c4279e425adc44a7287751d97e8206a26e3c26e7e400 *src/Lod.Grasshopper/Icons/ProgramPresetParameter.png
90646f46d781eca01dcdf67554987ee04fdd80c450e5f94647858b5f7e7b613d *src/Lod.Grasshopper/Icons/ScheduleComponent.png
693d5513ffe5f813ed3a77dc7b68cfce31e008b407e6410ac34c9ed374c6bfaa *src/Lod.Grasshopper/Icons/ScheduleParameter.png
8217bd3bbe904a6026ef7434606168b5d466341f356280a623f92278908a4068 *src/Lod.Grasshopper/Icons/SemanticMergeComponent.png
682ee28436ffab1bc47382ff9915e27f2c774d0ef22448422d2316d739b5727f *src/Lod.Grasshopper/Icons/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
939d76b08d77d2d099bd823473f1d86eb8b16983840230cf995f8ade9d0debcc *src/Lod.Grasshopper/Icons/StairPresetComponent.png
3ce5e835f0921d1dee8feeaf23a45aecd36c3aedb55be5a8386fdb3e540e5246 *src/Lod.Grasshopper/Icons/TransformPlanComponent.png
20a372402d7ca8ae673db6a0a1299d7a3b6dcae9141d29de2b535cc543d950ce *src/Lod.Grasshopper/Icons/ValidateComponent.png
git status --short shows the two renames (R) and the two new PNGs (??). Run python scripts/make_icons.py a second time: the hashes do not change.
- [x] Step 6: Look at the icons
Draw a contact sheet of all icons at 8× (nearest neighbour) into the build folder, which git ignores:
python - <<'EOF'
from pathlib import Path
from PIL import Image
icons = sorted(Path("src/Lod.Grasshopper/Icons").glob("*.png"))
cell = 24 * 8 + 16
sheet = Image.new("RGBA", (6 * cell, -(-len(icons) // 6) * cell), (235, 235, 235, 255))
for i, path in enumerate(icons):
big = Image.open(path).convert("RGBA").resize((24 * 8, 24 * 8), Image.Resampling.NEAREST)
sheet.alpha_composite(big, ((i % 6) * cell + 8, (i // 6) * cell + 8))
Path("src/Lod.Grasshopper/bin").mkdir(parents=True, exist_ok=True)
sheet.save("src/Lod.Grasshopper/bin/icon-sheet.png")
print(len(icons), "icons drawn on src/Lod.Grasshopper/bin/icon-sheet.png")
EOF
Expected: 28 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 26 icons of S4.1 look as in the S4.1 plan (Task 7, Step 5), two of them under new names, and the two new ones are added; row 4 and row 5 read: row 4 holds the Schedule parameter, Semantic Merge, Single Zone Building (one tall purple block, the former Single Zone Merged icon), Single Zone per Floor (one blue block), Single Zone per Floor Type (a tall purple block above a short one), and Stacked Floor Zone Multiplier (purple slab, "×", three bars, the former Floor Area Multiplier icon); row 5 holds Stack Floors (three purple slabs), Stair Preset, Transform Plan (a dark footprint outline at the lower left, a dark arc, and a green turned square at the upper right), and Validate. Single Zone per Floor Type and Single Zone Building are told apart at 24 × 24, and Transform Plan has the green of Linear Plan Generator.
If the hashes of Step 5 differed (another Pillow version), this check decides: when every icon matches the description above, continue and record the Pillow version in the commit message body; otherwise stop and report the difference. The script is never edited to match hashes.
- [x] Step 7: Build and check the embedded icons
dotnet build BEMGen.sln -c Release
git grep -c "IconLibrary.For(this)" -- src/Lod.Grasshopper
powershell -NoProfile -Command "([Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path 'src/Lod.Grasshopper/bin/Release/net7.0/BEMGen.gha').Path)).GetManifestResourceNames() | 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, BemParameters.cs:1 (with their paths; the aggregator components inherit the icon of FloorAggregatorComponent); and the 28 resource names Lod.Grasshopper.Icons.<name>.png, one for each PNG of Step 5, including Lod.Grasshopper.Icons.SingleZoneBuildingComponent.png, Lod.Grasshopper.Icons.SingleZonePerFloorTypeComponent.png, Lod.Grasshopper.Icons.StackedFloorZoneMultiplierComponent.png, and Lod.Grasshopper.Icons.TransformPlanComponent.png, and no FloorAreaMultiplierComponent or SingleZoneMergedComponent.
- [x] Step 8: Commit
git add scripts/make_icons.py src/Lod.Grasshopper/Icons
git commit -m "feature(grasshopper): draw icons for the transform plan and the renamed and new aggregators"
Expected before git add: scripts/make_icons.py modified, the two renames, and the two new PNGs; nothing under bin/.
- [x] Step 9: Merge Slice F (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch feature/grasshopper-s4.2-icons
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only feature/grasshopper-s4.2-icons
git branch -d feature/grasshopper-s4.2-icons
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0. Do not push (D-075).
Slice G — docs/architecture-aggregation-transform¶
Task 17: Architecture documents¶
Files:
- Modify: docs/architecture/pipeline.md (S4.1), docs/architecture/convert2bem.md (S3), docs/architecture/validation.md (S3)
Interfaces:
- Consumes: Tasks 1–16 (types, rules, component names, inputs, GUIDs).
- Produces: the pipeline, Convert2BEM, and validation descriptions after S4.2; the anchors #grasshopper-components-through-s42 and #heat-transfer-between-zones.
- [x] Step 1: Create the branch
git switch -c docs/architecture-aggregation-transform main
- [x] Step 2: Edit
docs/architecture/pipeline.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file.
- 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. 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. 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
→ plan generator LinearPlanGenerator : PlanGenerator<...> → IGeneratedPlan one preset input per space type; Z0: one zone per dwelling unit, explicit windows
with
→ plan generator LinearPlanGenerator : PlanGenerator<...> → IGeneratedPlan one preset input per space type; Z0: one zone per dwelling unit, explicit windows
(→ PlanTransform optional: rotation about z, x-y translation → IGeneratedPlan D-070)
- Replace
→ IFloorAggregator Stack | FloorAreaMultiplier | SingleZoneMerged → IGeneratedBuilding floors bottom to top as FloorEntry(IFloor, Multiplier)
with
→ IFloorAggregator Stack | StackedFloorZoneMultiplier | → IGeneratedBuilding floors bottom to top as FloorEntry(IFloor, Multiplier)
SingleZonePerFloorType | SingleZoneBuilding
- Replace
→ Convert2BEM Grasshopper component → data trees for a ClimateStudio definition (provisional, D-023)
with
→ Convert2BEM Grasshopper component, HeatTransferOptions → data trees for a ClimateStudio definition (provisional, D-023)
- Replace
| Type | `LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>` → `IGeneratedPlan` |
| Component | *Linear Plan Generator* |
| Decisions | D-009, D-015, D-026, D-039, D-061; ADR-002 |
with
| Types | `LinearPlanGenerator : PlanGenerator<LinearPlanParameters, LinearPlanPresets>` → `IGeneratedPlan`; `PlanTransform.Apply(IGeneratedPlan, rotationDegrees, translationX, translationY)` → `IGeneratedPlan` |
| Components | *Linear Plan Generator*, *Transform Plan* |
| Decisions | D-009, D-015, D-026, D-039, D-061, D-070; ADR-002, [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |
- Replace
- Walls carry explicit windows (position along the wall, sill, width, height; any number per wall, D-039). This generator's rule: one centered window per outdoor wall with area = the zone preset's WWR × wall area (D-026).
with
- Walls carry explicit windows (position along the wall, sill, width, height; any number per wall, D-039). This generator's rule: one centered window per outdoor wall with area = the zone preset's WWR × wall area (D-026).
- Transform (D-070): `PlanTransform.Apply` rotates a plan about the world origin by an angle about +z (counter-clockwise seen from above, degrees) and then moves it in x and y (metres). Footprint, zones, walls, and floors and ceilings move rigidly (`PlanMotion`; whole quarter turns are exact); windows keep their offsets, sills, and sizes; IDs are kept. Wall facing follows the rotated geometry; the plan's *Orientation* (true north) is unchanged. Provenance: operation `TransformPlan`, parameters `RotationDegrees`, `TranslationX`, `TranslationY`, the source plan as input. A value that is not finite is the error `InvalidParameter`. Only plans are transformed; floors and buildings keep the geometry of their plans, and storey elevations come from the floor aggregator.
- Replace
| Components | *Stack Floors*, *Floor Area Multiplier*, *Single Zone Merged*; inputs Floors (bottom to top) and Multipliers (optional, one per floor, default 1) |
| Decisions | D-018, D-021, D-031, D-032, D-034, D-039, D-041, D-045, D-046; [ADR-009](../decisions/ADR-009-vertical-aggregation.md) |
with
| Components | *Stack Floors*, *Stacked Floor Zone Multiplier*, *Single Zone per Floor Type*, *Single Zone Building*; inputs Floors (bottom to top) and Multipliers (optional, one per floor, whole numbers from 1, D-072, default 1) |
| Decisions | D-018, D-021, D-031, D-032, D-034, D-039, D-041, D-045, D-046, D-068, D-071, D-072, D-074, D-075; [ADR-009](../decisions/ADR-009-vertical-aggregation.md), [ADR-013](../decisions/ADR-013-vertical-aggregation-methods.md) |
- Replace
All aggregators reject an empty list (`NoFloors`), multipliers below 1 (`InvalidParameter`), and floors with different orientations (`OrientationMismatch`). Floor types may have different footprints. `Stack` and `FloorAreaMultiplier` give zone and surface IDs the storey prefix `L{k}/`, where `k` is the storey's index in the fully stacked building.
with
All aggregators reject an empty list (`NoFloors`), multipliers below 1 (`InvalidParameter`), and floors with different orientations (`OrientationMismatch`). Floor types may have different footprints and may come from differently transformed plans.
- Replace
| Aggregator | Storeys | Multiplier Nᵢ | Floors and ceilings | Walls and windows | Internal mass |
with
Every aggregator first assembles the full stack (`Storeys.Assemble`, D-068): each entry repeated by its multiplier, storey `k` at the sum of the floor-to-floor heights below it, zone and surface IDs prefixed `L{k}/`. Every floor and ceiling piece is designated there: split by overlap with the zones of the storeys below and above into pieces between two storeys (`Interzone`), uncovered parts `Outdoors`, the floors of storey 0 `Ground`, and the ceilings of the top storey roofs (`Outdoors`). A storey `k > 0` whose floor area covered by storey `k − 1` differs from its floor area by more than the relative area tolerance gets the warning `UnsupportedStorey`, naming the storey and the uncovered area in m² (D-074); the building is still produced. The four methods differ in what they model:
| Aggregator | Storeys and zones | Multiplier Nᵢ | Floors and ceilings | Walls and windows | Internal mass |
- Replace
| `Stack` | every entry repeated Nᵢ times, all storeys explicit | explicit repetition | split by overlap with the storeys below and above: interzone between storeys, uncovered parts outdoors, lowest floors ground, highest ceilings roof | as in the floors | none |
| `FloorAreaMultiplier` | type `i` stands for Nᵢ storeys; storeys of a multiplied type with an exposed floor or ceiling are split off as × 1 storeys (info `FloorTypeSplit`, D-046); each remaining group of n storeys from storey `first` is one representative storey, storey `first + ⌊n/2⌋`, at its true elevation; the storeys between stay empty and the building keeps its true height (D-045) | `Zone.Multiplier = n` on every zone of the group; exposure is never multiplied; provenance parameter `Storeys`, e.g. `L0x1,L5x8,L9x1` | split by overlap with the representative storeys below and above: overlap adiabatic, uncovered parts outdoors (setback roofs, overhanging floors), lowest floors ground, highest ceilings roof (D-046) | as in the floors | none |
| `SingleZoneMerged` | `Stack` of the entries, then one zone `BUILDING` with one part per storey outline | explicit repetition | ground floors, roofs, and floors or ceilings exposed to outdoors kept; slabs between storeys are no longer surfaces | rebuilt per storey outline, all outdoors, covering every source façade (D-041); every window kept in place (D-039); partitions discarded (D-034) | one per slab level: slab area, 2 exposed faces (D-031) |
with
| `Stack` | every storey of the assembly, zones `L{k}/…` | explicit repetition | as designated | as in the floors | none |
| `StackedFloorZoneMultiplier` | type `i` stands for Nᵢ storeys; a storey of a multiplied type whose floor or ceiling is ground, roof, or exposed on the stack is split off as × 1 (info `FloorTypeSplit`, D-046); each remaining group of n storeys from storey `first` is modelled by storey `first + ⌊n/2⌋` at its true elevation, zones `L{k}/…`; the other storeys are represented but not modelled (D-045) | `Zone.Multiplier = n` on every zone of the group; exposure is never multiplied; provenance parameter `Storeys`, e.g. `L0x1,L5x8,L9x1` | the modelled storeys' pieces from the stack: ground, roof, and exposed pieces kept; every piece between two storeys `Adiabatic`, whether the other storey is modelled or not (D-075) | as in the floors | none |
| `SingleZonePerFloorType` | one zone `E{i}` ("Floor type i") per floor entry, spanning its Nᵢ storeys, one part per storey outline | explicit repetition | ground, roof, and exposed pieces kept; pieces between two entries `Interzone` between `E{i}` and `E{i+1}` | rebuilt per storey outline, all outdoors, covering every source façade (D-041); every window kept in place (D-039); partitions discarded (D-034) | one per slab level within an entry: slab area, 2 exposed faces (D-031) |
| `SingleZoneBuilding` | one zone `BUILDING` ("Building") with one part per storey outline | explicit repetition | ground, roof, and exposed pieces kept | as `SingleZonePerFloorType` | one per slab level: slab area, 2 exposed faces (D-031) |
The single-zone methods share one merge (`SingleZoneMerge`); their storey outlines are unions of the zone footprints as tiles (`PolygonOps.UnionTiles`), so rotated plans give clean outlines. Programs are aggregated per zone with `EquivalentPropertyAggregator` from the stacked zones (fractions 1), so every invariant is conserved; a zone is conditioned if any source is (D-038). `SingleZoneBuilding` is the former `SingleZoneMerged`, and `StackedFloorZoneMultiplier` the former `FloorAreaMultiplier` (D-071).
- Replace
| Decisions | D-016, D-023, D-038, D-039; GLOBAL.md scientific rule 6 |
- Validates the building with `BuildingValidator` first. A failed enforced check blocks conversion (`ValidationFailed`) unless *Override* is true; then the component converts, warns (`ValidationOverridden`), and writes the override into its *Provenance* output.
with
| Decisions | D-016, D-023, D-038, D-039, D-069; GLOBAL.md scientific rule 6 |
- Inputs *Building*, *Override*, *EnableInternalWallHeatTransfer* (IWHT), and *EnableFloorHeatTransfer* (FHT); both options default to `False` (D-069). The *Boundaries* output comes from `HeatTransferOptions.Exported` (`Lod.Core.Conversion`): an `Interzone` wall is written `Adiabatic` unless IWHT is set, an `Interzone` floor or ceiling `Adiabatic` unless FHT is set, and every other surface as it is. The building is not changed; *Provenance* records both values. *Convert2IDF* (S6) uses the same function.
- Validates the building with `BuildingValidator` first. A failed enforced check blocks conversion (`ValidationFailed`) unless *Override* is true; then the component converts, warns (`ValidationOverridden`), and writes the override into its *Provenance* output.
- Replace
| Program types (schedules, loads, programs, presets) | Schedule length, values, and kind; load values and allowed bases; one load per type and basis (D-047); per-person loads need an occupancy load; setpoints are temperature schedules; WWR in [0, 1) | Error; no object |
with
| Program types (schedules, loads, programs, presets) | Schedule length, values, and kind; load values and allowed bases; one load per type and basis (D-047); per-person loads need an occupancy load; setpoints are temperature schedules; WWR in [0, 1) | Error; no object |
| *Transform Plan* | Rotation and translation finite (`InvalidParameter`); parts of the Grasshopper transform other than the rotation about z and the x-y translation are not applied and give a warning (D-070) | Error; no plan (after the warning the plan is produced) |
- Replace
| Property aggregation (plan simplifiers, `SingleZoneMerged`) | Fractions in [0, 1]; a positive load component needs a positive target basis quantity (`ZeroBasisQuantity`); valid measures; positive setpoint weights | Error; a zero load only gives the info `ZeroLoadSchedule` |
with
| Property aggregation (plan simplifiers, single-zone aggregators) | Fractions in [0, 1]; a positive load component needs a positive target basis quantity (`ZeroBasisQuantity`); valid measures; positive setpoint weights | Error; a zero load only gives the info `ZeroLoadSchedule` |
- Replace
| Floor aggregators | At least one floor (`NoFloors`); multipliers from 1 (`InvalidParameter`); one orientation (`OrientationMismatch`); façade coverage and window hosting (`FacadeNotCovered`, `WindowNotHosted`, `SingleZoneMerged`). `FloorAreaMultiplier` rejects no input for exposure: it splits exposed storeys of multiplied types off and reports the info `FloorTypeSplit` (D-046) | Error; no building (`FloorTypeSplit` is a remark; the building is produced) |
with
| Floor aggregators | At least one floor (`NoFloors`); multipliers from 1 (`InvalidParameter`); one orientation (`OrientationMismatch`); façade coverage and window hosting (`FacadeNotCovered`, `WindowNotHosted`, single-zone methods). A storey not carried by the storey below is the warning `UnsupportedStorey` (D-074). `StackedFloorZoneMultiplier` rejects no input for exposure: it splits exposed storeys of multiplied types off and reports the info `FloorTypeSplit` (D-046) | Error; no building (`UnsupportedStorey` is a warning and `FloorTypeSplit` a remark; the building is produced) |
- Replace
## Grasshopper components through S4.1
with
## Grasshopper components through S4.2
- Replace
- **Preview Location (D-062).** *Linear Plan Generator*, the four plan simplifiers, and the three floor aggregators have an optional last input *Preview Location* (PL): a vector in model units, default `0,0,0` (a point converts to a vector). It moves only that component's viewport preview: the plan, floor, or building data, *Inspect*, *Validate*, and *Convert2BEM* keep the true coordinates, and downstream components do not inherit the move (each wraps its output with its own input). *Convert2BEM* has no such input.
with
- **Preview Location (D-062).** *Linear Plan Generator*, *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
- *Example Residential Presets* (`0239f4c9-0f19-4cf1-9d90-08db878eac8a`, S2) was deleted in S4.1 (D-061); its GUID is retired.
with
- *Example Residential Presets* (`0239f4c9-0f19-4cf1-9d90-08db878eac8a`, S2) was deleted in S4.1 (D-061); its GUID is retired.
- *Floor Area Multiplier* became *Stacked Floor Zone Multiplier* and *Single Zone Merged* became *Single Zone per Floor Type* in S4.2, keeping their GUIDs (D-064, D-071); *Single Zone Building* is new.
- **Aggregator previews (D-071, D-075).** *Stacked Floor Zone Multiplier* previews the whole stacked building: the modelled storeys as before and the storeys that are represented but not modelled in transparent grey (`StackedFloorZoneMultiplier.UnmodelledStoreys`). A zone with several parts (*Single Zone per Floor Type*, *Single Zone Building*) is previewed as one merged volume, with its windows. Only the preview changes: the building and the *Convert2BEM* outputs keep one part per storey outline.
- **Transform Plan (D-070).** Its *Transform* input is a Grasshopper transform in model units. The component applies the rotation about the world z axis, `atan2(M10, M00)`, and the x-y translation (`M03`, `M13`) converted to metres with the preview's unit scale (`PlanMotionDecomposition`). A translation along z, a plan part that is not a pure rotation (scaling, shear, mirroring), a rotation about another axis or scaling along z, and a projection are not applied; they are named in one warning, `Not applied (D-070): …. Applied: a rotation of …° about the world z axis and a translation of (…, …) m in x and y.` Matrix entries are compared within `ToleranceSettings.Angle`, the z translation within `Distance`.
- Replace
| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Dwelling Unit, Corridor, Stair, Preview Location → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 (inputs S4.1) |
with
| 2 Generate | *Linear Plan Generator* (Linear) | Length, Unit Depth, Corridor Width, Unit Width, Stair Length, Floor Height, Orientation, Dwelling Unit, Corridor, Stair, Preview Location → Plan | `LinearPlanGeneratorComponent` | `a4a9131e-0a0f-49e0-99a4-f26760f6810f` | S2 (inputs S4.1) |
| 2 Generate | *Transform Plan* (TPlan) | Plan, Transform, Preview Location → Plan | `TransformPlanComponent` | `57c17b9c-04b4-4595-9150-bb923877d95e` | S4.2 |
- Replace
| 4 Aggregate | *Floor Area Multiplier* (Mult) | Floors, Multipliers, Preview Location → Building | `FloorAreaMultiplierComponent` | `e4b44821-c9ed-49b9-9c4e-7c08355254dd` | S4 |
| 4 Aggregate | *Single Zone Merged* (1Zone) | Floors, Multipliers, Preview Location → Building | `SingleZoneMergedComponent` | `506d5037-de93-4aee-a6c4-47dcc97509e1` | S4 |
| 5 Convert | *Convert2BEM* (2BEM) | Building, Override → Zones, Names, Space Types, Multipliers, Conditioned, Load Types, Load Bases, Load Values, Load Schedules, Heating Setpoints, Cooling Setpoints, Surfaces, Boundaries, Windows, Internal Mass, Provenance | `Convert2BemComponent` | `d091d70c-4de3-4f29-a900-ea4c9b777ba3` | S2 (validation gate S3) |
with
| 4 Aggregate | *Stacked Floor Zone Multiplier* (ZMult) | Floors, Multipliers, Preview Location → Building | `StackedFloorZoneMultiplierComponent` | `e4b44821-c9ed-49b9-9c4e-7c08355254dd` | S4 (renamed S4.2) |
| 4 Aggregate | *Single Zone per Floor Type* (1ZType) | Floors, Multipliers, Preview Location → Building | `SingleZonePerFloorTypeComponent` | `506d5037-de93-4aee-a6c4-47dcc97509e1` | S4 (renamed, new method S4.2) |
| 4 Aggregate | *Single Zone Building* (1ZBldg) | Floors, Multipliers, Preview Location → Building | `SingleZoneBuildingComponent` | `50965154-e31e-4730-b61c-15a6f40bee6f` | S4.2 |
| 5 Convert | *Convert2BEM* (2BEM) | Building, Override, EnableInternalWallHeatTransfer, EnableFloorHeatTransfer → Zones, Names, Space Types, Multipliers, Conditioned, Load Types, Load Bases, Load Values, Load Schedules, Heating Setpoints, Cooling Setpoints, Surfaces, Boundaries, Windows, Internal Mass, Provenance | `Convert2BemComponent` | `d091d70c-4de3-4f29-a900-ea4c9b777ba3` | S2 (validation gate S3, options S4.2) |
- Replace
The Multipliers input of the three aggregators means: explicit repetition for *Stack Floors* and *Single Zone Merged* (the storeys are stacked before they are merged), the number of storeys each floor type stands for in *Floor Area Multiplier*, which models them as representative storeys with zone multipliers at their true elevations and splits storeys with an exposed floor or ceiling off as × 1 storeys, so exposure is never multiplied (D-045, D-046).
with
The Multipliers input of the four aggregators means: explicit repetition for *Stack Floors*, *Single Zone per Floor Type*, and *Single Zone Building* (the storeys are stacked before they are merged), and the number of storeys each floor type stands for in *Stacked Floor Zone Multiplier*, which models them as representative storeys with zone multipliers at their true elevations and splits storeys with an exposed floor or ceiling off as × 1 storeys, so exposure is never multiplied (D-045, D-046). Multipliers are whole numbers from 1 (D-072). The aggregators show `UnsupportedStorey` as a warning and `FloorTypeSplit` as a remark.
- Replace
- [ADR-009 Vertical aggregation geometry](../decisions/ADR-009-vertical-aggregation.md)
with
- [ADR-009 Vertical aggregation geometry](../decisions/ADR-009-vertical-aggregation.md)
- [ADR-013 Vertical aggregation methods](../decisions/ADR-013-vertical-aggregation-methods.md)
- [x] Step 3: Edit
docs/architecture/convert2bem.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file.
- Replace
| 1 | Override | Ovr | item | Boolean, default `false`. Convert even if validation fails; the override is recorded in *Provenance*. |
with
| 1 | Override | Ovr | item | Boolean, default `false`. Convert even if validation fails; the override is recorded in *Provenance*. |
| 2 | EnableInternalWallHeatTransfer | IWHT | item | Boolean, default `false` (D-069, S4.2). When `false`, every wall between two zones is written `Adiabatic`; when `true`, it is written `Interzone:<zone ID>`. See [Heat transfer between zones](#heat-transfer-between-zones). |
| 3 | EnableFloorHeatTransfer | FHT | item | Boolean, default `false` (D-069, S4.2). The same for floors and ceilings between two zones. |
- Replace
2. Passed: the conversion runs, outputs 0–14 are filled, and *Provenance* holds the building's provenance tree followed by the validation report.
with
2. Passed: the conversion runs, outputs 0–14 are filled, and *Provenance* holds the building's provenance tree followed by the validation report and the line `OPTIONS: EnableInternalWallHeatTransfer=<True|False> EnableFloorHeatTransfer=<True|False>`.
- Replace
- Branch `{i}` belongs to zone `i` of `IGeneratedBuilding.Zones`, in the building's zone order. For *Stack Floors* that is storey by storey from the bottom (zone IDs `L0/…`, `L1/…`, …) and, within a storey, the floor's zone order; for the linear plan `ST`, `CO`, `US1` … `USn`, `UN1` … `UNn`.
with
- Branch `{i}` belongs to zone `i` of `IGeneratedBuilding.Zones`, in the building's zone order. For *Stack Floors* that is storey by storey from the bottom (zone IDs `L0/…`, `L1/…`, …) and, within a storey, the floor's zone order; for the linear plan `ST`, `CO`, `US1` … `USn`, `UN1` … `UNn`. *Stacked Floor Zone Multiplier* lists its modelled storeys the same way; *Single Zone per Floor Type* has one zone per floor entry, `E0`, `E1`, …, bottom to top; *Single Zone Building* has the one zone `BUILDING`.
- 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. Zones have one part, except in `SingleZoneMerged` buildings (S4), which have one part per storey. | 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. 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 |
- Replace
| 3 | Multipliers | M | `{i}`: one integer | Zone multiplier; always 1 for *Stack Floors*. | — |
with
| 3 | Multipliers | M | `{i}`: one integer | Zone multiplier: the number of storeys a representative storey stands for in *Stacked Floor Zone Multiplier*; always 1 for the other aggregators. | — |
- Replace
| 12 | Boundaries | B | `{i}`: one text per *Surfaces* item | Boundary condition of the surface at the same index (format below). | — |
with
| 12 | Boundaries | B | `{i}`: one text per *Surfaces* item | Boundary written for the surface at the same index (format below): its boundary condition, except that interzone surfaces are `Adiabatic` unless their heat-transfer option is on (D-069). | — |
- Replace
| 15 | Provenance | P | item: one text | The building's provenance tree, then the validation report (`PASSED` or `FAILED`, then one line per check), then the line `OVERRIDE: converted despite failed validation` when *Override* was used. Set on every solve that has a building, including blocked ones. | — |
with
| 15 | Provenance | P | item: one text | The building's provenance tree, then the validation report (`PASSED` or `FAILED`, then one line per check), then the line `OPTIONS: EnableInternalWallHeatTransfer=… EnableFloorHeatTransfer=…` with the values used, then the line `OVERRIDE: converted despite failed validation` when *Override* was used. Set on every solve that has a building, including blocked ones. | — |
- Replace
| `Adiabatic` | No heat transfer. *Stack Floors* never produces it; `FloorAreaMultiplier` (S4) uses it where the floors and ceilings of representative storeys overlap the representative storey below or above (D-046). |
| `Interzone:<zone ID>` | Another zone of the same building, e.g. `Interzone:L1/US1`: interior walls and the floors and ceilings between storeys. Every interzone surface has a matching surface in the other zone that refers back to it. |
with
| `Adiabatic` | No heat transfer. Written for every surface between two zones whose heat-transfer option is off (the default, D-069), and for the floors and ceilings of *Stacked Floor Zone Multiplier* storeys that lie between two storeys, which are adiabatic in the building itself whatever the options say (D-075). |
| `Interzone:<zone ID>` | Another zone of the same building, e.g. `Interzone:L1/US1`: walls between zones when *EnableInternalWallHeatTransfer* is on, floors and ceilings between storeys (or, for *Single Zone per Floor Type*, between floor entries) when *EnableFloorHeatTransfer* is on. Every interzone surface has a matching surface in the other zone that refers back to it. |
- Replace
There is no separate roof condition: a roof is an `Outdoors` ceiling. `Unresolved` marks floors and ceilings of plans and floors before stacking; a building from a floor aggregator has none.
with
There is no separate roof condition: a roof is an `Outdoors` ceiling. `Unresolved` marks floors and ceilings of plans and floors before stacking; a building from a floor aggregator has none.
## Heat transfer between zones
Since S4.2 (D-069) heat transfer between zones is a choice of the conversion, not of the building model. The building keeps its interzone surfaces, and its validation does not depend on the options. *Convert2BEM* writes each surface's boundary through `HeatTransferOptions.Exported` in `Lod.Core` (namespace `Lod.Core.Conversion`), which *Convert2IDF* (S6) uses too:
| Surface | IWHT `false`, FHT `false` (default) | IWHT `true` | FHT `true` |
| --- | --- | --- | --- |
| Wall between two zones | `Adiabatic` | `Interzone:<zone ID>` | `Adiabatic` |
| Floor or ceiling between two zones | `Adiabatic` | `Adiabatic` | `Interzone:<zone ID>` |
| Any other surface | its boundary | its boundary | its boundary |
For the canonical linear plan with Multipliers 1, 3, 1 and *Stack Floors*, the 90 interior walls and the 48 floors and ceilings between storeys are `Adiabatic` by default; for *Single Zone per Floor Type* the 24 floor and ceiling pieces between the three entries are; *Single Zone Building* has no interzone surface, so the options change nothing.
- Replace
An `InternalMass` object (D-031) describes a slab whose faces both lie inside one zone. This happens only when `SingleZoneMerged` (S4, D-021) merges all storeys into one zone: the slabs between storeys are then no longer boundaries between two zones and become internal mass of the single zone, keeping their construction and area (D-031; ADR-009 in S4). Each object records the slab area (one face) and the number of faces exposed to the zone air. *Internal Mass* gives, per zone, the sum of slab area × exposed faces: the slab surface area that exchanges heat with the zone air, in m². Walls between merged zones are discarded, not turned into internal mass (D-034).
with
An `InternalMass` object (D-031) describes a slab whose faces both lie inside one zone. This happens when a single-zone aggregator merges storeys into one zone: *Single Zone Building* (all storeys, D-021) and *Single Zone per Floor Type* (the storeys of one floor entry, D-071). The slabs between storeys of that zone are then no longer boundaries between two zones and become internal mass, keeping their construction and area (D-031; ADR-009, ADR-013). Each object records the slab area (one face) and the number of faces exposed to the zone air. *Internal Mass* gives, per zone, the sum of slab area × exposed faces: the slab surface area that exchanges heat with the zone air, in m². Walls between merged zones are discarded, not turned into internal mass (D-034).
- Replace
*Stack Floors* creates no internal mass, so in S2 the output is 0 for every zone; `SingleZoneMerged` populates it from S4.
with
*Stack Floors* and *Stacked Floor Zone Multiplier* create no internal mass, so their output is 0 for every zone.
- [x] Step 4: Edit
docs/architecture/validation.md
Make these replacements, in order; each "Replace" text occurs exactly once in the file.
- Replace
| `FacadeCoverage` (floor) | every source outdoor wall is covered over its full length by target outdoor walls on the same façade line, before any normalisation | `RelativeArea`, applied to lengths | yes (D-041) |
with
| `FacadeCoverage` (floor) | every source outdoor wall is covered over its full length by target outdoor walls on the same façade line, before any normalisation. Two walls are on the same façade line when they face the same way (`Angle`), share elevation and height (`Distance`), overlap, and are within `Distance` of each other over their overlap (ADR-013) | `RelativeArea`, applied to lengths | yes (D-041) |
- Replace
- *Convert2BEM* (`5 Convert`): validates the building with `BuildingValidator` before converting it. Passed: the conversion runs and `Provenance` (output 15) holds the provenance tree followed by the report. Failed with `Override` false (the default): error `ValidationFailed`, no conversion (outputs 0–14 stay empty), and only `Provenance` is set so the failing checks can be read. Failed with `Override` true: warning `ValidationOverridden`, the conversion runs, and `Provenance` ends with the line `OVERRIDE: converted despite failed validation`. The override is recorded only in that text, so keep `Provenance` with any result produced from an overridden model.
with
- *Convert2BEM* (`5 Convert`): validates the building with `BuildingValidator` before converting it; the heat-transfer options (D-069) change only the written boundaries, never the building or its validation. Passed: the conversion runs and `Provenance` (output 15) holds the provenance tree followed by the report and the options line. Failed with `Override` false (the default): error `ValidationFailed`, no conversion (outputs 0–14 stay empty), and only `Provenance` is set so the failing checks can be read. Failed with `Override` true: warning `ValidationOverridden`, the conversion runs, and `Provenance` ends with the line `OVERRIDE: converted despite failed validation`. The override is recorded only in that text, so keep `Provenance` with any result produced from an overridden model.
- Replace
- `ValidationCatchesAChangedProgram`: a tampered program fails.
with
- `ValidationCatchesAChangedProgram`: a tampered program fails.
- `EveryCombinationValidatesOnARotatedAndTranslatedPlan` (S4.2): every plan simplifier with every floor aggregator on plans rotated and moved by *Transform Plan* (four placements, 64 cases) validates without warnings; `AStoreyMovedOffTheStoreyBelowIsUnsupported` checks the exposed floor and roof areas of storeys from differently moved plans, and `RoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported` a rotated setback.
- [x] Step 5: Check the component table against the code
git grep -n -E "ComponentGuid => new\(" src/Lod.Grasshopper
git grep -n -E "Floor Area Multiplier|Single Zone Merged" -- docs/architecture/pipeline.md
Expected: 27 lines for the first command (21 components, 6 parameters), each GUID appearing once in the tables of pipeline.md; one line for the second, the sentence that records the two renames of S4.2.
- [x] Step 6: Commit
git add docs/architecture/pipeline.md docs/architecture/convert2bem.md docs/architecture/validation.md
git commit -m "docs(architecture): describe the aggregation methods, plan transform, and heat transfer options"
Task 18: S4.2 smoke-test checklist¶
Files:
- Modify: docs/development/grasshopper-smoke-test.md (append)
Interfaces: - Consumes: the plugin built from Slices A–F. - Produces: the checklist that Task 19 runs.
- [x] Step 1: Append the S4.2 checklist at the end of
docs/development/grasshopper-smoke-test.md
Append this section after the last line of the file (after the S4.1 checklist), separated by one blank line, so that it nests under ## Stage checklists like the S2–S4.1 checklists:
### S4.2 checklist — aggregation methods, plan transform, and heat-transfer options (`v0.4.2`)
Build the plugin with `dotnet build src/Lod.Grasshopper -c Release` (0 warnings), close Rhino, and start it again with the plugin loaded as described above. Use a Rhino model in metres. The canonical floor is: *Dwelling Unit Preset*, *Corridor Preset*, and *Stair Preset* → *Linear Plan Generator* with its default inputs (24 m × 18 m, 3 m storeys) → *No Simplification*. To give an aggregator the same floor three times, pass it through *Duplicate Data* (Sets › List) with Number = 3 and give Multipliers as a panel with the lines `1`, `3`, `1`. Definitions saved before S4.2 keep their components (D-064): a *Floor Area Multiplier* opens as *Stacked Floor Zone Multiplier*, and a *Single Zone Merged* opens as *Single Zone per Floor Type*, which now makes one zone per floor entry instead of one zone for the building.
#### Toolbar and icons
- [ ] *2 Generate* shows *Linear Plan Generator* and *Transform Plan* (TPlan). *4 Aggregate* shows *Stack Floors* (Stack), *Stacked Floor Zone Multiplier* (ZMult), *Single Zone per Floor Type* (1ZType), and *Single Zone Building* (1ZBldg); *Floor Area Multiplier* and *Single Zone Merged* are gone.
- [ ] *Transform Plan* has a green icon (a footprint, a turning arrow, and the turned footprint); *Single Zone per Floor Type* (a short block below a tall one) and *Single Zone Building* (one tall block) have purple icons that are told apart at toolbar size.
#### Aggregation methods and previews
1. Connect the canonical floor three times with Multipliers 1, 8, 1 to *Stacked Floor Zone Multiplier*.
- [ ] No runtime message. *Inspect* lists zones `L0/…`, `L5/…` (`multiplier=8`), and `L9/…`; its Provenance output starts with `StackedFloorZoneMultiplier Multipliers=1,8,1 Storeys=L0x1,L5x8,L9x1`. *Validate* gives Passed = True.
- [ ] The preview shows the whole 30 m building (bounding box 0,0,0 to 24,18,30): the modelled storeys at 0, 15, and 27 m in their space-type colours with their windows, and the storeys at 3 to 15 m and 18 to 27 m in transparent grey, without windows.
- [ ] With *Preview Location* `30,0,0` the coloured and the grey storeys move 30 m east together; *Convert2BEM* still outputs zone Breps between 0,0,0 and 24,18,30.
2. Connect the canonical floor three times with Multipliers 1, 3, 1 to *Single Zone Building*.
- [ ] No runtime message. The preview shows one closed volume in the Mixed colour from 0 to 15 m (bounding box 0,0,0 to 24,18,15) without horizontal seams at the slab levels, with the plan's windows on every storey.
- [ ] *Inspect* starts its zone list with `zone BUILDING Mixed "Building" area=2160.000000 volume=6480.000000 parts=5 multiplier=1` and lists four lines `mass BUILDING area=432.000000 faces=2` at z = 3, 6, 9, and 12; its Provenance output starts with `SingleZoneBuilding Multipliers=1,3,1`. *Convert2BEM* outputs five Breps in *Zones* {0} and the *Internal Mass* value 3456.
3. Connect the same floors and multipliers to *Single Zone per Floor Type*.
- [ ] No runtime message. *Inspect* lists three zones: `zone E0 Mixed "Floor type 0" area=432.000000 volume=1296.000000 parts=1`, `zone E1 Mixed "Floor type 1" area=1296.000000 volume=3888.000000 parts=3`, and `zone E2 Mixed "Floor type 2" area=432.000000 volume=1296.000000 parts=1`, and two lines `mass E1 area=432.000000 faces=2` at z = 6 and 9; its Provenance output starts with `SingleZonePerFloorType Multipliers=1,3,1`.
- [ ] The preview shows three volumes, 0 to 3 m, 3 to 12 m (one closed volume without seams at 6 and 9 m), and 12 to 15 m.
- [ ] *Validate* gives Passed = True. *Convert2BEM*: *Internal Mass* is 0, 1728, and 0 for branches {0}, {1}, {2}.
#### Transform Plan
4. Build a transform: *Rotate* (Transform › Euclidean) with any geometry, plane *XY Plane* at the origin, and angle 30° (switch the *Angle* input to degrees, or give 0.5235987756 rad), then *Move* with the vector 5.3, −2.1, 0, and combine their transforms (*X* outputs, rotation first) with *Compound* (Transform › Util). Connect the canonical plan and the compound transform to *Transform Plan*.
- [ ] No runtime message. The preview shows the plan rotated 30° counter-clockwise about the origin and moved (bounding box about −3.7, −2.1, 0 to 26.084610, 25.488457, 3).
- [ ] *Inspect* on the output: its Provenance starts with `TransformPlan RotationDegrees=` followed by 30 (to about 12 digits), `TranslationX=5.3` and `TranslationY=-2.1` (to about 12 digits), with `LinearPlanGenerator …` indented below; its Report lists the same zone and surface IDs as the input plan.
- [ ] The rotated plan through each of the four simplifiers and each of the four aggregators (Multipliers 1, 3, 1) gives *Validate* Passed = True and no runtime message on the aggregators (16 combinations).
5. Replace the transform by the *X* output of *Scale* (Transform › Affine) with factor 2 about the origin.
- [ ] *Transform Plan* shows the warning `Not applied (D-070): scaling, shear, or mirroring in plan, a rotation about another axis or scaling along z. Applied: a rotation of 0° about the world z axis and a translation of (0, 0) m in x and y.` and outputs the plan unscaled (bounding box 0,0,0 to 24,18,3).
6. Replace the transform by the *X* output of *Move* with the vector 0, 0, 3.
- [ ] The warning starts `Not applied (D-070): a translation along z.`; the plan stays at elevation 0.
#### Unsupported storey
7. Give a second *Transform Plan* only the *Move* transform with the vector 5.3, −2.1, 0, pass the moved plan through *No Simplification*, and *Merge* (Sets › Tree) the canonical floor and the moved floor into the Floors of each aggregator with Multipliers 2, 2.
- [ ] Every one of the four aggregators shows the warning `Warning UnsupportedStorey [L2]: The floor of storey 2 is not fully covered by storey 1: 134.670000 m² of it faces outdoors (D-074).` and still outputs a building; *Stacked Floor Zone Multiplier* also shows `FloorTypeSplit` remarks.
- [ ] *Validate* on each gives Passed = True with `ok Building.ExposedFloorArea: reference 134.670000, actual 134.670000`.
#### Heat-transfer options
8. Connect the *Stack Floors* building of the canonical floor (Multipliers 1, 3, 1) to *Convert2BEM*; *Convert2BEM* has the inputs Building, Override, EnableInternalWallHeatTransfer (IWHT), and EnableFloorHeatTransfer (FHT), both options `False` by default.
- [ ] *Boundaries* contains no `Interzone:` text: the 90 walls between zones and the 48 floors and ceilings between storeys read `Adiabatic`. *Provenance* contains the line `OPTIONS: EnableInternalWallHeatTransfer=False EnableFloorHeatTransfer=False`.
- [ ] IWHT = True: the 90 walls read `Interzone:<zone ID>` (for example `Interzone:L0/CO` on `L0/ST`), the floors and ceilings between storeys stay `Adiabatic`, and the *OPTIONS* line reads `EnableInternalWallHeatTransfer=True EnableFloorHeatTransfer=False`.
- [ ] FHT = True (IWHT False): the 48 floors and ceilings between storeys read `Interzone:<zone ID>` and the walls `Adiabatic`.
- [ ] *Validate* on the building gives the same report with every combination; *Zones*, *Surfaces*, and *Windows* are the same Breps.
9. Connect the *Stacked Floor Zone Multiplier* building of step 1 and set FHT = True.
- [ ] Its floors and ceilings between storeys stay `Adiabatic` (D-075); with IWHT = True its walls between zones read `Interzone:<zone ID>`.
10. Connect the *Single Zone per Floor Type* building of step 3 and set FHT = True.
- [ ] The 24 floor and ceiling pieces at 3 m and 12 m read `Interzone:E1`, `Interzone:E0`, or `Interzone:E2`; with FHT = False they read `Adiabatic`.
- [x] Step 2: Commit
git add docs/development/grasshopper-smoke-test.md
git commit -m "docs(development): add the s4.2 smoke-test checklist"
Task 19: 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 20, Step 8)
- [x] Step 1: Build the branch
dotnet build BEMGen.sln -c Release
git rev-parse HEAD
Expected: Build succeeded., 0 Warning(s), 0 Error(s). Note the SHA.
- [x] Step 2: Run the S4.2 checklist (person in Rhino 8, or the controller headless)
Load the plugin as docs/development/grasshopper-smoke-test.md describes and tick every item of the S4.2 checklist. In this run (D-075, 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, Goo bounding boxes, and the preview Breps of each Goo written to a file). The following values are expected; they were observed in such sessions on the verified reference (Rhino 8.25.25314.11001, .NET 8.0.31; BEMGen Info version text 0.4.1+<sha>, since the bump to 0.4.2 comes in Task 20). The canonical floor is the three default preset components → Linear Plan Generator with its default inputs → No Simplification; "1, 3, 1" means that floor given three times with those multipliers.
- Registration. 21 components and 6 parameters, each with a 24 × 24 icon, plus a 24 × 24 assembly icon. 2 Generate: Linear Plan Generator (Linear) and Transform Plan (TPlan,
57c17b9c-04b4-4595-9150-bb923877d95e). 4 Aggregate: Stack Floors (Stack,6ce6524d-09b0-49fc-9b88-4c15bdcc8797), Stacked Floor Zone Multiplier (ZMult,e4b44821-c9ed-49b9-9c4e-7c08355254dd), Single Zone per Floor Type (1ZType,506d5037-de93-4aee-a6c4-47dcc97509e1), and Single Zone Building (1ZBldg,50965154-e31e-4730-b61c-15a6f40bee6f), all primary; no object is named Floor Area Multiplier or Single Zone Merged. Every other object keeps its panel, nickname, GUID, and exposure from S4.1. - Inputs. Transform Plan: 0 Plan (required item), 1 Transform (T,
Param_Transform, required item), 2 Preview Location (PL, optional vector, persistent{0,0,0}). Each aggregator: 0 Floors (F, required list), 1 Multipliers (N, optional integer list), 2 Preview Location. Convert2BEM: 0 Building, 1 Override (Ovr,False), 2 EnableInternalWallHeatTransfer (IWHT, Boolean, persistentFalse), 3 EnableFloorHeatTransfer (FHT, Boolean, persistentFalse). With nothing connected each shows only Grasshopper's warningsInput parameter … failed to collect data. - Transform Plan, 30° and (5.3, −2.1) m. The transform of a 30° rotation about the world z axis followed by a move by (5.3, −2.1, 0): no message; plan Goo box
-3.7,-2.1,0to26.084610,25.488457,3(to the shown digits); Inspect ProvenanceTransformPlan RotationDegrees=29.999999999999996 TranslationX=5.3 TranslationY=-2.1 [<version>]followed by the indentedLinearPlanGenerator CorridorWidth=2 FloorHeight=3 Length=24 OrientationDegrees=0 Presets=DwellingUnit:Example Dwelling Unit;Corridor:Example Corridor;Stair:Example Stair StairLength=4 TargetUnitWidth=10 UnitDepth=8 [<version>]. In a millimetre document with the move (5300, −2100, 0) mm: the same provenance and the box × 1000. - Transform Plan, other parts. A scaling by 2 about the origin: the warning
Not applied (D-070): scaling, shear, or mirroring in plan, a rotation about another axis or scaling along z. Applied: a rotation of 0° about the world z axis and a translation of (0, 0) m in x and y.and the plan box0,0,0to24,18,3. A move by (0, 0, 3): the warningNot applied (D-070): a translation along z. Applied: a rotation of 0° about the world z axis and a translation of (0, 0) m in x and y.and the same box. - Rotated plan through every method. The plan of item 3 through each of the four simplifiers, each floor given three times with Multipliers 1, 3, 1 to each of the four aggregators: no runtime message on any simplifier or aggregator, and Validate Passed =
Truefor all 16 buildings. - Unsupported storey. The canonical floor × 2 under a floor from a plan moved by (5.3, −2.1) × 2: Stack Floors, Single Zone per Floor Type, and Single Zone Building show the warning
Warning UnsupportedStorey [L2]: The floor of storey 2 is not fully covered by storey 1: 134.670000 m² of it faces outdoors (D-074).; Stacked Floor Zone Multiplier has the state Warning (the same warning) and the remarksInfo FloorTypeSplit: Floor type 0 (x2) has an exposed floor; split into x1 + x1 so exposure is not multiplied (D-046).and the same for floor type 1. Every one outputs a building, and Validate gives Passed =Truewithok Building.ExposedFloorArea: reference 134.670000, actual 134.670000. - Heat-transfer options. Convert2BEM on the canonical 1, 3, 1 building of each aggregator; tally of the Boundaries texts by kind (
Interzonecounts everyInterzone:<zone ID>):
| Aggregator | IWHT False, FHT False |
IWHT True |
FHT True |
both True |
|---|---|---|---|---|
| Stack Floors | Adiabatic 138, Ground 6, Outdoors 56 | Adiabatic 48, Ground 6, Interzone 90, Outdoors 56 | Adiabatic 90, Ground 6, Interzone 48, Outdoors 56 | Ground 6, Interzone 138, Outdoors 56 |
| Stacked Floor Zone Multiplier | Adiabatic 78, Ground 6, Outdoors 36 | Adiabatic 24, Ground 6, Interzone 54, Outdoors 36 | as False/False |
as IWHT True |
| Single Zone per Floor Type | Adiabatic 24, Ground 6, Outdoors 26 | as False/False |
Ground 6, Interzone 24, Outdoors 26 | as FHT True |
| Single Zone Building | Ground 6, Outdoors 26 | the same | the same | the same |
Provenance holds the line OPTIONS: EnableInternalWallHeatTransfer=<IWHT> EnableFloorHeatTransfer=<FHT> with the values used (False/True). Zones (30, 18, 5, and 5 Breps), Surfaces (200, 120, 56, and 32), and Windows (50, 30, 50, and 50) have the same counts for every combination.
8. Previews. Stacked Floor Zone Multiplier 1, 8, 1: building Goo box 0,0,0 to 24,18,30; 90 preview Breps: 42 closed grey Breps (colour 150,150,150, transparency 0.85) between z = 3 and 27, the 18 zones of the modelled storeys in their space-type colours (transparency 0.6), and 30 windows; Convert2BEM Zones 18 branches of one Brep, Internal Mass 0 for every zone. With Preview Location 40,0,0: offset 40,0,0, box 40,0,0 to 64,18,30, the grey Breps between 40,0,3 and 64,18,27. Single Zone Building 1, 3, 1: one closed Brep 0,0,0 to 24,18,15 plus 50 windows; Convert2BEM Zones one branch of 5 Breps and Internal Mass 3456. Single Zone per Floor Type 1, 3, 1: three closed Breps, z 0–3, 3–12, and 12–15, plus 50 windows; Zones 1, 3, and 1 Breps and Internal Mass 0, 1728, and 0. Single Zone Building on the Perimeter Core floor of the plan of item 3 (1, 3, 1): one closed Brep. The setback (wide × 2 under narrow, Length 14, × 3) with either single-zone method: two closed Breps, z 0–6 (24 m wide) and 6–15 (14 m wide), since the outlines differ and the union falls back to the runs. Stack Floors 1, 3, 1: 30 zone Breps as before.
Items that a headless session cannot observe stay open for a person (D-056, D-058): the look of the previews in the viewport (colours, the grey transparency, no seams at slab levels, the moved previews side by side), the icons on the toolbar and the canvas, building the transforms with Rotate, Move, and Compound as the checklist describes (the headless session sets the transform directly), the Inspect zone and mass lines of checklist items 1–3 and the Boundaries texts per surface of items 8–10 (their values come from the core tests and the tallies above), and Duplicate Data for the repeated floor (the headless session uses separate floors). No example definition is part of S4.2; the pending S4 definition (examples/grasshopper/checkpoint-1-pipeline.gh) stays person-only (D-058).
Any failure stops the stage: fix it on this branch with a test (core) or a build plus smoke test (Grasshopper) before continuing. Write down, following the guide's "Recording the result": who tested and how (person in Rhino, or the controller headless per D-056), when, the Rhino version and runtime, the SHA, the BEMGen Info version text, pass or fail for each item, and the items not performed.
- [x] Step 3: Merge Slice G (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch docs/architecture-aggregation-transform
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only docs/architecture-aggregation-transform
git branch -d docs/architecture-aggregation-transform
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0. Do not push (D-075).
Stage close-out¶
Task 20: Version 0.4.2, S4.2 records, tag v0.4.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-01-s4.2-aggregation-transform-heat-transfer.md (this plan: checkboxes and status, D-051)
Interfaces:
- Consumes: the merged Slices A–G; the smoke-test record of Task 19; BuildInfoTests.InformationalVersionStartsWithTheAssemblyVersion (S0), which keeps passing after the bump.
- Produces: the local tag v0.4.2; the S4.2 records.
The decision-log entry in Step 7 has one field filled in when this task runs: <date>, the date the stage is closed (ISO format, YYYY-MM-DD). It is decided by the controller (unsupervised run, D-075) and Provisional. The roadmap Progress line of Step 6 carries the same <date>. The commit body of Step 8 uses the values written down in Task 19. The close-out uses its own branch, as the S1–S4.1 close-outs do.
- [x] Step 1: Create the branch and run the gate
git switch -c chore/repo-s4.2-close-out main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0.
- [x] Step 2: Bump the version in
Directory.Build.props
Replace
<Version>0.4.1</Version>
with
<Version>0.4.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.4.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.4.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). The pipeline runs end to end for the linear plan: program presets → `LinearPlanGenerator` (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 zoning level keeps the generator's windows in place (W0, D-039); vertical aggregation follows [ADR-013](docs/decisions/ADR-013-vertical-aggregation-methods.md), which supersedes [ADR-009](docs/decisions/ADR-009-vertical-aggregation.md) where it differs; ADR-013 and the S4.2 plan were accepted by the controller and await the owner's reading (D-075). 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: window transformations W1–W5 (S5), `Lod.Export` and Convert2IDF (S6), the precedent study (S7), the refined linear, point, and courtyard typologies (S8), and Grasshopper UX and release (S9). The example program presets hold illustrative values, not DOE prototype values, and the `Convert2BEM` output layout stays provisional until a ClimateStudio reference definition exists (D-023).
In 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`). In Rhino 8 the plugin runs the whole pipeline for a linear plan: program presets with conditioning (a default preset component per space type), the linear plan generator with one preset input per space type and explicit windows, *Transform Plan* to rotate and move a plan, four plan simplifiers (Z0–Z3) that keep every window in place, 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). 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: window transformations (S5), Convert2IDF (S6), and the precedent study (S7). The `Convert2BEM` output stays provisional until a ClimateStudio reference definition exists (D-023), and the example program presets hold illustrative values, not DOE prototype values.
In the block under ## What it does of README.md, make these replacements; each "Replace" text occurs exactly once in the file.
- Replace
→ PlanGenerator (LinearPlanGenerator, ...) → IGeneratedPlan detailed plan, one zone per dwelling unit (Z0)
with
→ PlanGenerator (LinearPlanGenerator, ...) → IGeneratedPlan detailed plan, one zone per dwelling unit (Z0)
→ PlanTransform (optional: rotation about z, x-y translation) → IGeneratedPlan
- Replace
→ IFloorAggregator (Stack, FloorAreaMultiplier, SingleZoneMerged) → IGeneratedBuilding
with
→ IFloorAggregator (Stack, StackedFloorZoneMultiplier, SingleZonePerFloorType, SingleZoneBuilding) → IGeneratedBuilding
- [x] Step 6: Update the roadmap
In docs/plans/2026-09-30-implementation-roadmap.md, insert these two lines directly below the S4.1 Progress line (the last > **Progress:** line), so that a > separator line stays between consecutive lines:
>
> **Progress:** <date> · S4.2 Vertical aggregation methods, plan transform, and heat-transfer options complete (tag `v0.4.2`, local until the owner pushes it, D-075); next: S5, S6, and S7. Every floor aggregator designates floors and ceilings on the assembled stack (D-068) and warns about unsupported storeys (D-074); the aggregators are `Stack`, `StackedFloorZoneMultiplier` (formerly `FloorAreaMultiplier`), `SingleZonePerFloorType` (one zone per floor entry, behind the former *Single Zone Merged* component), and `SingleZoneBuilding` (formerly `SingleZoneMerged`, D-071), following ADR-013; *Transform Plan* rotates and moves a plan, and every simplifier and aggregator validates on rotated plans (D-070, D-076); *Convert2BEM* writes heat transfer between zones only when asked (D-069); existing buildings unchanged, two snapshots renamed with identical content; 136 core, 19 generator, and 163 integration tests pass on `net8.0`.
In the pipeline block near the top of the same file and in §2 (target repository layout), make these replacements; each "Replace" text occurs exactly once in the file.
- Replace
→ PlanGenerator (LinearPlanGenerator, ...) → IGeneratedPlan Z0: one zone per dwelling unit, explicit windows
with
→ PlanGenerator (LinearPlanGenerator, ...) → IGeneratedPlan Z0: one zone per dwelling unit, explicit windows
→ PlanTransform (optional, D-070) → IGeneratedPlan rotation about z, translation in x and y
- Replace
Stack | FloorAreaMultiplier | SingleZoneMerged (whole building = 1 zone)
with
Stack | StackedFloorZoneMultiplier | SingleZonePerFloorType | SingleZoneBuilding (whole building = 1 zone)
- Replace
Geometry/ Point2, Polygon2 (outer ring + holes), PolygonOps (Clipper2), Orientation, Window
with
Geometry/ Point2, Polygon2 (outer ring + holes), PolygonOps (Clipper2), Orientation, Window, PlanMotion
- Replace
Plans/ PlanGenerator (abstract), PlanParameters
with
Plans/ PlanGenerator (abstract), PlanParameters, PlanTransform
- Replace
Buildings/ IFloorAggregator, Stack, FloorAreaMultiplier, SingleZoneMerged
with
Buildings/ IFloorAggregator, Storeys, Stack, StackedFloorZoneMultiplier, SingleZonePerFloorType, SingleZoneBuilding
- Replace
Validation/ Totals, FloorValidator, BuildingValidator, ValidationReport
with
Validation/ Totals, FloorValidator, BuildingValidator, ValidationReport
Conversion/ HeatTransferOptions (export options shared by the converters)
The S4.2 rows of the stage table and the detailed-plans table, the diagram, the sentence below it, and the revision note were committed with this plan and stay as they are.
- [x] Step 7: Append the decision-log entry (template; fill
<date>now)
Append at the end of docs/decisions/decision-log.md, after a blank line. The log ends with D-077 when this plan is approved, so the entry is D-078; if the log ends with another number, use the next free number and say so in the commit body.
### D-078 — S4.2 complete
- **Date:** <date> · **Decided by:** controller (unsupervised run, D-075) · **Status:** Provisional
- The revision stage S4.2 (D-073) is complete and tagged `v0.4.2` locally; pushing `main` and the tag waits for the owner (D-075). [ADR-013](ADR-013-vertical-aggregation-methods.md), accepted by the controller under D-075 and pending the owner's reading, supersedes ADR-009 where it differs. D-068: every floor aggregator starts from `Storeys.Assemble` (`AssembledStack`), and `Storeys.ResolveRepresentative` is deleted. D-071: `FloorAreaMultiplier` is renamed `StackedFloorZoneMultiplier` (*Stacked Floor Zone Multiplier*, ZMult, GUID kept); `SingleZoneMerged` is renamed `SingleZoneBuilding` (*Single Zone Building*, 1ZBldg, new GUID `50965154-e31e-4730-b61c-15a6f40bee6f`); the new `SingleZonePerFloorType` (zones `E{i}`) is behind *Single Zone per Floor Type* (1ZType), which keeps the GUID of *Single Zone Merged*; both single-zone methods share `SingleZoneMerge`. D-074: the warning `UnsupportedStorey`. D-070: `PlanMotion`, `PlanTransform`, and *Transform Plan* (TPlan, `57c17b9c-04b4-4595-9150-bb923877d95e`). D-069: `HeatTransferOptions` in `Lod.Core.Conversion`; *Convert2BEM* has the inputs *EnableInternalWallHeatTransfer* and *EnableFloorHeatTransfer*, both `False` by default, and records them in *Provenance*. D-075 defaults: pieces between storeys of the zone multiplier stay adiabatic whatever *EnableFloorHeatTransfer* says; unmodelled storeys are previewed transparent grey. D-076: façade lines matched over their overlap, storey outlines unioned as tiles, unsupported storeys compared with their floor area.
- Every existing building is numerically unchanged: five snapshots are byte-identical, `aggregator-StackedFloorZoneMultiplier.txt` and `aggregator-SingleZoneBuilding.txt` are the S4 snapshots renamed with identical content, and `aggregator-SingleZonePerFloorType.txt` is new. 136 core, 19 generator, and 163 integration tests pass on `net8.0`, including every simplifier with every aggregator on plans rotated and moved by four placements.
- The S4.2 smoke test is recorded in the close-out commit; items it did not perform stay open for a person (D-058).
- S5 (window transformations, gated on ADR-012) can start. S6 (Convert2IDF, which depends on S4.2 since D-077) implements the *Convert2IDF* part of D-069 with `HeatTransferOptions`; S7 depends on S4 only.
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-075; pending the owner's reading) · … (D-051). Leave unticked any step that was not performed, in particular Step 11 below, and name them in the commit body. The second -m records the smoke test as docs/development/grasshopper-smoke-test.md prescribes ("Recording the result"); fill <tester and method>, <date>, <version>, <sha>, <version text>, and <items> with the values written down in Task 19.
git add AGENTS.md README.md docs/plans/2026-09-30-implementation-roadmap.md docs/decisions/decision-log.md docs/plans/2026-10-01-s4.2-aggregation-transform-heat-transfer.md
git commit -m "docs(repo): record s4.2 completion" -m "Smoke test (docs/development/grasshopper-smoke-test.md, S4.2 checklist) by <tester and method> on <date>: Rhino <version>, .NET Core runtime, build <sha>, Version output '<version text>', all performed items passed. Not performed, pending a person (D-058): <items>."
- [ ] Step 9: Merge (D-004)
git fetch origin
git switch main
git pull --ff-only
git switch chore/repo-s4.2-close-out
git rebase main
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify.ps1
git switch main
git merge --ff-only chore/repo-s4.2-close-out
git branch -d chore/repo-s4.2-close-out
Expected: VERIFY PASSED, with 136 core, 19 generators, and 163 integration tests passed on net8.0.
- [ ] Step 10: Tag (local)
git tag -a v0.4.2 -m "S4.2 vertical aggregation methods, plan transform, and heat-transfer options complete"
- [ ] Step 11: Push (not performed in this run, D-075)
Pushing waits for the owner. When the owner approves it:
git push origin main
git push origin v0.4.2
Exit criteria¶
- ADR-013 exists, is accepted by the controller under D-075 (pending the owner's reading), and ADR-009's status line points to it.
- D-068: every aggregator builds on
Storeys.Assemble; the zone multiplier takes its pieces from the stack with pieces between storeys adiabatic (MultiplierTakesTheDesignationsOfItsStoreysFromTheStack);ResolveRepresentativeis gone. D-074: an overhanging or moved storey gives oneUnsupportedStoreywarning with its uncovered area and the building still validates (AnOverhangingStoreyIsReportedAsUnsupported,AStoreyMovedOffTheStoreyBelowIsUnsupported); setbacks and rounding slivers do not (SetbacksAndIdenticalStoreysAreSupported,RoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported). - D-071:
StackedFloorZoneMultiplier,SingleZonePerFloorType(zonesE{i}, slabs within an entry as internal mass, pieces between entries interzone), andSingleZoneBuilding(zoneBUILDING) exist;FloorAreaMultiplierandSingleZoneMergedare gone; Stacked Floor Zone Multiplier and Single Zone per Floor Type keep their GUIDs, Single Zone Building has50965154-e31e-4730-b61c-15a6f40bee6f. - D-070, D-076:
PlanTransformkeeps area, volume, wall area, and glazing, turns façades with the geometry, and recordsTransformPlanin provenance; Transform Plan (57c17b9c-04b4-4595-9150-bb923877d95e) applies only the rotation about z and the x-y translation and warns about the rest; all 64 rotated simplifier × aggregator cases validate without warnings. - D-069:
HeatTransferOptions.Exportedgives the four combinations; Convert2BEM has IWHT and FHT, defaultFalse, and records them in Provenance; the building and its validation do not depend on them. - Previews: unmodelled storeys of the zone multiplier in transparent grey, multi-part zones as merged volumes, computed from core data; Preview Location moves them all.
- 28 icons drawn by
scripts/make_icons.py; the two renamed ones byte-identical to their S4.1 files. - Snapshots: five byte-identical, two renamed byte-identical, one new;
scripts/verify.ps1printsVERIFY PASSED; tests: 136 core, 19 generators, 163 integration onnet8.0; 0 warnings. - The S4.2 smoke test is recorded in the close-out commit, with the items left for a person named.
- Version 0.4.2, local tag
v0.4.2; AGENTS.md, README, the roadmap Progress line, pipeline block, and layout, and the decision-log entry D-078 are written; this plan is ticked and marked Done; nothing is pushed (D-075).
Notes for the reviewer¶
- Source. Every code block was taken from a verified reference implementation built on
mainat79f5094(D-068 to D-075;mainis nowb8bba9f, which adds only the D-076 entry), 21 commits in the order of this plan. Three late commits of the reference are folded into the task that owns their files, so that execution produces the end state directly: the rotated-plan rules of ADR-013 (recorded as D-076 onmainbefore this plan) into Task 1, the final wording of the Transform Plan warning into Task 13, and the vertical-run preview into Task 15. Every task was then replayed on a clone ofb8bba9ffrom this plan's file contents: the red errors of Tasks 2, 5, 6, 7, 9, and 11 (including line and column numbers), the failing tests and messages of Tasks 2 (Step 5), 5 (snapshot), 8, 9, and 10, the received snapshot of Task 5 (byte-identical to Step 6), the counts after every task, a 0-warning build after every task, and the 28 icons (byte-identical to the reference) are observed results; the replayed end state equals the reference apart from line endings. - Slices and branches. The roadmap has no slice list for S4.2; this plan uses seven slices and a close-out, named after D-004:
docs/decisions-adr-013,feature/aggregators-assembled-stack,feature/plans-transform,feature/convert-heat-transfer-options,feature/grasshopper-aggregation-transform,feature/grasshopper-s4.2-icons,docs/architecture-aggregation-transform, andchore/repo-s4.2-close-out. Merges rebase onto localmain; nothing is pushed in this run, and the tag stays local (D-075). - ADR-013 is accepted by the controller (D-075), not by the owner. D-073 requires acceptance before any S4.2 code; D-075 lets the controller accept it, provisionally. Task 1, Step 5 records the check against D-068 to D-076; the owner's reading stays open, as it did for ADR-009 (D-060).
- Designation on the assembled stack changes no number. For every building S4.1 accepts,
Storeys.Assemblegives the same pieces as the per-method resolution did, because the representative storey below always had the floor of the storey physically below.MultiplierTakesTheDesignationsOfItsStoreysFromTheStackand the setback cases therefore pass in Task 2, Step 5 already (they characterise the rule rather than drive it); the overhang cases and the overhang row ofVaryingFootprints…are what fail first. - Zone multiplier: pieces between storeys stay adiabatic whatever EnableFloorHeatTransfer says (D-075 default, ADR-013 option a). They are
Adiabaticin the building, not only on export, so FHT changes nothing for Stacked Floor Zone Multiplier (smoke test item 7). Ground, roof, and exposed pieces come from the stack and lie on × 1 storeys. - Renames. ADR-002 and ADR-007 keep the name
SingleZoneMergedand ADR-009 the namesFloorAreaMultiplierandSingleZoneMerged: they are accepted history; ADR-009 gets only the status line. The S2–S4.1 smoke-test checklists keep the old component names (D-064); the S4.2 checklist's preamble says how saved definitions open. Test names follow the renames (ZoneMultiplierCarriesMultipliersAndOrderedBoundaries,SingleZoneBuilding…). The snapshot contents are byte-identical becauseTextReport.Describeprints no operation name. - The Single Zone Merged GUID now carries other semantics. D-071 keeps
506d5037-…for Single Zone per Floor Type; a definition saved before S4.2 therefore opens with one zone per floor entry instead of one zone for the building (D-064 allows this). Between Task 4 and Task 12 that component runsSingleZoneBuilding, which is exactly its old behaviour. - Intermediate files. Task 2 changes
FloorAreaMultiplier.csandSingleZoneMerged.cs, which Tasks 3 and 4 rename and replace, so that every commit builds and every red phase is observable; Tasks 3 and 4 change one line each ofAggregatorComponents.csfor the same reason. - Rotated-plan rules (D-076, ADR-013). The façade-line test now measures the separation of two walls over their overlap; it is exact for rotated walls and identical for axis-aligned ones, and it does not relax
FacadeCoverage(walls that do not overlap are no longer on the same line).PolygonOps.UnionTilessharesVertexGridwithLayoutSurfaceBuilder; the builder's output and every snapshot are unchanged. The unsupported-storey test compares the covered area with the floor area throughAreaEquals, while the D-046 split still compares the exposed area with zero, so a rounding sliver of a rotated setback (about 1.4e-5 m² inRoundingSliversOfARotatedSetbackDoNotMakeAStoreyUnsupported) gives no warning but is still not multiplied. The sliver stays in the building as a tinyOutdoorsfloor piece: it is not repaired (GLOBAL.md quality rule 6), and validation compares it with the same sliver in the stacked reference. - Transform Plan. The decomposition reads a Rhino
Transformand therefore lives inLod.Grasshopper(PlanMotionDecomposition, internal); the geometry is moved by the corePlanTransform. The rotation isatan2(M10, M00), so a 30° rotation is recorded asRotationDegrees=29.999999999999996. The matrix entries, which are dimensionless, are compared withinToleranceSettings.Angle(1e-6), and the z translation withinDistance; no tolerance is added. The warning is a runtime message without a diagnostic code; the plan is still produced with the applied parts (GLOBAL.md quality rule 6: nothing is changed silently).PlanMotionuses exact cosines and sines for whole quarter turns, so axis-aligned plans stay axis-aligned and their results stay exact. - Previews. The unmodelled storeys come from the core (
StackedFloorZoneMultiplier.UnmodelledStoreys), are passed intoBuildingGooby the component, and are only drawn by the Goo. A multi-part zone is drawn as its vertical runs (VerticalRuns, preview-only, inLod.Grasshopper/Convert): parts of one outline stacked on each other are extruded once, so the usual building needs no boolean union. Only runs of different outlines are united withBrep.CreateBooleanUnionatToleranceSettings.Distance× unit scale; where that fails, as for the setback in smoke test item 8, the runs are drawn as they are (two solids). The building data and the Convert2BEM outputs keep one part per storey outline. A first version united the parts directly; it was replaced in the reference because the union of touching extrusions did not give one closed volume. - Heat-transfer options live in
Lod.Core.Conversionso that Convert2IDF (S6) applies the same rule. Convert2BEM places IWHT and FHT after Override and writes theOPTIONS:line after the validation report and before the override line, also on blocked solves. The Boundaries tallies of smoke test item 7 are the check that only the written boundary changes; Zones, Surfaces, and Windows keep their counts. - Tests changed, not only added.
VaryingFootprintsKeepExposedAreasWithoutMultiplyingThemgains the argumentunsupported; the S4 overhang row now expects the warning; aggregator test names follow the renames; the simplifier and aggregator switches move fromAggregatorTeststoPipeline(Task 7). No test is removed or weakened, and no tolerance or validation check changes. - D-075 defaults as implemented: pieces facing a represented but unmodelled storey adiabatic whatever FHT says (note 5); unmodelled storeys transparent grey (
150, 150, 150, transparency 0.85); zonesE{i}named "Floor type i", andBUILDINGkept; nicknames Stack, ZMult, 1ZType, 1ZBldg, and TPlan for Transform Plan (chosen in the reference); provenanceTransformPlan RotationDegrees=… TranslationX=… TranslationY=…; ADR-013. The only new diagnostic code isUnsupportedStorey. - Documents. The research brief and
domain-model.mdname no aggregator class and are unchanged;validation.mdgets the façade-line rule and the new tests. README "Status", AGENTS.md "Project state", the roadmap Progress line, pipeline block, and layout, the decision-log entry, the version, the plan ticks (D-051), and the tag are all in Task 20. The roadmap's S4.2 stage row, S5's dependency on S4.2, the diagram, the sentence below it, the detailed-plans row, and the revision note v6 are committed with this plan. - Smoke-test values. The expected values of Task 19 were observed in three scripted headless Rhino 8.25 sessions (D-056) on the reference: registration, inputs, Transform Plan, and the unsupported storey on the build before the vertical-run preview, the 16 rotated combinations and the heat-transfer tallies on the same build, and the previews on the final build. The Stacked Floor Zone Multiplier message list of item 6 was cut off in the log after the two
FloorTypeSplitremarks; its Warning state shows theUnsupportedStoreywarning, whichAnOverhangingStoreyIsReportedAsUnsupportedandAStoreyMovedOffTheStoreyBelowIsUnsupportedcheck in code. The viewport look, the toolbar icons, the transform built from Grasshopper components, the Inspect lines, and the per-surface Boundaries texts were not observed there. check_plans.pyfalse positives. As in S4.1, themake_icons.pydocstring line that names the Pillow version that drew the icons is flagged as a possible attribution; it names the image library, not an author. The Global Constraints line that states the no-attribution rule is flagged too.